Compare commits

...

28 Commits

Author SHA1 Message Date
dependabot[bot]
e108984a13 chore(deps): bump koffi from 2.15.2 to 2.15.4
Bumps [koffi](https://github.com/Koromix/koffi) from 2.15.2 to 2.15.4.
- [Commits](https://github.com/Koromix/koffi/commits)

---
updated-dependencies:
- dependency-name: koffi
  dependency-version: 2.15.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-03 23:16:21 +00:00
H3CoF6
d1b8d86a20 Merge pull request #625 from H3CoF6/dev
修复biz的一些问题
2026-04-04 02:58:54 +08:00
H3CoF6
25ef7c5d8a 更快的排序 2026-04-04 02:52:12 +08:00
H3CoF6
db429abf5b 时间排序 2026-04-04 02:34:57 +08:00
H3CoF6
19d5ae7e15 fix: 修复账号类型,删除广告账号 2026-04-04 01:53:03 +08:00
cc
fcbd613f4a Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-03 23:23:47 +08:00
cc
5fae370c55 更新打包 2026-04-03 23:23:42 +08:00
xuncha
f2dbe6ee8f Merge pull request #622 from xunchahaha/dev
Dev
2026-04-03 21:11:08 +08:00
xuncha
0175a6998b Merge branch 'dev' into dev 2026-04-03 21:08:36 +08:00
xuncha
758de9949b 新增开机自启动 [Enhancement]: 希望能够支持静默启动和开机自启动
Fixes #516
2026-04-03 21:08:05 +08:00
xuncha
81b8960d41 双人年度报告支持导出 [Enhancement]: 双人年度报告不支持导出 但总年度报告支持
Fixes #531
2026-04-03 21:07:44 +08:00
xuncha
5b25619b24 Merge pull request #620 from xunchahaha/dev
卡片链接新增解析
2026-04-03 20:50:44 +08:00
xuncha
62e23aaf23 卡片链接新增解析 2026-04-03 20:47:15 +08:00
cc
aac8eed898 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-03 20:35:10 +08:00
cc
108980befb 修复了一些问题 2026-04-03 20:34:57 +08:00
xuncha
a6c899c098 Merge pull request #557 from jinkangHe/dev
feat(sns):增加朋友圈相关api
2026-04-03 20:14:57 +08:00
xuncha
28170d31df Merge branch 'dev' into dev 2026-04-03 20:11:25 +08:00
xuncha
2cc0fc64a4 Merge branch 'dev' into dev 2026-04-03 20:08:03 +08:00
xuncha
67642cebfd fix(http): stream live sns media and clarify docs 2026-04-03 20:07:11 +08:00
cc
8c4f42bab1 Merge branch 'dev' into dev 2026-04-03 19:52:35 +08:00
xuncha
0235ec7edc Merge branch 'dev' into dev 2026-04-03 19:49:29 +08:00
cc
7b8bd747ad Merge branch 'dev' into dev 2026-04-02 21:39:55 +08:00
hejk
e4872a78f5 feat(http): change default API host to 0.0.0.0 for external access
- 修改 httpApiHost 默认值从 127.0.0.1 到 0.0.0.0
- 允许 HTTP API 服务接受外网访问
- 用户需要配置 httpApiToken 以确保安全

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 11:32:46 +08:00
hejinkang
71238d4a01 Merge branch 'dev' into dev 2026-03-30 10:04:56 +08:00
cc
4ea4020faa Merge branch 'dev' into dev 2026-03-27 20:53:56 +08:00
hejinkang
c88a1c5848 Merge branch 'hicccc77:dev' into dev 2026-03-27 10:08:08 +08:00
hejk
0162769d22 fix(sns): fallback usernames from timeline when SQL result is empty 2026-03-26 19:38:06 +08:00
hejk
fa55755921 feat(http): add sns HTTP API endpoints 2026-03-26 19:36:19 +08:00
22 changed files with 1585 additions and 182 deletions

View File

@@ -308,11 +308,11 @@ jobs:
- 此版本为每日构建的开发版,包含最新的功能和修复,但可能不够稳定,仅推荐用于测试和验证。 - 此版本为每日构建的开发版,包含最新的功能和修复,但可能不够稳定,仅推荐用于测试和验证。
## 下载 ## 下载
- Windows x64: ${WINDOWS_URL:-$RELEASE_PAGE} - Windows x64: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE} - Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
- macOSApple Silicon: ${MAC_URL:-$RELEASE_PAGE} - macOSApple Silicon: [点击下载](${MAC_URL:-$RELEASE_PAGE})
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE} - Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
- Linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE} - Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
## macOS 安装提示 ## macOS 安装提示
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记: - 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
@@ -321,7 +321,7 @@ jobs:
## 说明 ## 说明
- 该发布页的同名资源会被后续构建覆盖,请勿将其视作长期归档版本。 - 该发布页的同名资源会被后续构建覆盖,请勿将其视作长期归档版本。
- 如某个平台资源暂未生成,请进入发布页查看最新状态:$RELEASE_PAGE - 如某个平台资源暂未生成,请进入[发布页]($RELEASE_PAGE)查看最新状态
EOF EOF
gh release edit "$TAG" --repo "$REPO" --title "Daily Dev Build" --notes-file dev_release_notes.md gh release edit "$TAG" --repo "$REPO" --title "Daily Dev Build" --notes-file dev_release_notes.md

View File

@@ -351,18 +351,18 @@ jobs:
- 当前版本号:\`$CURRENT_PREVIEW_VERSION\` - 当前版本号:\`$CURRENT_PREVIEW_VERSION\`
## 下载 ## 下载
- Windows x64: ${WINDOWS_URL:-$RELEASE_PAGE} - Windows x64: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE} - Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
- macOSApple Silicon: ${MAC_URL:-$RELEASE_PAGE} - macOSApple Silicon: [点击下载](${MAC_URL:-$RELEASE_PAGE})
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE} - Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
- Linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE} - Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
## macOS 安装提示 ## macOS 安装提示
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记: - 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\` - \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
- 执行后重新打开 WeFlow。 - 执行后重新打开 WeFlow。
> 如某个平台链接暂未生成,请前往发布页查看最新资源:$RELEASE_PAGE > 如某个平台链接暂未生成,请前往[发布页]($RELEASE_PAGE)查看最新资源
EOF EOF
gh release edit "$TAG" --repo "$REPO" --notes-file preview_release_notes.md gh release edit "$TAG" --repo "$REPO" --notes-file preview_release_notes.md

View File

@@ -276,18 +276,18 @@ jobs:
[点击加入 Telegram 频道](https://t.me/weflow_cc) [点击加入 Telegram 频道](https://t.me/weflow_cc)
## 下载 ## 下载
- Windows x64Win10+: ${WINDOWS_URL:-$RELEASE_PAGE} - Windows x64Win10+: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE} - Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
- macOSM系列芯片: ${MAC_URL:-$RELEASE_PAGE} - macOSM系列芯片: [点击下载](${MAC_URL:-$RELEASE_PAGE})
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE} - Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
- Linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE} - Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
## macOS 安装提示 ## macOS 安装提示
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记: - 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\` - \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
- 执行后重新打开 WeFlow。 - 执行后重新打开 WeFlow。
> 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE > 如果某个平台链接暂时未生成,可进入[完整发布页]($RELEASE_PAGE)查看全部资源
EOF EOF
gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md

View File

@@ -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 > 当使用 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 ### PowerShell
@@ -525,7 +641,7 @@ members = requests.get(
--- ---
## 9. 注意事项 ## 10. 注意事项
1. API 仅监听本机 `127.0.0.1`,不对外网开放。 1. API 仅监听本机 `127.0.0.1`,不对外网开放。
2. 使用前需要先在 WeFlow 中完成数据库连接。 2. 使用前需要先在 WeFlow 中完成数据库连接。

View File

@@ -135,17 +135,33 @@ const shouldOfferUpdateForTrack = (latestVersion: string, currentVersion: string
return false 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 applyAutoUpdateChannel = (reason: 'startup' | 'settings' = 'startup') => {
const track = getEffectiveUpdateTrack() const track = getEffectiveUpdateTrack()
const currentTrack = inferUpdateTrackFromVersion(appVersion) const currentTrack = inferUpdateTrackFromVersion(appVersion)
const baseUpdateChannel = track === 'stable' ? 'latest' : track const baseUpdateChannel = track === 'stable' ? 'latest' : track
autoUpdater.allowPrerelease = track !== 'stable' const nextUpdaterChannel =
// 只要用户当前选择的目标通道与当前安装版本所属通道不同,就允许跨通道更新(含降级)
autoUpdater.allowDowngrade = track !== currentTrack
autoUpdater.channel =
process.platform === 'win32' && process.arch === 'arm64' process.platform === 'win32' && process.arch === 'arm64'
? `${baseUpdateChannel}-arm64` ? `${baseUpdateChannel}-arm64`
: baseUpdateChannel : 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}`) 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 === '1' ||
(process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL) (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<typeof app.setLoginItemSettings>[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++ 运行库劫持。 // 使用白名单过滤 PATH避免被第三方目录中的旧版 VC++ 运行库劫持。
// 仅保留系统目录Windows/System32/SysWOW64和应用自身目录可执行目录、resources // 仅保留系统目录Windows/System32/SysWOW64和应用自身目录可执行目录、resources
function sanitizePathEnv() { function sanitizePathEnv() {
@@ -1234,7 +1362,12 @@ function registerIpcHandlers() {
}) })
ipcMain.handle('config:set', async (_, key: string, value: any) => { 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') { if (key === 'updateChannel') {
applyAutoUpdateChannel('settings') applyAutoUpdateChannel('settings')
} }
@@ -1243,6 +1376,12 @@ function registerIpcHandlers() {
}) })
ipcMain.handle('config:clear', async () => { ipcMain.handle('config:clear', async () => {
if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) {
const result = setSystemLaunchAtStartup(false)
if (!result.success && result.error) {
console.error('[WeFlow] 清空配置时关闭开机自启动失败:', result.error)
}
}
configService?.clear() configService?.clear()
messagePushService.handleConfigCleared() messagePushService.handleConfigCleared()
return true return true
@@ -1285,6 +1424,14 @@ function registerIpcHandlers() {
return app.getVersion() 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 () => { ipcMain.handle('app:checkWayland', async () => {
if (process.platform !== 'linux') return false; if (process.platform !== 'linux') return false;
@@ -1354,6 +1501,8 @@ function registerIpcHandlers() {
if (!AUTO_UPDATE_ENABLED) { if (!AUTO_UPDATE_ENABLED) {
return { hasUpdate: false } return { hasUpdate: false }
} }
// 每次主动检查前重新应用一次通道配置,确保使用最新选择的更新通道。
applyAutoUpdateChannel('settings')
try { try {
const result = await autoUpdater.checkForUpdates() const result = await autoUpdater.checkForUpdates()
if (result && result.updateInfo) { if (result && result.updateInfo) {
@@ -2863,6 +3012,7 @@ app.whenReady().then(async () => {
updateSplashProgress(5, '正在加载配置...') updateSplashProgress(5, '正在加载配置...')
configService = new ConfigService() configService = new ConfigService()
applyAutoUpdateChannel('startup') applyAutoUpdateChannel('startup')
syncLaunchAtStartupPreference()
// 将用户主题配置推送给 Splash 窗口 // 将用户主题配置推送给 Splash 窗口
if (splashWindow && !splashWindow.isDestroyed()) { if (splashWindow && !splashWindow.isDestroyed()) {

View File

@@ -53,6 +53,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
app: { app: {
getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'), getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'),
getVersion: () => ipcRenderer.invoke('app:getVersion'), getVersion: () => ipcRenderer.invoke('app:getVersion'),
getLaunchAtStartupStatus: () => ipcRenderer.invoke('app:getLaunchAtStartupStatus'),
setLaunchAtStartup: (enabled: boolean) => ipcRenderer.invoke('app:setLaunchAtStartup', enabled),
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'), checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'), downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version), ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version),

View File

@@ -87,11 +87,14 @@ export class BizService {
async listAccounts(account?: string): Promise<BizAccount[]> { async listAccounts(account?: string): Promise<BizAccount[]> {
try { try {
// 1. 获取公众号联系人列表
const contactsResult = await chatService.getContacts({ lite: true }) const contactsResult = await chatService.getContacts({ lite: true })
if (!contactsResult.success || !contactsResult.contacts) return [] if (!contactsResult.success || !contactsResult.contacts) return []
const officialContacts = contactsResult.contacts.filter(c => c.type === 'official') const officialContacts = contactsResult.contacts.filter(c => c.type === 'official')
const usernames = officialContacts.map(c => c.username) const usernames = officialContacts.map(c => c.username)
// 获取头像和昵称等补充信息
const enrichment = await chatService.enrichSessionsContactInfo(usernames) const enrichment = await chatService.enrichSessionsContactInfo(usernames)
const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {} const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {}
@@ -100,31 +103,45 @@ export class BizService {
const accountWxid = account || myWxid const accountWxid = account || myWxid
if (!root || !accountWxid) return [] if (!root || !accountWxid) return []
const dbDir = join(root, accountWxid, 'db_storage', 'message')
const bizLatestTime: Record<string, number> = {} const bizLatestTime: Record<string, number> = {}
if (existsSync(dbDir)) { try {
const bizDbFiles = readdirSync(dbDir).filter(f => f.startsWith('biz_message') && f.endsWith('.db')) const sessionsRes = await wcdbService.getSessions()
for (const file of bizDbFiles) { if (sessionsRes.success && sessionsRes.sessions) {
const dbPath = join(dbDir, file) for (const session of sessionsRes.sessions) {
const name2idRes = await wcdbService.execQuery('message', dbPath, 'SELECT username FROM Name2Id') const uname = session.username || session.strUsrName || session.userName || session.id
if (name2idRes.success && name2idRes.rows) { // 适配日志中发现的字段,注意转为整型数字
for (const row of name2idRes.rows) { const timeStr = session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0'
const uname = row.username || row.user_name const time = parseInt(timeStr.toString(), 10)
if (uname) {
const md5 = createHash('md5').update(uname).digest('hex').toLowerCase() if (usernames.includes(uname) && time > 0) {
const tName = `Msg_${md5}` bizLatestTime[uname] = time
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)
}
}
} }
} }
} }
} 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 result: BizAccount[] = officialContacts.map(contact => {
const uname = contact.username const uname = contact.username
const info = contactInfoMap[uname] const info = contactInfoMap[uname]
@@ -135,11 +152,12 @@ export class BizService {
avatar: info?.avatarUrl || '', avatar: info?.avatarUrl || '',
type: 0, type: 0,
last_time: lastTime, 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)) { if (existsSync(contactDbPath)) {
const bizInfoRes = await wcdbService.execQuery('contact', contactDbPath, 'SELECT username, type FROM biz_info') const bizInfoRes = await wcdbService.execQuery('contact', contactDbPath, 'SELECT username, type FROM biz_info')
if (bizInfoRes.success && bizInfoRes.rows) { if (bizInfoRes.success && bizInfoRes.rows) {
@@ -149,14 +167,18 @@ export class BizService {
} }
} }
// 6. 排序输出
return result return result
.filter(acc => !acc.name.includes('朋友圈广告')) .filter(acc => !acc.name.includes('广告'))
.sort((a, b) => { .sort((a, b) => {
if (a.username === 'gh_3dfda90e39d6') return -1 if (a.username === 'gh_3dfda90e39d6') return -1 // 微信支付置顶
if (b.username === 'gh_3dfda90e39d6') return 1 if (b.username === 'gh_3dfda90e39d6') return 1
return b.last_time - a.last_time return b.last_time - a.last_time // 按最新时间降序排列
}) })
} catch (e) { return [] } } catch (e) {
console.error('获取账号列表发生错误:', e)
return []
}
} }
async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise<BizMessage[]> { async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise<BizMessage[]> {

View File

@@ -27,6 +27,7 @@ interface ConfigSchema {
themeId: string themeId: string
language: string language: string
logEnabled: boolean logEnabled: boolean
launchAtStartup?: boolean
llmModelPath: string llmModelPath: string
whisperModelName: string whisperModelName: string
whisperModelDir: string whisperModelDir: string
@@ -128,7 +129,7 @@ export class ConfigService {
httpApiToken: '', httpApiToken: '',
httpApiEnabled: false, httpApiEnabled: false,
httpApiPort: 5031, httpApiPort: 5031,
httpApiHost: '127.0.0.1', httpApiHost: '0.0.0.0',
messagePushEnabled: false, messagePushEnabled: false,
windowCloseBehavior: 'ask', windowCloseBehavior: 'ask',
quoteLayout: 'quote-top', quoteLayout: 'quote-top',

View File

@@ -3310,15 +3310,29 @@ class ExportService {
const subType = this.extractAppMessageType(normalized) const subType = this.extractAppMessageType(normalized)
if (subType && subType !== '5' && subType !== '49') return null 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 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 } return { title, url }
} }
private normalizeHtmlLinkUrl(rawUrl: string): string { private normalizeHtmlLinkUrl(rawUrl: string): string {
const value = (rawUrl || '').trim() const value = (rawUrl || '').trim().replace(/&amp;/gi, '&')
if (!value) return '' if (!value) return ''
const parseHttpUrl = (candidate: string): string => { const parseHttpUrl = (candidate: string): string => {
@@ -3349,6 +3363,46 @@ class ExportService {
return '' 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 = { const message: ChatLabMessage = {
sender: msg.senderUsername, sender: msg.senderUsername,
accountName: senderProfile.displayName || memberInfo.accountName, accountName: senderProfile.displayName || memberInfo.accountName,
@@ -5558,6 +5617,13 @@ class ExportService {
content = this.buildQuotedReplyText(quotedReplyDisplay) 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 senderWxid = msg.senderUsername
const contact = senderWxid const contact = senderWxid
@@ -6484,16 +6550,14 @@ class ExportService {
enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay)
} }
// 调试日志 const contentCellIndex = useCompactColumns ? 5 : 9
if (msg.localType === 3 || msg.localType === 47) { const contentCell = worksheet.getCell(currentRow, contentCellIndex)
}
worksheet.getCell(currentRow, 1).value = i + 1 worksheet.getCell(currentRow, 1).value = i + 1
worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime) worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime)
if (useCompactColumns) { if (useCompactColumns) {
worksheet.getCell(currentRow, 3).value = senderRole worksheet.getCell(currentRow, 3).value = senderRole
worksheet.getCell(currentRow, 4).value = this.getMessageTypeName(msg.localType) worksheet.getCell(currentRow, 4).value = this.getMessageTypeName(msg.localType)
worksheet.getCell(currentRow, 5).value = enrichedContentValue
} else { } else {
worksheet.getCell(currentRow, 3).value = senderNickname worksheet.getCell(currentRow, 3).value = senderNickname
worksheet.getCell(currentRow, 4).value = senderWxid worksheet.getCell(currentRow, 4).value = senderWxid
@@ -6501,7 +6565,10 @@ class ExportService {
worksheet.getCell(currentRow, 6).value = senderGroupNickname worksheet.getCell(currentRow, 6).value = senderGroupNickname
worksheet.getCell(currentRow, 7).value = senderRole worksheet.getCell(currentRow, 7).value = senderRole
worksheet.getCell(currentRow, 8).value = this.getMessageTypeName(msg.localType) 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++ currentRow++
@@ -6747,7 +6814,7 @@ class ExportService {
enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay)
} }
appendRow(useCompactColumns const row = worksheet.addRow(useCompactColumns
? [ ? [
i + 1, i + 1,
this.formatTimestamp(msg.createTime), this.formatTimestamp(msg.createTime),
@@ -6766,6 +6833,10 @@ class ExportService {
this.getMessageTypeName(msg.localType), this.getMessageTypeName(msg.localType),
enrichedContentValue enrichedContentValue
]) ])
if (!quotedReplyDisplay) {
this.applyExcelLinkCardCell(row.getCell(useCompactColumns ? 5 : 9), msg.content, msg.localType)
}
row.commit()
if ((i + 1) % 200 === 0) { if ((i + 1) % 200 === 0) {
onProgress?.({ onProgress?.({
@@ -7119,6 +7190,13 @@ class ExportService {
enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay)
} }
const appendedLinkContent = quotedReplyDisplay
? null
: this.formatLinkCardExportText(msg.content, msg.localType, 'append-url')
if (appendedLinkContent) {
enrichedContentValue = appendedLinkContent
}
let senderRole: string let senderRole: string
let senderWxid: string let senderWxid: string
let senderNickname: string let senderNickname: string

View File

@@ -12,6 +12,7 @@ import { ConfigService } from './config'
import { videoService } from './videoService' import { videoService } from './videoService'
import { imageDecryptService } from './imageDecryptService' import { imageDecryptService } from './imageDecryptService'
import { groupAnalyticsService } from './groupAnalyticsService' import { groupAnalyticsService } from './groupAnalyticsService'
import { snsService } from './snsService'
// ChatLab 格式定义 // ChatLab 格式定义
interface ChatLabHeader { interface ChatLabHeader {
@@ -308,7 +309,7 @@ class HttpService {
*/ */
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> { private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
res.setHeader('Access-Control-Allow-Origin', '*') 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') res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
if (req.method === 'OPTIONS') { if (req.method === 'OPTIONS') {
@@ -348,6 +349,33 @@ class HttpService {
await this.handleContacts(url, res) await this.handleContacts(url, res)
} else if (pathname === '/api/v1/group-members') { } else if (pathname === '/api/v1/group-members') {
await this.handleGroupMembers(url, res) 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/')) { } else if (pathname.startsWith('/api/v1/media/')) {
this.handleMediaRequest(pathname, res) this.handleMediaRequest(pathname, res)
} else { } else {
@@ -559,6 +587,15 @@ class HttpService {
return defaultValue 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 { private parseMediaOptions(url: URL): ApiMediaOptions {
const mediaEnabled = this.parseBooleanParam(url, ['media', 'meiti'], false) const mediaEnabled = this.parseBooleanParam(url, ['media', 'meiti'], false)
if (!mediaEnabled) { if (!mediaEnabled) {
@@ -790,6 +827,313 @@ class HttpService {
} }
} }
private async handleSnsTimeline(url: URL, res: http.ServerResponse): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<any[]> {
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 { private getApiMediaExportPath(): string {
return path.join(this.configService.getCacheBasePath(), 'api-media') return path.join(this.configService.getCacheBasePath(), 'api-media')
} }
@@ -1451,6 +1795,11 @@ class HttpService {
res.end(JSON.stringify(data, null, 2)) 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}`)
}
/** /**
* 发送错误响应 * 发送错误响应
*/ */

View File

@@ -537,6 +537,32 @@ class SnsService {
return raw.trim() return raw.trim()
} }
private async collectSnsUsernamesFromTimeline(maxRounds: number = 2000): Promise<string[]> {
const pageSize = 500
const uniqueUsers = new Set<string>()
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 }> { private async getExportStatsFromTimeline(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
const pageSize = 500 const pageSize = 500
const uniqueUsers = new Set<string>() const uniqueUsers = new Set<string>()
@@ -794,7 +820,22 @@ class SnsService {
if (!result.success) { if (!result.success) {
return { success: false, error: result.error || '获取朋友圈联系人失败' } 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 }> { 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 } 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) return this.fetchAndDecryptImage(url, key)
} }

8
package-lock.json generated
View File

@@ -19,7 +19,7 @@
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jieba-wasm": "^2.2.0", "jieba-wasm": "^2.2.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"koffi": "^2.9.0", "koffi": "^2.15.4",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
@@ -6531,9 +6531,9 @@
} }
}, },
"node_modules/koffi": { "node_modules/koffi": {
"version": "2.15.2", "version": "2.15.4",
"resolved": "https://registry.npmjs.org/koffi/-/koffi-2.15.2.tgz", "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.15.4.tgz",
"integrity": "sha512-r9tjJLVRSOhCRWdVyQlF3/Ugzeg13jlzS4czS82MAgLff4W+BcYOW7g8Y62t9O5JYjYOLAjAovAZDNlDfZNu+g==", "integrity": "sha512-6l7xxt8heHWQ63WyGd8ofne4TrzhqeKHhvSlI3GnxMIHp3PlDrOPyZbW5YNINXNma1qrKkpM/PGLY8U0V8Hxbw==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {

View File

@@ -33,7 +33,7 @@
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jieba-wasm": "^2.2.0", "jieba-wasm": "^2.2.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"koffi": "^2.9.0", "koffi": "^2.15.4",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",

View File

@@ -8,44 +8,9 @@ import {
registerBackgroundTask, registerBackgroundTask,
updateBackgroundTask updateBackgroundTask
} from '../services/backgroundTaskMonitor' } from '../services/backgroundTaskMonitor'
import { drawPatternBackground } from '../utils/reportExport'
import './AnnualReportWindow.scss' import './AnnualReportWindow.scss'
// SVG 背景图案 (用于导出)
const PATTERN_LIGHT_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#000;stroke-width:1.2;opacity:0.045}.b{fill:none;stroke:#000;stroke-width:1;opacity:0.035}.c{fill:none;stroke:#000;stroke-width:0.8;opacity:0.04}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>`
const PATTERN_DARK_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#fff;stroke-width:1.2;opacity:0.055}.b{fill:none;stroke:#fff;stroke-width:1;opacity:0.045}.c{fill:none;stroke:#fff;stroke-width:0.8;opacity:0.05}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></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<void>((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 { interface TopContact {
username: string username: string
displayName: string displayName: string

View File

@@ -125,7 +125,22 @@
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
flex-direction: column; 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;
} }
} }

View File

@@ -7,7 +7,7 @@ export interface BizAccount {
username: string; username: string;
name: string; name: string;
avatar: string; avatar: string;
type: number; type: string;
last_time: number; last_time: number;
formatted_last_time: string; formatted_last_time: string;
} }
@@ -55,15 +55,24 @@ export const BizAccountList: React.FC<{
fetch().then(_r => { } ); fetch().then(_r => { } );
}, [myWxid]); }, [myWxid]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!searchKeyword) return accounts; let result = accounts;
const q = searchKeyword.toLowerCase(); if (searchKeyword) {
return accounts.filter(a => const q = searchKeyword.toLowerCase();
(a.name && a.name.toLowerCase().includes(q)) || result = accounts.filter(a =>
(a.username && a.username.toLowerCase().includes(q)) (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]); }, [accounts, searchKeyword]);
if (loading) return <div className="biz-loading">...</div>; if (loading) return <div className="biz-loading">...</div>;
return ( return (
@@ -84,18 +93,18 @@ export const BizAccountList: React.FC<{
<span className="biz-name">{item.name || item.username}</span> <span className="biz-name">{item.name || item.username}</span>
<span className="biz-time">{item.formatted_last_time}</span> <span className="biz-time">{item.formatted_last_time}</span>
</div> </div>
{item.username === 'gh_3dfda90e39d6' && ( {/*{item.username === 'gh_3dfda90e39d6' && (*/}
<div className="biz-badge type-service"></div> {/* <div className="biz-badge type-service">微信支付</div>*/}
)} {/*)}*/}
{/* 我看了下没有接口获取相关type如果exec没法用的话确实无能为力后面再适配吧 */} <div className={`biz-badge ${
{/*<div className={`biz-badge ${*/} item.type === '1' ? 'type-service' :
{/* item.type === 1 ? 'type-service' :*/} item.type === '0' ? 'type-sub' :
{/* item.type === 0 ? 'type-sub' :*/} item.type === '2' ? 'type-enterprise' :
{/* item.type === 2 ? 'type-enterprise' : 'type-unknown'*/} item.type === '3' ? 'type-enterprise' : 'type-unknown'
{/*}`}>*/} }`}>
{/* {item.type === 1 ? '服务号' : item.type === 0 ? '订阅号' : item.type === 2 ? '企业号' : '未知'}*/} {item.type === '0' ? '公众号' : item.type === '1' ? '服务号' : item.type === '2' ? '企业号' : item.type === '3' ? '企业附属' : '未知'}
{/*</div>*/} </div>
</div> </div>
</div> </div>
@@ -104,7 +113,6 @@ export const BizAccountList: React.FC<{
); );
}; };
// 2. 公众号消息区域组件
export const BizMessageArea: React.FC<{ export const BizMessageArea: React.FC<{
account: BizAccount | null; account: BizAccount | null;
}> = ({ account }) => { }> = ({ account }) => {
@@ -115,6 +123,8 @@ export const BizMessageArea: React.FC<{
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const limit = 20; const limit = 20;
const messageListRef = useRef<HTMLDivElement>(null); const messageListRef = useRef<HTMLDivElement>(null);
const lastScrollHeightRef = useRef<number>(0);
const isInitialLoadRef = useRef<boolean>(true);
const [myWxid, setMyWxid] = useState<string>(''); const [myWxid, setMyWxid] = useState<string>('');
@@ -143,6 +153,7 @@ export const BizMessageArea: React.FC<{
setMessages([]); setMessages([]);
setOffset(0); setOffset(0);
setHasMore(true); setHasMore(true);
isInitialLoadRef.current = true;
loadMessages(account.username, 0); loadMessages(account.username, 0);
} }
}, [account, myWxid]); }, [account, myWxid]);
@@ -151,6 +162,10 @@ export const BizMessageArea: React.FC<{
if (loading || !myWxid) return; if (loading || !myWxid) return;
setLoading(true); setLoading(true);
if (messageListRef.current) {
lastScrollHeightRef.current = messageListRef.current.scrollHeight;
}
try { try {
let res; let res;
if (username === 'gh_3dfda90e39d6') { if (username === 'gh_3dfda90e39d6') {
@@ -158,9 +173,15 @@ export const BizMessageArea: React.FC<{
} else { } else {
res = await window.electronAPI.biz.listMessages(username, myWxid, limit, currentOffset); res = await window.electronAPI.biz.listMessages(username, myWxid, limit, currentOffset);
} }
if (res) { if (res) {
if (res.length < limit) setHasMore(false); 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); setOffset(currentOffset + limit);
} }
} catch (err) { } 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<HTMLDivElement>) => { const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.currentTarget; const target = e.currentTarget;
if (target.scrollHeight - Math.abs(target.scrollTop) - target.clientHeight < 50) { // 向上滚动到顶部附近触发加载更多(更旧的消息)
if (target.scrollTop < 50) {
if (!loading && hasMore && account) { if (!loading && hasMore && account) {
loadMessages(account.username, offset); 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=='; const defaultImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iMTgwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2Y1ZjVmNSIvPjwvc3ZnPg==';
return ( return (
@@ -197,6 +259,9 @@ export const BizMessageArea: React.FC<{
</div> </div>
<div className="message-container" onScroll={handleScroll} ref={messageListRef}> <div className="message-container" onScroll={handleScroll} ref={messageListRef}>
<div className="messages-wrapper"> <div className="messages-wrapper">
{hasMore && messages.length > 0 && (
<div className="biz-loading-more">{loading ? '加载中...' : '向上滚动加载更多历史消息'}</div>
)}
{!loading && messages.length === 0 && ( {!loading && messages.length === 0 && (
<div className="biz-no-record-container"> <div className="biz-no-record-container">
<div className="no-record-icon"> <div className="no-record-icon">
@@ -206,40 +271,50 @@ export const BizMessageArea: React.FC<{
<p></p> <p></p>
</div> </div>
)} )}
{messages.map((msg) => ( {messages.map((msg, index) => {
<div key={msg.local_id}> const showTime = true;
{account.username === 'gh_3dfda90e39d6' ? (
<div className="pay-card"> return (
<div className="pay-header"> <div key={msg.local_id || index}>
{msg.merchant_icon ? <img src={msg.merchant_icon} className="pay-icon" alt=""/> : <div className="pay-icon-placeholder">¥</div>} {showTime && (
<span>{msg.merchant_name || '微信支付'}</span> <div className="time-divider">
</div> <span>{formatMessageTime(msg.create_time)}</span>
<div className="pay-title">{msg.title}</div> </div>
<div className="pay-desc">{msg.description}</div> )}
<div className="pay-footer">{msg.formatted_time}</div>
</div> {account.username === 'gh_3dfda90e39d6' ? (
) : ( <div className="pay-card">
<div className="article-card"> <div className="pay-header">
<div onClick={() => window.electronAPI.shell.openExternal(msg.url)} className="main-article"> {msg.merchant_icon ? <img src={msg.merchant_icon} className="pay-icon" alt=""/> : <div className="pay-icon-placeholder">¥</div>}
<img src={msg.cover || defaultImage} className="article-cover" alt=""/> <span>{msg.merchant_name || '微信支付'}</span>
<div className="article-overlay"><h3 className="article-title">{msg.title}</h3></div>
</div>
{msg.des && <div className="article-digest">{msg.des}</div>}
{msg.content_list && msg.content_list.length > 1 && (
<div className="sub-articles">
{msg.content_list.slice(1).map((item: any, idx: number) => (
<div key={idx} onClick={() => window.electronAPI.shell.openExternal(item.url)} className="sub-item">
<span className="sub-title">{item.title}</span>
{item.cover && <img src={item.cover} className="sub-cover" alt=""/>}
</div>
))}
</div> </div>
)} <div className="pay-title">{msg.title}</div>
</div> <div className="pay-desc">{msg.description}</div>
)} {/* <div className="pay-footer">{msg.formatted_time}</div> */}
</div> </div>
))} ) : (
{loading && <div className="biz-loading-more">...</div>} <div className="article-card">
<div onClick={() => window.electronAPI.shell.openExternal(msg.url)} className="main-article">
<img src={msg.cover || defaultImage} className="article-cover" alt=""/>
<div className="article-overlay"><h3 className="article-title">{msg.title}</h3></div>
</div>
{msg.des && <div className="article-digest">{msg.des}</div>}
{msg.content_list && msg.content_list.length > 1 && (
<div className="sub-articles">
{msg.content_list.slice(1).map((item: any, idx: number) => (
<div key={idx} onClick={() => window.electronAPI.shell.openExternal(item.url)} className="sub-item">
<span className="sub-title">{item.title}</span>
{item.cover && <img src={item.cover} className="sub-cover" alt=""/>}
</div>
))}
</div>
)}
</div>
)}
</div>
);
})}
{loading && offset === 0 && <div className="biz-loading-more">...</div>}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -238,7 +238,7 @@
} }
.scene-message.sent .scene-avatar { .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 { .dual-stat-grid {

View File

@@ -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 ReportHeatmap from '../components/ReportHeatmap'
import ReportWordCloud from '../components/ReportWordCloud' import ReportWordCloud from '../components/ReportWordCloud'
import { useThemeStore } from '../stores/themeStore'
import { drawPatternBackground } from '../utils/reportExport'
import './AnnualReportWindow.scss' import './AnnualReportWindow.scss'
import './DualReportWindow.scss' import './DualReportWindow.scss'
@@ -66,6 +70,12 @@ interface DualReportData {
streak?: { days: number; startDate: string; endDate: string } streak?: { days: number; startDate: string; endDate: string }
} }
interface SectionInfo {
id: string
name: string
ref: React.RefObject<HTMLElement | null>
}
function DualReportWindow() { function DualReportWindow() {
const [reportData, setReportData] = useState<DualReportData | null>(null) const [reportData, setReportData] = useState<DualReportData | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@@ -75,6 +85,29 @@ function DualReportWindow() {
const [myEmojiUrl, setMyEmojiUrl] = useState<string | null>(null) const [myEmojiUrl, setMyEmojiUrl] = useState<string | null>(null)
const [friendEmojiUrl, setFriendEmojiUrl] = useState<string | null>(null) const [friendEmojiUrl, setFriendEmojiUrl] = useState<string | null>(null)
const [activeWordCloudTab, setActiveWordCloudTab] = useState<'shared' | 'my' | 'friend'>('shared') 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<Set<string>>(new Set())
const [fabOpen, setFabOpen] = useState(false)
const [exportMode, setExportMode] = useState<'separate' | 'long'>('separate')
const { themeMode } = useThemeStore()
const sectionRefs = {
cover: useRef<HTMLElement>(null),
firstChat: useRef<HTMLElement>(null),
yearFirstChat: useRef<HTMLElement>(null),
heatmap: useRef<HTMLElement>(null),
initiative: useRef<HTMLElement>(null),
response: useRef<HTMLElement>(null),
streak: useRef<HTMLElement>(null),
wordCloud: useRef<HTMLElement>(null),
stats: useRef<HTMLElement>(null),
ending: useRef<HTMLElement>(null)
}
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '') const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
@@ -151,6 +184,351 @@ function DualReportWindow() {
void loadEmojis() void loadEmojis()
}, [reportData]) }, [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<HTMLElement> | 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<HTMLElement>
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<string>) => {
if (!containerRef.current || !reportData) {
return
}
setIsExporting(true)
setExportProgress('正在生成长图...')
let wordCloudInner: HTMLElement | null = null
let wordTags: NodeListOf<HTMLElement> | 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<HTMLElement>
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) { if (isLoading) {
return ( return (
<div className="annual-report-window loading"> <div className="annual-report-window loading">
@@ -305,7 +683,7 @@ function DualReportWindow() {
if (emojiUrl) { if (emojiUrl) {
return ( return (
<div className="report-emoji-container"> <div className="report-emoji-container">
<img src={emojiUrl} alt="表情" className="report-emoji-img" onError={(e) => { <img src={emojiUrl} alt="表情" className="report-emoji-img" crossOrigin="anonymous" onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
}} /> }} />
@@ -356,7 +734,7 @@ function DualReportWindow() {
if (avatarUrl) { if (avatarUrl) {
return ( return (
<div className="scene-avatar with-image"> <div className="scene-avatar with-image">
<img src={avatarUrl} alt={isSentByMe ? 'me-avatar' : 'friend-avatar'} /> <img src={avatarUrl} alt={isSentByMe ? 'me-avatar' : 'friend-avatar'} crossOrigin="anonymous" />
</div> </div>
) )
} }
@@ -419,9 +797,99 @@ function DualReportWindow() {
<div className="deco-circle c5" /> <div className="deco-circle c5" />
</div> </div>
<div className={`fab-container ${fabOpen ? 'open' : ''}`}>
<button
className="fab-item"
onClick={() => {
setFabOpen(false)
setExportMode('separate')
setShowExportModal(true)
}}
title="分模块导出"
>
<Image size={18} />
</button>
<button
className="fab-item"
onClick={() => {
setFabOpen(false)
setExportMode('long')
setShowExportModal(true)
}}
title="自定义导出长图"
>
<SlidersHorizontal size={18} />
</button>
<button
className="fab-item"
onClick={() => {
setFabOpen(false)
void exportFullReport()
}}
title="导出长图"
>
<Download size={18} />
</button>
<button className="fab-main" onClick={() => setFabOpen(!fabOpen)}>
{fabOpen ? <X size={22} /> : <Download size={22} />}
</button>
</div>
{isExporting && (
<div className="export-overlay">
<div className="export-progress-modal">
<div className="export-spinner">
<div className="spinner-ring"></div>
<Download size={24} className="spinner-icon" />
</div>
<p className="export-title"></p>
<p className="export-status">{exportProgress}</p>
</div>
</div>
)}
{showExportModal && (
<div className="export-overlay" onClick={() => setShowExportModal(false)}>
<div className="export-modal section-selector" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>{exportMode === 'long' ? '自定义导出长图' : '选择要导出的板块'}</h3>
<button className="close-btn" onClick={() => setShowExportModal(false)}>
<X size={20} />
</button>
</div>
<div className="section-grid">
{getAvailableSections().map((section) => (
<div
key={section.id}
className={`section-card ${selectedSections.has(section.id) ? 'selected' : ''}`}
onClick={() => toggleSection(section.id)}
>
<div className="card-check">
{selectedSections.has(section.id) && <Check size={14} />}
</div>
<span>{section.name}</span>
</div>
))}
</div>
<div className="modal-footer">
<button className="select-all-btn" onClick={toggleAll}>
{selectedSections.size === getAvailableSections().length ? '取消全选' : '全选'}
</button>
<button
className="confirm-btn"
onClick={() => void exportSelectedSections()}
disabled={selectedSections.size === 0}
>
{exportMode === 'long' ? '生成长图' : '导出'} {selectedSections.size > 0 ? `(${selectedSections.size})` : ''}
</button>
</div>
</div>
</div>
)}
<div className="report-scroll-view"> <div className="report-scroll-view">
<div className="report-container"> <div className="report-container" ref={containerRef}>
<section className="section"> <section className="section" ref={sectionRefs.cover}>
<div className="label-text">WEFLOW · DUAL REPORT</div> <div className="label-text">WEFLOW · DUAL REPORT</div>
<h1 className="hero-title dual-cover-title">{yearTitle}<br /></h1> <h1 className="hero-title dual-cover-title">{yearTitle}<br /></h1>
<hr className="divider" /> <hr className="divider" />
@@ -433,7 +901,7 @@ function DualReportWindow() {
<p className="hero-desc"></p> <p className="hero-desc"></p>
</section> </section>
<section className="section"> <section className="section" ref={sectionRefs.firstChat}>
<div className="label-text"></div> <div className="label-text"></div>
<h2 className="hero-title"></h2> <h2 className="hero-title"></h2>
{firstChat ? ( {firstChat ? (
@@ -457,7 +925,7 @@ function DualReportWindow() {
</section> </section>
{yearFirstChat && (!firstChat || yearFirstChat.createTime !== firstChat.createTime) ? ( {yearFirstChat && (!firstChat || yearFirstChat.createTime !== firstChat.createTime) ? (
<section className="section"> <section className="section" ref={sectionRefs.yearFirstChat}>
<div className="label-text"></div> <div className="label-text"></div>
<h2 className="hero-title"> <h2 className="hero-title">
{reportData.year === 0 ? '你们的第一段对话' : `${reportData.year}年的第一段对话`} {reportData.year === 0 ? '你们的第一段对话' : `${reportData.year}年的第一段对话`}
@@ -473,7 +941,7 @@ function DualReportWindow() {
) : null} ) : null}
{reportData.heatmap && ( {reportData.heatmap && (
<section className="section"> <section className="section" ref={sectionRefs.heatmap}>
<div className="label-text"></div> <div className="label-text"></div>
<h2 className="hero-title"></h2> <h2 className="hero-title"></h2>
{mostActive && ( {mostActive && (
@@ -486,14 +954,14 @@ function DualReportWindow() {
)} )}
{reportData.initiative && ( {reportData.initiative && (
<section className="section"> <section className="section" ref={sectionRefs.initiative}>
<div className="label-text"></div> <div className="label-text"></div>
<h2 className="hero-title"></h2> <h2 className="hero-title"></h2>
<div className="initiative-container"> <div className="initiative-container">
<div className="initiative-bar-wrapper"> <div className="initiative-bar-wrapper">
<div className="initiative-side"> <div className="initiative-side">
<div className="avatar-placeholder"> <div className="avatar-placeholder">
{reportData.selfAvatarUrl ? <img src={reportData.selfAvatarUrl} alt="me-avatar" /> : '我'} {reportData.selfAvatarUrl ? <img src={reportData.selfAvatarUrl} alt="me-avatar" crossOrigin="anonymous" /> : '我'}
</div> </div>
<div className="count">{reportData.initiative.initiated}</div> <div className="count">{reportData.initiative.initiated}</div>
<div className="percent">{initiatedPercent.toFixed(1)}%</div> <div className="percent">{initiatedPercent.toFixed(1)}%</div>
@@ -507,7 +975,7 @@ function DualReportWindow() {
</div> </div>
<div className="initiative-side"> <div className="initiative-side">
<div className="avatar-placeholder"> <div className="avatar-placeholder">
{reportData.friendAvatarUrl ? <img src={reportData.friendAvatarUrl} alt="friend-avatar" /> : reportData.friendName.substring(0, 1)} {reportData.friendAvatarUrl ? <img src={reportData.friendAvatarUrl} alt="friend-avatar" crossOrigin="anonymous" /> : reportData.friendName.substring(0, 1)}
</div> </div>
<div className="count">{reportData.initiative.received}</div> <div className="count">{reportData.initiative.received}</div>
<div className="percent">{receivedPercent.toFixed(1)}%</div> <div className="percent">{receivedPercent.toFixed(1)}%</div>
@@ -521,7 +989,7 @@ function DualReportWindow() {
)} )}
{reportData.response && ( {reportData.response && (
<section className="section"> <section className="section" ref={sectionRefs.response}>
<div className="label-text"></div> <div className="label-text"></div>
<h2 className="hero-title"></h2> <h2 className="hero-title"></h2>
<div className="response-pulse-container"> <div className="response-pulse-container">
@@ -558,7 +1026,7 @@ function DualReportWindow() {
)} )}
{reportData.streak && ( {reportData.streak && (
<section className="section"> <section className="section" ref={sectionRefs.streak}>
<div className="label-text"></div> <div className="label-text"></div>
<h2 className="hero-title"></h2> <h2 className="hero-title"></h2>
<div className="streak-spark-visual premium"> <div className="streak-spark-visual premium">
@@ -596,7 +1064,7 @@ function DualReportWindow() {
</section> </section>
)} )}
<section className="section word-cloud-section"> <section className="section word-cloud-section" ref={sectionRefs.wordCloud}>
<div className="label-text"></div> <div className="label-text"></div>
<h2 className="hero-title">{yearTitle}</h2> <h2 className="hero-title">{yearTitle}</h2>
@@ -640,7 +1108,7 @@ function DualReportWindow() {
</div> </div>
</section> </section>
<section className="section"> <section className="section" ref={sectionRefs.stats}>
<div className="label-text"></div> <div className="label-text"></div>
<h2 className="hero-title">{yearTitle}</h2> <h2 className="hero-title">{yearTitle}</h2>
<div className="dual-stat-grid"> <div className="dual-stat-grid">
@@ -664,7 +1132,7 @@ function DualReportWindow() {
<div className="emoji-card"> <div className="emoji-card">
<div className="emoji-title"></div> <div className="emoji-title"></div>
{myEmojiUrl ? ( {myEmojiUrl ? (
<img src={myEmojiUrl} alt="my-emoji" onError={(e) => { <img src={myEmojiUrl} alt="my-emoji" crossOrigin="anonymous" onError={(e) => {
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
(e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).style.display = 'none';
}} /> }} />
@@ -677,7 +1145,7 @@ function DualReportWindow() {
<div className="emoji-card"> <div className="emoji-card">
<div className="emoji-title">{reportData.friendName}</div> <div className="emoji-title">{reportData.friendName}</div>
{friendEmojiUrl ? ( {friendEmojiUrl ? (
<img src={friendEmojiUrl} alt="friend-emoji" onError={(e) => { <img src={friendEmojiUrl} alt="friend-emoji" crossOrigin="anonymous" onError={(e) => {
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
(e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).style.display = 'none';
}} /> }} />
@@ -690,7 +1158,7 @@ function DualReportWindow() {
</div> </div>
</section> </section>
<section className="section"> <section className="section" ref={sectionRefs.ending}>
<div className="label-text"></div> <div className="label-text"></div>
<h2 className="hero-title"></h2> <h2 className="hero-title"></h2>
<p className="hero-desc"></p> <p className="hero-desc"></p>

View File

@@ -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 [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'>('top-right')
const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all') const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all')
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([]) const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
const [launchAtStartup, setLaunchAtStartup] = useState(false)
const [launchAtStartupSupported, setLaunchAtStartupSupported] = useState(isWindows || isMac)
const [launchAtStartupReason, setLaunchAtStartupReason] = useState('')
const [windowCloseBehavior, setWindowCloseBehavior] = useState<configService.WindowCloseBehavior>('ask') const [windowCloseBehavior, setWindowCloseBehavior] = useState<configService.WindowCloseBehavior>('ask')
const [quoteLayout, setQuoteLayout] = useState<configService.QuoteLayout>('quote-top') const [quoteLayout, setQuoteLayout] = useState<configService.QuoteLayout>('quote-top')
const [updateChannel, setUpdateChannel] = useState<configService.UpdateChannel>('stable') const [updateChannel, setUpdateChannel] = useState<configService.UpdateChannel>('stable')
@@ -162,6 +165,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false) const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
const [isFetchingImageKey, setIsFetchingImageKey] = useState(false) const [isFetchingImageKey, setIsFetchingImageKey] = useState(false)
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false) const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
const [isUpdatingLaunchAtStartup, setIsUpdatingLaunchAtStartup] = useState(false)
const [appVersion, setAppVersion] = useState('') const [appVersion, setAppVersion] = useState('')
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null) const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null)
const [showDecryptKey, setShowDecryptKey] = useState(false) const [showDecryptKey, setShowDecryptKey] = useState(false)
@@ -337,6 +341,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedNotificationFilterMode = await configService.getNotificationFilterMode() const savedNotificationFilterMode = await configService.getNotificationFilterMode()
const savedNotificationFilterList = await configService.getNotificationFilterList() const savedNotificationFilterList = await configService.getNotificationFilterList()
const savedMessagePushEnabled = await configService.getMessagePushEnabled() const savedMessagePushEnabled = await configService.getMessagePushEnabled()
const savedLaunchAtStartupStatus = await window.electronAPI.app.getLaunchAtStartupStatus()
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior() const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
const savedQuoteLayout = await configService.getQuoteLayout() const savedQuoteLayout = await configService.getQuoteLayout()
const savedUpdateChannel = await configService.getUpdateChannel() const savedUpdateChannel = await configService.getUpdateChannel()
@@ -386,6 +391,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setNotificationFilterMode(savedNotificationFilterMode) setNotificationFilterMode(savedNotificationFilterMode)
setNotificationFilterList(savedNotificationFilterList) setNotificationFilterList(savedNotificationFilterList)
setMessagePushEnabled(savedMessagePushEnabled) setMessagePushEnabled(savedMessagePushEnabled)
setLaunchAtStartup(savedLaunchAtStartupStatus.enabled)
setLaunchAtStartupSupported(savedLaunchAtStartupStatus.supported)
setLaunchAtStartupReason(savedLaunchAtStartupStatus.reason || '')
setWindowCloseBehavior(savedWindowCloseBehavior) setWindowCloseBehavior(savedWindowCloseBehavior)
setQuoteLayout(savedQuoteLayout) setQuoteLayout(savedQuoteLayout)
if (savedUpdateChannel) { 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) => { const refreshWhisperStatus = async (modelDirValue = whisperModelDir) => {
try { try {
const result = await window.electronAPI.whisper?.getModelStatus() const result = await window.electronAPI.whisper?.getModelStatus()
@@ -1199,6 +1230,39 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="divider" /> <div className="divider" />
<div className="form-group">
<label></label>
<span className="form-hint">
{launchAtStartupSupported
? '开启后,登录系统时会自动启动 WeFlow。'
: launchAtStartupReason || '当前环境暂不支持开机自启动。'}
</span>
<div className="log-toggle-line">
<span className="log-status">
{isUpdatingLaunchAtStartup
? '保存中...'
: launchAtStartupSupported
? (launchAtStartup ? '已开启' : '已关闭')
: '当前不可用'}
</span>
<label className="switch" htmlFor="launch-at-startup-toggle">
<input
id="launch-at-startup-toggle"
className="switch-input"
type="checkbox"
checked={launchAtStartup}
disabled={!launchAtStartupSupported || isUpdatingLaunchAtStartup}
onChange={(e) => {
void handleLaunchAtStartupChange(e.target.checked)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="divider" />
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
<span className="form-hint"></span> <span className="form-hint"></span>

View File

@@ -13,6 +13,7 @@ export const CONFIG_KEYS = {
LAST_SESSION: 'lastSession', LAST_SESSION: 'lastSession',
WINDOW_BOUNDS: 'windowBounds', WINDOW_BOUNDS: 'windowBounds',
CACHE_PATH: 'cachePath', CACHE_PATH: 'cachePath',
LAUNCH_AT_STARTUP: 'launchAtStartup',
EXPORT_PATH: 'exportPath', EXPORT_PATH: 'exportPath',
AGREEMENT_ACCEPTED: 'agreementAccepted', AGREEMENT_ACCEPTED: 'agreementAccepted',
@@ -258,6 +259,18 @@ export async function setLogEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.LOG_ENABLED, enabled) await config.set(CONFIG_KEYS.LOG_ENABLED, enabled)
} }
// 获取开机自启动偏好
export async function getLaunchAtStartup(): Promise<boolean | null> {
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<void> {
await config.set(CONFIG_KEYS.LAUNCH_AT_STARTUP, enabled)
}
// 获取 LLM 模型路径 // 获取 LLM 模型路径
export async function getLlmModelPath(): Promise<string | null> { export async function getLlmModelPath(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.LLM_MODEL_PATH) const value = await config.get(CONFIG_KEYS.LLM_MODEL_PATH)

View File

@@ -56,6 +56,14 @@ export interface ElectronAPI {
app: { app: {
getDownloadsPath: () => Promise<string> getDownloadsPath: () => Promise<string>
getVersion: () => Promise<string> getVersion: () => Promise<string>
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 }> checkForUpdates: () => Promise<{ hasUpdate: boolean; version?: string; releaseNotes?: string }>
downloadAndInstall: () => Promise<void> downloadAndInstall: () => Promise<void>
ignoreUpdate: (version: string) => Promise<{ success: boolean }> ignoreUpdate: (version: string) => Promise<{ success: boolean }>

36
src/utils/reportExport.ts Normal file
View File

@@ -0,0 +1,36 @@
const PATTERN_LIGHT_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#000;stroke-width:1.2;opacity:0.045}.b{fill:none;stroke:#000;stroke-width:1;opacity:0.035}.c{fill:none;stroke:#000;stroke-width:0.8;opacity:0.04}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>`
const PATTERN_DARK_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#fff;stroke-width:1.2;opacity:0.055}.b{fill:none;stroke:#fff;stroke-width:1;opacity:0.045}.c{fill:none;stroke:#fff;stroke-width:0.8;opacity:0.05}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></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<void>((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
})
}