diff --git a/.github/workflows/dev-daily-fixed.yml b/.github/workflows/dev-daily-fixed.yml index cdd756f..7666825 100644 --- a/.github/workflows/dev-daily-fixed.yml +++ b/.github/workflows/dev-daily-fixed.yml @@ -308,11 +308,11 @@ jobs: - 此版本为每日构建的开发版,包含最新的功能和修复,但可能不够稳定,仅推荐用于测试和验证。 ## 下载 - - Windows x64: ${WINDOWS_URL:-$RELEASE_PAGE} - - Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE} - - macOS(Apple Silicon): ${MAC_URL:-$RELEASE_PAGE} - - Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE} - - Linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE} + - Windows x64: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE}) + - Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE}) + - macOS(Apple Silicon): [点击下载](${MAC_URL:-$RELEASE_PAGE}) + - Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE}) + - Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}) ## macOS 安装提示 - 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记: @@ -321,7 +321,7 @@ jobs: ## 说明 - 该发布页的同名资源会被后续构建覆盖,请勿将其视作长期归档版本。 - - 如某个平台资源暂未生成,请进入发布页查看最新状态:$RELEASE_PAGE + - 如某个平台资源暂未生成,请进入[发布页]($RELEASE_PAGE)查看最新状态 EOF gh release edit "$TAG" --repo "$REPO" --title "Daily Dev Build" --notes-file dev_release_notes.md diff --git a/.github/workflows/preview-nightly-main.yml b/.github/workflows/preview-nightly-main.yml index c67048d..13e04aa 100644 --- a/.github/workflows/preview-nightly-main.yml +++ b/.github/workflows/preview-nightly-main.yml @@ -351,18 +351,18 @@ jobs: - 当前版本号:\`$CURRENT_PREVIEW_VERSION\` ## 下载 - - Windows x64: ${WINDOWS_URL:-$RELEASE_PAGE} - - Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE} - - macOS(Apple Silicon): ${MAC_URL:-$RELEASE_PAGE} - - Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE} - - Linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE} + - Windows x64: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE}) + - Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE}) + - macOS(Apple Silicon): [点击下载](${MAC_URL:-$RELEASE_PAGE}) + - Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE}) + - Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}) ## macOS 安装提示 - 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记: - \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\` - 执行后重新打开 WeFlow。 - > 如某个平台链接暂未生成,请前往发布页查看最新资源:$RELEASE_PAGE + > 如某个平台链接暂未生成,请前往[发布页]($RELEASE_PAGE)查看最新资源 EOF gh release edit "$TAG" --repo "$REPO" --notes-file preview_release_notes.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index daf1d58..f0b6ac9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -276,18 +276,18 @@ jobs: [点击加入 Telegram 频道](https://t.me/weflow_cc) ## 下载 - - Windows x64(Win10+): ${WINDOWS_URL:-$RELEASE_PAGE} - - Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE} - - macOS(M系列芯片): ${MAC_URL:-$RELEASE_PAGE} - - Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE} - - Linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE} + - Windows x64(Win10+): [点击下载](${WINDOWS_URL:-$RELEASE_PAGE}) + - Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE}) + - macOS(M系列芯片): [点击下载](${MAC_URL:-$RELEASE_PAGE}) + - Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE}) + - Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}) ## macOS 安装提示 - 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记: - \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\` - 执行后重新打开 WeFlow。 - > 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE + > 如果某个平台链接暂时未生成,可进入[完整发布页]($RELEASE_PAGE)查看全部资源 EOF gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index 052dd8a..fb2c636 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -433,7 +433,123 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&include --- -## 7. 访问导出媒体 +## 7. 朋友圈接口 + +### 7.1 获取朋友圈时间线 + +```http +GET /api/v1/sns/timeline +``` + +参数: + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `limit` | number | 否 | 返回数量,默认 20,范围 `1~200` | +| `offset` | number | 否 | 偏移量,默认 0 | +| `usernames` | string | 否 | 发布者过滤,逗号分隔,如 `wxid_a,wxid_b` | +| `keyword` | string | 否 | 关键词过滤(正文) | +| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 | +| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 | +| `media` | number | 否 | 是否返回可直接访问的媒体地址,默认 `1` | +| `replace` | number | 否 | `media=1` 时,是否用解析地址覆盖 `media.url/thumb`,默认 `1` | +| `inline` | number | 否 | `media=1` 时,是否内联返回 `data:` URL,默认 `0` | + +示例: + +```bash +curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=5" +curl "http://127.0.0.1:5031/api/v1/sns/timeline?usernames=wxid_a,wxid_b&keyword=旅行" +curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&replace=1" +curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&inline=1" +``` + +媒体字段说明(`media=1`): + +- `media[].url/thumb`:你应该优先直接使用的字段。 +- `replace=1`(默认)时,`media[].url/thumb` 会直接被替换成可访问地址,等价于 `resolvedUrl/resolvedThumbUrl`。 +- `replace=0` 时,`media[].url/thumb` 仍保留微信原始地址;这时再结合下面的 `raw/proxy/resolved` 字段自己决定用哪一个。 +- `media[].rawUrl/rawThumb`:原始朋友圈地址 +- `media[].proxyUrl/proxyThumbUrl`:可直接访问的代理地址 +- `media[].resolvedUrl/resolvedThumbUrl`:最终可用地址(`inline=1` 时可能是 `data:` URL) +- `media[].token/key/encIdx`:微信源数据里的访问/解密参数。通常不需要你自己处理;如果你手动调用 `/api/v1/sns/media/proxy`,把当前条目的 `url` 和 `key` 原样传回即可。 +- `media[].livePhoto`:实况图的视频部分。外层 `media[].url/thumb` 仍是封面图,`livePhoto` 内部会再提供一组自己的 `url/thumb/raw*/proxy*/resolved*` 字段。 +- `media=0` 时,不会补充 `raw*/proxy*/resolved*`,接口只返回原始 `url/thumb` 以及源字段(如 `key/token/encIdx`)。 + +### 7.2 获取朋友圈发布者 + +```http +GET /api/v1/sns/usernames +``` + +### 7.3 获取朋友圈导出统计 + +```http +GET /api/v1/sns/export/stats +``` + +参数: + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `fast` | number | 否 | `1` 使用快速统计(优先缓存) | + +### 7.4 朋友圈媒体代理 + +```http +GET /api/v1/sns/media/proxy +``` + +参数: + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `url` | string | 是 | 媒体原始 URL | +| `key` | string/number | 否 | 解密 key(部分资源需要) | + +### 7.5 导出朋友圈 + +```http +POST /api/v1/sns/export +Content-Type: application/json +``` + +Body 示例: + +```json +{ + "outputDir": "C:\\Users\\Alice\\Desktop\\sns-export", + "format": "json", + "usernames": "wxid_a,wxid_b", + "keyword": "旅行", + "exportMedia": true, + "exportImages": true, + "exportLivePhotos": true, + "exportVideos": true, + "start": "20250101", + "end": "20251231" +} +``` + +`format` 支持:`json`、`html`、`arkmejson`(兼容写法:`arkme-json`)。 + +### 7.6 朋友圈防删开关 + +```http +GET /api/v1/sns/block-delete/status +POST /api/v1/sns/block-delete/install +POST /api/v1/sns/block-delete/uninstall +``` + +### 7.7 删除单条朋友圈 + +```http +DELETE /api/v1/sns/post/{postId} +``` + +--- + +## 8. 访问导出媒体 > 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json) @@ -476,7 +592,7 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif" --- -## 8. 使用示例 +## 9. 使用示例 ### PowerShell @@ -525,7 +641,7 @@ members = requests.get( --- -## 9. 注意事项 +## 10. 注意事项 1. API 仅监听本机 `127.0.0.1`,不对外网开放。 2. 使用前需要先在 WeFlow 中完成数据库连接。 diff --git a/electron/main.ts b/electron/main.ts index 980c972..b2a93e5 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -135,17 +135,33 @@ const shouldOfferUpdateForTrack = (latestVersion: string, currentVersion: string return false } +let lastAppliedUpdaterChannel: string | null = null +const resetUpdaterProviderCache = () => { + const updater = autoUpdater as any + // electron-updater 会缓存 provider;切换 channel 后需清理缓存,避免仍请求旧通道 + for (const key of ['clientPromise', '_clientPromise', 'updateInfoAndProvider']) { + if (Object.prototype.hasOwnProperty.call(updater, key)) { + updater[key] = null + } + } +} + const applyAutoUpdateChannel = (reason: 'startup' | 'settings' = 'startup') => { const track = getEffectiveUpdateTrack() const currentTrack = inferUpdateTrackFromVersion(appVersion) const baseUpdateChannel = track === 'stable' ? 'latest' : track - autoUpdater.allowPrerelease = track !== 'stable' - // 只要用户当前选择的目标通道与当前安装版本所属通道不同,就允许跨通道更新(含降级) - autoUpdater.allowDowngrade = track !== currentTrack - autoUpdater.channel = + const nextUpdaterChannel = process.platform === 'win32' && process.arch === 'arm64' ? `${baseUpdateChannel}-arm64` : baseUpdateChannel + if (lastAppliedUpdaterChannel && lastAppliedUpdaterChannel !== nextUpdaterChannel) { + resetUpdaterProviderCache() + } + autoUpdater.allowPrerelease = track !== 'stable' + // 只要用户当前选择的目标通道与当前安装版本所属通道不同,就允许跨通道更新(含降级) + autoUpdater.allowDowngrade = track !== currentTrack + autoUpdater.channel = nextUpdaterChannel + lastAppliedUpdaterChannel = nextUpdaterChannel console.log(`[Update](${reason}) 当前版本 ${appVersion},当前轨道: ${currentTrack},渠道偏好: ${track},更新通道: ${autoUpdater.channel},allowDowngrade=${autoUpdater.allowDowngrade}`) } @@ -155,6 +171,118 @@ const AUTO_UPDATE_ENABLED = process.env.AUTO_UPDATE_ENABLED === '1' || (process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL) +const getLaunchAtStartupUnsupportedReason = (): string | null => { + if (process.platform !== 'win32' && process.platform !== 'darwin') { + return '当前平台暂不支持开机自启动' + } + if (!app.isPackaged) { + return '仅安装后的 Windows / macOS 版本支持开机自启动' + } + return null +} + +const isLaunchAtStartupSupported = (): boolean => getLaunchAtStartupUnsupportedReason() == null + +const getStoredLaunchAtStartupPreference = (): boolean | undefined => { + const value = configService?.get('launchAtStartup') + return typeof value === 'boolean' ? value : undefined +} + +const getSystemLaunchAtStartup = (): boolean => { + if (!isLaunchAtStartupSupported()) return false + try { + return app.getLoginItemSettings().openAtLogin === true + } catch (error) { + console.error('[WeFlow] 读取开机自启动状态失败:', error) + return false + } +} + +const buildLaunchAtStartupSettings = (enabled: boolean): Parameters[0] => + process.platform === 'win32' + ? { openAtLogin: enabled, path: process.execPath } + : { openAtLogin: enabled } + +const setSystemLaunchAtStartup = (enabled: boolean): { success: boolean; enabled: boolean; error?: string } => { + try { + app.setLoginItemSettings(buildLaunchAtStartupSettings(enabled)) + const effectiveEnabled = app.getLoginItemSettings().openAtLogin === true + if (effectiveEnabled !== enabled) { + return { + success: false, + enabled: effectiveEnabled, + error: '系统未接受该开机自启动设置' + } + } + return { success: true, enabled: effectiveEnabled } + } catch (error) { + return { + success: false, + enabled: getSystemLaunchAtStartup(), + error: `设置开机自启动失败: ${String((error as Error)?.message || error)}` + } + } +} + +const getLaunchAtStartupStatus = (): { enabled: boolean; supported: boolean; reason?: string } => { + const unsupportedReason = getLaunchAtStartupUnsupportedReason() + if (unsupportedReason) { + return { + enabled: getStoredLaunchAtStartupPreference() === true, + supported: false, + reason: unsupportedReason + } + } + return { + enabled: getSystemLaunchAtStartup(), + supported: true + } +} + +const applyLaunchAtStartupPreference = ( + enabled: boolean +): { success: boolean; enabled: boolean; supported: boolean; reason?: string; error?: string } => { + const unsupportedReason = getLaunchAtStartupUnsupportedReason() + if (unsupportedReason) { + return { + success: false, + enabled: getStoredLaunchAtStartupPreference() === true, + supported: false, + reason: unsupportedReason + } + } + + const result = setSystemLaunchAtStartup(enabled) + configService?.set('launchAtStartup', result.enabled) + return { + ...result, + supported: true + } +} + +const syncLaunchAtStartupPreference = () => { + if (!configService) return + + const unsupportedReason = getLaunchAtStartupUnsupportedReason() + if (unsupportedReason) return + + const storedPreference = getStoredLaunchAtStartupPreference() + const systemEnabled = getSystemLaunchAtStartup() + + if (typeof storedPreference !== 'boolean') { + configService.set('launchAtStartup', systemEnabled) + return + } + + if (storedPreference === systemEnabled) return + + const result = setSystemLaunchAtStartup(storedPreference) + configService.set('launchAtStartup', result.enabled) + if (!result.success && result.error) { + console.error('[WeFlow] 同步开机自启动设置失败:', result.error) + } +} + // 使用白名单过滤 PATH,避免被第三方目录中的旧版 VC++ 运行库劫持。 // 仅保留系统目录(Windows/System32/SysWOW64)和应用自身目录(可执行目录、resources)。 function sanitizePathEnv() { @@ -1234,7 +1362,12 @@ function registerIpcHandlers() { }) ipcMain.handle('config:set', async (_, key: string, value: any) => { - const result = configService?.set(key as any, value) + let result: unknown + if (key === 'launchAtStartup') { + result = applyLaunchAtStartupPreference(value === true) + } else { + result = configService?.set(key as any, value) + } if (key === 'updateChannel') { applyAutoUpdateChannel('settings') } @@ -1243,6 +1376,12 @@ function registerIpcHandlers() { }) ipcMain.handle('config:clear', async () => { + if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) { + const result = setSystemLaunchAtStartup(false) + if (!result.success && result.error) { + console.error('[WeFlow] 清空配置时关闭开机自启动失败:', result.error) + } + } configService?.clear() messagePushService.handleConfigCleared() return true @@ -1285,6 +1424,14 @@ function registerIpcHandlers() { return app.getVersion() }) + ipcMain.handle('app:getLaunchAtStartupStatus', async () => { + return getLaunchAtStartupStatus() + }) + + ipcMain.handle('app:setLaunchAtStartup', async (_, enabled: boolean) => { + return applyLaunchAtStartupPreference(enabled === true) + }) + ipcMain.handle('app:checkWayland', async () => { if (process.platform !== 'linux') return false; @@ -1354,6 +1501,8 @@ function registerIpcHandlers() { if (!AUTO_UPDATE_ENABLED) { return { hasUpdate: false } } + // 每次主动检查前重新应用一次通道配置,确保使用最新选择的更新通道。 + applyAutoUpdateChannel('settings') try { const result = await autoUpdater.checkForUpdates() if (result && result.updateInfo) { @@ -2863,6 +3012,7 @@ app.whenReady().then(async () => { updateSplashProgress(5, '正在加载配置...') configService = new ConfigService() applyAutoUpdateChannel('startup') + syncLaunchAtStartupPreference() // 将用户主题配置推送给 Splash 窗口 if (splashWindow && !splashWindow.isDestroyed()) { diff --git a/electron/preload.ts b/electron/preload.ts index 38e722f..db103ef 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -53,6 +53,8 @@ contextBridge.exposeInMainWorld('electronAPI', { app: { getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'), getVersion: () => ipcRenderer.invoke('app:getVersion'), + getLaunchAtStartupStatus: () => ipcRenderer.invoke('app:getLaunchAtStartupStatus'), + setLaunchAtStartup: (enabled: boolean) => ipcRenderer.invoke('app:setLaunchAtStartup', enabled), checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'), downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'), ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version), diff --git a/electron/services/bizService.ts b/electron/services/bizService.ts index 110b22c..f7c0eed 100644 --- a/electron/services/bizService.ts +++ b/electron/services/bizService.ts @@ -87,11 +87,14 @@ export class BizService { async listAccounts(account?: string): Promise { try { + // 1. 获取公众号联系人列表 const contactsResult = await chatService.getContacts({ lite: true }) if (!contactsResult.success || !contactsResult.contacts) return [] const officialContacts = contactsResult.contacts.filter(c => c.type === 'official') const usernames = officialContacts.map(c => c.username) + + // 获取头像和昵称等补充信息 const enrichment = await chatService.enrichSessionsContactInfo(usernames) const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {} @@ -100,31 +103,45 @@ export class BizService { const accountWxid = account || myWxid if (!root || !accountWxid) return [] - const dbDir = join(root, accountWxid, 'db_storage', 'message') const bizLatestTime: Record = {} - if (existsSync(dbDir)) { - const bizDbFiles = readdirSync(dbDir).filter(f => f.startsWith('biz_message') && f.endsWith('.db')) - for (const file of bizDbFiles) { - const dbPath = join(dbDir, file) - const name2idRes = await wcdbService.execQuery('message', dbPath, 'SELECT username FROM Name2Id') - if (name2idRes.success && name2idRes.rows) { - for (const row of name2idRes.rows) { - const uname = row.username || row.user_name - if (uname) { - const md5 = createHash('md5').update(uname).digest('hex').toLowerCase() - const tName = `Msg_${md5}` - const timeRes = await wcdbService.execQuery('message', dbPath, `SELECT MAX(create_time) as max_time FROM ${tName}`) - if (timeRes.success && timeRes.rows && timeRes.rows[0]?.max_time) { - const t = parseInt(timeRes.rows[0].max_time) - if (!isNaN(t)) bizLatestTime[uname] = Math.max(bizLatestTime[uname] || 0, t) - } - } + try { + const sessionsRes = await wcdbService.getSessions() + if (sessionsRes.success && sessionsRes.sessions) { + for (const session of sessionsRes.sessions) { + const uname = session.username || session.strUsrName || session.userName || session.id + // 适配日志中发现的字段,注意转为整型数字 + const timeStr = session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0' + const time = parseInt(timeStr.toString(), 10) + + if (usernames.includes(uname) && time > 0) { + bizLatestTime[uname] = time } } } + } catch (e) { + console.error('获取 Sessions 失败:', e) } + // 3. 格式化时间显示 + const formatBizTime = (ts: number) => { + if (!ts) return '' + const date = new Date(ts * 1000) + const now = new Date() + const isToday = date.toDateString() === now.toDateString() + if (isToday) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }) + + const yesterday = new Date(now) + yesterday.setDate(now.getDate() - 1) + if (date.toDateString() === yesterday.toDateString()) return '昨天' + + const isThisYear = date.getFullYear() === now.getFullYear() + if (isThisYear) return `${date.getMonth() + 1}/${date.getDate()}` + + return `${date.getFullYear().toString().slice(-2)}/${date.getMonth() + 1}/${date.getDate()}` + } + + // 4. 组装数据 const result: BizAccount[] = officialContacts.map(contact => { const uname = contact.username const info = contactInfoMap[uname] @@ -135,11 +152,12 @@ export class BizService { avatar: info?.avatarUrl || '', type: 0, last_time: lastTime, - formatted_last_time: lastTime ? new Date(lastTime * 1000).toISOString().split('T')[0] : '' + formatted_last_time: formatBizTime(lastTime) } }) - const contactDbPath = join(root, accountWxid, 'contact.db') + // 5. 补充公众号类型 (订阅号/服务号) + const contactDbPath = join(root, accountWxid, 'db_storage', 'contact', 'contact.db') if (existsSync(contactDbPath)) { const bizInfoRes = await wcdbService.execQuery('contact', contactDbPath, 'SELECT username, type FROM biz_info') if (bizInfoRes.success && bizInfoRes.rows) { @@ -149,14 +167,18 @@ export class BizService { } } + // 6. 排序输出 return result - .filter(acc => !acc.name.includes('朋友圈广告')) - .sort((a, b) => { - if (a.username === 'gh_3dfda90e39d6') return -1 - if (b.username === 'gh_3dfda90e39d6') return 1 - return b.last_time - a.last_time - }) - } catch (e) { return [] } + .filter(acc => !acc.name.includes('广告')) + .sort((a, b) => { + if (a.username === 'gh_3dfda90e39d6') return -1 // 微信支付置顶 + if (b.username === 'gh_3dfda90e39d6') return 1 + return b.last_time - a.last_time // 按最新时间降序排列 + }) + } catch (e) { + console.error('获取账号列表发生错误:', e) + return [] + } } async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise { diff --git a/electron/services/config.ts b/electron/services/config.ts index 3269b0b..3039412 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -27,6 +27,7 @@ interface ConfigSchema { themeId: string language: string logEnabled: boolean + launchAtStartup?: boolean llmModelPath: string whisperModelName: string whisperModelDir: string @@ -128,7 +129,7 @@ export class ConfigService { httpApiToken: '', httpApiEnabled: false, httpApiPort: 5031, - httpApiHost: '127.0.0.1', + httpApiHost: '0.0.0.0', messagePushEnabled: false, windowCloseBehavior: 'ask', quoteLayout: 'quote-top', diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 34815cb..9e71159 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -3310,15 +3310,29 @@ class ExportService { const subType = this.extractAppMessageType(normalized) if (subType && subType !== '5' && subType !== '49') return null - const url = this.normalizeHtmlLinkUrl(this.extractXmlValue(normalized, 'url')) + const url = [ + this.extractXmlValue(normalized, 'url'), + this.extractXmlValue(normalized, 'shareurlopen'), + this.extractXmlValue(normalized, 'shareurloriginal'), + this.extractXmlValue(normalized, 'shareurl'), + this.extractXmlValue(normalized, 'shorturl'), + this.extractXmlValue(normalized, 'dataurl'), + this.extractXmlValue(normalized, 'lowurl'), + this.extractXmlValue(normalized, 'streamvideoweburl'), + this.extractXmlValue(normalized, 'weburl') + ] + .map(candidate => this.normalizeHtmlLinkUrl(candidate)) + .find(Boolean) || '' if (!url) return null - const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'des') || url + const title = this.stripSenderPrefix( + this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'des') || url + ) || url return { title, url } } private normalizeHtmlLinkUrl(rawUrl: string): string { - const value = (rawUrl || '').trim() + const value = (rawUrl || '').trim().replace(/&/gi, '&') if (!value) return '' const parseHttpUrl = (candidate: string): string => { @@ -3349,6 +3363,46 @@ class ExportService { return '' } + private getLinkCardDisplayTitle(linkCard: { title: string; url: string }): string { + const normalizedTitle = this.stripSenderPrefix(String(linkCard.title || '').trim()) + return normalizedTitle || linkCard.url || '链接' + } + + private formatLinkCardExportText( + content: string, + localType: number, + style: 'markdown' | 'append-url' + ): string | null { + const linkCard = this.extractHtmlLinkCard(content, localType) + if (!linkCard?.url) return null + + const title = this.getLinkCardDisplayTitle(linkCard) + if (style === 'markdown') { + return `[${title}](${linkCard.url})` + } + + const prefix = title && title !== linkCard.url ? `[链接] ${title}` : '[链接]' + return `${prefix}\n${linkCard.url}` + } + + private applyExcelLinkCardCell(cell: ExcelJS.Cell, content: string, localType: number): boolean { + const linkCard = this.extractHtmlLinkCard(content, localType) + if (!linkCard?.url) return false + + const title = this.getLinkCardDisplayTitle(linkCard) + cell.value = { + text: title, + hyperlink: linkCard.url, + tooltip: linkCard.url + } as any + cell.font = { + ...(cell.font || {}), + color: { argb: 'FF0563C1' }, + underline: true + } + return true + } + /** * 导出媒体文件到指定目录 */ @@ -5066,6 +5120,11 @@ class ExportService { } } + const markdownLinkContent = this.formatLinkCardExportText(msg.content, msg.localType, 'markdown') + if (markdownLinkContent) { + content = markdownLinkContent + } + const message: ChatLabMessage = { sender: msg.senderUsername, accountName: senderProfile.displayName || memberInfo.accountName, @@ -5558,6 +5617,13 @@ class ExportService { content = this.buildQuotedReplyText(quotedReplyDisplay) } + const appendedLinkContent = quotedReplyDisplay + ? null + : this.formatLinkCardExportText(msg.content, msg.localType, 'append-url') + if (appendedLinkContent) { + content = appendedLinkContent + } + // 获取发送者信息用于名称显示 const senderWxid = msg.senderUsername const contact = senderWxid @@ -6484,16 +6550,14 @@ class ExportService { enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) } - // 调试日志 - if (msg.localType === 3 || msg.localType === 47) { - } + const contentCellIndex = useCompactColumns ? 5 : 9 + const contentCell = worksheet.getCell(currentRow, contentCellIndex) worksheet.getCell(currentRow, 1).value = i + 1 worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime) if (useCompactColumns) { worksheet.getCell(currentRow, 3).value = senderRole worksheet.getCell(currentRow, 4).value = this.getMessageTypeName(msg.localType) - worksheet.getCell(currentRow, 5).value = enrichedContentValue } else { worksheet.getCell(currentRow, 3).value = senderNickname worksheet.getCell(currentRow, 4).value = senderWxid @@ -6501,7 +6565,10 @@ class ExportService { worksheet.getCell(currentRow, 6).value = senderGroupNickname worksheet.getCell(currentRow, 7).value = senderRole worksheet.getCell(currentRow, 8).value = this.getMessageTypeName(msg.localType) - worksheet.getCell(currentRow, 9).value = enrichedContentValue + } + contentCell.value = enrichedContentValue + if (!quotedReplyDisplay) { + this.applyExcelLinkCardCell(contentCell, msg.content, msg.localType) } currentRow++ @@ -6747,7 +6814,7 @@ class ExportService { enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) } - appendRow(useCompactColumns + const row = worksheet.addRow(useCompactColumns ? [ i + 1, this.formatTimestamp(msg.createTime), @@ -6766,6 +6833,10 @@ class ExportService { this.getMessageTypeName(msg.localType), enrichedContentValue ]) + if (!quotedReplyDisplay) { + this.applyExcelLinkCardCell(row.getCell(useCompactColumns ? 5 : 9), msg.content, msg.localType) + } + row.commit() if ((i + 1) % 200 === 0) { onProgress?.({ @@ -7119,6 +7190,13 @@ class ExportService { enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) } + const appendedLinkContent = quotedReplyDisplay + ? null + : this.formatLinkCardExportText(msg.content, msg.localType, 'append-url') + if (appendedLinkContent) { + enrichedContentValue = appendedLinkContent + } + let senderRole: string let senderWxid: string let senderNickname: string diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index 02fa030..29d8952 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -12,6 +12,7 @@ import { ConfigService } from './config' import { videoService } from './videoService' import { imageDecryptService } from './imageDecryptService' import { groupAnalyticsService } from './groupAnalyticsService' +import { snsService } from './snsService' // ChatLab 格式定义 interface ChatLabHeader { @@ -308,7 +309,7 @@ class HttpService { */ private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { res.setHeader('Access-Control-Allow-Origin', '*') - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS') res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') if (req.method === 'OPTIONS') { @@ -348,6 +349,33 @@ class HttpService { await this.handleContacts(url, res) } else if (pathname === '/api/v1/group-members') { await this.handleGroupMembers(url, res) + } else if (pathname === '/api/v1/sns/timeline') { + if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') + await this.handleSnsTimeline(url, res) + } else if (pathname === '/api/v1/sns/usernames') { + if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') + await this.handleSnsUsernames(res) + } else if (pathname === '/api/v1/sns/export/stats') { + if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') + await this.handleSnsExportStats(url, res) + } else if (pathname === '/api/v1/sns/media/proxy') { + if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') + await this.handleSnsMediaProxy(url, res) + } else if (pathname === '/api/v1/sns/export') { + if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST') + await this.handleSnsExport(url, res) + } else if (pathname === '/api/v1/sns/block-delete/status') { + if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') + await this.handleSnsBlockDeleteStatus(res) + } else if (pathname === '/api/v1/sns/block-delete/install') { + if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST') + await this.handleSnsBlockDeleteInstall(res) + } else if (pathname === '/api/v1/sns/block-delete/uninstall') { + if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST') + await this.handleSnsBlockDeleteUninstall(res) + } else if (pathname.startsWith('/api/v1/sns/post/')) { + if (req.method !== 'DELETE') return this.sendMethodNotAllowed(res, 'DELETE') + await this.handleSnsDeletePost(pathname, res) } else if (pathname.startsWith('/api/v1/media/')) { this.handleMediaRequest(pathname, res) } else { @@ -559,6 +587,15 @@ class HttpService { return defaultValue } + private parseStringListParam(value: string | null): string[] | undefined { + if (!value) return undefined + const values = value + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + return values.length > 0 ? Array.from(new Set(values)) : undefined + } + private parseMediaOptions(url: URL): ApiMediaOptions { const mediaEnabled = this.parseBooleanParam(url, ['media', 'meiti'], false) if (!mediaEnabled) { @@ -790,6 +827,313 @@ class HttpService { } } + private async handleSnsTimeline(url: URL, res: http.ServerResponse): Promise { + const limit = this.parseIntParam(url.searchParams.get('limit'), 20, 1, 200) + const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER) + const usernames = this.parseStringListParam(url.searchParams.get('usernames')) + const keyword = (url.searchParams.get('keyword') || '').trim() || undefined + const resolveMedia = this.parseBooleanParam(url, ['media', 'resolveMedia', 'meiti'], true) + const inlineMedia = resolveMedia && this.parseBooleanParam(url, ['inline'], false) + const replaceMedia = resolveMedia && this.parseBooleanParam(url, ['replace'], true) + const startTimeRaw = this.parseTimeParam(url.searchParams.get('start')) + const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true) + const startTime = startTimeRaw > 0 ? startTimeRaw : undefined + const endTime = endTimeRaw > 0 ? endTimeRaw : undefined + + const result = await snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime) + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to get sns timeline') + return + } + + let timeline = result.timeline || [] + if (resolveMedia && timeline.length > 0) { + timeline = await this.enrichSnsTimelineMedia(timeline, inlineMedia, replaceMedia) + } + + this.sendJson(res, { + success: true, + count: timeline.length, + timeline + }) + } + + private async handleSnsUsernames(res: http.ServerResponse): Promise { + const result = await snsService.getSnsUsernames() + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to get sns usernames') + return + } + this.sendJson(res, { + success: true, + usernames: result.usernames || [] + }) + } + + private async handleSnsExportStats(url: URL, res: http.ServerResponse): Promise { + const fast = this.parseBooleanParam(url, ['fast'], false) + const result = fast + ? await snsService.getExportStatsFast() + : await snsService.getExportStats() + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to get sns export stats') + return + } + this.sendJson(res, result) + } + + private async handleSnsMediaProxy(url: URL, res: http.ServerResponse): Promise { + const mediaUrl = (url.searchParams.get('url') || '').trim() + if (!mediaUrl) { + this.sendError(res, 400, 'Missing required parameter: url') + return + } + + const key = this.toSnsMediaKey(url.searchParams.get('key')) + const result = await snsService.downloadImage(mediaUrl, key) + if (!result.success) { + this.sendError(res, 502, result.error || 'Failed to proxy sns media') + return + } + + if (result.data) { + res.setHeader('Content-Type', result.contentType || 'application/octet-stream') + res.setHeader('Content-Length', result.data.length) + res.writeHead(200) + res.end(result.data) + return + } + + if (result.cachePath && fs.existsSync(result.cachePath)) { + try { + const stat = fs.statSync(result.cachePath) + res.setHeader('Content-Type', result.contentType || 'application/octet-stream') + res.setHeader('Content-Length', stat.size) + res.writeHead(200) + + const stream = fs.createReadStream(result.cachePath) + stream.on('error', () => { + if (!res.headersSent) { + this.sendError(res, 500, 'Failed to read proxied sns media') + } else { + try { res.destroy() } catch {} + } + }) + stream.pipe(res) + return + } catch (error) { + console.error('[HttpService] Failed to stream sns media cache:', error) + } + } + + this.sendError(res, 502, result.error || 'Failed to proxy sns media') + } + + private async handleSnsExport(url: URL, res: http.ServerResponse): Promise { + const outputDir = String(url.searchParams.get('outputDir') || '').trim() + if (!outputDir) { + this.sendError(res, 400, 'Missing required field: outputDir') + return + } + + const rawFormat = String(url.searchParams.get('format') || 'json').trim().toLowerCase() + const format = rawFormat === 'arkme-json' ? 'arkmejson' : rawFormat + if (!['json', 'html', 'arkmejson'].includes(format)) { + this.sendError(res, 400, 'Invalid format, supported: json/html/arkmejson') + return + } + + const usernames = this.parseStringListParam(url.searchParams.get('usernames')) + const keyword = String(url.searchParams.get('keyword') || '').trim() || undefined + const startTimeRaw = this.parseTimeParam(url.searchParams.get('start')) + const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true) + + const options: { + outputDir: string + format: 'json' | 'html' | 'arkmejson' + usernames?: string[] + keyword?: string + exportMedia?: boolean + exportImages?: boolean + exportLivePhotos?: boolean + exportVideos?: boolean + startTime?: number + endTime?: number + } = { + outputDir, + format: format as 'json' | 'html' | 'arkmejson', + usernames, + keyword, + exportMedia: this.parseBooleanParam(url, ['exportMedia'], false) + } + + if (url.searchParams.has('exportImages')) options.exportImages = this.parseBooleanParam(url, ['exportImages'], false) + if (url.searchParams.has('exportLivePhotos')) options.exportLivePhotos = this.parseBooleanParam(url, ['exportLivePhotos'], false) + if (url.searchParams.has('exportVideos')) options.exportVideos = this.parseBooleanParam(url, ['exportVideos'], false) + if (startTimeRaw > 0) options.startTime = startTimeRaw + if (endTimeRaw > 0) options.endTime = endTimeRaw + + const result = await snsService.exportTimeline(options) + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to export sns timeline') + return + } + this.sendJson(res, result) + } + + private async handleSnsBlockDeleteStatus(res: http.ServerResponse): Promise { + const result = await snsService.checkSnsBlockDeleteTrigger() + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to check sns block-delete status') + return + } + this.sendJson(res, result) + } + + private async handleSnsBlockDeleteInstall(res: http.ServerResponse): Promise { + const result = await snsService.installSnsBlockDeleteTrigger() + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to install sns block-delete trigger') + return + } + this.sendJson(res, result) + } + + private async handleSnsBlockDeleteUninstall(res: http.ServerResponse): Promise { + const result = await snsService.uninstallSnsBlockDeleteTrigger() + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to uninstall sns block-delete trigger') + return + } + this.sendJson(res, result) + } + + private async handleSnsDeletePost(pathname: string, res: http.ServerResponse): Promise { + const postId = decodeURIComponent(pathname.replace('/api/v1/sns/post/', '')).trim() + if (!postId) { + this.sendError(res, 400, 'Missing required path parameter: postId') + return + } + + const result = await snsService.deleteSnsPost(postId) + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to delete sns post') + return + } + this.sendJson(res, result) + } + + private toSnsMediaKey(value: unknown): string | number | undefined { + if (value == null) return undefined + if (typeof value === 'number' && Number.isFinite(value)) return value + const text = String(value).trim() + if (!text) return undefined + if (/^-?\d+$/.test(text)) return Number(text) + return text + } + + private buildSnsMediaProxyUrl(rawUrl: string, key?: string | number): string | undefined { + const target = String(rawUrl || '').trim() + if (!target) return undefined + const params = new URLSearchParams({ url: target }) + if (key !== undefined) params.set('key', String(key)) + return `http://${this.host}:${this.port}/api/v1/sns/media/proxy?${params.toString()}` + } + + private async resolveSnsMediaUrl( + rawUrl: string, + key: string | number | undefined, + inline: boolean + ): Promise<{ resolvedUrl?: string; proxyUrl?: string }> { + const proxyUrl = this.buildSnsMediaProxyUrl(rawUrl, key) + if (!proxyUrl) return {} + if (!inline) return { resolvedUrl: proxyUrl, proxyUrl } + + try { + const resolved = await snsService.proxyImage(rawUrl, key) + if (resolved.success && resolved.dataUrl) { + return { resolvedUrl: resolved.dataUrl, proxyUrl } + } + } catch (error) { + console.warn('[HttpService] resolveSnsMediaUrl inline failed:', error) + } + + return { resolvedUrl: proxyUrl, proxyUrl } + } + + private async enrichSnsTimelineMedia(posts: any[], inline: boolean, replace: boolean): Promise { + return Promise.all( + (posts || []).map(async (post) => { + const mediaList = Array.isArray(post?.media) ? post.media : [] + if (mediaList.length === 0) return post + + const nextMedia = await Promise.all( + mediaList.map(async (media: any) => { + const rawUrl = typeof media?.url === 'string' ? media.url : '' + const rawThumb = typeof media?.thumb === 'string' ? media.thumb : '' + const mediaKey = this.toSnsMediaKey(media?.key) + + const [urlResolved, thumbResolved] = await Promise.all([ + this.resolveSnsMediaUrl(rawUrl, mediaKey, inline), + this.resolveSnsMediaUrl(rawThumb, mediaKey, inline) + ]) + + const nextItem: any = { + ...media, + rawUrl, + rawThumb, + resolvedUrl: urlResolved.resolvedUrl, + resolvedThumbUrl: thumbResolved.resolvedUrl, + proxyUrl: urlResolved.proxyUrl, + proxyThumbUrl: thumbResolved.proxyUrl + } + + if (replace) { + nextItem.url = urlResolved.resolvedUrl || rawUrl + nextItem.thumb = thumbResolved.resolvedUrl || rawThumb + } + + if (media?.livePhoto && typeof media.livePhoto === 'object') { + const livePhoto = media.livePhoto + const rawLiveUrl = typeof livePhoto.url === 'string' ? livePhoto.url : '' + const rawLiveThumb = typeof livePhoto.thumb === 'string' ? livePhoto.thumb : '' + const liveKey = this.toSnsMediaKey(livePhoto.key ?? mediaKey) + + const [liveUrlResolved, liveThumbResolved] = await Promise.all([ + this.resolveSnsMediaUrl(rawLiveUrl, liveKey, inline), + this.resolveSnsMediaUrl(rawLiveThumb, liveKey, inline) + ]) + + const nextLive: any = { + ...livePhoto, + rawUrl: rawLiveUrl, + rawThumb: rawLiveThumb, + resolvedUrl: liveUrlResolved.resolvedUrl, + resolvedThumbUrl: liveThumbResolved.resolvedUrl, + proxyUrl: liveUrlResolved.proxyUrl, + proxyThumbUrl: liveThumbResolved.proxyUrl + } + + if (replace) { + nextLive.url = liveUrlResolved.resolvedUrl || rawLiveUrl + nextLive.thumb = liveThumbResolved.resolvedUrl || rawLiveThumb + } + + nextItem.livePhoto = nextLive + } + + return nextItem + }) + ) + + return { + ...post, + media: nextMedia + } + }) + ) + } + private getApiMediaExportPath(): string { return path.join(this.configService.getCacheBasePath(), 'api-media') } @@ -1451,6 +1795,11 @@ class HttpService { res.end(JSON.stringify(data, null, 2)) } + private sendMethodNotAllowed(res: http.ServerResponse, allow: string): void { + res.setHeader('Allow', allow) + this.sendError(res, 405, `Method Not Allowed. Allowed: ${allow}`) + } + /** * 发送错误响应 */ diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index e0d31f9..d7fe76e 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -537,6 +537,32 @@ class SnsService { return raw.trim() } + private async collectSnsUsernamesFromTimeline(maxRounds: number = 2000): Promise { + const pageSize = 500 + const uniqueUsers = new Set() + let offset = 0 + + for (let round = 0; round < maxRounds; round++) { + const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0) + if (!result.success || !Array.isArray(result.timeline)) { + throw new Error(result.error || '获取朋友圈发布者失败') + } + + const rows = result.timeline + if (rows.length === 0) break + + for (const row of rows) { + const username = this.pickTimelineUsername(row) + if (username) uniqueUsers.add(username) + } + + if (rows.length < pageSize) break + offset += rows.length + } + + return Array.from(uniqueUsers) + } + private async getExportStatsFromTimeline(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> { const pageSize = 500 const uniqueUsers = new Set() @@ -794,7 +820,22 @@ class SnsService { if (!result.success) { return { success: false, error: result.error || '获取朋友圈联系人失败' } } - return { success: true, usernames: result.usernames || [] } + const directUsernames = Array.isArray(result.usernames) ? result.usernames : [] + if (directUsernames.length > 0) { + return { success: true, usernames: directUsernames } + } + + // 回退:通过 timeline 分页拉取收集用户名,兼容底层接口暂时返回空数组的场景。 + try { + const timelineUsers = await this.collectSnsUsernamesFromTimeline() + if (timelineUsers.length > 0) { + return { success: true, usernames: timelineUsers } + } + } catch { + // 忽略回退错误,保持与原行为一致返回空数组 + } + + return { success: true, usernames: directUsernames } } private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> { @@ -1199,7 +1240,7 @@ class SnsService { return { success: false, error: result.error } } - async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; error?: string }> { + async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> { return this.fetchAndDecryptImage(url, key) } diff --git a/src/pages/AnnualReportWindow.tsx b/src/pages/AnnualReportWindow.tsx index ab5fd0d..5b2d510 100644 --- a/src/pages/AnnualReportWindow.tsx +++ b/src/pages/AnnualReportWindow.tsx @@ -8,44 +8,9 @@ import { registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor' +import { drawPatternBackground } from '../utils/reportExport' import './AnnualReportWindow.scss' -// SVG 背景图案 (用于导出) -const PATTERN_LIGHT_SVG = `` - -const PATTERN_DARK_SVG = `` - -// 绘制 SVG 图案背景到 canvas -const drawPatternBackground = async (ctx: CanvasRenderingContext2D, width: number, height: number, bgColor: string, isDark: boolean) => { - // 先填充背景色 - ctx.fillStyle = bgColor - ctx.fillRect(0, 0, width, height) - - // 加载 SVG 图案 - const svgString = isDark ? PATTERN_DARK_SVG : PATTERN_LIGHT_SVG - const blob = new Blob([svgString], { type: 'image/svg+xml' }) - const url = URL.createObjectURL(blob) - - return new Promise((resolve) => { - const img = new window.Image() - img.onload = () => { - // 平铺绘制图案 - const pattern = ctx.createPattern(img, 'repeat') - if (pattern) { - ctx.fillStyle = pattern - ctx.fillRect(0, 0, width, height) - } - URL.revokeObjectURL(url) - resolve() - } - img.onerror = () => { - URL.revokeObjectURL(url) - resolve() - } - img.src = url - }) -} - interface TopContact { username: string displayName: string diff --git a/src/pages/BizPage.scss b/src/pages/BizPage.scss index 26df02b..a2faddb 100644 --- a/src/pages/BizPage.scss +++ b/src/pages/BizPage.scss @@ -125,7 +125,22 @@ margin: 0 auto; display: flex; flex-direction: column; - gap: 24px; + gap: 16px; // 减小间距,因为有了 time-divider + } + } + + .time-divider { + text-align: center; + margin: 16px 0 8px; + + span { + display: inline-block; + padding: 2px 8px; + background-color: var(--bg-primary); + color: var(--text-tertiary); + font-size: 11px; + border-radius: 4px; + opacity: 0.8; } } diff --git a/src/pages/BizPage.tsx b/src/pages/BizPage.tsx index 457d1b2..6831d54 100644 --- a/src/pages/BizPage.tsx +++ b/src/pages/BizPage.tsx @@ -7,7 +7,7 @@ export interface BizAccount { username: string; name: string; avatar: string; - type: number; + type: string; last_time: number; formatted_last_time: string; } @@ -55,15 +55,24 @@ export const BizAccountList: React.FC<{ fetch().then(_r => { } ); }, [myWxid]); + const filtered = useMemo(() => { - if (!searchKeyword) return accounts; - const q = searchKeyword.toLowerCase(); - return accounts.filter(a => - (a.name && a.name.toLowerCase().includes(q)) || - (a.username && a.username.toLowerCase().includes(q)) - ); + let result = accounts; + if (searchKeyword) { + const q = searchKeyword.toLowerCase(); + result = accounts.filter(a => + (a.name && a.name.toLowerCase().includes(q)) || + (a.username && a.username.toLowerCase().includes(q)) + ); + } + return result.sort((a, b) => { + if (a.username === 'gh_3dfda90e39d6') return -1; // 微信支付置顶 + if (b.username === 'gh_3dfda90e39d6') return 1; + return b.last_time - a.last_time; + }); }, [accounts, searchKeyword]); + if (loading) return
加载中...
; return ( @@ -84,18 +93,18 @@ export const BizAccountList: React.FC<{ {item.name || item.username} {item.formatted_last_time} - {item.username === 'gh_3dfda90e39d6' && ( -
服务号
- )} + {/*{item.username === 'gh_3dfda90e39d6' && (*/} + {/*
微信支付
*/} + {/*)}*/} - {/* 我看了下没有接口获取相关type,如果exec没法用的话确实无能为力,后面再适配吧 */} - {/*
*/} - {/* {item.type === 1 ? '服务号' : item.type === 0 ? '订阅号' : item.type === 2 ? '企业号' : '未知'}*/} - {/*
*/} +
+ {item.type === '0' ? '公众号' : item.type === '1' ? '服务号' : item.type === '2' ? '企业号' : item.type === '3' ? '企业附属' : '未知'} +
@@ -104,7 +113,6 @@ export const BizAccountList: React.FC<{ ); }; -// 2. 公众号消息区域组件 export const BizMessageArea: React.FC<{ account: BizAccount | null; }> = ({ account }) => { @@ -115,6 +123,8 @@ export const BizMessageArea: React.FC<{ const [hasMore, setHasMore] = useState(true); const limit = 20; const messageListRef = useRef(null); + const lastScrollHeightRef = useRef(0); + const isInitialLoadRef = useRef(true); const [myWxid, setMyWxid] = useState(''); @@ -143,6 +153,7 @@ export const BizMessageArea: React.FC<{ setMessages([]); setOffset(0); setHasMore(true); + isInitialLoadRef.current = true; loadMessages(account.username, 0); } }, [account, myWxid]); @@ -151,6 +162,10 @@ export const BizMessageArea: React.FC<{ if (loading || !myWxid) return; setLoading(true); + if (messageListRef.current) { + lastScrollHeightRef.current = messageListRef.current.scrollHeight; + } + try { let res; if (username === 'gh_3dfda90e39d6') { @@ -158,9 +173,15 @@ export const BizMessageArea: React.FC<{ } else { res = await window.electronAPI.biz.listMessages(username, myWxid, limit, currentOffset); } + if (res) { if (res.length < limit) setHasMore(false); - setMessages(prev => currentOffset === 0 ? res : [...prev, ...res]); + + setMessages(prev => { + const combined = currentOffset === 0 ? res : [...res, ...prev]; + const uniqueMessages = Array.from(new Map(combined.map(item => [item.local_id || item.create_time, item])).values()); + return uniqueMessages.sort((a, b) => a.create_time - b.create_time); + }); setOffset(currentOffset + limit); } } catch (err) { @@ -170,9 +191,26 @@ export const BizMessageArea: React.FC<{ } }; + useEffect(() => { + if (!messageListRef.current) return; + + if (isInitialLoadRef.current && messages.length > 0) { + messageListRef.current.scrollTop = messageListRef.current.scrollHeight; + isInitialLoadRef.current = false; + } else if (messages.length > 0 && !isInitialLoadRef.current && !loading) { + + const newScrollHeight = messageListRef.current.scrollHeight; + const heightDiff = newScrollHeight - lastScrollHeightRef.current; + if (heightDiff > 0 && messageListRef.current.scrollTop < 100) { + messageListRef.current.scrollTop += heightDiff; + } + } + }, [messages, loading]); + const handleScroll = (e: React.UIEvent) => { const target = e.currentTarget; - if (target.scrollHeight - Math.abs(target.scrollTop) - target.clientHeight < 50) { + // 向上滚动到顶部附近触发加载更多(更旧的消息) + if (target.scrollTop < 50) { if (!loading && hasMore && account) { loadMessages(account.username, offset); } @@ -188,6 +226,30 @@ export const BizMessageArea: React.FC<{ ); } + const formatMessageTime = (timestamp: number) => { + if (!timestamp) return ''; + const date = new Date(timestamp * 1000); + const now = new Date(); + + const isToday = date.toDateString() === now.toDateString(); + if (isToday) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }); + } + + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + if (date.toDateString() === yesterday.toDateString()) { + return `昨天 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`; + } + + const isThisYear = date.getFullYear() === now.getFullYear(); + if (isThisYear) { + return `${date.getMonth() + 1}月${date.getDate()}日 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`; + } + + return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`; + }; + const defaultImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iMTgwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2Y1ZjVmNSIvPjwvc3ZnPg=='; return ( @@ -197,6 +259,9 @@ export const BizMessageArea: React.FC<{
+ {hasMore && messages.length > 0 && ( +
{loading ? '加载中...' : '向上滚动加载更多历史消息'}
+ )} {!loading && messages.length === 0 && (
@@ -206,40 +271,50 @@ export const BizMessageArea: React.FC<{

该公众号在当前数据库中没有可显示的聊天历史

)} - {messages.map((msg) => ( -
- {account.username === 'gh_3dfda90e39d6' ? ( -
-
- {msg.merchant_icon ? :
¥
} - {msg.merchant_name || '微信支付'} -
-
{msg.title}
-
{msg.description}
-
{msg.formatted_time}
-
- ) : ( -
-
window.electronAPI.shell.openExternal(msg.url)} className="main-article"> - -

{msg.title}

-
- {msg.des &&
{msg.des}
} - {msg.content_list && msg.content_list.length > 1 && ( -
- {msg.content_list.slice(1).map((item: any, idx: number) => ( -
window.electronAPI.shell.openExternal(item.url)} className="sub-item"> - {item.title} - {item.cover && } -
- ))} + {messages.map((msg, index) => { + const showTime = true; + + return ( +
+ {showTime && ( +
+ {formatMessageTime(msg.create_time)} +
+ )} + + {account.username === 'gh_3dfda90e39d6' ? ( +
+
+ {msg.merchant_icon ? :
¥
} + {msg.merchant_name || '微信支付'}
- )} -
- )} -
- ))} - {loading &&
加载中...
} +
{msg.title}
+
{msg.description}
+ {/*
{msg.formatted_time}
*/} +
+ ) : ( +
+
window.electronAPI.shell.openExternal(msg.url)} className="main-article"> + +

{msg.title}

+
+ {msg.des &&
{msg.des}
} + {msg.content_list && msg.content_list.length > 1 && ( +
+ {msg.content_list.slice(1).map((item: any, idx: number) => ( +
window.electronAPI.shell.openExternal(item.url)} className="sub-item"> + {item.title} + {item.cover && } +
+ ))} +
+ )} +
+ )} +
+ ); + })} + {loading && offset === 0 &&
加载中...
}
diff --git a/src/pages/DualReportWindow.scss b/src/pages/DualReportWindow.scss index 44bee66..7eb5ff9 100644 --- a/src/pages/DualReportWindow.scss +++ b/src/pages/DualReportWindow.scss @@ -238,7 +238,7 @@ } .scene-message.sent .scene-avatar { - border-color: color-mix(in srgb, var(--primary) 30%, var(--bg-tertiary, rgba(0, 0, 0, 0.08))); + border-color: rgba(var(--ar-primary-rgb), 0.3); } .dual-stat-grid { @@ -981,4 +981,4 @@ transform: translateY(0); } } -} \ No newline at end of file +} diff --git a/src/pages/DualReportWindow.tsx b/src/pages/DualReportWindow.tsx index 585fb1e..e9f9627 100644 --- a/src/pages/DualReportWindow.tsx +++ b/src/pages/DualReportWindow.tsx @@ -1,6 +1,10 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' +import { Check, Download, Image, SlidersHorizontal, X } from 'lucide-react' +import html2canvas from 'html2canvas' import ReportHeatmap from '../components/ReportHeatmap' import ReportWordCloud from '../components/ReportWordCloud' +import { useThemeStore } from '../stores/themeStore' +import { drawPatternBackground } from '../utils/reportExport' import './AnnualReportWindow.scss' import './DualReportWindow.scss' @@ -66,6 +70,12 @@ interface DualReportData { streak?: { days: number; startDate: string; endDate: string } } +interface SectionInfo { + id: string + name: string + ref: React.RefObject +} + function DualReportWindow() { const [reportData, setReportData] = useState(null) const [isLoading, setIsLoading] = useState(true) @@ -75,6 +85,29 @@ function DualReportWindow() { const [myEmojiUrl, setMyEmojiUrl] = useState(null) const [friendEmojiUrl, setFriendEmojiUrl] = useState(null) const [activeWordCloudTab, setActiveWordCloudTab] = useState<'shared' | 'my' | 'friend'>('shared') + 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 [exportMode, setExportMode] = useState<'separate' | 'long'>('separate') + + const { themeMode } = useThemeStore() + + const sectionRefs = { + cover: useRef(null), + firstChat: useRef(null), + yearFirstChat: useRef(null), + heatmap: useRef(null), + initiative: useRef(null), + response: useRef(null), + streak: useRef(null), + wordCloud: useRef(null), + stats: useRef(null), + ending: useRef(null) + } + + const containerRef = useRef(null) useEffect(() => { const params = new URLSearchParams(window.location.hash.split('?')[1] || '') @@ -151,6 +184,351 @@ function DualReportWindow() { void loadEmojis() }, [reportData]) + const formatFileYearLabel = (year: number) => (year === 0 ? '历史以来' : String(year)) + + const sanitizeFileNameSegment = (value: string) => { + const sanitized = value.replace(/[\\/:*?"<>|]/g, '_').trim() + return sanitized || '好友' + } + + const getAvailableSections = (): SectionInfo[] => { + if (!reportData) return [] + + const sections: SectionInfo[] = [ + { id: 'cover', name: '封面', ref: sectionRefs.cover }, + { id: 'firstChat', name: '首次聊天', ref: sectionRefs.firstChat } + ] + + if (reportData.yearFirstChat && (!reportData.firstChat || reportData.yearFirstChat.createTime !== reportData.firstChat.createTime)) { + sections.push({ id: 'yearFirstChat', name: '第一段对话', ref: sectionRefs.yearFirstChat }) + } + if (reportData.heatmap) { + sections.push({ id: 'heatmap', name: '作息规律', ref: sectionRefs.heatmap }) + } + if (reportData.initiative) { + sections.push({ id: 'initiative', name: '主动性', ref: sectionRefs.initiative }) + } + if (reportData.response) { + sections.push({ id: 'response', name: '回应速度', ref: sectionRefs.response }) + } + if (reportData.streak) { + sections.push({ id: 'streak', name: '最长连续聊天', ref: sectionRefs.streak }) + } + + sections.push({ id: 'wordCloud', name: '常用语', ref: sectionRefs.wordCloud }) + sections.push({ id: 'stats', name: '年度统计', ref: sectionRefs.stats }) + sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending }) + + return sections + } + + const exportSection = async (section: SectionInfo): Promise<{ name: string; data: string } | null> => { + const element = section.ref.current + if (!element) { + return null + } + + const OUTPUT_WIDTH = 1920 + const OUTPUT_HEIGHT = 1080 + let wordCloudInner: HTMLElement | null = null + let wordTags: NodeListOf | null = null + let wordCloudOriginalStyle = '' + const wordTagOriginalStyles: string[] = [] + const originalStyle = element.style.cssText + + 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') + + element.style.minHeight = 'auto' + element.style.padding = '40px 20px' + element.style.background = 'transparent' + element.style.backgroundColor = 'transparent' + element.style.boxShadow = 'none' + + wordCloudInner = element.querySelector('.word-cloud-inner') as HTMLElement | null + wordTags = element.querySelectorAll('.word-tag') as NodeListOf + + if (wordCloudInner) { + wordCloudOriginalStyle = wordCloudInner.style.cssText + wordCloudInner.style.transform = 'none' + } + + wordTags.forEach((tag, index) => { + wordTagOriginalStyles[index] = tag.style.cssText + tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1') + tag.style.animation = 'none' + }) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + const computedStyle = getComputedStyle(document.documentElement) + const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6' + + const canvas = await html2canvas(element, { + 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() + } + }) + + const outputCanvas = document.createElement('canvas') + outputCanvas.width = OUTPUT_WIDTH + outputCanvas.height = OUTPUT_HEIGHT + const ctx = outputCanvas.getContext('2d') + if (!ctx) { + return null + } + + 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 + let drawHeight: number + let drawX: number + let 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 { + return null + } finally { + element.style.cssText = originalStyle + if (wordCloudInner) { + wordCloudInner.style.cssText = wordCloudOriginalStyle + } + wordTags?.forEach((tag, index) => { + tag.style.cssText = wordTagOriginalStyles[index] + }) + document.body.classList.remove('exporting-snapshot') + document.documentElement.classList.remove('exporting-snapshot') + } + } + + const exportFullReport = async (filterIds?: Set) => { + if (!containerRef.current || !reportData) { + return + } + + setIsExporting(true) + setExportProgress('正在生成长图...') + + let wordCloudInner: HTMLElement | null = null + let wordTags: NodeListOf | null = null + let wordCloudOriginalStyle = '' + const wordTagOriginalStyles: string[] = [] + const container = containerRef.current + const sections = Array.from(container.querySelectorAll('.section')) as HTMLElement[] + const originalStyles = sections.map((section) => section.style.cssText) + + 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') + + sections.forEach((section) => { + section.style.minHeight = 'auto' + section.style.padding = '40px 0' + }) + + if (filterIds) { + getAvailableSections().forEach((section) => { + if (!filterIds.has(section.id) && section.ref.current) { + section.ref.current.style.display = 'none' + } + }) + } + + wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement | null + wordTags = container.querySelectorAll('.word-tag') as NodeListOf + + if (wordCloudInner) { + wordCloudOriginalStyle = wordCloudInner.style.cssText + wordCloudInner.style.transform = 'none' + } + + wordTags.forEach((tag, index) => { + wordTagOriginalStyles[index] = tag.style.cssText + tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1') + tag.style.animation = 'none' + }) + + await new Promise((resolve) => setTimeout(resolve, 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() + } + }) + + const outputCanvas = document.createElement('canvas') + outputCanvas.width = canvas.width + outputCanvas.height = canvas.height + const ctx = outputCanvas.getContext('2d') + if (!ctx) { + throw new Error('无法创建导出画布') + } + + const isDark = themeMode === 'dark' + await drawPatternBackground(ctx, canvas.width, canvas.height, bgColor, isDark) + ctx.drawImage(canvas, 0, 0) + + const yearFilePrefix = formatFileYearLabel(reportData.year) + const friendFileSegment = sanitizeFileNameSegment(reportData.friendName || reportData.friendUsername) + const link = document.createElement('a') + link.download = `${yearFilePrefix}双人年度报告_${friendFileSegment}${filterIds ? '_自定义' : ''}.png` + link.href = outputCanvas.toDataURL('image/png') + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } catch (e) { + alert('导出失败: ' + String(e)) + } finally { + sections.forEach((section, index) => { + section.style.cssText = originalStyles[index] + }) + if (wordCloudInner) { + wordCloudInner.style.cssText = wordCloudOriginalStyle + } + wordTags?.forEach((tag, index) => { + tag.style.cssText = wordTagOriginalStyles[index] + }) + document.body.classList.remove('exporting-snapshot') + document.documentElement.classList.remove('exporting-snapshot') + setIsExporting(false) + setExportProgress('') + } + } + + const exportSelectedSections = async () => { + if (!reportData) return + + const sections = getAvailableSections().filter((section) => selectedSections.has(section.id)) + if (sections.length === 0) { + alert('请至少选择一个板块') + return + } + + if (exportMode === 'long') { + setShowExportModal(false) + await exportFullReport(selectedSections) + setSelectedSections(new Set()) + return + } + + setIsExporting(true) + setShowExportModal(false) + + const exportedImages: Array<{ name: string; data: string }> = [] + + for (let index = 0; index < sections.length; index++) { + const section = sections[index] + setExportProgress(`正在导出: ${section.name} (${index + 1}/${sections.length})`) + + const result = await exportSection(section) + if (result) { + exportedImages.push(result) + } + } + + if (exportedImages.length === 0) { + alert('导出失败') + setIsExporting(false) + setExportProgress('') + return + } + + const dirResult = await window.electronAPI.dialog.openDirectory({ + title: '选择导出文件夹', + properties: ['openDirectory', 'createDirectory'] + }) + if (dirResult.canceled || !dirResult.filePaths?.[0]) { + setIsExporting(false) + setExportProgress('') + return + } + + setExportProgress('正在写入文件...') + const yearFilePrefix = formatFileYearLabel(reportData.year) + const friendFileSegment = sanitizeFileNameSegment(reportData.friendName || reportData.friendUsername) + const exportResult = await window.electronAPI.annualReport.exportImages({ + baseDir: dirResult.filePaths[0], + folderName: `${yearFilePrefix}双人年度报告_${friendFileSegment}_分模块`, + images: exportedImages.map((image) => ({ + name: `${yearFilePrefix}双人年度报告_${friendFileSegment}_${image.name}.png`, + dataUrl: image.data + })) + }) + + if (!exportResult.success) { + alert('导出失败: ' + (exportResult.error || '未知错误')) + } + + setIsExporting(false) + setExportProgress('') + setSelectedSections(new Set()) + } + + const toggleSection = (id: string) => { + const next = new Set(selectedSections) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + setSelectedSections(next) + } + + const toggleAll = () => { + const sections = getAvailableSections() + if (selectedSections.size === sections.length) { + setSelectedSections(new Set()) + return + } + setSelectedSections(new Set(sections.map((section) => section.id))) + } + if (isLoading) { return (
@@ -305,7 +683,7 @@ function DualReportWindow() { if (emojiUrl) { return (
- 表情 { + 表情 { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); }} /> @@ -356,7 +734,7 @@ function DualReportWindow() { if (avatarUrl) { return (
- {isSentByMe + {isSentByMe
) } @@ -419,9 +797,99 @@ function DualReportWindow() {
+
+ + + + +
+ + {isExporting && ( +
+
+
+
+ +
+

正在导出

+

{exportProgress}

+
+
+ )} + + {showExportModal && ( +
setShowExportModal(false)}> +
e.stopPropagation()}> +
+

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

+ +
+
+ {getAvailableSections().map((section) => ( +
toggleSection(section.id)} + > +
+ {selectedSections.has(section.id) && } +
+ {section.name} +
+ ))} +
+
+ + +
+
+
+ )} +
-
-
+
+
WEFLOW · DUAL REPORT

{yearTitle}
双人聊天报告


@@ -433,7 +901,7 @@ function DualReportWindow() {

每一次对话都值得被珍藏

-
+
首次聊天

故事的开始

{firstChat ? ( @@ -457,7 +925,7 @@ function DualReportWindow() {
{yearFirstChat && (!firstChat || yearFirstChat.createTime !== firstChat.createTime) ? ( -
+
第一段对话

{reportData.year === 0 ? '你们的第一段对话' : `${reportData.year}年的第一段对话`} @@ -473,7 +941,7 @@ function DualReportWindow() { ) : null} {reportData.heatmap && ( -
+
聊天习惯

作息规律

{mostActive && ( @@ -486,14 +954,14 @@ function DualReportWindow() { )} {reportData.initiative && ( -
+
主动性

情感的天平

- {reportData.selfAvatarUrl ? me-avatar : '我'} + {reportData.selfAvatarUrl ? me-avatar : '我'}
{reportData.initiative.initiated}次
{initiatedPercent.toFixed(1)}%
@@ -507,7 +975,7 @@ function DualReportWindow() {
- {reportData.friendAvatarUrl ? friend-avatar : reportData.friendName.substring(0, 1)} + {reportData.friendAvatarUrl ? friend-avatar : reportData.friendName.substring(0, 1)}
{reportData.initiative.received}次
{receivedPercent.toFixed(1)}%
@@ -521,7 +989,7 @@ function DualReportWindow() { )} {reportData.response && ( -
+
回应速度

你说,我在

@@ -558,7 +1026,7 @@ function DualReportWindow() { )} {reportData.streak && ( -
+
聊天火花

最长连续聊天

@@ -596,7 +1064,7 @@ function DualReportWindow() {
)} -
+
常用语

{yearTitle}常用语

@@ -640,7 +1108,7 @@ function DualReportWindow() {
-
+
年度统计

{yearTitle}数据概览

@@ -664,7 +1132,7 @@ function DualReportWindow() {
我常用的表情
{myEmojiUrl ? ( - my-emoji { + my-emoji { (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); (e.target as HTMLImageElement).style.display = 'none'; }} /> @@ -677,7 +1145,7 @@ function DualReportWindow() {
{reportData.friendName}常用的表情
{friendEmojiUrl ? ( - friend-emoji { + friend-emoji { (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); (e.target as HTMLImageElement).style.display = 'none'; }} /> @@ -690,7 +1158,7 @@ function DualReportWindow() {
-
+
尾声

谢谢你一直在

愿我们继续把故事写下去

diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 98fe8b3..808a601 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -138,6 +138,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'>('top-right') const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all') const [notificationFilterList, setNotificationFilterList] = useState([]) + const [launchAtStartup, setLaunchAtStartup] = useState(false) + const [launchAtStartupSupported, setLaunchAtStartupSupported] = useState(isWindows || isMac) + const [launchAtStartupReason, setLaunchAtStartupReason] = useState('') const [windowCloseBehavior, setWindowCloseBehavior] = useState('ask') const [quoteLayout, setQuoteLayout] = useState('quote-top') const [updateChannel, setUpdateChannel] = useState('stable') @@ -162,6 +165,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [isFetchingDbKey, setIsFetchingDbKey] = useState(false) const [isFetchingImageKey, setIsFetchingImageKey] = useState(false) const [isCheckingUpdate, setIsCheckingUpdate] = useState(false) + const [isUpdatingLaunchAtStartup, setIsUpdatingLaunchAtStartup] = useState(false) const [appVersion, setAppVersion] = useState('') const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null) const [showDecryptKey, setShowDecryptKey] = useState(false) @@ -337,6 +341,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedNotificationFilterMode = await configService.getNotificationFilterMode() const savedNotificationFilterList = await configService.getNotificationFilterList() const savedMessagePushEnabled = await configService.getMessagePushEnabled() + const savedLaunchAtStartupStatus = await window.electronAPI.app.getLaunchAtStartupStatus() const savedWindowCloseBehavior = await configService.getWindowCloseBehavior() const savedQuoteLayout = await configService.getQuoteLayout() const savedUpdateChannel = await configService.getUpdateChannel() @@ -386,6 +391,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setNotificationFilterMode(savedNotificationFilterMode) setNotificationFilterList(savedNotificationFilterList) setMessagePushEnabled(savedMessagePushEnabled) + setLaunchAtStartup(savedLaunchAtStartupStatus.enabled) + setLaunchAtStartupSupported(savedLaunchAtStartupStatus.supported) + setLaunchAtStartupReason(savedLaunchAtStartupStatus.reason || '') setWindowCloseBehavior(savedWindowCloseBehavior) setQuoteLayout(savedQuoteLayout) if (savedUpdateChannel) { @@ -428,6 +436,29 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { + const handleLaunchAtStartupChange = async (enabled: boolean) => { + if (isUpdatingLaunchAtStartup) return + + try { + setIsUpdatingLaunchAtStartup(true) + const result = await window.electronAPI.app.setLaunchAtStartup(enabled) + setLaunchAtStartup(result.enabled) + setLaunchAtStartupSupported(result.supported) + setLaunchAtStartupReason(result.reason || '') + + if (result.success) { + showMessage(enabled ? '已开启开机自启动' : '已关闭开机自启动', true) + return + } + + showMessage(result.error || result.reason || '设置开机自启动失败', false) + } catch (e: any) { + showMessage(`设置开机自启动失败: ${e?.message || String(e)}`, false) + } finally { + setIsUpdatingLaunchAtStartup(false) + } + } + const refreshWhisperStatus = async (modelDirValue = whisperModelDir) => { try { const result = await window.electronAPI.whisper?.getModelStatus() @@ -1199,6 +1230,39 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
+
+ + + {launchAtStartupSupported + ? '开启后,登录系统时会自动启动 WeFlow。' + : launchAtStartupReason || '当前环境暂不支持开机自启动。'} + +
+ + {isUpdatingLaunchAtStartup + ? '保存中...' + : launchAtStartupSupported + ? (launchAtStartup ? '已开启' : '已关闭') + : '当前不可用'} + + +
+
+ +
+
设置点击关闭按钮后的默认行为;选择“每次询问”时会弹出关闭确认。 diff --git a/src/services/config.ts b/src/services/config.ts index 59e8afa..1f687e7 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -13,6 +13,7 @@ export const CONFIG_KEYS = { LAST_SESSION: 'lastSession', WINDOW_BOUNDS: 'windowBounds', CACHE_PATH: 'cachePath', + LAUNCH_AT_STARTUP: 'launchAtStartup', EXPORT_PATH: 'exportPath', AGREEMENT_ACCEPTED: 'agreementAccepted', @@ -258,6 +259,18 @@ export async function setLogEnabled(enabled: boolean): Promise { await config.set(CONFIG_KEYS.LOG_ENABLED, enabled) } +// 获取开机自启动偏好 +export async function getLaunchAtStartup(): Promise { + const value = await config.get(CONFIG_KEYS.LAUNCH_AT_STARTUP) + if (typeof value === 'boolean') return value + return null +} + +// 设置开机自启动偏好 +export async function setLaunchAtStartup(enabled: boolean): Promise { + await config.set(CONFIG_KEYS.LAUNCH_AT_STARTUP, enabled) +} + // 获取 LLM 模型路径 export async function getLlmModelPath(): Promise { const value = await config.get(CONFIG_KEYS.LLM_MODEL_PATH) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index c174983..19f33a5 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -56,6 +56,14 @@ export interface ElectronAPI { app: { getDownloadsPath: () => Promise getVersion: () => Promise + getLaunchAtStartupStatus: () => Promise<{ enabled: boolean; supported: boolean; reason?: string }> + setLaunchAtStartup: (enabled: boolean) => Promise<{ + success: boolean + enabled: boolean + supported: boolean + reason?: string + error?: string + }> checkForUpdates: () => Promise<{ hasUpdate: boolean; version?: string; releaseNotes?: string }> downloadAndInstall: () => Promise ignoreUpdate: (version: string) => Promise<{ success: boolean }> diff --git a/src/utils/reportExport.ts b/src/utils/reportExport.ts new file mode 100644 index 0000000..224b99e --- /dev/null +++ b/src/utils/reportExport.ts @@ -0,0 +1,36 @@ +const PATTERN_LIGHT_SVG = `` + +const PATTERN_DARK_SVG = `` + +export const drawPatternBackground = async ( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + bgColor: string, + isDark: boolean +) => { + ctx.fillStyle = bgColor + ctx.fillRect(0, 0, width, height) + + const svgString = isDark ? PATTERN_DARK_SVG : PATTERN_LIGHT_SVG + const blob = new Blob([svgString], { type: 'image/svg+xml' }) + const url = URL.createObjectURL(blob) + + return new Promise((resolve) => { + const img = new window.Image() + img.onload = () => { + const pattern = ctx.createPattern(img, 'repeat') + if (pattern) { + ctx.fillStyle = pattern + ctx.fillRect(0, 0, width, height) + } + URL.revokeObjectURL(url) + resolve() + } + img.onerror = () => { + URL.revokeObjectURL(url) + resolve() + } + img.src = url + }) +}