mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-26 07:26:46 +00:00
Compare commits
1 Commits
dev
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d64efdddf |
29
.github/scripts/release-utils.sh
vendored
29
.github/scripts/release-utils.sh
vendored
@@ -58,26 +58,12 @@ wait_for_release_id() {
|
|||||||
|
|
||||||
local i
|
local i
|
||||||
local release_id
|
local release_id
|
||||||
local release_api_url
|
|
||||||
for ((i = 1; i <= attempts; i++)); do
|
for ((i = 1; i <= attempts; i++)); do
|
||||||
release_id="$(gh api "repos/$repo/releases/tags/$tag" --jq '.id' 2>/dev/null || true)"
|
release_id="$(gh api "repos/$repo/releases/tags/$tag" --jq '.id' 2>/dev/null || true)"
|
||||||
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
|
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
|
||||||
echo "$release_id"
|
echo "$release_id"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
release_id="$(gh release view "$tag" --repo "$repo" --json databaseId --jq '.databaseId // empty' 2>/dev/null || true)"
|
|
||||||
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
|
|
||||||
echo "$release_id"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
release_api_url="$(gh release view "$tag" --repo "$repo" --json apiUrl --jq '.apiUrl // empty' 2>/dev/null || true)"
|
|
||||||
if [[ "$release_api_url" =~ /releases/([0-9]+)$ ]]; then
|
|
||||||
echo "${BASH_REMATCH[1]}"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$i" -lt "$attempts" ]; then
|
if [ "$i" -lt "$attempts" ]; then
|
||||||
echo "Release id for tag '$tag' is not ready yet (attempt $i/$attempts), retrying in ${delay_seconds}s..." >&2
|
echo "Release id for tag '$tag' is not ready yet (attempt $i/$attempts), retrying in ${delay_seconds}s..." >&2
|
||||||
sleep "$delay_seconds"
|
sleep "$delay_seconds"
|
||||||
@@ -85,7 +71,6 @@ wait_for_release_id() {
|
|||||||
done
|
done
|
||||||
|
|
||||||
echo "Unable to fetch release id for tag '$tag' after $attempts attempts." >&2
|
echo "Unable to fetch release id for tag '$tag' after $attempts attempts." >&2
|
||||||
gh release view "$tag" --repo "$repo" --json databaseId,id,isDraft,isPrerelease,url 2>/dev/null || true
|
|
||||||
gh api "repos/$repo/releases/tags/$tag" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
|
gh api "repos/$repo/releases/tags/$tag" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
@@ -102,10 +87,9 @@ settle_release_state() {
|
|||||||
local draft_state
|
local draft_state
|
||||||
local prerelease_state
|
local prerelease_state
|
||||||
for ((i = 1; i <= attempts; i++)); do
|
for ((i = 1; i <= attempts; i++)); do
|
||||||
gh release edit "$tag" --repo "$repo" --draft=false --prerelease >/dev/null 2>&1 || true
|
|
||||||
gh api --method PATCH "repos/$repo/releases/$release_id" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
gh api --method PATCH "repos/$repo/releases/$release_id" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
||||||
draft_state="$(gh api "$endpoint" --jq '.draft' 2>/dev/null || gh release view "$tag" --repo "$repo" --json isDraft --jq '.isDraft' 2>/dev/null || echo true)"
|
draft_state="$(gh api "$endpoint" --jq '.draft' 2>/dev/null || echo true)"
|
||||||
prerelease_state="$(gh api "$endpoint" --jq '.prerelease' 2>/dev/null || gh release view "$tag" --repo "$repo" --json isPrerelease --jq '.isPrerelease' 2>/dev/null || echo false)"
|
prerelease_state="$(gh api "$endpoint" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||||
if [ "$draft_state" = "false" ] && [ "$prerelease_state" = "true" ]; then
|
if [ "$draft_state" = "false" ] && [ "$prerelease_state" = "true" ]; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -116,19 +100,10 @@ settle_release_state() {
|
|||||||
done
|
done
|
||||||
|
|
||||||
echo "Failed to settle release state for tag '$tag'." >&2
|
echo "Failed to settle release state for tag '$tag'." >&2
|
||||||
gh release view "$tag" --repo "$repo" --json isDraft,isPrerelease,url 2>/dev/null || true
|
|
||||||
gh api "$endpoint" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
|
gh api "$endpoint" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
print_release_state() {
|
|
||||||
local repo="$1"
|
|
||||||
local tag="$2"
|
|
||||||
|
|
||||||
gh api "repos/$repo/releases/tags/$tag" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}' 2>/dev/null \
|
|
||||||
|| gh release view "$tag" --repo "$repo" --json isDraft,isPrerelease,url --jq '{isDraft: .isDraft, isPrerelease: .isPrerelease, url: .url}'
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_release_absent() {
|
wait_for_release_absent() {
|
||||||
local repo="$1"
|
local repo="$1"
|
||||||
local tag="$2"
|
local tag="$2"
|
||||||
|
|||||||
2
.github/workflows/dev-daily-fixed.yml
vendored
2
.github/workflows/dev-daily-fixed.yml
vendored
@@ -386,4 +386,4 @@ jobs:
|
|||||||
source .github/scripts/release-utils.sh
|
source .github/scripts/release-utils.sh
|
||||||
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
|
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
|
||||||
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
|
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
|
||||||
print_release_state "$REPO" "$TAG"
|
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
||||||
|
|||||||
2
.github/workflows/preview-nightly-main.yml
vendored
2
.github/workflows/preview-nightly-main.yml
vendored
@@ -429,4 +429,4 @@ jobs:
|
|||||||
source .github/scripts/release-utils.sh
|
source .github/scripts/release-utils.sh
|
||||||
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
|
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
|
||||||
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
|
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
|
||||||
print_release_state "$REPO" "$TAG"
|
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
||||||
|
|||||||
@@ -3,15 +3,17 @@
|
|||||||
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告。
|
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告。
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="app.jpg" alt="WeFlow 应用预览" width="90%">
|
<img src="app.png" alt="WeFlow 应用预览" width="90%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
<!-- 第一行修复样式 -->
|
||||||
<a href="https://github.com/hicccc77/WeFlow/stargazers"><img src="https://img.shields.io/github/stars/hicccc77/WeFlow?style=flat&label=Stars&labelColor=1F2937&color=2563EB" alt="Stargazers"></a>
|
<a href="https://github.com/hicccc77/WeFlow/stargazers"><img src="https://img.shields.io/github/stars/hicccc77/WeFlow?style=flat&label=Stars&labelColor=1F2937&color=2563EB" alt="Stargazers"></a>
|
||||||
<a href="https://github.com/hicccc77/WeFlow/network/members"><img src="https://img.shields.io/github/forks/hicccc77/WeFlow?style=flat&label=Forks&labelColor=1F2937&color=7C3AED" alt="Forks"></a>
|
<a href="https://github.com/hicccc77/WeFlow/network/members"><img src="https://img.shields.io/github/forks/hicccc77/WeFlow?style=flat&label=Forks&labelColor=1F2937&color=7C3AED" alt="Forks"></a>
|
||||||
<a href="https://github.com/hicccc77/WeFlow/issues"><img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat&label=Issues&labelColor=1F2937&color=D97706" alt="Issues"></a>
|
<a href="https://github.com/hicccc77/WeFlow/issues"><img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat&label=Issues&labelColor=1F2937&color=D97706" alt="Issues"></a>
|
||||||
<a href="https://github.com/hicccc77/WeFlow/releases"><img src="https://img.shields.io/github/downloads/hicccc77/WeFlow/total?style=flat&label=Downloads&labelColor=1F2937&color=059669" alt="Downloads"></a>
|
<a href="https://github.com/hicccc77/WeFlow/releases"><img src="https://img.shields.io/github/downloads/hicccc77/WeFlow/total?style=flat&label=Downloads&labelColor=1F2937&color=059669" alt="Downloads"></a>
|
||||||
<br><br>
|
<br><br>
|
||||||
|
<!-- 第二行:电报矮一点(22px),排名高一点(32px),使用 vertical-align: middle 居中对齐 -->
|
||||||
<a href="https://t.me/weflow_cc"><img src="https://img.shields.io/badge/Telegram-频道-1D9BF0?style=flat&logo=telegram&logoColor=white&labelColor=1F2937&color=1D9BF0" alt="Telegram Channel" style="height: 22px; vertical-align: middle;"></a>
|
<a href="https://t.me/weflow_cc"><img src="https://img.shields.io/badge/Telegram-频道-1D9BF0?style=flat&logo=telegram&logoColor=white&labelColor=1F2937&color=1D9BF0" alt="Telegram Channel" style="height: 22px; vertical-align: middle;"></a>
|
||||||
<a href="https://www.star-history.com/hicccc77/weflow"><img src="https://api.star-history.com/badge?repo=hicccc77/WeFlow&theme=dark" alt="Star History Rank" style="height: 32px; vertical-align: middle;"></a>
|
<a href="https://www.star-history.com/hicccc77/weflow"><img src="https://api.star-history.com/badge?repo=hicccc77/WeFlow&theme=dark" alt="Star History Rank" style="height: 32px; vertical-align: middle;"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -20,10 +20,9 @@
|
|||||||
1. **降级微信版本**。找一个经过大家验证、兼容性更好的老版本,目前最推荐先退回到 4.1.7.57 或者 4.1.8.100。
|
1. **降级微信版本**。找一个经过大家验证、兼容性更好的老版本,目前最推荐先退回到 4.1.7.57 或者 4.1.8.100。
|
||||||
2. **彻底退出微信**。请使用快捷键 Command + Q 或在活动监视器中结束进程,而不仅仅是关闭窗口。
|
2. **彻底退出微信**。请使用快捷键 Command + Q 或在活动监视器中结束进程,而不仅仅是关闭窗口。
|
||||||
3. **重启你的 Mac**。这一步极其关键,必须是真正的重新启动。注销或睡眠唤醒无法清除系统底层的拦截状态。
|
3. **重启你的 Mac**。这一步极其关键,必须是真正的重新启动。注销或睡眠唤醒无法清除系统底层的拦截状态。
|
||||||
4. **重新打开微信**。随便点击几下保持它在最前台,并且确保它是未登录的状态。
|
4. **重新打开微信**。随便点击几下保持它在最前台,并且确保它是可以正常交互的状态。
|
||||||
5. **回到 WeFlow**。仅仅尝试一次“自动获取密钥”。
|
5. **回到 WeFlow**。仅仅尝试一次“自动获取密钥”。
|
||||||
6. **输入密码并登录**。先在弹窗中输入你的系统密码后,确认页面弹出允许登录了再登录微信
|
6. **恢复日常使用**。只要成功拿到了密钥,你就可以放心地把微信更新回你平时爱用的最新版本。
|
||||||
7. **恢复日常使用**。只要成功拿到了密钥,你就可以放心地把微信更新回你平时爱用的最新版本。
|
|
||||||
|
|
||||||
### 常见报错与应对方法
|
### 常见报错与应对方法
|
||||||
|
|
||||||
@@ -51,4 +50,4 @@
|
|||||||
|
|
||||||
首次失败后,首要任务是排查原因,切忌盲目地连续点击自动获取。如果你在看到这篇文档前已经失败了好几次,最好的做法是直接清零重来:彻底退出微信,重启电脑,然后再进行下一次尝试。
|
首次失败后,首要任务是排查原因,切忌盲目地连续点击自动获取。如果你在看到这篇文档前已经失败了好几次,最好的做法是直接清零重来:彻底退出微信,重启电脑,然后再进行下一次尝试。
|
||||||
|
|
||||||
最后,如果尝试了上述所有方法依然无法解决,请记得保存完整的报错文本,特别是 SCAN_FAILED 或 HOOK_FAILED 后面跟着的英文细节。把这些信息提交到[issue](https://github.com/hicccc77/WeFlow/issues/745),会大大加快定位和修复兼容性问题的速度。
|
最后,如果尝试了上述所有方法依然无法解决,请记得保存完整的报错文本,特别是 SCAN_FAILED 或 HOOK_FAILED 后面跟着的英文细节。把这些信息提交到[issue](https://github.com/hicccc77/WeFlow/issues/745),会大大加快定位和修复兼容性问题的速度。
|
||||||
@@ -5,7 +5,6 @@ interface ExportWorkerConfig {
|
|||||||
sessionIds: string[]
|
sessionIds: string[]
|
||||||
outputDir: string
|
outputDir: string
|
||||||
options: ExportOptions
|
options: ExportOptions
|
||||||
taskId?: string
|
|
||||||
dbPath?: string
|
dbPath?: string
|
||||||
decryptKey?: string
|
decryptKey?: string
|
||||||
myWxid?: string
|
myWxid?: string
|
||||||
@@ -15,27 +14,6 @@ interface ExportWorkerConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = workerData as ExportWorkerConfig
|
const config = workerData as ExportWorkerConfig
|
||||||
const controlState = {
|
|
||||||
pauseRequested: false,
|
|
||||||
stopRequested: false
|
|
||||||
}
|
|
||||||
|
|
||||||
parentPort?.on('message', (message: any) => {
|
|
||||||
if (!message || typeof message.type !== 'string') return
|
|
||||||
if (message.type === 'export:pause') {
|
|
||||||
controlState.pauseRequested = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (message.type === 'export:resume') {
|
|
||||||
controlState.pauseRequested = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (message.type === 'export:cancel') {
|
|
||||||
controlState.stopRequested = true
|
|
||||||
controlState.pauseRequested = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
process.env.WEFLOW_WORKER = '1'
|
process.env.WEFLOW_WORKER = '1'
|
||||||
if (config.resourcesPath) {
|
if (config.resourcesPath) {
|
||||||
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
|
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
|
||||||
@@ -69,19 +47,7 @@ async function run() {
|
|||||||
type: 'export:progress',
|
type: 'export:progress',
|
||||||
data: progress
|
data: progress
|
||||||
})
|
})
|
||||||
},
|
}
|
||||||
config.taskId
|
|
||||||
? {
|
|
||||||
shouldPause: () => controlState.pauseRequested,
|
|
||||||
shouldStop: () => controlState.stopRequested,
|
|
||||||
recordCreatedFile: (filePath: string) => {
|
|
||||||
parentPort?.postMessage({ type: 'export:createdFile', filePath })
|
|
||||||
},
|
|
||||||
recordCreatedDir: (dirPath: string) => {
|
|
||||||
parentPort?.postMessage({ type: 'export:createdDir', dirPath })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
)
|
)
|
||||||
|
|
||||||
parentPort?.postMessage({
|
parentPort?.postMessage({
|
||||||
|
|||||||
178
electron/main.ts
178
electron/main.ts
@@ -16,7 +16,6 @@ import { analyticsService } from './services/analyticsService'
|
|||||||
import { groupAnalyticsService } from './services/groupAnalyticsService'
|
import { groupAnalyticsService } from './services/groupAnalyticsService'
|
||||||
import { annualReportService } from './services/annualReportService'
|
import { annualReportService } from './services/annualReportService'
|
||||||
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
|
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
|
||||||
import { exportTaskControlService } from './services/exportTaskControlService'
|
|
||||||
import { KeyService } from './services/keyService'
|
import { KeyService } from './services/keyService'
|
||||||
import { KeyServiceLinux } from './services/keyServiceLinux'
|
import { KeyServiceLinux } from './services/keyServiceLinux'
|
||||||
import { KeyServiceMac } from './services/keyServiceMac'
|
import { KeyServiceMac } from './services/keyServiceMac'
|
||||||
@@ -65,42 +64,6 @@ const defaultUpdateTrack: 'stable' | 'preview' | 'dev' = (() => {
|
|||||||
return 'stable'
|
return 'stable'
|
||||||
})()
|
})()
|
||||||
let configService: ConfigService | null = null
|
let configService: ConfigService | null = null
|
||||||
const activeExportWorkers = new Map<string, Worker>()
|
|
||||||
const activeExportTasks = new Set<string>()
|
|
||||||
|
|
||||||
const normalizeExportTaskId = (taskId: unknown): string => String(taskId || '').trim()
|
|
||||||
|
|
||||||
const postExportWorkerControl = (taskId: string, action: 'pause' | 'resume' | 'cancel') => {
|
|
||||||
const worker = activeExportWorkers.get(taskId)
|
|
||||||
if (!worker) return
|
|
||||||
try {
|
|
||||||
worker.postMessage({ type: `export:${action}` })
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`[export-task-control] failed to post ${action} to worker:`, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalizeExportTaskControlResult = async (taskId: string, result: any) => {
|
|
||||||
if (!taskId) return result
|
|
||||||
if (result?.stopped) {
|
|
||||||
const cleanup = await exportTaskControlService.cleanupTask(taskId)
|
|
||||||
if (!cleanup.success) {
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
success: false,
|
|
||||||
error: `导出已停止,但清理已导出文件失败:${cleanup.error || '未知错误'}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
cleanup
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!result?.paused) {
|
|
||||||
exportTaskControlService.releaseTask(taskId)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeUpdateTrack = (raw: unknown): 'stable' | 'preview' | 'dev' | null => {
|
const normalizeUpdateTrack = (raw: unknown): 'stable' | 'preview' | 'dev' | null => {
|
||||||
if (raw === 'stable' || raw === 'preview' || raw === 'dev') return raw
|
if (raw === 'stable' || raw === 'preview' || raw === 'dev') return raw
|
||||||
@@ -785,10 +748,6 @@ const getWindowCloseBehavior = (): WindowCloseBehavior => {
|
|||||||
return behavior === 'tray' || behavior === 'quit' ? behavior : 'ask'
|
return behavior === 'tray' || behavior === 'quit' ? behavior : 'ask'
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSilentStartupEnabled = (): boolean => {
|
|
||||||
return configService?.get('silentStartup') === true
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestMainWindowCloseConfirmation = (win: BrowserWindow): void => {
|
const requestMainWindowCloseConfirmation = (win: BrowserWindow): void => {
|
||||||
if (isClosePromptVisible) return
|
if (isClosePromptVisible) return
|
||||||
isClosePromptVisible = true
|
isClosePromptVisible = true
|
||||||
@@ -2278,10 +2237,6 @@ function registerIpcHandlers() {
|
|||||||
return chatService.getNewMessages(sessionId, minTime, limit)
|
return chatService.getNewMessages(sessionId, minTime, limit)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getAntiRevokeSessions', async () => {
|
|
||||||
return chatService.getAntiRevokeSessions()
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('chat:updateMessage', async (_, sessionId: string, localId: number, createTime: number, newContent: string) => {
|
ipcMain.handle('chat:updateMessage', async (_, sessionId: string, localId: number, createTime: number, newContent: string) => {
|
||||||
return chatService.updateMessage(sessionId, localId, createTime, newContent)
|
return chatService.updateMessage(sessionId, localId, createTime, newContent)
|
||||||
})
|
})
|
||||||
@@ -2673,25 +2628,16 @@ function registerIpcHandlers() {
|
|||||||
|
|
||||||
ipcMain.handle('sns:exportTimeline', async (event, options: any) => {
|
ipcMain.handle('sns:exportTimeline', async (event, options: any) => {
|
||||||
const exportOptions = { ...(options || {}) }
|
const exportOptions = { ...(options || {}) }
|
||||||
const taskId = normalizeExportTaskId(exportOptions.taskId)
|
|
||||||
delete exportOptions.taskId
|
delete exportOptions.taskId
|
||||||
const taskControl = taskId ? exportTaskControlService.createControl(taskId, String(exportOptions.outputDir || '')) : undefined
|
|
||||||
if (taskId) activeExportTasks.add(taskId)
|
|
||||||
|
|
||||||
try {
|
return snsService.exportTimeline(
|
||||||
const result = await snsService.exportTimeline(
|
exportOptions,
|
||||||
exportOptions,
|
(progress) => {
|
||||||
(progress) => {
|
if (!event.sender.isDestroyed()) {
|
||||||
if (!event.sender.isDestroyed()) {
|
event.sender.send('sns:exportProgress', progress)
|
||||||
event.sender.send('sns:exportProgress', progress)
|
}
|
||||||
}
|
}
|
||||||
},
|
)
|
||||||
taskControl
|
|
||||||
)
|
|
||||||
return finalizeExportTaskControlResult(taskId, result)
|
|
||||||
} finally {
|
|
||||||
if (taskId) activeExportTasks.delete(taskId)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('sns:selectExportDir', async () => {
|
ipcMain.handle('sns:selectExportDir', async () => {
|
||||||
@@ -3014,40 +2960,7 @@ function registerIpcHandlers() {
|
|||||||
return exportService.getExportStats(sessionIds, options)
|
return exportService.getExportStats(sessionIds, options)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('export:pauseTask', async (_, taskId: string) => {
|
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
|
||||||
const normalizedTaskId = normalizeExportTaskId(taskId)
|
|
||||||
if (!normalizedTaskId) return { success: false, error: '缺少导出任务 ID' }
|
|
||||||
const success = exportTaskControlService.pauseTask(normalizedTaskId)
|
|
||||||
if (success) postExportWorkerControl(normalizedTaskId, 'pause')
|
|
||||||
return { success }
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('export:resumeTask', async (_, taskId: string) => {
|
|
||||||
const normalizedTaskId = normalizeExportTaskId(taskId)
|
|
||||||
if (!normalizedTaskId) return { success: false, error: '缺少导出任务 ID' }
|
|
||||||
const success = exportTaskControlService.resumeTask(normalizedTaskId)
|
|
||||||
if (success) postExportWorkerControl(normalizedTaskId, 'resume')
|
|
||||||
return { success }
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('export:cancelTask', async (_, taskId: string) => {
|
|
||||||
const normalizedTaskId = normalizeExportTaskId(taskId)
|
|
||||||
if (!normalizedTaskId) return { success: false, error: '缺少导出任务 ID' }
|
|
||||||
const success = exportTaskControlService.cancelTask(normalizedTaskId)
|
|
||||||
if (success) postExportWorkerControl(normalizedTaskId, 'cancel')
|
|
||||||
if (success && !activeExportTasks.has(normalizedTaskId)) {
|
|
||||||
const cleanup = await exportTaskControlService.cleanupTask(normalizedTaskId)
|
|
||||||
return cleanup.success
|
|
||||||
? { success: true, cleanup }
|
|
||||||
: { success: false, error: cleanup.error || '清理已导出文件失败' }
|
|
||||||
}
|
|
||||||
return { success }
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions, controlOptions?: { taskId?: string }) => {
|
|
||||||
const taskId = normalizeExportTaskId(controlOptions?.taskId)
|
|
||||||
const taskControl = taskId ? exportTaskControlService.createControl(taskId, outputDir) : undefined
|
|
||||||
if (taskId) activeExportTasks.add(taskId)
|
|
||||||
const PROGRESS_FORWARD_INTERVAL_MS = 180
|
const PROGRESS_FORWARD_INTERVAL_MS = 180
|
||||||
let pendingProgress: ExportProgress | null = null
|
let pendingProgress: ExportProgress | null = null
|
||||||
let progressTimer: NodeJS.Timeout | null = null
|
let progressTimer: NodeJS.Timeout | null = null
|
||||||
@@ -3093,7 +3006,7 @@ function registerIpcHandlers() {
|
|||||||
|
|
||||||
const runMainFallback = async (reason: string) => {
|
const runMainFallback = async (reason: string) => {
|
||||||
console.warn(`[fallback-export-main] ${reason}`)
|
console.warn(`[fallback-export-main] ${reason}`)
|
||||||
return exportService.exportSessions(sessionIds, outputDir, options, onProgress, taskControl)
|
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfg = configService || new ConfigService()
|
const cfg = configService || new ConfigService()
|
||||||
@@ -3115,7 +3028,6 @@ function registerIpcHandlers() {
|
|||||||
sessionIds,
|
sessionIds,
|
||||||
outputDir,
|
outputDir,
|
||||||
options,
|
options,
|
||||||
taskId,
|
|
||||||
dbPath,
|
dbPath,
|
||||||
decryptKey,
|
decryptKey,
|
||||||
myWxid,
|
myWxid,
|
||||||
@@ -3126,15 +3038,9 @@ function registerIpcHandlers() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
let settled = false
|
let settled = false
|
||||||
if (taskId) {
|
|
||||||
activeExportWorkers.set(taskId, worker)
|
|
||||||
}
|
|
||||||
const finalizeResolve = (value: any) => {
|
const finalizeResolve = (value: any) => {
|
||||||
if (settled) return
|
if (settled) return
|
||||||
settled = true
|
settled = true
|
||||||
if (taskId && activeExportWorkers.get(taskId) === worker) {
|
|
||||||
activeExportWorkers.delete(taskId)
|
|
||||||
}
|
|
||||||
worker.removeAllListeners()
|
worker.removeAllListeners()
|
||||||
void worker.terminate()
|
void worker.terminate()
|
||||||
resolve(value)
|
resolve(value)
|
||||||
@@ -3142,9 +3048,6 @@ function registerIpcHandlers() {
|
|||||||
const finalizeReject = (error: Error) => {
|
const finalizeReject = (error: Error) => {
|
||||||
if (settled) return
|
if (settled) return
|
||||||
settled = true
|
settled = true
|
||||||
if (taskId && activeExportWorkers.get(taskId) === worker) {
|
|
||||||
activeExportWorkers.delete(taskId)
|
|
||||||
}
|
|
||||||
worker.removeAllListeners()
|
worker.removeAllListeners()
|
||||||
void worker.terminate()
|
void worker.terminate()
|
||||||
reject(error)
|
reject(error)
|
||||||
@@ -3155,14 +3058,6 @@ function registerIpcHandlers() {
|
|||||||
onProgress(msg.data as ExportProgress)
|
onProgress(msg.data as ExportProgress)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (msg && msg.type === 'export:createdFile' && taskId) {
|
|
||||||
exportTaskControlService.recordCreatedFile(taskId, String(msg.filePath || ''))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (msg && msg.type === 'export:createdDir' && taskId) {
|
|
||||||
exportTaskControlService.recordCreatedDir(taskId, String(msg.dirPath || ''))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (msg && msg.type === 'export:result') {
|
if (msg && msg.type === 'export:result') {
|
||||||
finalizeResolve(msg.data)
|
finalizeResolve(msg.data)
|
||||||
return
|
return
|
||||||
@@ -3188,13 +3083,10 @@ function registerIpcHandlers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runWorker()
|
return await runWorker()
|
||||||
return await finalizeExportTaskControlResult(taskId, result)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const result = await runMainFallback(error instanceof Error ? error.message : String(error))
|
return runMainFallback(error instanceof Error ? error.message : String(error))
|
||||||
return await finalizeExportTaskControlResult(taskId, result)
|
|
||||||
} finally {
|
} finally {
|
||||||
if (taskId) activeExportTasks.delete(taskId)
|
|
||||||
flushProgress()
|
flushProgress()
|
||||||
if (progressTimer) {
|
if (progressTimer) {
|
||||||
clearTimeout(progressTimer)
|
clearTimeout(progressTimer)
|
||||||
@@ -3848,31 +3740,21 @@ function checkForUpdatesOnStartup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
// 先初始化配置,以便在启动早期判定是否需要静默启动
|
// 立即创建 Splash 窗口,确保用户尽快看到反馈
|
||||||
configService = new ConfigService()
|
createSplashWindow()
|
||||||
applyAutoUpdateChannel('startup')
|
|
||||||
syncLaunchAtStartupPreference()
|
|
||||||
const onboardingDone = configService.get('onboardingDone') === true
|
|
||||||
const startInBackground = onboardingDone && isSilentStartupEnabled()
|
|
||||||
shouldShowMain = onboardingDone
|
|
||||||
|
|
||||||
if (!startInBackground) {
|
// 等待 Splash 页面加载完成后再推送进度
|
||||||
// 非静默模式下显示 Splash,提供启动反馈
|
if (splashWindow) {
|
||||||
createSplashWindow()
|
await new Promise<void>((resolve) => {
|
||||||
|
if (splashWindow!.webContents.isLoading()) {
|
||||||
// 等待 Splash 页面加载完成后再推送进度
|
splashWindow!.webContents.once('did-finish-load', () => resolve())
|
||||||
if (splashWindow) {
|
} else {
|
||||||
await new Promise<void>((resolve) => {
|
resolve()
|
||||||
if (splashWindow!.webContents.isLoading()) {
|
}
|
||||||
splashWindow!.webContents.once('did-finish-load', () => resolve())
|
})
|
||||||
} else {
|
splashWindow.webContents
|
||||||
resolve()
|
.executeJavaScript(`setVersion(${JSON.stringify(app.getVersion())})`)
|
||||||
}
|
.catch(() => {})
|
||||||
})
|
|
||||||
splashWindow.webContents
|
|
||||||
.executeJavaScript(`setVersion(${JSON.stringify(app.getVersion())})`)
|
|
||||||
.catch(() => {})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
@@ -3901,7 +3783,13 @@ app.whenReady().then(async () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 初始化配置服务
|
||||||
updateSplashProgress(5, '正在加载配置...')
|
updateSplashProgress(5, '正在加载配置...')
|
||||||
|
configService = new ConfigService()
|
||||||
|
applyAutoUpdateChannel('startup')
|
||||||
|
syncLaunchAtStartupPreference()
|
||||||
|
const onboardingDone = configService.get('onboardingDone') === true
|
||||||
|
shouldShowMain = onboardingDone
|
||||||
|
|
||||||
// 将用户主题配置推送给 Splash 窗口
|
// 将用户主题配置推送给 Splash 窗口
|
||||||
if (splashWindow && !splashWindow.isDestroyed()) {
|
if (splashWindow && !splashWindow.isDestroyed()) {
|
||||||
@@ -4068,8 +3956,6 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
if (!onboardingDone) {
|
if (!onboardingDone) {
|
||||||
createOnboardingWindow()
|
createOnboardingWindow()
|
||||||
} else if (startInBackground && tray) {
|
|
||||||
mainWindow?.hide()
|
|
||||||
} else {
|
} else {
|
||||||
mainWindow?.show()
|
mainWindow?.show()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,7 +185,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
chat: {
|
chat: {
|
||||||
connect: () => ipcRenderer.invoke('chat:connect'),
|
connect: () => ipcRenderer.invoke('chat:connect'),
|
||||||
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
||||||
getAntiRevokeSessions: () => ipcRenderer.invoke('chat:getAntiRevokeSessions'),
|
|
||||||
getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames),
|
getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames),
|
||||||
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
|
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
|
||||||
getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'),
|
getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'),
|
||||||
@@ -463,14 +462,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
export: {
|
export: {
|
||||||
getExportStats: (sessionIds: string[], options: any) =>
|
getExportStats: (sessionIds: string[], options: any) =>
|
||||||
ipcRenderer.invoke('export:getExportStats', sessionIds, options),
|
ipcRenderer.invoke('export:getExportStats', sessionIds, options),
|
||||||
exportSessions: (sessionIds: string[], outputDir: string, options: any, controlOptions?: { taskId?: string }) =>
|
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
|
||||||
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options, controlOptions),
|
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
||||||
pauseTask: (taskId: string) =>
|
|
||||||
ipcRenderer.invoke('export:pauseTask', taskId),
|
|
||||||
resumeTask: (taskId: string) =>
|
|
||||||
ipcRenderer.invoke('export:resumeTask', taskId),
|
|
||||||
cancelTask: (taskId: string) =>
|
|
||||||
ipcRenderer.invoke('export:cancelTask', taskId),
|
|
||||||
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
||||||
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
||||||
exportContacts: (outputDir: string, options: any) =>
|
exportContacts: (outputDir: string, options: any) =>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BrowserWindow, app } from 'electron'
|
import { BrowserWindow, app } from 'electron'
|
||||||
import { createWriteStream, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'fs'
|
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'fs'
|
||||||
import { copyFile, link, readFile as readFileAsync, mkdtemp, writeFile } from 'fs/promises'
|
import { copyFile, link, readFile as readFileAsync, mkdtemp, writeFile } from 'fs/promises'
|
||||||
import { basename, dirname, join, relative, resolve, sep } from 'path'
|
import { basename, dirname, join, relative, resolve, sep } from 'path'
|
||||||
import { tmpdir } from 'os'
|
import { tmpdir } from 'os'
|
||||||
@@ -7,12 +7,11 @@ import * as tar from 'tar'
|
|||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { expandHomePath } from '../utils/pathUtils'
|
import { expandHomePath } from '../utils/pathUtils'
|
||||||
|
import { decryptDatViaNative, encryptDatViaNative } from './nativeImageDecrypt'
|
||||||
|
|
||||||
type BackupDbKind = 'session' | 'contact' | 'emoticon' | 'message' | 'media' | 'sns' | 'hardlink'
|
type BackupDbKind = 'session' | 'contact' | 'emoticon' | 'message' | 'media' | 'sns'
|
||||||
type BackupPhase = 'preparing' | 'scanning' | 'exporting' | 'packing' | 'inspecting' | 'restoring' | 'done' | 'failed'
|
type BackupPhase = 'preparing' | 'scanning' | 'exporting' | 'packing' | 'inspecting' | 'restoring' | 'done' | 'failed'
|
||||||
type BackupResourceKind = 'image' | 'video' | 'file'
|
type BackupResourceKind = 'image' | 'video' | 'file'
|
||||||
const TEMP_MARKER = '.weflow-backup-temp'
|
|
||||||
const TEMP_TTL_MS = 24 * 60 * 60 * 1000
|
|
||||||
|
|
||||||
export interface BackupOptions {
|
export interface BackupOptions {
|
||||||
includeImages?: boolean
|
includeImages?: boolean
|
||||||
@@ -141,42 +140,8 @@ function hasResourceOptions(options: BackupOptions): boolean {
|
|||||||
return options.includeImages === true || options.includeVideos === true || options.includeFiles === true
|
return options.includeImages === true || options.includeVideos === true || options.includeFiles === true
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeArchivePath(value: string): string {
|
|
||||||
return String(value || '').replace(/\\/g, '/')
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BackupService {
|
export class BackupService {
|
||||||
private configService = new ConfigService()
|
private configService = new ConfigService()
|
||||||
private cleanedTempDirs = false
|
|
||||||
|
|
||||||
private cleanupStaleTempDirs(): void {
|
|
||||||
if (this.cleanedTempDirs) return
|
|
||||||
this.cleanedTempDirs = true
|
|
||||||
const root = tmpdir()
|
|
||||||
const now = Date.now()
|
|
||||||
try {
|
|
||||||
for (const entry of readdirSync(root)) {
|
|
||||||
if (!entry.startsWith('weflow-backup-')) continue
|
|
||||||
const dir = join(root, entry)
|
|
||||||
const marker = join(dir, TEMP_MARKER)
|
|
||||||
try {
|
|
||||||
const stat = statSync(dir)
|
|
||||||
if (!stat.isDirectory()) continue
|
|
||||||
if (!existsSync(marker)) continue
|
|
||||||
const age = now - stat.mtimeMs
|
|
||||||
if (age < TEMP_TTL_MS) continue
|
|
||||||
rmSync(dir, { recursive: true, force: true })
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async createTempDir(prefix: string): Promise<string> {
|
|
||||||
this.cleanupStaleTempDirs()
|
|
||||||
const dir = await mkdtemp(join(tmpdir(), prefix))
|
|
||||||
await writeFile(join(dir, TEMP_MARKER), String(Date.now()), 'utf8')
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildWxidCandidates(wxid: string): string[] {
|
private buildWxidCandidates(wxid: string): string[] {
|
||||||
const wxidCandidates = Array.from(new Set([
|
const wxidCandidates = Array.from(new Set([
|
||||||
@@ -288,6 +253,27 @@ export class BackupService {
|
|||||||
return suffixMatch ? suffixMatch[1] : trimmed
|
return suffixMatch ? suffixMatch[1] : trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseImageXorKey(value: unknown): number {
|
||||||
|
if (typeof value === 'number') return value
|
||||||
|
const text = String(value ?? '').trim()
|
||||||
|
if (!text) return Number.NaN
|
||||||
|
return text.toLowerCase().startsWith('0x') ? parseInt(text, 16) : parseInt(text, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getImageKeysForWxid(wxid: string): { xorKey: number; aesKey?: string } | null {
|
||||||
|
const wxidConfigs = this.configService.get('wxidConfigs') || {}
|
||||||
|
const candidates = this.buildWxidCandidates(wxid)
|
||||||
|
const matchedKey = Object.keys(wxidConfigs).find((key) => {
|
||||||
|
const cleanKey = this.cleanAccountDirName(key).toLowerCase()
|
||||||
|
return candidates.some(candidate => cleanKey === candidate.toLowerCase())
|
||||||
|
})
|
||||||
|
const cfg = matchedKey ? wxidConfigs[matchedKey] : undefined
|
||||||
|
const xorKey = this.parseImageXorKey(cfg?.imageXorKey ?? this.configService.get('imageXorKey'))
|
||||||
|
if (!Number.isFinite(xorKey)) return null
|
||||||
|
const aesKey = String(cfg?.imageAesKey ?? this.configService.get('imageAesKey') ?? '').trim()
|
||||||
|
return { xorKey, aesKey: aesKey || undefined }
|
||||||
|
}
|
||||||
|
|
||||||
private async listFilesForArchive(root: string, rel = '', state = { visited: 0 }): Promise<string[]> {
|
private async listFilesForArchive(root: string, rel = '', state = { visited: 0 }): Promise<string[]> {
|
||||||
const dir = join(root, rel)
|
const dir = join(root, rel)
|
||||||
const files: string[] = []
|
const files: string[] = []
|
||||||
@@ -309,7 +295,7 @@ export class BackupService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resolveExtractedPath(extractDir: string, archivePath: string): string | null {
|
private resolveExtractedPath(extractDir: string, archivePath: string): string | null {
|
||||||
const normalized = normalizeArchivePath(archivePath)
|
const normalized = String(archivePath || '').replace(/\\/g, '/')
|
||||||
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null
|
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null
|
||||||
const root = resolve(extractDir)
|
const root = resolve(extractDir)
|
||||||
const target = resolve(join(extractDir, normalized))
|
const target = resolve(join(extractDir, normalized))
|
||||||
@@ -317,12 +303,8 @@ export class BackupService {
|
|||||||
return target
|
return target
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveStagingPath(stagingDir: string, archivePath: string): string | null {
|
|
||||||
return this.resolveExtractedPath(stagingDir, archivePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveTargetResourcePath(accountDir: string, relativePath: string): string | null {
|
private resolveTargetResourcePath(accountDir: string, relativePath: string): string | null {
|
||||||
const normalized = normalizeArchivePath(relativePath)
|
const normalized = String(relativePath || '').replace(/\\/g, '/')
|
||||||
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null
|
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null
|
||||||
const root = resolve(accountDir)
|
const root = resolve(accountDir)
|
||||||
const target = resolve(join(accountDir, normalized))
|
const target = resolve(join(accountDir, normalized))
|
||||||
@@ -369,18 +351,6 @@ export class BackupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async writeTarEntryToFile(entry: any, outputPath: string): Promise<void> {
|
|
||||||
mkdirSync(dirname(outputPath), { recursive: true })
|
|
||||||
await new Promise<void>((resolvePromise, rejectPromise) => {
|
|
||||||
const out = createWriteStream(outputPath)
|
|
||||||
const fail = (error: unknown) => rejectPromise(error instanceof Error ? error : new Error(String(error)))
|
|
||||||
out.on('finish', resolvePromise)
|
|
||||||
out.on('error', fail)
|
|
||||||
entry.on('error', fail)
|
|
||||||
entry.pipe(out)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listChatImageDatFiles(accountDir: string): Promise<string[]> {
|
private async listChatImageDatFiles(accountDir: string): Promise<string[]> {
|
||||||
const attachRoot = join(accountDir, 'msg', 'attach')
|
const attachRoot = join(accountDir, 'msg', 'attach')
|
||||||
const result: string[] = []
|
const result: string[] = []
|
||||||
@@ -474,7 +444,7 @@ export class BackupService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildDbId(kind: BackupDbKind, index: number, dbPath: string): string {
|
private buildDbId(kind: BackupDbKind, index: number, dbPath: string): string {
|
||||||
if (kind === 'session' || kind === 'contact' || kind === 'emoticon' || kind === 'sns' || kind === 'hardlink') return kind
|
if (kind === 'session' || kind === 'contact' || kind === 'emoticon' || kind === 'sns') return kind
|
||||||
return `${kind}-${index}-${safeName(basename(dbPath)).slice(0, 80)}`
|
return `${kind}-${index}-${safeName(basename(dbPath)).slice(0, 80)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,7 +468,6 @@ export class BackupService {
|
|||||||
if (kind === 'contact') return 'contact/contact.db'
|
if (kind === 'contact') return 'contact/contact.db'
|
||||||
if (kind === 'emoticon') return 'emoticon/emoticon.db'
|
if (kind === 'emoticon') return 'emoticon/emoticon.db'
|
||||||
if (kind === 'sns') return 'sns/sns.db'
|
if (kind === 'sns') return 'sns/sns.db'
|
||||||
if (kind === 'hardlink') return 'hardlink/hardlink.db'
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,19 +517,12 @@ export class BackupService {
|
|||||||
join(dirname(dbStorage), 'sns', 'sns.db')
|
join(dirname(dbStorage), 'sns', 'sns.db')
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
if (kind === 'hardlink') {
|
|
||||||
return this.findFirstExisting([
|
|
||||||
join(dbStorage, 'hardlink', 'hardlink.db'),
|
|
||||||
join(dbStorage, 'hardlink.db'),
|
|
||||||
join(dirname(dbStorage), 'hardlink.db')
|
|
||||||
])
|
|
||||||
}
|
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
private async collectDatabases(dbStorage: string): Promise<Array<Omit<BackupDbEntry, 'tables'>>> {
|
private async collectDatabases(dbStorage: string): Promise<Array<Omit<BackupDbEntry, 'tables'>>> {
|
||||||
const result: Array<Omit<BackupDbEntry, 'tables'>> = []
|
const result: Array<Omit<BackupDbEntry, 'tables'>> = []
|
||||||
for (const kind of ['session', 'contact', 'emoticon', 'sns', 'hardlink'] as const) {
|
for (const kind of ['session', 'contact', 'emoticon', 'sns'] as const) {
|
||||||
const dbPath = this.resolveKnownDbPath(kind, dbStorage)
|
const dbPath = this.resolveKnownDbPath(kind, dbStorage)
|
||||||
result.push({
|
result.push({
|
||||||
id: kind,
|
id: kind,
|
||||||
@@ -603,9 +565,11 @@ export class BackupService {
|
|||||||
manifest: BackupManifest
|
manifest: BackupManifest
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const accountDir = dirname(connected.dbStorage)
|
const accountDir = dirname(connected.dbStorage)
|
||||||
|
const keys = this.getImageKeysForWxid(connected.wxid)
|
||||||
const imagesDir = join(stagingDir, 'resources', 'images')
|
const imagesDir = join(stagingDir, 'resources', 'images')
|
||||||
const imagePaths = await this.listChatImageDatFiles(accountDir)
|
const imagePaths = await this.listChatImageDatFiles(accountDir)
|
||||||
if (imagePaths.length === 0) return
|
if (imagePaths.length === 0) return
|
||||||
|
if (!keys) throw new Error('存在图片资源,但未配置图片解密密钥')
|
||||||
|
|
||||||
mkdirSync(imagesDir, { recursive: true })
|
mkdirSync(imagesDir, { recursive: true })
|
||||||
const resources: BackupResourceEntry[] = []
|
const resources: BackupResourceEntry[] = []
|
||||||
@@ -616,16 +580,18 @@ export class BackupService {
|
|||||||
if (!relativeTarget) continue
|
if (!relativeTarget) continue
|
||||||
emitImageProgress({
|
emitImageProgress({
|
||||||
phase: 'exporting',
|
phase: 'exporting',
|
||||||
message: '正在打包图片资源',
|
message: '正在解密图片资源',
|
||||||
current: index + 1,
|
current: index + 1,
|
||||||
total: imagePaths.length,
|
total: imagePaths.length,
|
||||||
detail: relativeTarget
|
detail: relativeTarget
|
||||||
})
|
})
|
||||||
const archivePath = toArchivePath(join('resources', 'images', relativeTarget))
|
const decrypted = decryptDatViaNative(sourcePath, keys.xorKey, keys.aesKey)
|
||||||
|
if (!decrypted) continue
|
||||||
|
const archivePath = toArchivePath(join('resources', 'images', `${relativeTarget}${decrypted.ext || '.bin'}`))
|
||||||
const outputPath = join(stagingDir, archivePath)
|
const outputPath = join(stagingDir, archivePath)
|
||||||
await this.stagePlainResource(sourcePath, outputPath)
|
mkdirSync(dirname(outputPath), { recursive: true })
|
||||||
|
await writeFile(outputPath, decrypted.data)
|
||||||
const stem = basename(sourcePath).replace(/\.dat$/i, '').toLowerCase()
|
const stem = basename(sourcePath).replace(/\.dat$/i, '').toLowerCase()
|
||||||
const stat = statSync(sourcePath)
|
|
||||||
resources.push({
|
resources.push({
|
||||||
kind: 'image',
|
kind: 'image',
|
||||||
id: relativeTarget,
|
id: relativeTarget,
|
||||||
@@ -633,7 +599,8 @@ export class BackupService {
|
|||||||
sourceFileName: basename(sourcePath),
|
sourceFileName: basename(sourcePath),
|
||||||
archivePath,
|
archivePath,
|
||||||
targetRelativePath: relativeTarget,
|
targetRelativePath: relativeTarget,
|
||||||
size: stat.size
|
ext: decrypted.ext || undefined,
|
||||||
|
size: decrypted.data.length
|
||||||
})
|
})
|
||||||
if (index % 20 === 0) await delay()
|
if (index % 20 === 0) await delay()
|
||||||
}
|
}
|
||||||
@@ -709,7 +676,7 @@ export class BackupService {
|
|||||||
return { success: false, error: connected.error || '数据库未连接' }
|
return { success: false, error: connected.error || '数据库未连接' }
|
||||||
}
|
}
|
||||||
|
|
||||||
stagingDir = await this.createTempDir('weflow-backup-')
|
stagingDir = await mkdtemp(join(tmpdir(), 'weflow-backup-'))
|
||||||
const snapshotsDir = join(stagingDir, 'snapshots')
|
const snapshotsDir = join(stagingDir, 'snapshots')
|
||||||
mkdirSync(snapshotsDir, { recursive: true })
|
mkdirSync(snapshotsDir, { recursive: true })
|
||||||
|
|
||||||
@@ -847,7 +814,7 @@ export class BackupService {
|
|||||||
let extractDir = ''
|
let extractDir = ''
|
||||||
try {
|
try {
|
||||||
emitBackupProgress({ phase: 'inspecting', message: '正在读取备份包' })
|
emitBackupProgress({ phase: 'inspecting', message: '正在读取备份包' })
|
||||||
extractDir = await this.createTempDir('weflow-backup-inspect-')
|
extractDir = await mkdtemp(join(tmpdir(), 'weflow-backup-inspect-'))
|
||||||
await tar.x({
|
await tar.x({
|
||||||
file: archivePath,
|
file: archivePath,
|
||||||
cwd: extractDir,
|
cwd: extractDir,
|
||||||
@@ -869,119 +836,12 @@ export class BackupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async streamRestoreArchive(
|
|
||||||
archivePath: string,
|
|
||||||
extractDir: string,
|
|
||||||
manifest: BackupManifest,
|
|
||||||
connected: { dbStorage: string; wxid?: string },
|
|
||||||
startCurrent: number,
|
|
||||||
total: number
|
|
||||||
): Promise<{ current: number; skipped: number }> {
|
|
||||||
const snapshotPaths = new Set<string>()
|
|
||||||
for (const db of manifest.databases || []) {
|
|
||||||
for (const table of db.tables || []) {
|
|
||||||
const path = normalizeArchivePath(table.snapshotPath)
|
|
||||||
if (path) snapshotPaths.add(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageByPath = new Map<string, BackupResourceEntry>()
|
|
||||||
for (const image of manifest.resources?.images || []) {
|
|
||||||
const path = normalizeArchivePath(image.archivePath)
|
|
||||||
if (path) imageByPath.set(path, image)
|
|
||||||
}
|
|
||||||
|
|
||||||
const plainByPath = new Map<string, BackupResourceEntry>()
|
|
||||||
for (const resource of [
|
|
||||||
...(manifest.resources?.videos || []),
|
|
||||||
...(manifest.resources?.files || [])
|
|
||||||
]) {
|
|
||||||
const path = normalizeArchivePath(resource.archivePath)
|
|
||||||
if (path) plainByPath.set(path, resource)
|
|
||||||
}
|
|
||||||
|
|
||||||
const accountDir = dirname(connected.dbStorage)
|
|
||||||
let current = startCurrent
|
|
||||||
let skipped = 0
|
|
||||||
const pending: Promise<void>[] = []
|
|
||||||
const emitRestoreProgress = createThrottledProgressEmitter(160)
|
|
||||||
await tar.t({
|
|
||||||
file: archivePath,
|
|
||||||
onReadEntry: (entry: any) => {
|
|
||||||
const entryPath = normalizeArchivePath(entry.path)
|
|
||||||
if (snapshotPaths.has(entryPath)) {
|
|
||||||
const outputPath = this.resolveStagingPath(extractDir, entryPath)
|
|
||||||
if (!outputPath) {
|
|
||||||
entry.resume()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pending.push(this.writeTarEntryToFile(entry, outputPath))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const image = imageByPath.get(entryPath)
|
|
||||||
if (image) {
|
|
||||||
const targetPath = this.resolveTargetResourcePath(accountDir, image.targetRelativePath)
|
|
||||||
if (!targetPath) {
|
|
||||||
skipped += 1
|
|
||||||
entry.resume()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
current += 1
|
|
||||||
emitRestoreProgress({
|
|
||||||
phase: 'restoring',
|
|
||||||
message: '正在写回图片资源',
|
|
||||||
current,
|
|
||||||
total,
|
|
||||||
detail: image.md5 || image.targetRelativePath
|
|
||||||
})
|
|
||||||
if (existsSync(targetPath)) {
|
|
||||||
skipped += 1
|
|
||||||
entry.resume()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pending.push(this.writeTarEntryToFile(entry, targetPath))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const resource = plainByPath.get(entryPath)
|
|
||||||
if (resource) {
|
|
||||||
const targetPath = this.resolveTargetResourcePath(accountDir, resource.targetRelativePath)
|
|
||||||
current += 1
|
|
||||||
emitRestoreProgress({
|
|
||||||
phase: 'restoring',
|
|
||||||
message: resource.kind === 'video' ? '正在写回视频资源' : '正在写回文件资源',
|
|
||||||
current,
|
|
||||||
total,
|
|
||||||
detail: resource.targetRelativePath
|
|
||||||
})
|
|
||||||
if (!targetPath || existsSync(targetPath)) {
|
|
||||||
skipped += 1
|
|
||||||
entry.resume()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pending.push(this.writeTarEntryToFile(entry, targetPath))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.resume()
|
|
||||||
}
|
|
||||||
} as any)
|
|
||||||
|
|
||||||
await Promise.all(pending)
|
|
||||||
return { current, skipped }
|
|
||||||
}
|
|
||||||
|
|
||||||
async restoreBackup(archivePath: string): Promise<{ success: boolean; inserted?: number; ignored?: number; skipped?: number; error?: string }> {
|
async restoreBackup(archivePath: string): Promise<{ success: boolean; inserted?: number; ignored?: number; skipped?: number; error?: string }> {
|
||||||
let extractDir = ''
|
let extractDir = ''
|
||||||
try {
|
try {
|
||||||
emitBackupProgress({ phase: 'inspecting', message: '正在读取备份信息' })
|
emitBackupProgress({ phase: 'inspecting', message: '正在解包备份' })
|
||||||
extractDir = await this.createTempDir('weflow-backup-restore-')
|
extractDir = await mkdtemp(join(tmpdir(), 'weflow-backup-restore-'))
|
||||||
await tar.x({
|
await tar.x({ file: archivePath, cwd: extractDir })
|
||||||
file: archivePath,
|
|
||||||
cwd: extractDir,
|
|
||||||
filter: (entryPath: string) => normalizeArchivePath(entryPath) === 'manifest.json'
|
|
||||||
} as any)
|
|
||||||
const manifestPath = join(extractDir, 'manifest.json')
|
const manifestPath = join(extractDir, 'manifest.json')
|
||||||
if (!existsSync(manifestPath)) return { success: false, error: '备份包缺少 manifest.json' }
|
if (!existsSync(manifestPath)) return { success: false, error: '备份包缺少 manifest.json' }
|
||||||
const manifest = JSON.parse(await readFileAsync(manifestPath, 'utf8')) as BackupManifest
|
const manifest = JSON.parse(await readFileAsync(manifestPath, 'utf8')) as BackupManifest
|
||||||
@@ -1006,26 +866,6 @@ export class BackupService {
|
|||||||
let ignored = 0
|
let ignored = 0
|
||||||
let skipped = 0
|
let skipped = 0
|
||||||
let current = 0
|
let current = 0
|
||||||
if (imageJobs.length > 0 || plainResourceJobs.length > 0 || tableJobs.length > 0) {
|
|
||||||
emitBackupProgress({
|
|
||||||
phase: 'inspecting',
|
|
||||||
message: '正在按需读取备份包',
|
|
||||||
current: 0,
|
|
||||||
total: totalRestoreJobs,
|
|
||||||
detail: archivePath
|
|
||||||
})
|
|
||||||
const streamed = await this.streamRestoreArchive(
|
|
||||||
archivePath,
|
|
||||||
extractDir,
|
|
||||||
manifest,
|
|
||||||
{ dbStorage: connected.dbStorage, wxid: connected.wxid },
|
|
||||||
0,
|
|
||||||
totalRestoreJobs
|
|
||||||
)
|
|
||||||
current = streamed.current
|
|
||||||
skipped += streamed.skipped
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const job of tableJobs) {
|
for (const job of tableJobs) {
|
||||||
current++
|
current++
|
||||||
const targetDbPath = this.resolveRestoreTargetDbPath(connected.dbStorage, job.db)
|
const targetDbPath = this.resolveRestoreTargetDbPath(connected.dbStorage, job.db)
|
||||||
@@ -1067,6 +907,68 @@ export class BackupService {
|
|||||||
if (current % 4 === 0) await delay()
|
if (current % 4 === 0) await delay()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (imageJobs.length > 0) {
|
||||||
|
const targetWxid = connected.wxid || String(manifest.source?.wxid || '').trim()
|
||||||
|
const imageKeys = this.getImageKeysForWxid(targetWxid)
|
||||||
|
if (!imageKeys) throw new Error('备份包包含图片资源,但目标账号未配置图片加密密钥')
|
||||||
|
const accountDir = dirname(connected.dbStorage)
|
||||||
|
for (const image of imageJobs) {
|
||||||
|
current += 1
|
||||||
|
emitBackupProgress({
|
||||||
|
phase: 'restoring',
|
||||||
|
message: '正在加密并写回图片资源',
|
||||||
|
current,
|
||||||
|
total: totalRestoreJobs,
|
||||||
|
detail: image.md5 || image.targetRelativePath
|
||||||
|
})
|
||||||
|
const inputPath = this.resolveExtractedPath(extractDir, image.archivePath)
|
||||||
|
const targetPath = this.resolveTargetResourcePath(accountDir, image.targetRelativePath)
|
||||||
|
if (!inputPath || !targetPath || !existsSync(inputPath)) {
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (existsSync(targetPath)) {
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const encrypted = encryptDatViaNative(inputPath, imageKeys.xorKey, imageKeys.aesKey)
|
||||||
|
if (!encrypted) {
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mkdirSync(dirname(targetPath), { recursive: true })
|
||||||
|
await writeFile(targetPath, encrypted)
|
||||||
|
if (current % 16 === 0) await delay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plainResourceJobs.length > 0) {
|
||||||
|
const accountDir = dirname(connected.dbStorage)
|
||||||
|
for (const resource of plainResourceJobs) {
|
||||||
|
current += 1
|
||||||
|
emitBackupProgress({
|
||||||
|
phase: 'restoring',
|
||||||
|
message: resource.kind === 'video' ? '正在写回视频资源' : '正在写回文件资源',
|
||||||
|
current,
|
||||||
|
total: totalRestoreJobs,
|
||||||
|
detail: resource.targetRelativePath
|
||||||
|
})
|
||||||
|
const inputPath = this.resolveExtractedPath(extractDir, resource.archivePath)
|
||||||
|
const targetPath = this.resolveTargetResourcePath(accountDir, resource.targetRelativePath)
|
||||||
|
if (!inputPath || !targetPath || !existsSync(inputPath)) {
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (existsSync(targetPath)) {
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mkdirSync(dirname(targetPath), { recursive: true })
|
||||||
|
await copyFile(inputPath, targetPath)
|
||||||
|
if (current % 30 === 0) await delay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
emitBackupProgress({ phase: 'done', message: '载入完成', current: totalRestoreJobs, total: totalRestoreJobs })
|
emitBackupProgress({ phase: 'done', message: '载入完成', current: totalRestoreJobs, total: totalRestoreJobs })
|
||||||
return { success: true, inserted, ignored, skipped }
|
return { success: true, inserted, ignored, skipped }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -666,9 +666,6 @@ class ChatService {
|
|||||||
if (this.connected && wcdbService.isReady()) {
|
if (this.connected && wcdbService.isReady()) {
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
if (!wcdbService.isReady()) {
|
|
||||||
this.monitorSetup = false
|
|
||||||
}
|
|
||||||
const result = await this.connect()
|
const result = await this.connect()
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
this.connected = false
|
this.connected = false
|
||||||
@@ -712,7 +709,6 @@ class ChatService {
|
|||||||
console.error('ChatService: 关闭数据库失败:', e)
|
console.error('ChatService: 关闭数据库失败:', e)
|
||||||
}
|
}
|
||||||
this.connected = false
|
this.connected = false
|
||||||
this.monitorSetup = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -749,12 +745,8 @@ class ChatService {
|
|||||||
try {
|
try {
|
||||||
const connectResult = await this.ensureConnected()
|
const connectResult = await this.ensureConnected()
|
||||||
if (!connectResult.success) return { success: false, error: connectResult.error }
|
if (!connectResult.success) return { success: false, error: connectResult.error }
|
||||||
const { validIds, invalidRows } = await this.filterAntiRevokeSessionIds(sessionIds)
|
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
||||||
const result = validIds.length > 0
|
return await wcdbService.checkMessageAntiRevokeTriggers(normalizedIds)
|
||||||
? await wcdbService.checkMessageAntiRevokeTriggers(validIds)
|
|
||||||
: { success: true, rows: [] }
|
|
||||||
if (!result.success) return result
|
|
||||||
return { success: true, rows: [...(result.rows || []), ...invalidRows] }
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
@@ -768,12 +760,8 @@ class ChatService {
|
|||||||
try {
|
try {
|
||||||
const connectResult = await this.ensureConnected()
|
const connectResult = await this.ensureConnected()
|
||||||
if (!connectResult.success) return { success: false, error: connectResult.error }
|
if (!connectResult.success) return { success: false, error: connectResult.error }
|
||||||
const { validIds, invalidRows } = await this.filterAntiRevokeSessionIds(sessionIds)
|
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
||||||
const result = validIds.length > 0
|
return await wcdbService.installMessageAntiRevokeTriggers(normalizedIds)
|
||||||
? await wcdbService.installMessageAntiRevokeTriggers(validIds)
|
|
||||||
: { success: true, rows: [] }
|
|
||||||
if (!result.success) return result
|
|
||||||
return { success: true, rows: [...(result.rows || []), ...invalidRows] }
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
@@ -787,12 +775,8 @@ class ChatService {
|
|||||||
try {
|
try {
|
||||||
const connectResult = await this.ensureConnected()
|
const connectResult = await this.ensureConnected()
|
||||||
if (!connectResult.success) return { success: false, error: connectResult.error }
|
if (!connectResult.success) return { success: false, error: connectResult.error }
|
||||||
const { validIds, invalidRows } = await this.filterAntiRevokeSessionIds(sessionIds)
|
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
||||||
const result = validIds.length > 0
|
return await wcdbService.uninstallMessageAntiRevokeTriggers(normalizedIds)
|
||||||
? await wcdbService.uninstallMessageAntiRevokeTriggers(validIds)
|
|
||||||
: { success: true, rows: [] }
|
|
||||||
if (!result.success) return result
|
|
||||||
return { success: true, rows: [...(result.rows || []), ...invalidRows] }
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
@@ -950,191 +934,6 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAntiRevokeSessions(): Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> {
|
|
||||||
try {
|
|
||||||
const result = await this.getSessions()
|
|
||||||
if (!result.success || !Array.isArray(result.sessions)) {
|
|
||||||
return { success: false, error: result.error || '获取会话失败' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
sessions: result.sessions.filter((session) => !String(session.username || '').startsWith('gh_'))
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('ChatService: 获取防撤回会话列表失败:', e)
|
|
||||||
return { success: false, error: String(e) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getSessionUsername(row: Record<string, any>): string {
|
|
||||||
return String(
|
|
||||||
row.username ||
|
|
||||||
row.user_name ||
|
|
||||||
row.userName ||
|
|
||||||
row.usrName ||
|
|
||||||
row.UsrName ||
|
|
||||||
row.talker ||
|
|
||||||
row.talker_id ||
|
|
||||||
row.talkerId ||
|
|
||||||
''
|
|
||||||
).trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
private isAntiRevokeContactRow(username: string, row: Record<string, any>): boolean {
|
|
||||||
if (!username) return false
|
|
||||||
if (username.endsWith('@chatroom')) return true
|
|
||||||
if (username.startsWith('gh_')) return false
|
|
||||||
|
|
||||||
const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], Number.NaN)
|
|
||||||
const lowered = username.toLowerCase()
|
|
||||||
if (this.isEnterpriseOpenimUsername(username)) {
|
|
||||||
return this.isAllowedEnterpriseOpenimByLocalType(username, localType)
|
|
||||||
}
|
|
||||||
if (lowered.startsWith('weixin') && lowered !== 'weixin') return true
|
|
||||||
return localType === 1 && !FRIEND_EXCLUDE_USERNAMES.has(username)
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadAntiRevokeContactMap(usernames: string[]): Promise<Map<string, { displayName?: string }>> {
|
|
||||||
const targets = Array.from(new Set((usernames || []).map((value) => String(value || '').trim()).filter(Boolean)))
|
|
||||||
const map = new Map<string, { displayName?: string }>()
|
|
||||||
if (targets.length === 0) return map
|
|
||||||
|
|
||||||
try {
|
|
||||||
const contactResult = await wcdbService.getContactsCompact(targets)
|
|
||||||
if (!contactResult.success || !Array.isArray(contactResult.contacts)) return map
|
|
||||||
|
|
||||||
for (const row of contactResult.contacts as Record<string, any>[]) {
|
|
||||||
const username = String(row.username || '').trim()
|
|
||||||
if (!username || !this.isAntiRevokeContactRow(username, row)) continue
|
|
||||||
map.set(username, {
|
|
||||||
displayName: String(row.remark || row.nick_name || row.nickName || row.alias || username).trim()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return map
|
|
||||||
}
|
|
||||||
|
|
||||||
return map
|
|
||||||
}
|
|
||||||
|
|
||||||
private async hasAntiRevokeMessageTables(sessionId: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const tableStatsResult = await wcdbService.getMessageTableStats(sessionId)
|
|
||||||
if (!tableStatsResult.success || !Array.isArray(tableStatsResult.tables)) return false
|
|
||||||
return tableStatsResult.tables.some((row: Record<string, any>) => {
|
|
||||||
const tableName = String(row.table_name || row.tableName || '').trim()
|
|
||||||
return tableName.length > 0
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async buildAntiRevokeSessionsFromRows(rows: Record<string, any>[]): Promise<ChatSession[]> {
|
|
||||||
if (rows.length > 0 && (rows[0]._error || rows[0]._info)) return []
|
|
||||||
|
|
||||||
const candidateRows: Array<{ username: string; row: Record<string, any> }> = []
|
|
||||||
const privateCandidateIds: string[] = []
|
|
||||||
const openimLocalTypeMap = await this.loadContactLocalTypeMapForEnterpriseOpenim(rows.map((row) => this.getSessionUsername(row)))
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
const username = this.getSessionUsername(row)
|
|
||||||
if (!username) continue
|
|
||||||
|
|
||||||
let sessionLocalType = this.getSessionLocalType(row)
|
|
||||||
if (!Number.isFinite(sessionLocalType) && this.isEnterpriseOpenimUsername(username)) {
|
|
||||||
sessionLocalType = openimLocalTypeMap.get(username)
|
|
||||||
}
|
|
||||||
if (!this.shouldKeepSession(username, sessionLocalType)) continue
|
|
||||||
|
|
||||||
if (username.endsWith('@chatroom')) {
|
|
||||||
candidateRows.push({ username, row })
|
|
||||||
} else {
|
|
||||||
privateCandidateIds.push(username)
|
|
||||||
candidateRows.push({ username, row })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const contactMap = await this.loadAntiRevokeContactMap(privateCandidateIds)
|
|
||||||
const sessions: ChatSession[] = []
|
|
||||||
const myWxid = this.configService.get('myWxid')
|
|
||||||
const now = Date.now()
|
|
||||||
|
|
||||||
for (const { username, row } of candidateRows) {
|
|
||||||
const isGroup = username.endsWith('@chatroom')
|
|
||||||
if (!isGroup && !contactMap.has(username)) continue
|
|
||||||
if (!await this.hasAntiRevokeMessageTables(username)) continue
|
|
||||||
|
|
||||||
const sortTs = parseInt(
|
|
||||||
row.sort_timestamp ||
|
|
||||||
row.sortTimestamp ||
|
|
||||||
row.sort_time ||
|
|
||||||
row.sortTime ||
|
|
||||||
'0',
|
|
||||||
10
|
|
||||||
)
|
|
||||||
const lastTs = parseInt(
|
|
||||||
row.last_timestamp ||
|
|
||||||
row.lastTimestamp ||
|
|
||||||
row.last_msg_time ||
|
|
||||||
row.lastMsgTime ||
|
|
||||||
String(sortTs),
|
|
||||||
10
|
|
||||||
)
|
|
||||||
const summary = this.cleanString(row.summary || row.digest || row.last_msg || row.lastMsg || '')
|
|
||||||
const lastMsgType = parseInt(row.last_msg_type || row.lastMsgType || '0', 10)
|
|
||||||
const cached = this.avatarCache.get(username)
|
|
||||||
const contact = contactMap.get(username)
|
|
||||||
|
|
||||||
const session: ChatSession = {
|
|
||||||
username,
|
|
||||||
type: parseInt(row.type || '0', 10),
|
|
||||||
unreadCount: parseInt(row.unread_count || row.unreadCount || row.unreadcount || '0', 10),
|
|
||||||
summary: summary || this.getMessageTypeLabel(lastMsgType),
|
|
||||||
sortTimestamp: sortTs,
|
|
||||||
lastTimestamp: lastTs,
|
|
||||||
lastMsgType,
|
|
||||||
displayName: contact?.displayName || cached?.displayName || username,
|
|
||||||
avatarUrl: cached?.avatarUrl,
|
|
||||||
lastMsgSender: row.last_msg_sender,
|
|
||||||
lastSenderDisplayName: row.last_sender_display_name,
|
|
||||||
selfWxid: myWxid
|
|
||||||
}
|
|
||||||
|
|
||||||
const cachedStatus = this.sessionStatusCache.get(username)
|
|
||||||
if (cachedStatus && now - cachedStatus.updatedAt <= this.sessionStatusCacheTtlMs) {
|
|
||||||
session.isFolded = cachedStatus.isFolded
|
|
||||||
session.isMuted = cachedStatus.isMuted
|
|
||||||
}
|
|
||||||
|
|
||||||
sessions.push(session)
|
|
||||||
}
|
|
||||||
|
|
||||||
return sessions
|
|
||||||
}
|
|
||||||
|
|
||||||
private async filterAntiRevokeSessionIds(sessionIds: string[]): Promise<{
|
|
||||||
validIds: string[]
|
|
||||||
invalidRows: Array<{ sessionId: string; success: false; error: string }>
|
|
||||||
}> {
|
|
||||||
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
|
||||||
if (normalizedIds.length === 0) return { validIds: [], invalidRows: [] }
|
|
||||||
|
|
||||||
const sessionsResult = await this.getAntiRevokeSessions()
|
|
||||||
const allowedIds = new Set((sessionsResult.sessions || []).map((session) => session.username))
|
|
||||||
const validIds = normalizedIds.filter((sessionId) => allowedIds.has(sessionId))
|
|
||||||
const invalidRows = normalizedIds
|
|
||||||
.filter((sessionId) => !allowedIds.has(sessionId))
|
|
||||||
.map((sessionId) => ({
|
|
||||||
sessionId,
|
|
||||||
success: false as const,
|
|
||||||
error: '该会话不是联系人或群聊,或不存在可安装防撤回的消息表'
|
|
||||||
}))
|
|
||||||
|
|
||||||
return { validIds, invalidRows }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async addMissingOfficialSessions(sessions: ChatSession[], myWxid?: string): Promise<void> {
|
private async addMissingOfficialSessions(sessions: ChatSession[], myWxid?: string): Promise<void> {
|
||||||
const existing = new Set(sessions.map((session) => String(session.username || '').trim()).filter(Boolean))
|
const existing = new Set(sessions.map((session) => String(session.username || '').trim()).filter(Boolean))
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ interface ConfigSchema {
|
|||||||
language: string
|
language: string
|
||||||
logEnabled: boolean
|
logEnabled: boolean
|
||||||
launchAtStartup?: boolean
|
launchAtStartup?: boolean
|
||||||
silentStartup?: boolean
|
|
||||||
llmModelPath: string
|
llmModelPath: string
|
||||||
whisperModelName: string
|
whisperModelName: string
|
||||||
whisperModelDir: string
|
whisperModelDir: string
|
||||||
@@ -164,7 +163,6 @@ export class ConfigService {
|
|||||||
themeId: 'cloud-dancer',
|
themeId: 'cloud-dancer',
|
||||||
language: 'zh-CN',
|
language: 'zh-CN',
|
||||||
logEnabled: false,
|
logEnabled: false,
|
||||||
silentStartup: false,
|
|
||||||
llmModelPath: '',
|
llmModelPath: '',
|
||||||
whisperModelName: 'base',
|
whisperModelName: 'base',
|
||||||
whisperModelDir: '',
|
whisperModelDir: '',
|
||||||
|
|||||||
@@ -200,8 +200,6 @@ interface MediaSourceResolution {
|
|||||||
interface ExportTaskControl {
|
interface ExportTaskControl {
|
||||||
shouldPause?: () => boolean
|
shouldPause?: () => boolean
|
||||||
shouldStop?: () => boolean
|
shouldStop?: () => boolean
|
||||||
recordCreatedFile?: (filePath: string) => void
|
|
||||||
recordCreatedDir?: (dirPath: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExportStatsResult {
|
interface ExportStatsResult {
|
||||||
@@ -281,7 +279,6 @@ class ExportService {
|
|||||||
private readonly exportAggregatedSessionStatsCacheTtlMs = 60 * 1000
|
private readonly exportAggregatedSessionStatsCacheTtlMs = 60 * 1000
|
||||||
private readonly exportStatsCacheMaxEntries = 16
|
private readonly exportStatsCacheMaxEntries = 16
|
||||||
private readonly STOP_ERROR_CODE = 'WEFLOW_EXPORT_STOP_REQUESTED'
|
private readonly STOP_ERROR_CODE = 'WEFLOW_EXPORT_STOP_REQUESTED'
|
||||||
private readonly PAUSE_ERROR_CODE = 'WEFLOW_EXPORT_PAUSE_REQUESTED'
|
|
||||||
private mediaFileCachePopulatePending = new Map<string, Promise<string | null>>()
|
private mediaFileCachePopulatePending = new Map<string, Promise<string | null>>()
|
||||||
private mediaFileCacheReadyDirs = new Set<string>()
|
private mediaFileCacheReadyDirs = new Set<string>()
|
||||||
private mediaExportTelemetry: MediaExportTelemetry | null = null
|
private mediaExportTelemetry: MediaExportTelemetry | null = null
|
||||||
@@ -314,12 +311,6 @@ class ExportService {
|
|||||||
return error
|
return error
|
||||||
}
|
}
|
||||||
|
|
||||||
private createPauseError(): Error {
|
|
||||||
const error = new Error('导出任务已暂停')
|
|
||||||
;(error as Error & { code?: string }).code = this.PAUSE_ERROR_CODE
|
|
||||||
return error
|
|
||||||
}
|
|
||||||
|
|
||||||
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string } | null): void {
|
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string } | null): void {
|
||||||
this.runtimeConfig = config
|
this.runtimeConfig = config
|
||||||
}
|
}
|
||||||
@@ -462,42 +453,10 @@ class ExportService {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private isPauseError(error: unknown): boolean {
|
|
||||||
if (!error) return false
|
|
||||||
if (typeof error === 'string') {
|
|
||||||
return error.includes(this.PAUSE_ERROR_CODE) || error.includes('导出任务已暂停')
|
|
||||||
}
|
|
||||||
if (error instanceof Error) {
|
|
||||||
const code = (error as Error & { code?: string }).code
|
|
||||||
return code === this.PAUSE_ERROR_CODE || error.message.includes(this.PAUSE_ERROR_CODE) || error.message.includes('导出任务已暂停')
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private throwIfStopRequested(control?: ExportTaskControl): void {
|
private throwIfStopRequested(control?: ExportTaskControl): void {
|
||||||
if (control?.shouldStop?.()) {
|
if (control?.shouldStop?.()) {
|
||||||
throw this.createStopError()
|
throw this.createStopError()
|
||||||
}
|
}
|
||||||
if (control?.shouldPause?.()) {
|
|
||||||
throw this.createPauseError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureExportDir(dirPath: string, control?: ExportTaskControl, dirCache?: Set<string>): Promise<void> {
|
|
||||||
if (dirCache?.has(dirPath)) return
|
|
||||||
const existed = await this.pathExists(dirPath)
|
|
||||||
await fs.promises.mkdir(dirPath, { recursive: true })
|
|
||||||
dirCache?.add(dirPath)
|
|
||||||
if (!existed) {
|
|
||||||
control?.recordCreatedDir?.(dirPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async recordCreatedFileBeforeWrite(filePath: string, control?: ExportTaskControl): Promise<void> {
|
|
||||||
if (!control?.recordCreatedFile) return
|
|
||||||
if (!await this.pathExists(filePath)) {
|
|
||||||
control.recordCreatedFile(filePath)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number {
|
private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number {
|
||||||
@@ -891,10 +850,8 @@ class ExportService {
|
|||||||
private async copyMediaWithCacheAndDedup(
|
private async copyMediaWithCacheAndDedup(
|
||||||
kind: 'image' | 'video' | 'emoji',
|
kind: 'image' | 'video' | 'emoji',
|
||||||
sourcePath: string,
|
sourcePath: string,
|
||||||
destPath: string,
|
destPath: string
|
||||||
control?: ExportTaskControl
|
|
||||||
): Promise<{ success: boolean; code?: string }> {
|
): Promise<{ success: boolean; code?: string }> {
|
||||||
const existedBeforeCopy = await this.pathExists(destPath)
|
|
||||||
const resolved = await this.resolvePreferredMediaSource(kind, sourcePath)
|
const resolved = await this.resolvePreferredMediaSource(kind, sourcePath)
|
||||||
if (resolved.cacheHit) {
|
if (resolved.cacheHit) {
|
||||||
this.noteMediaTelemetry({ cacheHitFiles: 1 })
|
this.noteMediaTelemetry({ cacheHitFiles: 1 })
|
||||||
@@ -913,9 +870,6 @@ class ExportService {
|
|||||||
dedupReuseFiles: 1,
|
dedupReuseFiles: 1,
|
||||||
bytesWritten: resolved.fileStat?.size || 0
|
bytesWritten: resolved.fileStat?.size || 0
|
||||||
})
|
})
|
||||||
if (!existedBeforeCopy) {
|
|
||||||
control?.recordCreatedFile?.(destPath)
|
|
||||||
}
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -932,9 +886,6 @@ class ExportService {
|
|||||||
doneFiles: 1,
|
doneFiles: 1,
|
||||||
bytesWritten: resolved.fileStat?.size || 0
|
bytesWritten: resolved.fileStat?.size || 0
|
||||||
})
|
})
|
||||||
if (!existedBeforeCopy) {
|
|
||||||
control?.recordCreatedFile?.(destPath)
|
|
||||||
}
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4011,7 +3962,6 @@ class ExportService {
|
|||||||
includeVideoPoster?: boolean
|
includeVideoPoster?: boolean
|
||||||
includeVoiceWithTranscript?: boolean
|
includeVoiceWithTranscript?: boolean
|
||||||
dirCache?: Set<string>
|
dirCache?: Set<string>
|
||||||
control?: ExportTaskControl
|
|
||||||
}
|
}
|
||||||
): Promise<MediaExportItem | null> {
|
): Promise<MediaExportItem | null> {
|
||||||
const localType = msg.localType
|
const localType = msg.localType
|
||||||
@@ -4023,8 +3973,7 @@ class ExportService {
|
|||||||
sessionId,
|
sessionId,
|
||||||
mediaRootDir,
|
mediaRootDir,
|
||||||
mediaRelativePrefix,
|
mediaRelativePrefix,
|
||||||
options.dirCache,
|
options.dirCache
|
||||||
options.control
|
|
||||||
)
|
)
|
||||||
if (result) {
|
if (result) {
|
||||||
}
|
}
|
||||||
@@ -4034,7 +3983,7 @@ class ExportService {
|
|||||||
// 语音消息
|
// 语音消息
|
||||||
if (localType === 34) {
|
if (localType === 34) {
|
||||||
if (options.exportVoices) {
|
if (options.exportVoices) {
|
||||||
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control)
|
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache)
|
||||||
}
|
}
|
||||||
if (options.exportVoiceAsText) {
|
if (options.exportVoiceAsText) {
|
||||||
return null
|
return null
|
||||||
@@ -4043,7 +3992,7 @@ class ExportService {
|
|||||||
|
|
||||||
// 动画表情
|
// 动画表情
|
||||||
if (localType === 47 && options.exportEmojis) {
|
if (localType === 47 && options.exportEmojis) {
|
||||||
const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control)
|
const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache)
|
||||||
if (result) {
|
if (result) {
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -4056,8 +4005,7 @@ class ExportService {
|
|||||||
mediaRootDir,
|
mediaRootDir,
|
||||||
mediaRelativePrefix,
|
mediaRelativePrefix,
|
||||||
options.dirCache,
|
options.dirCache,
|
||||||
options.includeVideoPoster === true,
|
options.includeVideoPoster === true
|
||||||
options.control
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4067,8 +4015,7 @@ class ExportService {
|
|||||||
mediaRootDir,
|
mediaRootDir,
|
||||||
mediaRelativePrefix,
|
mediaRelativePrefix,
|
||||||
options.maxFileSizeMb,
|
options.maxFileSizeMb,
|
||||||
options.dirCache,
|
options.dirCache
|
||||||
options.control
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4083,12 +4030,14 @@ class ExportService {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
mediaRootDir: string,
|
mediaRootDir: string,
|
||||||
mediaRelativePrefix: string,
|
mediaRelativePrefix: string,
|
||||||
dirCache?: Set<string>,
|
dirCache?: Set<string>
|
||||||
control?: ExportTaskControl
|
|
||||||
): Promise<MediaExportItem | null> {
|
): Promise<MediaExportItem | null> {
|
||||||
try {
|
try {
|
||||||
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
|
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
|
||||||
await this.ensureExportDir(imagesDir, control, dirCache)
|
if (!dirCache?.has(imagesDir)) {
|
||||||
|
await fs.promises.mkdir(imagesDir, { recursive: true })
|
||||||
|
dirCache?.add(imagesDir)
|
||||||
|
}
|
||||||
|
|
||||||
const tryResolveImagePath = async (imageMd5?: string, imageDatName?: string): Promise<string | null> => {
|
const tryResolveImagePath = async (imageMd5?: string, imageDatName?: string): Promise<string | null> => {
|
||||||
if (!imageMd5 && !imageDatName) return null
|
if (!imageMd5 && !imageDatName) return null
|
||||||
@@ -4174,7 +4123,6 @@ class ExportService {
|
|||||||
const destPath = path.join(imagesDir, fileName)
|
const destPath = path.join(imagesDir, fileName)
|
||||||
|
|
||||||
const buffer = Buffer.from(base64Data, 'base64')
|
const buffer = Buffer.from(base64Data, 'base64')
|
||||||
await this.recordCreatedFileBeforeWrite(destPath, control)
|
|
||||||
await fs.promises.writeFile(destPath, buffer)
|
await fs.promises.writeFile(destPath, buffer)
|
||||||
this.noteMediaTelemetry({
|
this.noteMediaTelemetry({
|
||||||
doneFiles: 1,
|
doneFiles: 1,
|
||||||
@@ -4194,7 +4142,7 @@ class ExportService {
|
|||||||
const ext = path.extname(sourcePath) || '.jpg'
|
const ext = path.extname(sourcePath) || '.jpg'
|
||||||
const fileName = `${messageId}_${imageKey}${ext}`
|
const fileName = `${messageId}_${imageKey}${ext}`
|
||||||
const destPath = path.join(imagesDir, fileName)
|
const destPath = path.join(imagesDir, fileName)
|
||||||
const copied = await this.copyMediaWithCacheAndDedup('image', sourcePath, destPath, control)
|
const copied = await this.copyMediaWithCacheAndDedup('image', sourcePath, destPath)
|
||||||
if (!copied.success) {
|
if (!copied.success) {
|
||||||
if (copied.code === 'ENOENT') {
|
if (copied.code === 'ENOENT') {
|
||||||
console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`)
|
console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`)
|
||||||
@@ -4313,12 +4261,14 @@ class ExportService {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
mediaRootDir: string,
|
mediaRootDir: string,
|
||||||
mediaRelativePrefix: string,
|
mediaRelativePrefix: string,
|
||||||
dirCache?: Set<string>,
|
dirCache?: Set<string>
|
||||||
control?: ExportTaskControl
|
|
||||||
): Promise<MediaExportItem | null> {
|
): Promise<MediaExportItem | null> {
|
||||||
try {
|
try {
|
||||||
const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices')
|
const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices')
|
||||||
await this.ensureExportDir(voicesDir, control, dirCache)
|
if (!dirCache?.has(voicesDir)) {
|
||||||
|
await fs.promises.mkdir(voicesDir, { recursive: true })
|
||||||
|
dirCache?.add(voicesDir)
|
||||||
|
}
|
||||||
|
|
||||||
const msgId = String(msg.localId)
|
const msgId = String(msg.localId)
|
||||||
const safeSession = this.cleanAccountDirName(sessionId)
|
const safeSession = this.cleanAccountDirName(sessionId)
|
||||||
@@ -4350,7 +4300,6 @@ class ExportService {
|
|||||||
|
|
||||||
// voiceResult.data 是 base64 编码的 wav 数据
|
// voiceResult.data 是 base64 编码的 wav 数据
|
||||||
const wavBuffer = Buffer.from(voiceResult.data, 'base64')
|
const wavBuffer = Buffer.from(voiceResult.data, 'base64')
|
||||||
await this.recordCreatedFileBeforeWrite(destPath, control)
|
|
||||||
await fs.promises.writeFile(destPath, wavBuffer)
|
await fs.promises.writeFile(destPath, wavBuffer)
|
||||||
this.noteMediaTelemetry({
|
this.noteMediaTelemetry({
|
||||||
doneFiles: 1,
|
doneFiles: 1,
|
||||||
@@ -4389,12 +4338,14 @@ class ExportService {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
mediaRootDir: string,
|
mediaRootDir: string,
|
||||||
mediaRelativePrefix: string,
|
mediaRelativePrefix: string,
|
||||||
dirCache?: Set<string>,
|
dirCache?: Set<string>
|
||||||
control?: ExportTaskControl
|
|
||||||
): Promise<MediaExportItem | null> {
|
): Promise<MediaExportItem | null> {
|
||||||
try {
|
try {
|
||||||
const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis')
|
const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis')
|
||||||
await this.ensureExportDir(emojisDir, control, dirCache)
|
if (!dirCache?.has(emojisDir)) {
|
||||||
|
await fs.promises.mkdir(emojisDir, { recursive: true })
|
||||||
|
dirCache?.add(emojisDir)
|
||||||
|
}
|
||||||
|
|
||||||
// 使用 chatService 下载表情包 (利用其重试和 fallback 逻辑)
|
// 使用 chatService 下载表情包 (利用其重试和 fallback 逻辑)
|
||||||
const localPath = await chatService.downloadEmojiFile(msg)
|
const localPath = await chatService.downloadEmojiFile(msg)
|
||||||
@@ -4408,7 +4359,7 @@ class ExportService {
|
|||||||
const key = msg.emojiMd5 || String(msg.localId)
|
const key = msg.emojiMd5 || String(msg.localId)
|
||||||
const fileName = `${key}${ext}`
|
const fileName = `${key}${ext}`
|
||||||
const destPath = path.join(emojisDir, fileName)
|
const destPath = path.join(emojisDir, fileName)
|
||||||
const copied = await this.copyMediaWithCacheAndDedup('emoji', localPath, destPath, control)
|
const copied = await this.copyMediaWithCacheAndDedup('emoji', localPath, destPath)
|
||||||
if (!copied.success) return null
|
if (!copied.success) return null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -4430,8 +4381,7 @@ class ExportService {
|
|||||||
mediaRootDir: string,
|
mediaRootDir: string,
|
||||||
mediaRelativePrefix: string,
|
mediaRelativePrefix: string,
|
||||||
dirCache?: Set<string>,
|
dirCache?: Set<string>,
|
||||||
includePoster = false,
|
includePoster = false
|
||||||
control?: ExportTaskControl
|
|
||||||
): Promise<MediaExportItem | null> {
|
): Promise<MediaExportItem | null> {
|
||||||
try {
|
try {
|
||||||
let videoMd5 = String(msg.videoMd5 || '').trim().toLowerCase()
|
let videoMd5 = String(msg.videoMd5 || '').trim().toLowerCase()
|
||||||
@@ -4454,13 +4404,16 @@ class ExportService {
|
|||||||
if (!videoInfo) return null
|
if (!videoInfo) return null
|
||||||
|
|
||||||
const videosDir = path.join(mediaRootDir, mediaRelativePrefix, 'videos')
|
const videosDir = path.join(mediaRootDir, mediaRelativePrefix, 'videos')
|
||||||
await this.ensureExportDir(videosDir, control, dirCache)
|
if (!dirCache?.has(videosDir)) {
|
||||||
|
await fs.promises.mkdir(videosDir, { recursive: true })
|
||||||
|
dirCache?.add(videosDir)
|
||||||
|
}
|
||||||
|
|
||||||
const sourcePath = videoInfo.videoUrl
|
const sourcePath = videoInfo.videoUrl
|
||||||
const fileName = path.basename(sourcePath)
|
const fileName = path.basename(sourcePath)
|
||||||
const destPath = path.join(videosDir, fileName)
|
const destPath = path.join(videosDir, fileName)
|
||||||
|
|
||||||
const copied = await this.copyMediaWithCacheAndDedup('video', sourcePath, destPath, control)
|
const copied = await this.copyMediaWithCacheAndDedup('video', sourcePath, destPath)
|
||||||
if (!copied.success) return null
|
if (!copied.success) return null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -4911,8 +4864,7 @@ class ExportService {
|
|||||||
mediaRootDir: string,
|
mediaRootDir: string,
|
||||||
mediaRelativePrefix: string,
|
mediaRelativePrefix: string,
|
||||||
maxFileSizeMb?: number,
|
maxFileSizeMb?: number,
|
||||||
dirCache?: Set<string>,
|
dirCache?: Set<string>
|
||||||
control?: ExportTaskControl
|
|
||||||
): Promise<MediaExportItem | null> {
|
): Promise<MediaExportItem | null> {
|
||||||
try {
|
try {
|
||||||
const fileNameRaw = String(msg?.fileName || '').trim()
|
const fileNameRaw = String(msg?.fileName || '').trim()
|
||||||
@@ -4920,7 +4872,10 @@ class ExportService {
|
|||||||
|
|
||||||
const fileExtDir = this.resolveFileAttachmentExtensionDir(msg, fileNameRaw)
|
const fileExtDir = this.resolveFileAttachmentExtensionDir(msg, fileNameRaw)
|
||||||
const fileDir = path.join(mediaRootDir, mediaRelativePrefix, 'file', fileExtDir)
|
const fileDir = path.join(mediaRootDir, mediaRelativePrefix, 'file', fileExtDir)
|
||||||
await this.ensureExportDir(fileDir, control, dirCache)
|
if (!dirCache?.has(fileDir)) {
|
||||||
|
await fs.promises.mkdir(fileDir, { recursive: true })
|
||||||
|
dirCache?.add(fileDir)
|
||||||
|
}
|
||||||
|
|
||||||
const candidates = await this.resolveFileAttachmentCandidates(msg)
|
const candidates = await this.resolveFileAttachmentCandidates(msg)
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
@@ -4964,7 +4919,6 @@ class ExportService {
|
|||||||
const messageId = String(msg?.localId || Date.now())
|
const messageId = String(msg?.localId || Date.now())
|
||||||
const destFileName = `${messageId}_${safeBaseName}`
|
const destFileName = `${messageId}_${safeBaseName}`
|
||||||
const destPath = path.join(fileDir, destFileName)
|
const destPath = path.join(fileDir, destFileName)
|
||||||
const existedBeforeCopy = await this.pathExists(destPath)
|
|
||||||
const copied = await this.copyFileOptimized(selected.sourcePath, destPath)
|
const copied = await this.copyFileOptimized(selected.sourcePath, destPath)
|
||||||
if (!copied.success) {
|
if (!copied.success) {
|
||||||
this.recordFileAttachmentMiss(msg, '附件复制失败', {
|
this.recordFileAttachmentMiss(msg, '附件复制失败', {
|
||||||
@@ -4975,9 +4929,6 @@ class ExportService {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existedBeforeCopy) {
|
|
||||||
control?.recordCreatedFile?.(destPath)
|
|
||||||
}
|
|
||||||
this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size })
|
this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size })
|
||||||
return {
|
return {
|
||||||
relativePath: path.posix.join(mediaRelativePrefix, 'file', fileExtDir, destFileName),
|
relativePath: path.posix.join(mediaRelativePrefix, 'file', fileExtDir, destFileName),
|
||||||
@@ -5933,15 +5884,16 @@ class ExportService {
|
|||||||
*/
|
*/
|
||||||
private async exportAvatarsToFiles(
|
private async exportAvatarsToFiles(
|
||||||
members: Array<{ username: string; avatarUrl?: string }>,
|
members: Array<{ username: string; avatarUrl?: string }>,
|
||||||
outputDir: string,
|
outputDir: string
|
||||||
control?: ExportTaskControl
|
|
||||||
): Promise<Map<string, string>> {
|
): Promise<Map<string, string>> {
|
||||||
const result = new Map<string, string>()
|
const result = new Map<string, string>()
|
||||||
if (members.length === 0) return result
|
if (members.length === 0) return result
|
||||||
|
|
||||||
// 创建 avatars 子目录
|
// 创建 avatars 子目录
|
||||||
const avatarsDir = path.join(outputDir, 'avatars')
|
const avatarsDir = path.join(outputDir, 'avatars')
|
||||||
await this.ensureExportDir(avatarsDir, control)
|
if (!fs.existsSync(avatarsDir)) {
|
||||||
|
fs.mkdirSync(avatarsDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
const AVATAR_CONCURRENCY = 8
|
const AVATAR_CONCURRENCY = 8
|
||||||
await parallelLimit(members, AVATAR_CONCURRENCY, async (member) => {
|
await parallelLimit(members, AVATAR_CONCURRENCY, async (member) => {
|
||||||
@@ -5982,7 +5934,6 @@ class ExportService {
|
|||||||
try {
|
try {
|
||||||
await fs.promises.access(avatarPath)
|
await fs.promises.access(avatarPath)
|
||||||
} catch {
|
} catch {
|
||||||
await this.recordCreatedFileBeforeWrite(avatarPath, control)
|
|
||||||
await fs.promises.writeFile(avatarPath, data)
|
await fs.promises.writeFile(avatarPath, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6251,8 +6202,7 @@ class ExportService {
|
|||||||
maxFileSizeMb: options.maxFileSizeMb,
|
maxFileSizeMb: options.maxFileSizeMb,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
dirCache: mediaDirCache,
|
dirCache: mediaDirCache
|
||||||
control
|
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
}
|
}
|
||||||
@@ -6601,11 +6551,9 @@ class ExportService {
|
|||||||
lines.push(JSON.stringify({ _type: 'message', ...message }))
|
lines.push(JSON.stringify({ _type: 'message', ...message }))
|
||||||
}
|
}
|
||||||
this.throwIfStopRequested(control)
|
this.throwIfStopRequested(control)
|
||||||
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
|
||||||
await fs.promises.writeFile(outputPath, lines.join('\n'), 'utf-8')
|
await fs.promises.writeFile(outputPath, lines.join('\n'), 'utf-8')
|
||||||
} else {
|
} else {
|
||||||
this.throwIfStopRequested(control)
|
this.throwIfStopRequested(control)
|
||||||
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
|
||||||
await fs.promises.writeFile(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8')
|
await fs.promises.writeFile(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6625,9 +6573,6 @@ class ExportService {
|
|||||||
if (this.isStopError(e)) {
|
if (this.isStopError(e)) {
|
||||||
return { success: false, error: '导出任务已停止' }
|
return { success: false, error: '导出任务已停止' }
|
||||||
}
|
}
|
||||||
if (this.isPauseError(e)) {
|
|
||||||
return { success: false, error: '导出任务已暂停' }
|
|
||||||
}
|
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6761,8 +6706,7 @@ class ExportService {
|
|||||||
maxFileSizeMb: options.maxFileSizeMb,
|
maxFileSizeMb: options.maxFileSizeMb,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
dirCache: mediaDirCache,
|
dirCache: mediaDirCache
|
||||||
control
|
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
}
|
}
|
||||||
@@ -7312,7 +7256,6 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.throwIfStopRequested(control)
|
this.throwIfStopRequested(control)
|
||||||
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
|
||||||
await fs.promises.writeFile(outputPath, JSON.stringify(arkmeExport, null, 2), 'utf-8')
|
await fs.promises.writeFile(outputPath, JSON.stringify(arkmeExport, null, 2), 'utf-8')
|
||||||
} else {
|
} else {
|
||||||
const detailedExport: any = {
|
const detailedExport: any = {
|
||||||
@@ -7336,7 +7279,6 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.throwIfStopRequested(control)
|
this.throwIfStopRequested(control)
|
||||||
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
|
||||||
await fs.promises.writeFile(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8')
|
await fs.promises.writeFile(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7356,9 +7298,6 @@ class ExportService {
|
|||||||
if (this.isStopError(e)) {
|
if (this.isStopError(e)) {
|
||||||
return { success: false, error: '导出任务已停止' }
|
return { success: false, error: '导出任务已停止' }
|
||||||
}
|
}
|
||||||
if (this.isPauseError(e)) {
|
|
||||||
return { success: false, error: '导出任务已暂停' }
|
|
||||||
}
|
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7632,8 +7571,7 @@ class ExportService {
|
|||||||
maxFileSizeMb: options.maxFileSizeMb,
|
maxFileSizeMb: options.maxFileSizeMb,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
dirCache: mediaDirCache,
|
dirCache: mediaDirCache
|
||||||
control
|
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
}
|
}
|
||||||
@@ -7897,7 +7835,6 @@ class ExportService {
|
|||||||
|
|
||||||
// 写入文件
|
// 写入文件
|
||||||
this.throwIfStopRequested(control)
|
this.throwIfStopRequested(control)
|
||||||
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
|
||||||
await workbook.xlsx.writeFile(outputPath)
|
await workbook.xlsx.writeFile(outputPath)
|
||||||
|
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
@@ -7916,9 +7853,6 @@ class ExportService {
|
|||||||
if (this.isStopError(e)) {
|
if (this.isStopError(e)) {
|
||||||
return { success: false, error: '导出任务已停止' }
|
return { success: false, error: '导出任务已停止' }
|
||||||
}
|
}
|
||||||
if (this.isPauseError(e)) {
|
|
||||||
return { success: false, error: '导出任务已暂停' }
|
|
||||||
}
|
|
||||||
// 处理文件被占用的错误
|
// 处理文件被占用的错误
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) {
|
if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) {
|
||||||
@@ -8200,9 +8134,6 @@ class ExportService {
|
|||||||
if (this.isStopError(e)) {
|
if (this.isStopError(e)) {
|
||||||
return { success: false, error: '导出任务已停止' }
|
return { success: false, error: '导出任务已停止' }
|
||||||
}
|
}
|
||||||
if (this.isPauseError(e)) {
|
|
||||||
return { success: false, error: '导出任务已暂停' }
|
|
||||||
}
|
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) {
|
if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) {
|
||||||
return { success: false, error: '文件已经打开,请关闭后再导出' }
|
return { success: false, error: '文件已经打开,请关闭后再导出' }
|
||||||
@@ -8384,8 +8315,7 @@ class ExportService {
|
|||||||
maxFileSizeMb: options.maxFileSizeMb,
|
maxFileSizeMb: options.maxFileSizeMb,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
dirCache: mediaDirCache,
|
dirCache: mediaDirCache
|
||||||
control
|
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
}
|
}
|
||||||
@@ -8452,7 +8382,6 @@ class ExportService {
|
|||||||
exportedMessages: 0
|
exportedMessages: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
|
||||||
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
|
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
|
||||||
const writeChunk = async (chunk: string): Promise<void> => {
|
const writeChunk = async (chunk: string): Promise<void> => {
|
||||||
await new Promise<void>((resolve, _reject) => {
|
await new Promise<void>((resolve, _reject) => {
|
||||||
@@ -8638,9 +8567,6 @@ class ExportService {
|
|||||||
if (this.isStopError(e)) {
|
if (this.isStopError(e)) {
|
||||||
return { success: false, error: '导出任务已停止' }
|
return { success: false, error: '导出任务已停止' }
|
||||||
}
|
}
|
||||||
if (this.isPauseError(e)) {
|
|
||||||
return { success: false, error: '导出任务已暂停' }
|
|
||||||
}
|
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8784,8 +8710,7 @@ class ExportService {
|
|||||||
maxFileSizeMb: options.maxFileSizeMb,
|
maxFileSizeMb: options.maxFileSizeMb,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
dirCache: mediaDirCache,
|
dirCache: mediaDirCache
|
||||||
control
|
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
}
|
}
|
||||||
@@ -8852,7 +8777,6 @@ class ExportService {
|
|||||||
exportedMessages: 0
|
exportedMessages: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
|
||||||
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
|
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
|
||||||
const writeChunk = async (chunk: string): Promise<void> => {
|
const writeChunk = async (chunk: string): Promise<void> => {
|
||||||
await new Promise<void>((resolve, _reject) => {
|
await new Promise<void>((resolve, _reject) => {
|
||||||
@@ -9005,9 +8929,6 @@ class ExportService {
|
|||||||
if (this.isStopError(e)) {
|
if (this.isStopError(e)) {
|
||||||
return { success: false, error: '导出任务已停止' }
|
return { success: false, error: '导出任务已停止' }
|
||||||
}
|
}
|
||||||
if (this.isPauseError(e)) {
|
|
||||||
return { success: false, error: '导出任务已暂停' }
|
|
||||||
}
|
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9232,8 +9153,7 @@ class ExportService {
|
|||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
includeVoiceWithTranscript: true,
|
includeVoiceWithTranscript: true,
|
||||||
exportVideos: options.exportVideos,
|
exportVideos: options.exportVideos,
|
||||||
dirCache: mediaDirCache,
|
dirCache: mediaDirCache
|
||||||
control
|
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
}
|
}
|
||||||
@@ -9304,8 +9224,7 @@ class ExportService {
|
|||||||
{ username: sessionId, avatarUrl: sessionInfo.avatarUrl },
|
{ username: sessionId, avatarUrl: sessionInfo.avatarUrl },
|
||||||
{ username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl }
|
{ username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl }
|
||||||
],
|
],
|
||||||
path.dirname(outputPath),
|
path.dirname(outputPath)
|
||||||
control
|
|
||||||
)
|
)
|
||||||
: new Map<string, string>()
|
: new Map<string, string>()
|
||||||
|
|
||||||
@@ -9322,7 +9241,6 @@ class ExportService {
|
|||||||
// ================= BEGIN STREAM WRITING =================
|
// ================= BEGIN STREAM WRITING =================
|
||||||
const exportMeta = this.getExportMeta(sessionId, sessionInfo, isGroup)
|
const exportMeta = this.getExportMeta(sessionId, sessionInfo, isGroup)
|
||||||
const htmlStyles = this.loadExportHtmlStyles()
|
const htmlStyles = this.loadExportHtmlStyles()
|
||||||
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
|
||||||
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
|
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
|
||||||
|
|
||||||
const writePromise = (str: string) => {
|
const writePromise = (str: string) => {
|
||||||
@@ -9687,9 +9605,6 @@ class ExportService {
|
|||||||
if (this.isStopError(e)) {
|
if (this.isStopError(e)) {
|
||||||
return { success: false, error: '导出任务已停止' }
|
return { success: false, error: '导出任务已停止' }
|
||||||
}
|
}
|
||||||
if (this.isPauseError(e)) {
|
|
||||||
return { success: false, error: '导出任务已暂停' }
|
|
||||||
}
|
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9993,7 +9908,7 @@ class ExportService {
|
|||||||
const reservedOutputPaths = new Set<string>()
|
const reservedOutputPaths = new Set<string>()
|
||||||
const ensureTaskDir = async (dirPath: string) => {
|
const ensureTaskDir = async (dirPath: string) => {
|
||||||
if (createdTaskDirs.has(dirPath)) return
|
if (createdTaskDirs.has(dirPath)) return
|
||||||
await this.ensureExportDir(dirPath, control)
|
await fs.promises.mkdir(dirPath, { recursive: true })
|
||||||
createdTaskDirs.add(dirPath)
|
createdTaskDirs.add(dirPath)
|
||||||
}
|
}
|
||||||
await ensureTaskDir(exportBaseDir)
|
await ensureTaskDir(exportBaseDir)
|
||||||
@@ -10170,7 +10085,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const runOne = async (sessionId: string): Promise<'done' | 'stopped' | 'paused'> => {
|
const runOne = async (sessionId: string): Promise<'done' | 'stopped'> => {
|
||||||
try {
|
try {
|
||||||
this.throwIfStopRequested(control)
|
this.throwIfStopRequested(control)
|
||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
@@ -10319,10 +10234,6 @@ class ExportService {
|
|||||||
activeSessionRatios.delete(sessionId)
|
activeSessionRatios.delete(sessionId)
|
||||||
return 'stopped'
|
return 'stopped'
|
||||||
}
|
}
|
||||||
if (!result.success && this.isPauseError(result.error)) {
|
|
||||||
activeSessionRatios.delete(sessionId)
|
|
||||||
return 'paused'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
successCount++
|
successCount++
|
||||||
@@ -10358,10 +10269,6 @@ class ExportService {
|
|||||||
activeSessionRatios.delete(sessionId)
|
activeSessionRatios.delete(sessionId)
|
||||||
return 'stopped'
|
return 'stopped'
|
||||||
}
|
}
|
||||||
if (this.isPauseError(error)) {
|
|
||||||
activeSessionRatios.delete(sessionId)
|
|
||||||
return 'paused'
|
|
||||||
}
|
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10387,11 +10294,6 @@ class ExportService {
|
|||||||
queue.unshift(sessionId)
|
queue.unshift(sessionId)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (runState === 'paused') {
|
|
||||||
pauseRequested = true
|
|
||||||
queue.unshift(sessionId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const workers = Array.from({ length: Math.min(sessionConcurrency, queue.length) }, async () => {
|
const workers = Array.from({ length: Math.min(sessionConcurrency, queue.length) }, async () => {
|
||||||
@@ -10413,11 +10315,6 @@ class ExportService {
|
|||||||
queue.unshift(sessionId)
|
queue.unshift(sessionId)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (runState === 'paused') {
|
|
||||||
pauseRequested = true
|
|
||||||
queue.unshift(sessionId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await Promise.all(workers)
|
await Promise.all(workers)
|
||||||
@@ -10436,7 +10333,7 @@ class ExportService {
|
|||||||
sessionOutputPaths
|
sessionOutputPaths
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (pauseRequested) {
|
if (pauseRequested && pendingSessionIds.length > 0) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
successCount,
|
successCount,
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
import * as path from 'path'
|
|
||||||
import { rm, rmdir } from 'fs/promises'
|
|
||||||
|
|
||||||
export type ExportTaskControlState = 'running' | 'pause_requested' | 'cancel_requested'
|
|
||||||
|
|
||||||
export interface ExportTaskControlHooks {
|
|
||||||
shouldPause: () => boolean
|
|
||||||
shouldStop: () => boolean
|
|
||||||
recordCreatedFile: (filePath: string) => void
|
|
||||||
recordCreatedDir: (dirPath: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExportTaskManifest {
|
|
||||||
outputDir: string
|
|
||||||
files: Set<string>
|
|
||||||
dirs: Set<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExportTaskControlRecord {
|
|
||||||
state: ExportTaskControlState
|
|
||||||
manifest: ExportTaskManifest
|
|
||||||
createdAt: number
|
|
||||||
updatedAt: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportTaskCleanupResult {
|
|
||||||
success: boolean
|
|
||||||
filesDeleted: number
|
|
||||||
dirsDeleted: number
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
class ExportTaskControlService {
|
|
||||||
private tasks = new Map<string, ExportTaskControlRecord>()
|
|
||||||
|
|
||||||
createControl(taskId: string, outputDir: string): ExportTaskControlHooks {
|
|
||||||
this.registerTask(taskId, outputDir)
|
|
||||||
return {
|
|
||||||
shouldPause: () => this.getState(taskId) === 'pause_requested',
|
|
||||||
shouldStop: () => this.getState(taskId) === 'cancel_requested',
|
|
||||||
recordCreatedFile: (filePath: string) => this.recordCreatedFile(taskId, filePath),
|
|
||||||
recordCreatedDir: (dirPath: string) => this.recordCreatedDir(taskId, dirPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerTask(taskId: string, outputDir: string): void {
|
|
||||||
const normalizedTaskId = this.normalizeTaskId(taskId)
|
|
||||||
if (!normalizedTaskId) return
|
|
||||||
|
|
||||||
const normalizedOutputDir = path.resolve(String(outputDir || '').trim() || '.')
|
|
||||||
const existing = this.tasks.get(normalizedTaskId)
|
|
||||||
if (existing) {
|
|
||||||
existing.state = 'running'
|
|
||||||
existing.updatedAt = Date.now()
|
|
||||||
if (!existing.manifest.outputDir) {
|
|
||||||
existing.manifest.outputDir = normalizedOutputDir
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.tasks.set(normalizedTaskId, {
|
|
||||||
state: 'running',
|
|
||||||
manifest: {
|
|
||||||
outputDir: normalizedOutputDir,
|
|
||||||
files: new Set<string>(),
|
|
||||||
dirs: new Set<string>()
|
|
||||||
},
|
|
||||||
createdAt: Date.now(),
|
|
||||||
updatedAt: Date.now()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pauseTask(taskId: string): boolean {
|
|
||||||
return this.setState(taskId, 'pause_requested')
|
|
||||||
}
|
|
||||||
|
|
||||||
resumeTask(taskId: string): boolean {
|
|
||||||
return this.setState(taskId, 'running')
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelTask(taskId: string): boolean {
|
|
||||||
return this.setState(taskId, 'cancel_requested')
|
|
||||||
}
|
|
||||||
|
|
||||||
getState(taskId: string): ExportTaskControlState | null {
|
|
||||||
const normalizedTaskId = this.normalizeTaskId(taskId)
|
|
||||||
if (!normalizedTaskId) return null
|
|
||||||
return this.tasks.get(normalizedTaskId)?.state || null
|
|
||||||
}
|
|
||||||
|
|
||||||
releaseTask(taskId: string): void {
|
|
||||||
const normalizedTaskId = this.normalizeTaskId(taskId)
|
|
||||||
if (!normalizedTaskId) return
|
|
||||||
this.tasks.delete(normalizedTaskId)
|
|
||||||
}
|
|
||||||
|
|
||||||
recordCreatedFile(taskId: string, filePath: string): void {
|
|
||||||
const task = this.getTaskForManifestWrite(taskId, filePath)
|
|
||||||
if (!task) return
|
|
||||||
task.manifest.files.add(path.resolve(filePath))
|
|
||||||
task.updatedAt = Date.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
recordCreatedDir(taskId: string, dirPath: string): void {
|
|
||||||
const task = this.getTaskForManifestWrite(taskId, dirPath)
|
|
||||||
if (!task) return
|
|
||||||
task.manifest.dirs.add(path.resolve(dirPath))
|
|
||||||
task.updatedAt = Date.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
async cleanupTask(taskId: string): Promise<ExportTaskCleanupResult> {
|
|
||||||
const normalizedTaskId = this.normalizeTaskId(taskId)
|
|
||||||
const task = normalizedTaskId ? this.tasks.get(normalizedTaskId) : undefined
|
|
||||||
if (!task) {
|
|
||||||
return { success: true, filesDeleted: 0, dirsDeleted: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputDir = task.manifest.outputDir
|
|
||||||
let filesDeleted = 0
|
|
||||||
let dirsDeleted = 0
|
|
||||||
const errors: string[] = []
|
|
||||||
|
|
||||||
const files = Array.from(task.manifest.files)
|
|
||||||
.filter(filePath => this.isInsideOutputDir(filePath, outputDir))
|
|
||||||
.sort((a, b) => b.length - a.length)
|
|
||||||
|
|
||||||
for (const filePath of files) {
|
|
||||||
try {
|
|
||||||
await rm(filePath, { force: true, recursive: false })
|
|
||||||
filesDeleted++
|
|
||||||
} catch (error) {
|
|
||||||
const code = (error as NodeJS.ErrnoException | undefined)?.code
|
|
||||||
if (code !== 'ENOENT') {
|
|
||||||
errors.push(`${filePath}: ${error instanceof Error ? error.message : String(error)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dirs = Array.from(task.manifest.dirs)
|
|
||||||
.filter(dirPath => this.isInsideOutputDir(dirPath, outputDir) || this.isSamePath(dirPath, outputDir))
|
|
||||||
.sort((a, b) => b.length - a.length)
|
|
||||||
|
|
||||||
for (const dirPath of dirs) {
|
|
||||||
try {
|
|
||||||
await rmdir(dirPath)
|
|
||||||
dirsDeleted++
|
|
||||||
} catch (error) {
|
|
||||||
const code = (error as NodeJS.ErrnoException | undefined)?.code
|
|
||||||
if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST') {
|
|
||||||
errors.push(`${dirPath}: ${error instanceof Error ? error.message : String(error)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errors.length === 0) {
|
|
||||||
this.releaseTask(normalizedTaskId)
|
|
||||||
return { success: true, filesDeleted, dirsDeleted }
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
filesDeleted,
|
|
||||||
dirsDeleted,
|
|
||||||
error: errors.slice(0, 3).join('; ')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setState(taskId: string, state: ExportTaskControlState): boolean {
|
|
||||||
const normalizedTaskId = this.normalizeTaskId(taskId)
|
|
||||||
if (!normalizedTaskId) return false
|
|
||||||
const task = this.tasks.get(normalizedTaskId)
|
|
||||||
if (!task) return false
|
|
||||||
task.state = state
|
|
||||||
task.updatedAt = Date.now()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTaskForManifestWrite(taskId: string, targetPath: string): ExportTaskControlRecord | null {
|
|
||||||
const normalizedTaskId = this.normalizeTaskId(taskId)
|
|
||||||
if (!normalizedTaskId) return null
|
|
||||||
const task = this.tasks.get(normalizedTaskId)
|
|
||||||
if (!task) return null
|
|
||||||
if (!this.isInsideOutputDir(targetPath, task.manifest.outputDir) && !this.isSamePath(targetPath, task.manifest.outputDir)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return task
|
|
||||||
}
|
|
||||||
|
|
||||||
private isInsideOutputDir(targetPath: string, outputDir: string): boolean {
|
|
||||||
const resolvedTarget = path.resolve(targetPath)
|
|
||||||
const resolvedOutputDir = path.resolve(outputDir)
|
|
||||||
const relativePath = path.relative(resolvedOutputDir, resolvedTarget)
|
|
||||||
return Boolean(relativePath) && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
private isSamePath(left: string, right: string): boolean {
|
|
||||||
const resolvedLeft = path.resolve(left)
|
|
||||||
const resolvedRight = path.resolve(right)
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
return resolvedLeft.toLowerCase() === resolvedRight.toLowerCase()
|
|
||||||
}
|
|
||||||
return resolvedLeft === resolvedRight
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeTaskId(taskId: string): string {
|
|
||||||
return String(taskId || '').trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const exportTaskControlService = new ExportTaskControlService()
|
|
||||||
@@ -167,7 +167,7 @@ export class KeyServiceLinux {
|
|||||||
|
|
||||||
await new Promise(r => setTimeout(r, 2000))
|
await new Promise(r => setTimeout(r, 2000))
|
||||||
|
|
||||||
return await this.getDbKey(pid, onStatus, timeoutMs)
|
return await this.getDbKey(pid, onStatus)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[Debug] 自动获取流程彻底崩溃:', err);
|
console.error('[Debug] 自动获取流程彻底崩溃:', err);
|
||||||
const errMsg = '自动获取微信 PID 失败: ' + err.message
|
const errMsg = '自动获取微信 PID 失败: ' + err.message
|
||||||
@@ -176,7 +176,7 @@ export class KeyServiceLinux {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void, timeoutMs = 180_000): Promise<DbKeyResult> {
|
public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void): Promise<DbKeyResult> {
|
||||||
try {
|
try {
|
||||||
const helperPath = this.getHelperPath()
|
const helperPath = this.getHelperPath()
|
||||||
|
|
||||||
@@ -200,56 +200,28 @@ export class KeyServiceLinux {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
const options = {
|
const options = { name: 'WeFlow' }
|
||||||
name: 'WeFlow',
|
const command = `"${helperPath}" db_hook ${pid} ${targetAddr}`
|
||||||
env: {
|
|
||||||
PATH: `${process.env.PATH || ''}:/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const timeoutSec = Math.ceil((timeoutMs + 15_000) / 1000)
|
|
||||||
const command = `timeout -k 5s ${timeoutSec}s "${helperPath}" db_hook ${pid} ${targetAddr} ${timeoutMs}`
|
|
||||||
let settled = false
|
|
||||||
const finish = (result: DbKeyResult) => {
|
|
||||||
if (settled) return
|
|
||||||
settled = true
|
|
||||||
clearTimeout(watchdog)
|
|
||||||
resolve(result)
|
|
||||||
}
|
|
||||||
const watchdog = setTimeout(() => {
|
|
||||||
execAsync(`kill -CONT ${pid}`).catch(() => {})
|
|
||||||
const err = `Hook 等待超时(${Math.round(timeoutMs / 1000)} 秒)。请确认微信登录确认已完成,或重启微信后重试。`
|
|
||||||
onStatus?.(err, 2)
|
|
||||||
finish({ success: false, error: err })
|
|
||||||
}, timeoutMs + 30_000)
|
|
||||||
|
|
||||||
onStatus?.('授权通过后请在手机上确认登录微信,正在等待密钥回调...', 0)
|
this.sudo.exec(command, options, (error, stdout) => {
|
||||||
|
|
||||||
this.sudo.exec(command, options, (error, stdout, stderr) => {
|
|
||||||
execAsync(`kill -CONT ${pid}`).catch(() => {})
|
execAsync(`kill -CONT ${pid}`).catch(() => {})
|
||||||
if (error) {
|
if (error) {
|
||||||
const detail = String(stderr || '').trim()
|
onStatus?.('授权失败或被取消', 2)
|
||||||
const message = detail ? `${error.message}: ${detail}` : error.message
|
resolve({ success: false, error: `授权失败或被取消: ${error.message}` })
|
||||||
onStatus?.('授权失败或 Hook 执行失败', 2)
|
|
||||||
finish({ success: false, error: `授权失败或 Hook 执行失败: ${message}` })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const output = String(stdout || '').trim()
|
const hookRes = JSON.parse((stdout as string).trim())
|
||||||
if (!output) {
|
|
||||||
const detail = String(stderr || '').trim()
|
|
||||||
throw new Error(detail ? `Hook 无输出: ${detail}` : 'Hook 无输出')
|
|
||||||
}
|
|
||||||
const hookRes = JSON.parse(output)
|
|
||||||
if (hookRes.success) {
|
if (hookRes.success) {
|
||||||
onStatus?.('密钥获取成功', 1)
|
onStatus?.('密钥获取成功', 1)
|
||||||
finish({ success: true, key: hookRes.key })
|
resolve({ success: true, key: hookRes.key })
|
||||||
} else {
|
} else {
|
||||||
onStatus?.(hookRes.result, 2)
|
onStatus?.(hookRes.result, 2)
|
||||||
finish({ success: false, error: hookRes.result })
|
resolve({ success: false, error: hookRes.result })
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e) {
|
||||||
onStatus?.('解析 Hook 结果失败', 2)
|
onStatus?.('解析 Hook 结果失败', 2)
|
||||||
finish({ success: false, error: e?.message || '解析 Hook 结果失败' })
|
resolve({ success: false, error: '解析 Hook 结果失败' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -707,7 +707,7 @@ export class KeyServiceMac {
|
|||||||
}
|
}
|
||||||
if (code === 'HOOK_FAILED') {
|
if (code === 'HOOK_FAILED') {
|
||||||
if (normalizedDetail.includes('HOOK_TIMEOUT')) {
|
if (normalizedDetail.includes('HOOK_TIMEOUT')) {
|
||||||
return 'Hook 已安装,但在等待时间内未触发登录流程。请退出微信账号后重新登录,或在未登录状态下直接登录微信,完成一次登录流程后重试。'
|
return 'Hook 已安装,但在等待时间内未触发目标函数。请保持微信前台并执行一次会话/数据库访问后重试。'
|
||||||
}
|
}
|
||||||
if (normalizedDetail.includes('attach_wait_timeout')) {
|
if (normalizedDetail.includes('attach_wait_timeout')) {
|
||||||
return '附加调试器超时,未能进入 Hook 阶段。请确认微信处于可交互状态并重试。'
|
return '附加调试器超时,未能进入 Hook 阶段。请确认微信处于可交互状态并重试。'
|
||||||
|
|||||||
@@ -6,30 +6,11 @@ type NativeDecryptResult = {
|
|||||||
ext: string
|
ext: string
|
||||||
isWxgf?: boolean
|
isWxgf?: boolean
|
||||||
is_wxgf?: boolean
|
is_wxgf?: boolean
|
||||||
version?: number
|
|
||||||
aesSize?: number
|
|
||||||
aes_size?: number
|
|
||||||
xorSize?: number
|
|
||||||
xor_size?: number
|
|
||||||
rawSize?: number
|
|
||||||
raw_size?: number
|
|
||||||
flag?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type NativeDatMeta = {
|
|
||||||
version?: number
|
|
||||||
aesSize?: number
|
|
||||||
aes_size?: number
|
|
||||||
xorSize?: number
|
|
||||||
xor_size?: number
|
|
||||||
rawSize?: number
|
|
||||||
raw_size?: number
|
|
||||||
flag?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type NativeAddon = {
|
type NativeAddon = {
|
||||||
decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult
|
decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult
|
||||||
encryptDatNative?: (inputPath: string, xorKey: number, aesKey?: string, meta?: NativeDatMeta) => Buffer
|
encryptDatNative?: (inputPath: string, xorKey: number, aesKey?: string) => Buffer
|
||||||
}
|
}
|
||||||
|
|
||||||
let cachedAddon: NativeAddon | null | undefined
|
let cachedAddon: NativeAddon | null | undefined
|
||||||
@@ -111,7 +92,7 @@ export function decryptDatViaNative(
|
|||||||
inputPath: string,
|
inputPath: string,
|
||||||
xorKey: number,
|
xorKey: number,
|
||||||
aesKey?: string
|
aesKey?: string
|
||||||
): { data: Buffer; ext: string; isWxgf: boolean; meta: NativeDatMeta } | null {
|
): { data: Buffer; ext: string; isWxgf: boolean } | null {
|
||||||
const addon = loadAddon()
|
const addon = loadAddon()
|
||||||
if (!addon) return null
|
if (!addon) return null
|
||||||
|
|
||||||
@@ -123,14 +104,7 @@ export function decryptDatViaNative(
|
|||||||
? result.ext.trim().toLowerCase()
|
? result.ext.trim().toLowerCase()
|
||||||
: ''
|
: ''
|
||||||
const ext = rawExt ? (rawExt.startsWith('.') ? rawExt : `.${rawExt}`) : ''
|
const ext = rawExt ? (rawExt.startsWith('.') ? rawExt : `.${rawExt}`) : ''
|
||||||
const meta: NativeDatMeta = {
|
return { data: result.data, ext, isWxgf }
|
||||||
version: result.version,
|
|
||||||
aes_size: result.aes_size ?? result.aesSize,
|
|
||||||
xor_size: result.xor_size ?? result.xorSize,
|
|
||||||
raw_size: result.raw_size ?? result.rawSize,
|
|
||||||
flag: result.flag
|
|
||||||
}
|
|
||||||
return { data: result.data, ext, isWxgf, meta }
|
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -139,14 +113,13 @@ export function decryptDatViaNative(
|
|||||||
export function encryptDatViaNative(
|
export function encryptDatViaNative(
|
||||||
inputPath: string,
|
inputPath: string,
|
||||||
xorKey: number,
|
xorKey: number,
|
||||||
aesKey?: string,
|
aesKey?: string
|
||||||
meta?: NativeDatMeta
|
|
||||||
): Buffer | null {
|
): Buffer | null {
|
||||||
const addon = loadAddon()
|
const addon = loadAddon()
|
||||||
if (!addon || typeof addon.encryptDatNative !== 'function') return null
|
if (!addon || typeof addon.encryptDatNative !== 'function') return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = addon.encryptDatNative(inputPath, xorKey, aesKey, meta)
|
const result = addon.encryptDatNative(inputPath, xorKey, aesKey)
|
||||||
return Buffer.isBuffer(result) ? result : null
|
return Buffer.isBuffer(result) ? result : null
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -1340,8 +1340,6 @@ class SnsService {
|
|||||||
}, progressCallback?: (progress: { current: number; total: number; status: string }) => void, control?: {
|
}, progressCallback?: (progress: { current: number; total: number; status: string }) => void, control?: {
|
||||||
shouldPause?: () => boolean
|
shouldPause?: () => boolean
|
||||||
shouldStop?: () => boolean
|
shouldStop?: () => boolean
|
||||||
recordCreatedFile?: (filePath: string) => void
|
|
||||||
recordCreatedDir?: (dirPath: string) => void
|
|
||||||
}): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }> {
|
}): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }> {
|
||||||
const { outputDir, format, usernames, keyword, startTime, endTime } = options
|
const { outputDir, format, usernames, keyword, startTime, endTime } = options
|
||||||
const hasExplicitMediaSelection =
|
const hasExplicitMediaSelection =
|
||||||
@@ -1363,18 +1361,6 @@ class SnsService {
|
|||||||
if (control?.shouldPause?.()) return 'paused'
|
if (control?.shouldPause?.()) return 'paused'
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const ensureExportDir = (dirPath: string) => {
|
|
||||||
const existed = existsSync(dirPath)
|
|
||||||
if (!existed) {
|
|
||||||
mkdirSync(dirPath, { recursive: true })
|
|
||||||
control?.recordCreatedDir?.(dirPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const recordCreatedFileBeforeWrite = (filePath: string) => {
|
|
||||||
if (!existsSync(filePath)) {
|
|
||||||
control?.recordCreatedFile?.(filePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const buildInterruptedResult = (state: 'paused' | 'stopped', postCount: number, mediaCount: number) => (
|
const buildInterruptedResult = (state: 'paused' | 'stopped', postCount: number, mediaCount: number) => (
|
||||||
state === 'stopped'
|
state === 'stopped'
|
||||||
? { success: true, stopped: true, filePath: '', postCount, mediaCount }
|
? { success: true, stopped: true, filePath: '', postCount, mediaCount }
|
||||||
@@ -1383,7 +1369,9 @@ class SnsService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 确保输出目录存在
|
// 确保输出目录存在
|
||||||
ensureExportDir(outputDir)
|
if (!existsSync(outputDir)) {
|
||||||
|
mkdirSync(outputDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 分页加载全部帖子
|
// 1. 分页加载全部帖子
|
||||||
const allPosts: SnsPost[] = []
|
const allPosts: SnsPost[] = []
|
||||||
@@ -1426,7 +1414,9 @@ class SnsService {
|
|||||||
const mediaDir = join(outputDir, 'media')
|
const mediaDir = join(outputDir, 'media')
|
||||||
|
|
||||||
if (shouldExportMedia) {
|
if (shouldExportMedia) {
|
||||||
ensureExportDir(mediaDir)
|
if (!existsSync(mediaDir)) {
|
||||||
|
mkdirSync(mediaDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
// 收集所有媒体下载任务
|
// 收集所有媒体下载任务
|
||||||
const mediaTasks: Array<{
|
const mediaTasks: Array<{
|
||||||
@@ -1495,7 +1485,6 @@ class SnsService {
|
|||||||
} else {
|
} else {
|
||||||
const result = await this.fetchAndDecryptImage(task.url, task.key)
|
const result = await this.fetchAndDecryptImage(task.url, task.key)
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
recordCreatedFileBeforeWrite(filePath)
|
|
||||||
await writeFile(filePath, result.data)
|
await writeFile(filePath, result.data)
|
||||||
if (task.kind === 'livephoto') {
|
if (task.kind === 'livephoto') {
|
||||||
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
|
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
|
||||||
@@ -1505,7 +1494,6 @@ class SnsService {
|
|||||||
mediaCount++
|
mediaCount++
|
||||||
} else if (result.success && result.cachePath) {
|
} else if (result.success && result.cachePath) {
|
||||||
const cachedData = await readFile(result.cachePath)
|
const cachedData = await readFile(result.cachePath)
|
||||||
recordCreatedFileBeforeWrite(filePath)
|
|
||||||
await writeFile(filePath, cachedData)
|
await writeFile(filePath, cachedData)
|
||||||
if (task.kind === 'livephoto') {
|
if (task.kind === 'livephoto') {
|
||||||
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
|
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
|
||||||
@@ -1543,7 +1531,7 @@ class SnsService {
|
|||||||
// 2.5 下载头像
|
// 2.5 下载头像
|
||||||
const avatarMap = new Map<string, string>()
|
const avatarMap = new Map<string, string>()
|
||||||
if (format === 'html') {
|
if (format === 'html') {
|
||||||
ensureExportDir(mediaDir)
|
if (!existsSync(mediaDir)) mkdirSync(mediaDir, { recursive: true })
|
||||||
const uniqueUsers = [...new Map(allPosts.filter(p => p.avatarUrl).map(p => [p.username, p])).values()]
|
const uniqueUsers = [...new Map(allPosts.filter(p => p.avatarUrl).map(p => [p.username, p])).values()]
|
||||||
let avatarDone = 0
|
let avatarDone = 0
|
||||||
const avatarQueue = [...uniqueUsers]
|
const avatarQueue = [...uniqueUsers]
|
||||||
@@ -1560,7 +1548,6 @@ class SnsService {
|
|||||||
} else {
|
} else {
|
||||||
const result = await this.fetchAndDecryptImage(post.avatarUrl!)
|
const result = await this.fetchAndDecryptImage(post.avatarUrl!)
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
recordCreatedFileBeforeWrite(filePath)
|
|
||||||
await writeFile(filePath, result.data)
|
await writeFile(filePath, result.data)
|
||||||
avatarMap.set(post.username, `media/${fileName}`)
|
avatarMap.set(post.username, `media/${fileName}`)
|
||||||
}
|
}
|
||||||
@@ -1615,7 +1602,6 @@ class SnsService {
|
|||||||
linkUrl: (p as any).linkUrl
|
linkUrl: (p as any).linkUrl
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
recordCreatedFileBeforeWrite(outputFilePath)
|
|
||||||
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
|
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
|
||||||
} else if (format === 'arkmejson') {
|
} else if (format === 'arkmejson') {
|
||||||
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`)
|
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`)
|
||||||
@@ -1703,13 +1689,11 @@ class SnsService {
|
|||||||
},
|
},
|
||||||
posts
|
posts
|
||||||
}
|
}
|
||||||
recordCreatedFileBeforeWrite(outputFilePath)
|
|
||||||
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
|
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
|
||||||
} else {
|
} else {
|
||||||
// HTML 格式
|
// HTML 格式
|
||||||
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`)
|
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`)
|
||||||
const html = this.generateHtml(allPosts, { usernames, keyword }, avatarMap)
|
const html = this.generateHtml(allPosts, { usernames, keyword }, avatarMap)
|
||||||
recordCreatedFileBeforeWrite(outputFilePath)
|
|
||||||
await writeFile(outputFilePath, html, 'utf-8')
|
await writeFile(outputFilePath, html, 'utf-8')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,9 +92,6 @@ export class WcdbService {
|
|||||||
this.setPaths(this.resourcesPath, this.userDataPath)
|
this.setPaths(this.resourcesPath, this.userDataPath)
|
||||||
}
|
}
|
||||||
this.setLogEnabled(this.logEnabled)
|
this.setLogEnabled(this.logEnabled)
|
||||||
if (this.monitorListener) {
|
|
||||||
this.callWorker<{ success?: boolean }>('setMonitor').catch(() => { })
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Failed to create worker
|
// Failed to create worker
|
||||||
|
|||||||
1286
package-lock.json
generated
1286
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -57,7 +57,7 @@
|
|||||||
"sass": "^1.98.0",
|
"sass": "^1.98.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^7.0.0",
|
"vite": "^8.0.10",
|
||||||
"vite-plugin-electron": "^0.28.8",
|
"vite-plugin-electron": "^0.28.8",
|
||||||
"vite-plugin-electron-renderer": "^0.14.6"
|
"vite-plugin-electron-renderer": "^0.14.6"
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -80,7 +80,7 @@ import {
|
|||||||
import './ExportPage.scss'
|
import './ExportPage.scss'
|
||||||
|
|
||||||
type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
|
type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
|
||||||
type TaskStatus = 'queued' | 'running' | 'pause_requested' | 'paused' | 'cancel_requested' | 'success' | 'error'
|
type TaskStatus = 'queued' | 'running' | 'success' | 'error'
|
||||||
type TaskScope = 'single' | 'multi' | 'content' | 'sns'
|
type TaskScope = 'single' | 'multi' | 'content' | 'sns'
|
||||||
type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
|
type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
|
||||||
type ContentCardType = ContentType | 'sns'
|
type ContentCardType = ContentType | 'sns'
|
||||||
@@ -578,27 +578,10 @@ const formatDurationMs = (ms: number): string => {
|
|||||||
const getTaskStatusLabel = (task: ExportTask): string => {
|
const getTaskStatusLabel = (task: ExportTask): string => {
|
||||||
if (task.status === 'queued') return '排队中'
|
if (task.status === 'queued') return '排队中'
|
||||||
if (task.status === 'running') return '进行中'
|
if (task.status === 'running') return '进行中'
|
||||||
if (task.status === 'pause_requested') return '暂停中'
|
|
||||||
if (task.status === 'paused') return '已暂停'
|
|
||||||
if (task.status === 'cancel_requested') return '取消中'
|
|
||||||
if (task.status === 'success') return '已完成'
|
if (task.status === 'success') return '已完成'
|
||||||
return '失败'
|
return '失败'
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveExportTaskCardClass = (status: TaskStatus): 'queued' | 'running' | 'paused' | 'stopped' | 'success' | 'error' => {
|
|
||||||
if (status === 'pause_requested' || status === 'paused') return 'paused'
|
|
||||||
if (status === 'cancel_requested') return 'stopped'
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
||||||
const isExportTaskActiveStatus = (status: TaskStatus): boolean => (
|
|
||||||
status === 'queued' ||
|
|
||||||
status === 'running' ||
|
|
||||||
status === 'pause_requested' ||
|
|
||||||
status === 'paused' ||
|
|
||||||
status === 'cancel_requested'
|
|
||||||
)
|
|
||||||
|
|
||||||
const resolveBackgroundTaskCardClass = (status: BackgroundTaskRecord['status']): 'running' | 'paused' | 'stopped' | 'success' | 'error' => {
|
const resolveBackgroundTaskCardClass = (status: BackgroundTaskRecord['status']): 'running' | 'paused' | 'stopped' | 'success' | 'error' => {
|
||||||
if (status === 'running') return 'running'
|
if (status === 'running') return 'running'
|
||||||
if (status === 'pause_requested' || status === 'paused') return 'paused'
|
if (status === 'pause_requested' || status === 'paused') return 'paused'
|
||||||
@@ -1826,9 +1809,6 @@ interface TaskCenterModalProps {
|
|||||||
nowTick: number
|
nowTick: number
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onTogglePerfTask: (taskId: string) => void
|
onTogglePerfTask: (taskId: string) => void
|
||||||
onPauseExportTask: (taskId: string) => void
|
|
||||||
onResumeExportTask: (taskId: string) => void
|
|
||||||
onCancelExportTask: (taskId: string) => void
|
|
||||||
onPauseBackgroundTask: (taskId: string) => void
|
onPauseBackgroundTask: (taskId: string) => void
|
||||||
onResumeBackgroundTask: (taskId: string) => void
|
onResumeBackgroundTask: (taskId: string) => void
|
||||||
onCancelBackgroundTask: (taskId: string) => void
|
onCancelBackgroundTask: (taskId: string) => void
|
||||||
@@ -1844,9 +1824,6 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
|||||||
nowTick,
|
nowTick,
|
||||||
onClose,
|
onClose,
|
||||||
onTogglePerfTask,
|
onTogglePerfTask,
|
||||||
onPauseExportTask,
|
|
||||||
onResumeExportTask,
|
|
||||||
onCancelExportTask,
|
|
||||||
onPauseBackgroundTask,
|
onPauseBackgroundTask,
|
||||||
onResumeBackgroundTask,
|
onResumeBackgroundTask,
|
||||||
onCancelBackgroundTask
|
onCancelBackgroundTask
|
||||||
@@ -1977,31 +1954,15 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
|||||||
: `图片耗时 ${formatDurationMs(imageTimingElapsedMs)}`
|
: `图片耗时 ${formatDurationMs(imageTimingElapsedMs)}`
|
||||||
)
|
)
|
||||||
: ''
|
: ''
|
||||||
const taskCardClass = resolveExportTaskCardClass(task.status)
|
|
||||||
const canShowProgress = (
|
|
||||||
task.status === 'running' ||
|
|
||||||
task.status === 'pause_requested' ||
|
|
||||||
task.status === 'paused' ||
|
|
||||||
task.status === 'cancel_requested'
|
|
||||||
)
|
|
||||||
const canPause = task.status === 'running'
|
|
||||||
const canResume = task.status === 'paused' || task.status === 'pause_requested'
|
|
||||||
const canCancel = (
|
|
||||||
task.status === 'queued' ||
|
|
||||||
task.status === 'running' ||
|
|
||||||
task.status === 'pause_requested' ||
|
|
||||||
task.status === 'paused' ||
|
|
||||||
task.status === 'cancel_requested'
|
|
||||||
)
|
|
||||||
return (
|
return (
|
||||||
<div key={task.id} className={`task-card ${taskCardClass}`}>
|
<div key={task.id} className={`task-card ${task.status}`}>
|
||||||
<div className="task-main">
|
<div className="task-main">
|
||||||
<div className="task-title">{task.title}</div>
|
<div className="task-title">{task.title}</div>
|
||||||
<div className="task-meta">
|
<div className="task-meta">
|
||||||
<span className={`task-status ${taskCardClass}`}>{getTaskStatusLabel(task)}</span>
|
<span className={`task-status ${task.status}`}>{getTaskStatusLabel(task)}</span>
|
||||||
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span>
|
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span>
|
||||||
</div>
|
</div>
|
||||||
{canShowProgress && (
|
{task.status === 'running' && (
|
||||||
<>
|
<>
|
||||||
<div className="task-progress-bar">
|
<div className="task-progress-bar">
|
||||||
<div
|
<div
|
||||||
@@ -2089,34 +2050,6 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
|||||||
{isPerfExpanded ? '收起详情' : '性能详情'}
|
{isPerfExpanded ? '收起详情' : '性能详情'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canPause && (
|
|
||||||
<button
|
|
||||||
className="task-action-btn"
|
|
||||||
type="button"
|
|
||||||
onClick={() => onPauseExportTask(task.id)}
|
|
||||||
>
|
|
||||||
<Pause size={14} /> 暂停
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canResume && (
|
|
||||||
<button
|
|
||||||
className="task-action-btn primary"
|
|
||||||
type="button"
|
|
||||||
onClick={() => onResumeExportTask(task.id)}
|
|
||||||
>
|
|
||||||
<Play size={14} /> 继续
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canCancel && (
|
|
||||||
<button
|
|
||||||
className="task-action-btn danger"
|
|
||||||
type="button"
|
|
||||||
onClick={() => onCancelExportTask(task.id)}
|
|
||||||
disabled={task.status === 'cancel_requested'}
|
|
||||||
>
|
|
||||||
<Square size={14} /> {task.status === 'cancel_requested' ? '取消中' : '取消'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
className="task-action-btn"
|
className="task-action-btn"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -5653,7 +5586,7 @@ function ExportPage() {
|
|||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const currentSessionId = String(payload.currentSessionId || '').trim()
|
const currentSessionId = String(payload.currentSessionId || '').trim()
|
||||||
updateTask(next.id, task => {
|
updateTask(next.id, task => {
|
||||||
if (task.status !== 'running' && task.status !== 'pause_requested' && task.status !== 'cancel_requested') return task
|
if (task.status !== 'running') return task
|
||||||
const performance = applyProgressToTaskPerformance(task, payload, now)
|
const performance = applyProgressToTaskPerformance(task, payload, now)
|
||||||
const settledSessionIds = task.settledSessionIds || []
|
const settledSessionIds = task.settledSessionIds || []
|
||||||
const nextSettledSessionIds = (
|
const nextSettledSessionIds = (
|
||||||
@@ -5807,8 +5740,7 @@ function ExportPage() {
|
|||||||
exportLivePhotos: snsOptions.exportLivePhotos,
|
exportLivePhotos: snsOptions.exportLivePhotos,
|
||||||
exportVideos: snsOptions.exportVideos,
|
exportVideos: snsOptions.exportVideos,
|
||||||
startTime: snsOptions.startTime,
|
startTime: snsOptions.startTime,
|
||||||
endTime: snsOptions.endTime,
|
endTime: snsOptions.endTime
|
||||||
taskId: next.id
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -5819,19 +5751,6 @@ function ExportPage() {
|
|||||||
error: result.error || '朋友圈导出失败',
|
error: result.error || '朋友圈导出失败',
|
||||||
performance: finalizeTaskPerformance(task, Date.now())
|
performance: finalizeTaskPerformance(task, Date.now())
|
||||||
}))
|
}))
|
||||||
} else if (result.stopped) {
|
|
||||||
setTasks(prev => prev.filter(task => task.id !== next.id))
|
|
||||||
} else if (result.paused) {
|
|
||||||
updateTask(next.id, task => ({
|
|
||||||
...task,
|
|
||||||
status: 'paused',
|
|
||||||
progress: {
|
|
||||||
...task.progress,
|
|
||||||
phaseLabel: '已暂停,可继续或取消',
|
|
||||||
current: Math.max(task.progress.current, result.postCount || 0),
|
|
||||||
total: Math.max(task.progress.total, result.postCount || 0)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
} else {
|
} else {
|
||||||
const doneAt = Date.now()
|
const doneAt = Date.now()
|
||||||
const exportedPosts = Math.max(0, result.postCount || 0)
|
const exportedPosts = Math.max(0, result.postCount || 0)
|
||||||
@@ -5863,8 +5782,7 @@ function ExportPage() {
|
|||||||
const result = await window.electronAPI.export.exportSessions(
|
const result = await window.electronAPI.export.exportSessions(
|
||||||
next.payload.sessionIds,
|
next.payload.sessionIds,
|
||||||
next.payload.outputDir,
|
next.payload.outputDir,
|
||||||
next.payload.options,
|
next.payload.options
|
||||||
{ taskId: next.id }
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -5875,33 +5793,6 @@ function ExportPage() {
|
|||||||
error: result.error || '导出失败',
|
error: result.error || '导出失败',
|
||||||
performance: finalizeTaskPerformance(task, Date.now())
|
performance: finalizeTaskPerformance(task, Date.now())
|
||||||
}))
|
}))
|
||||||
} else if (result.stopped) {
|
|
||||||
setTasks(prev => prev.filter(task => task.id !== next.id))
|
|
||||||
} else if (result.paused) {
|
|
||||||
const pendingSessionIds = Array.isArray(result.pendingSessionIds)
|
|
||||||
? result.pendingSessionIds
|
|
||||||
: []
|
|
||||||
updateTask(next.id, task => ({
|
|
||||||
...task,
|
|
||||||
status: 'paused',
|
|
||||||
payload: {
|
|
||||||
...task.payload,
|
|
||||||
sessionIds: pendingSessionIds.length > 0 ? pendingSessionIds : task.payload.sessionIds
|
|
||||||
},
|
|
||||||
settledSessionIds: Array.isArray(result.successSessionIds)
|
|
||||||
? Array.from(new Set([...(task.settledSessionIds || []), ...result.successSessionIds]))
|
|
||||||
: task.settledSessionIds,
|
|
||||||
sessionOutputPaths: {
|
|
||||||
...(task.sessionOutputPaths || {}),
|
|
||||||
...((result.sessionOutputPaths && typeof result.sessionOutputPaths === 'object')
|
|
||||||
? result.sessionOutputPaths
|
|
||||||
: {})
|
|
||||||
},
|
|
||||||
progress: {
|
|
||||||
...task.progress,
|
|
||||||
phaseLabel: '已暂停,可继续或取消'
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
} else {
|
} else {
|
||||||
const doneAt = Date.now()
|
const doneAt = Date.now()
|
||||||
const contentTypes = next.payload.contentType
|
const contentTypes = next.payload.contentType
|
||||||
@@ -6022,13 +5913,7 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasConflict = tasksRef.current.some((item) => {
|
const hasConflict = tasksRef.current.some((item) => {
|
||||||
if (
|
if (item.status !== 'running' && item.status !== 'queued') return false
|
||||||
item.status !== 'running' &&
|
|
||||||
item.status !== 'queued' &&
|
|
||||||
item.status !== 'pause_requested' &&
|
|
||||||
item.status !== 'paused' &&
|
|
||||||
item.status !== 'cancel_requested'
|
|
||||||
) return false
|
|
||||||
return item.payload.automationTaskId === task.id
|
return item.payload.automationTaskId === task.id
|
||||||
})
|
})
|
||||||
if (hasConflict) {
|
if (hasConflict) {
|
||||||
@@ -6315,7 +6200,7 @@ function ExportPage() {
|
|||||||
const runningSessionIds = useMemo(() => {
|
const runningSessionIds = useMemo(() => {
|
||||||
const set = new Set<string>()
|
const set = new Set<string>()
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
if (task.status !== 'running' && task.status !== 'pause_requested' && task.status !== 'cancel_requested') continue
|
if (task.status !== 'running') continue
|
||||||
const settled = new Set(task.settledSessionIds || [])
|
const settled = new Set(task.settledSessionIds || [])
|
||||||
for (const id of task.payload.sessionIds) {
|
for (const id of task.payload.sessionIds) {
|
||||||
if (settled.has(id)) continue
|
if (settled.has(id)) continue
|
||||||
@@ -6328,7 +6213,7 @@ function ExportPage() {
|
|||||||
const queuedSessionIds = useMemo(() => {
|
const queuedSessionIds = useMemo(() => {
|
||||||
const set = new Set<string>()
|
const set = new Set<string>()
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
if (task.status !== 'queued' && task.status !== 'paused') continue
|
if (task.status !== 'queued') continue
|
||||||
for (const id of task.payload.sessionIds) {
|
for (const id of task.payload.sessionIds) {
|
||||||
set.add(id)
|
set.add(id)
|
||||||
}
|
}
|
||||||
@@ -6339,7 +6224,7 @@ function ExportPage() {
|
|||||||
const inProgressSessionIds = useMemo(() => {
|
const inProgressSessionIds = useMemo(() => {
|
||||||
const set = new Set<string>()
|
const set = new Set<string>()
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
if (!isExportTaskActiveStatus(task.status)) continue
|
if (task.status !== 'running' && task.status !== 'queued') continue
|
||||||
for (const id of task.payload.sessionIds) {
|
for (const id of task.payload.sessionIds) {
|
||||||
set.add(id)
|
set.add(id)
|
||||||
}
|
}
|
||||||
@@ -6347,7 +6232,7 @@ function ExportPage() {
|
|||||||
return Array.from(set).sort()
|
return Array.from(set).sort()
|
||||||
}, [tasks])
|
}, [tasks])
|
||||||
const activeTaskCount = useMemo(
|
const activeTaskCount = useMemo(
|
||||||
() => tasks.filter(task => isExportTaskActiveStatus(task.status)).length,
|
() => tasks.filter(task => task.status === 'running' || task.status === 'queued').length,
|
||||||
[tasks]
|
[tasks]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -6362,7 +6247,7 @@ function ExportPage() {
|
|||||||
if (previousStatus === task.status) continue
|
if (previousStatus === task.status) continue
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (task.status === 'running' || task.status === 'pause_requested' || task.status === 'paused' || task.status === 'cancel_requested') {
|
if (task.status === 'running') {
|
||||||
patchAutomationTask(automationTaskId, (current) => ({
|
patchAutomationTask(automationTaskId, (current) => ({
|
||||||
...current,
|
...current,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -6453,13 +6338,7 @@ function ExportPage() {
|
|||||||
if (task.runState?.lastScheduleKey === scheduleKey) continue
|
if (task.runState?.lastScheduleKey === scheduleKey) continue
|
||||||
|
|
||||||
const hasConflict = tasksRef.current.some((item) => {
|
const hasConflict = tasksRef.current.some((item) => {
|
||||||
if (
|
if (item.status !== 'running' && item.status !== 'queued') return false
|
||||||
item.status !== 'running' &&
|
|
||||||
item.status !== 'queued' &&
|
|
||||||
item.status !== 'pause_requested' &&
|
|
||||||
item.status !== 'paused' &&
|
|
||||||
item.status !== 'cancel_requested'
|
|
||||||
) return false
|
|
||||||
return item.payload.automationTaskId === task.id
|
return item.payload.automationTaskId === task.id
|
||||||
})
|
})
|
||||||
if (hasConflict) {
|
if (hasConflict) {
|
||||||
@@ -6569,7 +6448,7 @@ function ExportPage() {
|
|||||||
const runningCardTypes = useMemo(() => {
|
const runningCardTypes = useMemo(() => {
|
||||||
const set = new Set<ContentCardType>()
|
const set = new Set<ContentCardType>()
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
if (!isExportTaskActiveStatus(task.status)) continue
|
if (task.status !== 'running') continue
|
||||||
if (task.payload.scope === 'sns') {
|
if (task.payload.scope === 'sns') {
|
||||||
set.add('sns')
|
set.add('sns')
|
||||||
continue
|
continue
|
||||||
@@ -8012,12 +7891,7 @@ function ExportPage() {
|
|||||||
)
|
)
|
||||||
const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady
|
const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady
|
||||||
const isSnsCardStatsLoading = !hasSeededSnsStats
|
const isSnsCardStatsLoading = !hasSeededSnsStats
|
||||||
const taskRunningCount = tasks.filter(task => (
|
const taskRunningCount = tasks.filter(task => task.status === 'running').length
|
||||||
task.status === 'running' ||
|
|
||||||
task.status === 'pause_requested' ||
|
|
||||||
task.status === 'paused' ||
|
|
||||||
task.status === 'cancel_requested'
|
|
||||||
)).length
|
|
||||||
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
|
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
|
||||||
const chatBackgroundTasks = useMemo(() => (
|
const chatBackgroundTasks = useMemo(() => (
|
||||||
backgroundTasks.filter(task => task.sourcePage === 'chat')
|
backgroundTasks.filter(task => task.sourcePage === 'chat')
|
||||||
@@ -8231,112 +8105,6 @@ function ExportPage() {
|
|||||||
const toggleTaskPerfDetail = useCallback((taskId: string) => {
|
const toggleTaskPerfDetail = useCallback((taskId: string) => {
|
||||||
setExpandedPerfTaskId(prev => (prev === taskId ? null : taskId))
|
setExpandedPerfTaskId(prev => (prev === taskId ? null : taskId))
|
||||||
}, [])
|
}, [])
|
||||||
const handlePauseExportTask = useCallback((taskId: string) => {
|
|
||||||
const task = tasksRef.current.find(item => item.id === taskId)
|
|
||||||
if (!task || task.status !== 'running') return
|
|
||||||
updateTask(taskId, current => ({
|
|
||||||
...current,
|
|
||||||
status: 'pause_requested',
|
|
||||||
progress: {
|
|
||||||
...current.progress,
|
|
||||||
phaseLabel: current.progress.phaseLabel || '暂停请求已发送'
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
window.electronAPI.export.pauseTask(taskId).then(result => {
|
|
||||||
if (result.success) return
|
|
||||||
updateTask(taskId, current => ({
|
|
||||||
...current,
|
|
||||||
status: current.status === 'pause_requested' ? 'running' : current.status,
|
|
||||||
error: result.error || '暂停请求失败'
|
|
||||||
}))
|
|
||||||
}).catch(error => {
|
|
||||||
updateTask(taskId, current => ({
|
|
||||||
...current,
|
|
||||||
status: current.status === 'pause_requested' ? 'running' : current.status,
|
|
||||||
error: String(error)
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
}, [updateTask])
|
|
||||||
const handleResumeExportTask = useCallback((taskId: string) => {
|
|
||||||
const task = tasksRef.current.find(item => item.id === taskId)
|
|
||||||
if (!task || (task.status !== 'paused' && task.status !== 'pause_requested')) return
|
|
||||||
window.electronAPI.export.resumeTask(taskId).then(result => {
|
|
||||||
const doneAt = Date.now()
|
|
||||||
if (!result.success) {
|
|
||||||
updateTask(taskId, current => ({
|
|
||||||
...current,
|
|
||||||
status: 'error',
|
|
||||||
finishedAt: doneAt,
|
|
||||||
error: result.error || '继续任务失败',
|
|
||||||
performance: finalizeTaskPerformance(current, doneAt)
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updateTask(taskId, current => ({
|
|
||||||
...current,
|
|
||||||
status: current.status === 'pause_requested' ? 'running' : 'queued',
|
|
||||||
finishedAt: undefined,
|
|
||||||
error: undefined,
|
|
||||||
progress: {
|
|
||||||
...current.progress,
|
|
||||||
phaseLabel: current.status === 'pause_requested' ? '继续中' : '等待继续'
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}).catch(error => {
|
|
||||||
const doneAt = Date.now()
|
|
||||||
updateTask(taskId, current => ({
|
|
||||||
...current,
|
|
||||||
status: 'error',
|
|
||||||
finishedAt: doneAt,
|
|
||||||
error: String(error),
|
|
||||||
performance: finalizeTaskPerformance(current, doneAt)
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
}, [updateTask])
|
|
||||||
const handleCancelExportTask = useCallback((taskId: string) => {
|
|
||||||
const task = tasksRef.current.find(item => item.id === taskId)
|
|
||||||
if (!task) return
|
|
||||||
if (task.status === 'queued') {
|
|
||||||
setTasks(prev => prev.filter(item => item.id !== taskId))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (task.status !== 'running' && task.status !== 'pause_requested' && task.status !== 'paused' && task.status !== 'cancel_requested') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updateTask(taskId, current => ({
|
|
||||||
...current,
|
|
||||||
status: 'cancel_requested',
|
|
||||||
progress: {
|
|
||||||
...current.progress,
|
|
||||||
phaseLabel: '取消请求已发送,正在安全停止'
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
window.electronAPI.export.cancelTask(taskId).then(result => {
|
|
||||||
if (result.success && task.status === 'paused') {
|
|
||||||
setTasks(prev => prev.filter(item => item.id !== taskId))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!result.success) {
|
|
||||||
const doneAt = Date.now()
|
|
||||||
updateTask(taskId, current => ({
|
|
||||||
...current,
|
|
||||||
status: 'error',
|
|
||||||
finishedAt: doneAt,
|
|
||||||
error: result.error || '取消任务失败',
|
|
||||||
performance: finalizeTaskPerformance(current, doneAt)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}).catch(error => {
|
|
||||||
const doneAt = Date.now()
|
|
||||||
updateTask(taskId, current => ({
|
|
||||||
...current,
|
|
||||||
status: 'error',
|
|
||||||
finishedAt: doneAt,
|
|
||||||
error: String(error),
|
|
||||||
performance: finalizeTaskPerformance(current, doneAt)
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
}, [updateTask])
|
|
||||||
|
|
||||||
const toggleAutomationTaskEnabled = useCallback((taskId: string, enabled: boolean) => {
|
const toggleAutomationTaskEnabled = useCallback((taskId: string, enabled: boolean) => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@@ -8796,9 +8564,6 @@ function ExportPage() {
|
|||||||
nowTick={nowTick}
|
nowTick={nowTick}
|
||||||
onClose={closeTaskCenter}
|
onClose={closeTaskCenter}
|
||||||
onTogglePerfTask={toggleTaskPerfDetail}
|
onTogglePerfTask={toggleTaskPerfDetail}
|
||||||
onPauseExportTask={handlePauseExportTask}
|
|
||||||
onResumeExportTask={handleResumeExportTask}
|
|
||||||
onCancelExportTask={handleCancelExportTask}
|
|
||||||
onPauseBackgroundTask={handlePauseBackgroundTask}
|
onPauseBackgroundTask={handlePauseBackgroundTask}
|
||||||
onResumeBackgroundTask={handleResumeBackgroundTask}
|
onResumeBackgroundTask={handleResumeBackgroundTask}
|
||||||
onCancelBackgroundTask={handleCancelBackgroundTask}
|
onCancelBackgroundTask={handleCancelBackgroundTask}
|
||||||
@@ -8857,12 +8622,12 @@ function ExportPage() {
|
|||||||
<div className="automation-task-list">
|
<div className="automation-task-list">
|
||||||
{sortedAutomationTasks.map((task) => {
|
{sortedAutomationTasks.map((task) => {
|
||||||
const linkedQueueTask = tasks.find((item) => (
|
const linkedQueueTask = tasks.find((item) => (
|
||||||
isExportTaskActiveStatus(item.status) &&
|
(item.status === 'running' || item.status === 'queued') &&
|
||||||
item.payload.automationTaskId === task.id
|
item.payload.automationTaskId === task.id
|
||||||
))
|
))
|
||||||
const queueState: 'queued' | 'running' | null = linkedQueueTask?.status === 'running'
|
const queueState: 'queued' | 'running' | null = linkedQueueTask?.status === 'running'
|
||||||
? 'running'
|
? 'running'
|
||||||
: linkedQueueTask && isExportTaskActiveStatus(linkedQueueTask.status)
|
: linkedQueueTask?.status === 'queued'
|
||||||
? 'queued'
|
? 'queued'
|
||||||
: null
|
: null
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useThemeStore, themes } from '../stores/themeStore'
|
|||||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||||
import { dialog } from '../services/ipc'
|
import { dialog } from '../services/ipc'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
import type { ChatSession, ContactInfo } from '../types/models'
|
import type { ContactInfo } from '../types/models'
|
||||||
import {
|
import {
|
||||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||||
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
|
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
|
||||||
@@ -195,7 +195,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const [launchAtStartup, setLaunchAtStartup] = useState(false)
|
const [launchAtStartup, setLaunchAtStartup] = useState(false)
|
||||||
const [launchAtStartupSupported, setLaunchAtStartupSupported] = useState(isWindows || isMac)
|
const [launchAtStartupSupported, setLaunchAtStartupSupported] = useState(isWindows || isMac)
|
||||||
const [launchAtStartupReason, setLaunchAtStartupReason] = useState('')
|
const [launchAtStartupReason, setLaunchAtStartupReason] = useState('')
|
||||||
const [silentStartup, setSilentStartup] = useState(false)
|
|
||||||
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')
|
||||||
@@ -223,7 +222,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
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 [isUpdatingLaunchAtStartup, setIsUpdatingLaunchAtStartup] = useState(false)
|
||||||
const [isUpdatingSilentStartup, setIsUpdatingSilentStartup] = 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)
|
||||||
@@ -265,7 +263,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const [messagePushFilterSearchKeyword, setMessagePushFilterSearchKeyword] = useState('')
|
const [messagePushFilterSearchKeyword, setMessagePushFilterSearchKeyword] = useState('')
|
||||||
const [messagePushTypeFilter, setMessagePushTypeFilter] = useState<SessionFilterTypeValue>('all')
|
const [messagePushTypeFilter, setMessagePushTypeFilter] = useState<SessionFilterTypeValue>('all')
|
||||||
const [messagePushContactOptions, setMessagePushContactOptions] = useState<ContactInfo[]>([])
|
const [messagePushContactOptions, setMessagePushContactOptions] = useState<ContactInfo[]>([])
|
||||||
const [antiRevokeSessions, setAntiRevokeSessions] = useState<ChatSession[]>([])
|
|
||||||
const [antiRevokeSearchKeyword, setAntiRevokeSearchKeyword] = useState('')
|
const [antiRevokeSearchKeyword, setAntiRevokeSearchKeyword] = useState('')
|
||||||
const [antiRevokeSelectedIds, setAntiRevokeSelectedIds] = useState<Set<string>>(new Set())
|
const [antiRevokeSelectedIds, setAntiRevokeSelectedIds] = useState<Set<string>>(new Set())
|
||||||
const [antiRevokeStatusMap, setAntiRevokeStatusMap] = useState<Record<string, { installed?: boolean; loading?: boolean; error?: string }>>({})
|
const [antiRevokeStatusMap, setAntiRevokeStatusMap] = useState<Record<string, { installed?: boolean; loading?: boolean; error?: string }>>({})
|
||||||
@@ -448,7 +445,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const savedMessagePushFilterList = await configService.getMessagePushFilterList()
|
const savedMessagePushFilterList = await configService.getMessagePushFilterList()
|
||||||
const contactsResult = await window.electronAPI.chat.getContacts({ lite: true })
|
const contactsResult = await window.electronAPI.chat.getContacts({ lite: true })
|
||||||
const savedLaunchAtStartupStatus = await window.electronAPI.app.getLaunchAtStartupStatus()
|
const savedLaunchAtStartupStatus = await window.electronAPI.app.getLaunchAtStartupStatus()
|
||||||
const savedSilentStartup = await configService.getSilentStartup()
|
|
||||||
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()
|
||||||
@@ -506,7 +502,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
setLaunchAtStartup(savedLaunchAtStartupStatus.enabled)
|
setLaunchAtStartup(savedLaunchAtStartupStatus.enabled)
|
||||||
setLaunchAtStartupSupported(savedLaunchAtStartupStatus.supported)
|
setLaunchAtStartupSupported(savedLaunchAtStartupStatus.supported)
|
||||||
setLaunchAtStartupReason(savedLaunchAtStartupStatus.reason || '')
|
setLaunchAtStartupReason(savedLaunchAtStartupStatus.reason || '')
|
||||||
setSilentStartup(savedSilentStartup)
|
|
||||||
setWindowCloseBehavior(savedWindowCloseBehavior)
|
setWindowCloseBehavior(savedWindowCloseBehavior)
|
||||||
setQuoteLayout(savedQuoteLayout)
|
setQuoteLayout(savedQuoteLayout)
|
||||||
if (savedUpdateChannel) {
|
if (savedUpdateChannel) {
|
||||||
@@ -620,21 +615,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSilentStartupChange = async (enabled: boolean) => {
|
|
||||||
if (isUpdatingSilentStartup) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsUpdatingSilentStartup(true)
|
|
||||||
await configService.setSilentStartup(enabled)
|
|
||||||
setSilentStartup(enabled)
|
|
||||||
showMessage(enabled ? '已开启静默启动' : '已关闭静默启动', true)
|
|
||||||
} catch (e: any) {
|
|
||||||
showMessage(`设置静默启动失败: ${e?.message || String(e)}`, false)
|
|
||||||
} finally {
|
|
||||||
setIsUpdatingSilentStartup(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()
|
||||||
@@ -772,10 +752,10 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
||||||
|
|
||||||
const getCurrentAntiRevokeSessionIds = (): string[] =>
|
const getCurrentAntiRevokeSessionIds = (): string[] =>
|
||||||
normalizeSessionIds(antiRevokeSessions.map((session) => session.username))
|
normalizeSessionIds(chatSessions.map((session) => session.username))
|
||||||
|
|
||||||
const ensureChatSessionsLoaded = async (): Promise<string[]> => {
|
const ensureAntiRevokeSessionsLoaded = async (): Promise<string[]> => {
|
||||||
const current = normalizeSessionIds(chatSessions.map((session) => session.username))
|
const current = getCurrentAntiRevokeSessionIds()
|
||||||
if (current.length > 0) return current
|
if (current.length > 0) return current
|
||||||
const sessionsResult = await window.electronAPI.chat.getSessions()
|
const sessionsResult = await window.electronAPI.chat.getSessions()
|
||||||
if (!sessionsResult.success || !sessionsResult.sessions) {
|
if (!sessionsResult.success || !sessionsResult.sessions) {
|
||||||
@@ -785,27 +765,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
return normalizeSessionIds(sessionsResult.sessions.map((session) => session.username))
|
return normalizeSessionIds(sessionsResult.sessions.map((session) => session.username))
|
||||||
}
|
}
|
||||||
|
|
||||||
const ensureAntiRevokeSessionsLoaded = async (): Promise<string[]> => {
|
|
||||||
const current = getCurrentAntiRevokeSessionIds()
|
|
||||||
if (current.length > 0) return current
|
|
||||||
const sessionsResult = await window.electronAPI.chat.getAntiRevokeSessions()
|
|
||||||
if (!sessionsResult.success || !sessionsResult.sessions) {
|
|
||||||
throw new Error(sessionsResult.error || '加载会话失败')
|
|
||||||
}
|
|
||||||
const nextSessions = sessionsResult.sessions
|
|
||||||
const nextIds = normalizeSessionIds(nextSessions.map((session) => session.username))
|
|
||||||
setAntiRevokeSessions(nextSessions)
|
|
||||||
setAntiRevokeSelectedIds((prev) => {
|
|
||||||
const allowed = new Set(nextIds)
|
|
||||||
return new Set(Array.from(prev).filter((sessionId) => allowed.has(sessionId)))
|
|
||||||
})
|
|
||||||
setAntiRevokeStatusMap((prev) => {
|
|
||||||
const allowed = new Set(nextIds)
|
|
||||||
return Object.fromEntries(Object.entries(prev).filter(([sessionId]) => allowed.has(sessionId)))
|
|
||||||
})
|
|
||||||
return nextIds
|
|
||||||
}
|
|
||||||
|
|
||||||
const markAntiRevokeRowsLoading = (sessionIds: string[]) => {
|
const markAntiRevokeRowsLoading = (sessionIds: string[]) => {
|
||||||
setAntiRevokeStatusMap((prev) => {
|
setAntiRevokeStatusMap((prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
@@ -1017,10 +976,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
let canceled = false
|
let canceled = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
|
// 两个 Tab 都需要会话列表;antiRevoke 还需要额外检查防撤回状态
|
||||||
|
const sessionIds = await ensureAntiRevokeSessionsLoaded()
|
||||||
|
if (canceled) return
|
||||||
if (activeTab === 'antiRevoke') {
|
if (activeTab === 'antiRevoke') {
|
||||||
await ensureAntiRevokeSessionsLoaded()
|
await handleRefreshAntiRevokeStatus(sessionIds)
|
||||||
} else {
|
|
||||||
await ensureChatSessionsLoaded()
|
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (!canceled) {
|
if (!canceled) {
|
||||||
@@ -1724,35 +1684,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
|
|
||||||
<div className="divider" />
|
<div className="divider" />
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>静默启动</label>
|
|
||||||
<span className="form-hint">
|
|
||||||
开启后,无论手动启动还是开机自启动,都会先驻留到系统托盘,不主动显示主窗口。
|
|
||||||
</span>
|
|
||||||
<div className="log-toggle-line">
|
|
||||||
<span className="log-status">
|
|
||||||
{isUpdatingSilentStartup
|
|
||||||
? '保存中...'
|
|
||||||
: (silentStartup ? '已开启' : '已关闭')}
|
|
||||||
</span>
|
|
||||||
<label className="switch" htmlFor="silent-startup-toggle">
|
|
||||||
<input
|
|
||||||
id="silent-startup-toggle"
|
|
||||||
className="switch-input"
|
|
||||||
type="checkbox"
|
|
||||||
checked={silentStartup}
|
|
||||||
disabled={isUpdatingSilentStartup}
|
|
||||||
onChange={(e) => {
|
|
||||||
void handleSilentStartupChange(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>
|
||||||
@@ -2051,7 +1982,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderAntiRevokeTab = () => {
|
const renderAntiRevokeTab = () => {
|
||||||
const sortedSessions = [...antiRevokeSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
|
const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
|
||||||
const keyword = antiRevokeSearchKeyword.trim().toLowerCase()
|
const keyword = antiRevokeSearchKeyword.trim().toLowerCase()
|
||||||
const filteredSessions = sortedSessions.filter((session) => {
|
const filteredSessions = sortedSessions.filter((session) => {
|
||||||
if (!keyword) return true
|
if (!keyword) return true
|
||||||
@@ -4830,3 +4761,4 @@ export default SettingsPage
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2729,54 +2729,6 @@
|
|||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-progress-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-progress-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 82px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: var(--hover-bg);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.primary {
|
|
||||||
border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color));
|
|
||||||
background: rgba(var(--primary-rgb), 0.1);
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.danger {
|
|
||||||
border-color: color-mix(in srgb, #ff4d4f 36%, var(--border-color));
|
|
||||||
background: color-mix(in srgb, #ff4d4f 10%, var(--bg-secondary));
|
|
||||||
color: #d9363e;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.55;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-result {
|
.export-result {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useLayoutEffect, useState, useRef, useCallback, useMemo } from 'react'
|
import { useEffect, useLayoutEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||||
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Info, Shield, ShieldOff, Loader2, Pause, Play, Square } from 'lucide-react'
|
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Info, Shield, ShieldOff, Loader2 } from 'lucide-react'
|
||||||
import './SnsPage.scss'
|
import './SnsPage.scss'
|
||||||
import { SnsPost } from '../types/sns'
|
import { SnsPost } from '../types/sns'
|
||||||
import { SnsPostItem } from '../components/Sns/SnsPostItem'
|
import { SnsPostItem } from '../components/Sns/SnsPostItem'
|
||||||
@@ -64,42 +64,10 @@ interface SnsOverviewStats {
|
|||||||
|
|
||||||
type OverviewStatsStatus = 'loading' | 'ready' | 'error'
|
type OverviewStatsStatus = 'loading' | 'ready' | 'error'
|
||||||
type SnsExportScope = { kind: 'all' } | { kind: 'selected'; usernames: string[] }
|
type SnsExportScope = { kind: 'all' } | { kind: 'selected'; usernames: string[] }
|
||||||
type SnsExportTaskStatus = 'idle' | 'running' | 'pause_requested' | 'paused' | 'cancel_requested'
|
|
||||||
|
|
||||||
interface SnsExportProgress {
|
|
||||||
current: number
|
|
||||||
total: number
|
|
||||||
status: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SnsExportResult {
|
|
||||||
success: boolean
|
|
||||||
filePath?: string
|
|
||||||
postCount?: number
|
|
||||||
mediaCount?: number
|
|
||||||
paused?: boolean
|
|
||||||
stopped?: boolean
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SnsExportRequest {
|
|
||||||
taskId: string
|
|
||||||
outputDir: string
|
|
||||||
format: 'json' | 'html' | 'arkmejson'
|
|
||||||
usernames?: string[]
|
|
||||||
keyword?: string
|
|
||||||
exportImages: boolean
|
|
||||||
exportLivePhotos: boolean
|
|
||||||
exportVideos: boolean
|
|
||||||
startTime?: number
|
|
||||||
endTime?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
||||||
const SNS_CACHE_MIGRATION_PROMPT_SESSION_KEY = 'sns_cache_migration_prompted_v1'
|
const SNS_CACHE_MIGRATION_PROMPT_SESSION_KEY = 'sns_cache_migration_prompted_v1'
|
||||||
|
|
||||||
const createSnsExportTaskId = (): string => `sns-export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
||||||
|
|
||||||
interface SnsCacheMigrationItem {
|
interface SnsCacheMigrationItem {
|
||||||
label: string
|
label: string
|
||||||
sourceDir: string
|
sourceDir: string
|
||||||
@@ -211,9 +179,8 @@ export default function SnsPage() {
|
|||||||
() => createExportDateRangeSelectionFromPreset('all')
|
() => createExportDateRangeSelectionFromPreset('all')
|
||||||
)
|
)
|
||||||
const [isExporting, setIsExporting] = useState(false)
|
const [isExporting, setIsExporting] = useState(false)
|
||||||
const [exportTaskStatus, setExportTaskStatus] = useState<SnsExportTaskStatus>('idle')
|
const [exportProgress, setExportProgress] = useState<{ current: number; total: number; status: string } | null>(null)
|
||||||
const [exportProgress, setExportProgress] = useState<SnsExportProgress | null>(null)
|
const [exportResult, setExportResult] = useState<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string } | null>(null)
|
||||||
const [exportResult, setExportResult] = useState<SnsExportResult | null>(null)
|
|
||||||
const [refreshSpin, setRefreshSpin] = useState(false)
|
const [refreshSpin, setRefreshSpin] = useState(false)
|
||||||
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
|
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
|
||||||
|
|
||||||
@@ -244,8 +211,6 @@ export default function SnsPage() {
|
|||||||
const snsUserPostCountsCacheScopeKeyRef = useRef('')
|
const snsUserPostCountsCacheScopeKeyRef = useRef('')
|
||||||
const activeContactsLoadTaskIdRef = useRef<string | null>(null)
|
const activeContactsLoadTaskIdRef = useRef<string | null>(null)
|
||||||
const activeContactsCountTaskIdRef = useRef<string | null>(null)
|
const activeContactsCountTaskIdRef = useRef<string | null>(null)
|
||||||
const activeExportTaskIdRef = useRef<string | null>(null)
|
|
||||||
const activeExportRequestRef = useRef<SnsExportRequest | null>(null)
|
|
||||||
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
|
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
|
||||||
const pendingResetFeedRef = useRef(false)
|
const pendingResetFeedRef = useRef(false)
|
||||||
const contactsLoadTokenRef = useRef(0)
|
const contactsLoadTokenRef = useRef(0)
|
||||||
@@ -500,11 +465,7 @@ export default function SnsPage() {
|
|||||||
: overviewStatsStatus === 'loading' || contactsLoading
|
: overviewStatsStatus === 'loading' || contactsLoading
|
||||||
)
|
)
|
||||||
|
|
||||||
const isExportLocked = isExporting || exportTaskStatus !== 'idle'
|
const canStartExport = Boolean(exportFolder) && !isExporting && (
|
||||||
const canPauseExport = exportTaskStatus === 'running'
|
|
||||||
const canResumeExport = exportTaskStatus === 'paused' || exportTaskStatus === 'pause_requested'
|
|
||||||
const canCancelExport = exportTaskStatus !== 'idle'
|
|
||||||
const canStartExport = Boolean(exportFolder) && !isExportLocked && (
|
|
||||||
exportScope.kind === 'all' || exportScope.usernames.length > 0
|
exportScope.kind === 'all' || exportScope.usernames.length > 0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -811,205 +772,14 @@ export default function SnsPage() {
|
|||||||
|
|
||||||
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDateRangeSelection), [exportDateRangeSelection])
|
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDateRangeSelection), [exportDateRangeSelection])
|
||||||
|
|
||||||
const clearActiveExportTask = useCallback(() => {
|
|
||||||
activeExportTaskIdRef.current = null
|
|
||||||
activeExportRequestRef.current = null
|
|
||||||
setExportTaskStatus('idle')
|
|
||||||
setIsExporting(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const buildSnsExportRequest = useCallback((taskId: string): SnsExportRequest => ({
|
|
||||||
taskId,
|
|
||||||
outputDir: exportFolder,
|
|
||||||
format: exportFormat,
|
|
||||||
usernames: exportScope.kind === 'selected' ? [...exportScope.usernames] : undefined,
|
|
||||||
keyword: searchKeyword || undefined,
|
|
||||||
exportImages,
|
|
||||||
exportLivePhotos,
|
|
||||||
exportVideos,
|
|
||||||
startTime: exportDateRangeSelection.useAllTime
|
|
||||||
? undefined
|
|
||||||
: Math.floor(exportDateRangeSelection.dateRange.start.getTime() / 1000),
|
|
||||||
endTime: exportDateRangeSelection.useAllTime
|
|
||||||
? undefined
|
|
||||||
: Math.floor(exportDateRangeSelection.dateRange.end.getTime() / 1000)
|
|
||||||
}), [
|
|
||||||
exportDateRangeSelection,
|
|
||||||
exportFolder,
|
|
||||||
exportFormat,
|
|
||||||
exportImages,
|
|
||||||
exportLivePhotos,
|
|
||||||
exportScope,
|
|
||||||
exportVideos,
|
|
||||||
searchKeyword
|
|
||||||
])
|
|
||||||
|
|
||||||
const runSnsExport = useCallback(async (request: SnsExportRequest, statusText = '准备导出...') => {
|
|
||||||
activeExportTaskIdRef.current = request.taskId
|
|
||||||
activeExportRequestRef.current = request
|
|
||||||
setIsExporting(true)
|
|
||||||
setExportTaskStatus('running')
|
|
||||||
setExportResult(null)
|
|
||||||
setExportProgress(prev => prev || { current: 0, total: 0, status: statusText })
|
|
||||||
|
|
||||||
let keepTaskActive = false
|
|
||||||
const removeProgress = window.electronAPI.sns.onExportProgress((progress: SnsExportProgress) => {
|
|
||||||
setExportProgress(progress)
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await window.electronAPI.sns.exportTimeline(request)
|
|
||||||
if (!result.success) {
|
|
||||||
setExportResult(result)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.paused) {
|
|
||||||
keepTaskActive = true
|
|
||||||
setExportTaskStatus('paused')
|
|
||||||
setExportProgress(prev => ({
|
|
||||||
current: Math.max(prev?.current || 0, result.postCount || 0),
|
|
||||||
total: Math.max(prev?.total || 0, result.postCount || 0),
|
|
||||||
status: '已暂停,可继续或取消'
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.stopped) {
|
|
||||||
setExportResult(null)
|
|
||||||
setExportProgress(null)
|
|
||||||
setShowExportDialog(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setExportResult(result)
|
|
||||||
} catch (e: any) {
|
|
||||||
setExportResult({ success: false, error: e.message || String(e) })
|
|
||||||
} finally {
|
|
||||||
removeProgress()
|
|
||||||
setIsExporting(false)
|
|
||||||
if (!keepTaskActive) {
|
|
||||||
activeExportTaskIdRef.current = null
|
|
||||||
activeExportRequestRef.current = null
|
|
||||||
setExportTaskStatus('idle')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleStartSnsExport = useCallback(() => {
|
|
||||||
if (!canStartExport) return
|
|
||||||
const request = buildSnsExportRequest(createSnsExportTaskId())
|
|
||||||
setExportProgress({ current: 0, total: 0, status: '准备导出...' })
|
|
||||||
void runSnsExport(request)
|
|
||||||
}, [buildSnsExportRequest, canStartExport, runSnsExport])
|
|
||||||
|
|
||||||
const handlePauseSnsExport = useCallback(() => {
|
|
||||||
const taskId = activeExportTaskIdRef.current
|
|
||||||
if (!taskId || exportTaskStatus !== 'running') return
|
|
||||||
setExportTaskStatus('pause_requested')
|
|
||||||
setExportProgress(prev => ({
|
|
||||||
current: prev?.current || 0,
|
|
||||||
total: prev?.total || 0,
|
|
||||||
status: '暂停请求已发送,正在等待安全检查点'
|
|
||||||
}))
|
|
||||||
window.electronAPI.export.pauseTask(taskId).then(result => {
|
|
||||||
if (result.success) return
|
|
||||||
setExportTaskStatus(current => current === 'pause_requested' ? 'running' : current)
|
|
||||||
setExportProgress(prev => ({
|
|
||||||
current: prev?.current || 0,
|
|
||||||
total: prev?.total || 0,
|
|
||||||
status: result.error || '暂停请求失败'
|
|
||||||
}))
|
|
||||||
}).catch(error => {
|
|
||||||
setExportTaskStatus(current => current === 'pause_requested' ? 'running' : current)
|
|
||||||
setExportProgress(prev => ({
|
|
||||||
current: prev?.current || 0,
|
|
||||||
total: prev?.total || 0,
|
|
||||||
status: String(error)
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
}, [exportTaskStatus])
|
|
||||||
|
|
||||||
const handleResumeSnsExport = useCallback(() => {
|
|
||||||
const taskId = activeExportTaskIdRef.current
|
|
||||||
const request = activeExportRequestRef.current
|
|
||||||
if (!taskId || !request || (exportTaskStatus !== 'paused' && exportTaskStatus !== 'pause_requested')) return
|
|
||||||
setExportTaskStatus('running')
|
|
||||||
setExportProgress(prev => ({
|
|
||||||
current: prev?.current || 0,
|
|
||||||
total: prev?.total || 0,
|
|
||||||
status: '正在继续导出...'
|
|
||||||
}))
|
|
||||||
window.electronAPI.export.resumeTask(taskId).then(result => {
|
|
||||||
if (!result.success) {
|
|
||||||
setExportTaskStatus('paused')
|
|
||||||
setExportProgress(prev => ({
|
|
||||||
current: prev?.current || 0,
|
|
||||||
total: prev?.total || 0,
|
|
||||||
status: result.error || '继续任务失败'
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void runSnsExport(request, '正在继续导出...')
|
|
||||||
}).catch(error => {
|
|
||||||
setExportTaskStatus('paused')
|
|
||||||
setExportProgress(prev => ({
|
|
||||||
current: prev?.current || 0,
|
|
||||||
total: prev?.total || 0,
|
|
||||||
status: String(error)
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
}, [exportTaskStatus, runSnsExport])
|
|
||||||
|
|
||||||
const handleCancelSnsExport = useCallback(() => {
|
|
||||||
const taskId = activeExportTaskIdRef.current
|
|
||||||
if (!taskId || exportTaskStatus === 'idle' || exportTaskStatus === 'cancel_requested') return
|
|
||||||
const shouldCloseAfterAck = exportTaskStatus === 'paused' || !isExporting
|
|
||||||
setExportTaskStatus('cancel_requested')
|
|
||||||
setExportProgress(prev => ({
|
|
||||||
current: prev?.current || 0,
|
|
||||||
total: prev?.total || 0,
|
|
||||||
status: '取消请求已发送,正在安全停止并清理'
|
|
||||||
}))
|
|
||||||
window.electronAPI.export.cancelTask(taskId).then(result => {
|
|
||||||
if (!result.success) {
|
|
||||||
setExportTaskStatus(shouldCloseAfterAck ? 'paused' : 'running')
|
|
||||||
setExportProgress(prev => ({
|
|
||||||
current: prev?.current || 0,
|
|
||||||
total: prev?.total || 0,
|
|
||||||
status: result.error || '取消任务失败'
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (shouldCloseAfterAck) {
|
|
||||||
clearActiveExportTask()
|
|
||||||
setExportResult(null)
|
|
||||||
setExportProgress(null)
|
|
||||||
setShowExportDialog(false)
|
|
||||||
}
|
|
||||||
}).catch(error => {
|
|
||||||
setExportTaskStatus(shouldCloseAfterAck ? 'paused' : 'running')
|
|
||||||
setExportProgress(prev => ({
|
|
||||||
current: prev?.current || 0,
|
|
||||||
total: prev?.total || 0,
|
|
||||||
status: String(error)
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
}, [clearActiveExportTask, exportTaskStatus, isExporting])
|
|
||||||
|
|
||||||
const openExportDialog = useCallback((scope: SnsExportScope) => {
|
const openExportDialog = useCallback((scope: SnsExportScope) => {
|
||||||
if (isExportLocked) {
|
|
||||||
setShowExportDialog(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setExportScope(scope)
|
setExportScope(scope)
|
||||||
setExportResult(null)
|
setExportResult(null)
|
||||||
setExportProgress(null)
|
setExportProgress(null)
|
||||||
clearActiveExportTask()
|
|
||||||
setExportDateRangeSelection(createExportDateRangeSelectionFromPreset('all'))
|
setExportDateRangeSelection(createExportDateRangeSelectionFromPreset('all'))
|
||||||
setIsExportDateRangeDialogOpen(false)
|
setIsExportDateRangeDialogOpen(false)
|
||||||
setShowExportDialog(true)
|
setShowExportDialog(true)
|
||||||
}, [clearActiveExportTask, isExportLocked])
|
}, [])
|
||||||
|
|
||||||
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
|
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
|
||||||
const { reset = false, direction = 'older' } = options
|
const { reset = false, direction = 'older' } = options
|
||||||
@@ -2278,11 +2048,11 @@ export default function SnsPage() {
|
|||||||
|
|
||||||
{/* 导出对话框 */}
|
{/* 导出对话框 */}
|
||||||
{showExportDialog && (
|
{showExportDialog && (
|
||||||
<div className="modal-overlay" onClick={() => !isExportLocked && setShowExportDialog(false)}>
|
<div className="modal-overlay" onClick={() => !isExporting && setShowExportDialog(false)}>
|
||||||
<div className="export-dialog" onClick={(e) => e.stopPropagation()}>
|
<div className="export-dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="export-dialog-header">
|
<div className="export-dialog-header">
|
||||||
<h3>导出朋友圈</h3>
|
<h3>导出朋友圈</h3>
|
||||||
<button className="close-btn" onClick={() => !isExportLocked && setShowExportDialog(false)} disabled={isExportLocked}>
|
<button className="close-btn" onClick={() => !isExporting && setShowExportDialog(false)} disabled={isExporting}>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2308,7 +2078,7 @@ export default function SnsPage() {
|
|||||||
<button
|
<button
|
||||||
className={`format-option ${exportFormat === 'html' ? 'active' : ''}`}
|
className={`format-option ${exportFormat === 'html' ? 'active' : ''}`}
|
||||||
onClick={() => setExportFormat('html')}
|
onClick={() => setExportFormat('html')}
|
||||||
disabled={isExportLocked}
|
disabled={isExporting}
|
||||||
>
|
>
|
||||||
<FileText size={20} />
|
<FileText size={20} />
|
||||||
<span>HTML</span>
|
<span>HTML</span>
|
||||||
@@ -2317,7 +2087,7 @@ export default function SnsPage() {
|
|||||||
<button
|
<button
|
||||||
className={`format-option ${exportFormat === 'json' ? 'active' : ''}`}
|
className={`format-option ${exportFormat === 'json' ? 'active' : ''}`}
|
||||||
onClick={() => setExportFormat('json')}
|
onClick={() => setExportFormat('json')}
|
||||||
disabled={isExportLocked}
|
disabled={isExporting}
|
||||||
>
|
>
|
||||||
<FileJson size={20} />
|
<FileJson size={20} />
|
||||||
<span>JSON</span>
|
<span>JSON</span>
|
||||||
@@ -2326,7 +2096,7 @@ export default function SnsPage() {
|
|||||||
<button
|
<button
|
||||||
className={`format-option ${exportFormat === 'arkmejson' ? 'active' : ''}`}
|
className={`format-option ${exportFormat === 'arkmejson' ? 'active' : ''}`}
|
||||||
onClick={() => setExportFormat('arkmejson')}
|
onClick={() => setExportFormat('arkmejson')}
|
||||||
disabled={isExportLocked}
|
disabled={isExporting}
|
||||||
>
|
>
|
||||||
<FileJson size={20} />
|
<FileJson size={20} />
|
||||||
<span>ArkmeJSON</span>
|
<span>ArkmeJSON</span>
|
||||||
@@ -2354,7 +2124,7 @@ export default function SnsPage() {
|
|||||||
setExportFolder(result.filePath)
|
setExportFolder(result.filePath)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isExportLocked}
|
disabled={isExporting}
|
||||||
>
|
>
|
||||||
<FolderOpen size={16} />
|
<FolderOpen size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -2369,9 +2139,9 @@ export default function SnsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="time-range-trigger sns-export-time-range-trigger"
|
className="time-range-trigger sns-export-time-range-trigger"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isExportLocked) setIsExportDateRangeDialogOpen(true)
|
if (!isExporting) setIsExportDateRangeDialogOpen(true)
|
||||||
}}
|
}}
|
||||||
disabled={isExportLocked}
|
disabled={isExporting}
|
||||||
>
|
>
|
||||||
<span>{exportDateRangeLabel}</span>
|
<span>{exportDateRangeLabel}</span>
|
||||||
<span className="time-range-arrow">></span>
|
<span className="time-range-arrow">></span>
|
||||||
@@ -2391,7 +2161,7 @@ export default function SnsPage() {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={exportImages}
|
checked={exportImages}
|
||||||
onChange={(e) => setExportImages(e.target.checked)}
|
onChange={(e) => setExportImages(e.target.checked)}
|
||||||
disabled={isExportLocked}
|
disabled={isExporting}
|
||||||
/>
|
/>
|
||||||
图片
|
图片
|
||||||
</label>
|
</label>
|
||||||
@@ -2400,7 +2170,7 @@ export default function SnsPage() {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={exportLivePhotos}
|
checked={exportLivePhotos}
|
||||||
onChange={(e) => setExportLivePhotos(e.target.checked)}
|
onChange={(e) => setExportLivePhotos(e.target.checked)}
|
||||||
disabled={isExportLocked}
|
disabled={isExporting}
|
||||||
/>
|
/>
|
||||||
实况图
|
实况图
|
||||||
</label>
|
</label>
|
||||||
@@ -2409,7 +2179,7 @@ export default function SnsPage() {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={exportVideos}
|
checked={exportVideos}
|
||||||
onChange={(e) => setExportVideos(e.target.checked)}
|
onChange={(e) => setExportVideos(e.target.checked)}
|
||||||
disabled={isExportLocked}
|
disabled={isExporting}
|
||||||
/>
|
/>
|
||||||
视频
|
视频
|
||||||
</label>
|
</label>
|
||||||
@@ -2424,7 +2194,7 @@ export default function SnsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 进度条 */}
|
{/* 进度条 */}
|
||||||
{isExportLocked && exportProgress && (
|
{isExporting && exportProgress && (
|
||||||
<div className="export-progress">
|
<div className="export-progress">
|
||||||
<div className="export-progress-bar">
|
<div className="export-progress-bar">
|
||||||
<div
|
<div
|
||||||
@@ -2433,39 +2203,6 @@ export default function SnsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="export-progress-text">{exportProgress.status}</span>
|
<span className="export-progress-text">{exportProgress.status}</span>
|
||||||
<div className="export-progress-actions">
|
|
||||||
{canPauseExport && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="export-progress-btn"
|
|
||||||
onClick={handlePauseSnsExport}
|
|
||||||
>
|
|
||||||
<Pause size={14} />
|
|
||||||
暂停
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canResumeExport && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="export-progress-btn primary"
|
|
||||||
onClick={handleResumeSnsExport}
|
|
||||||
>
|
|
||||||
<Play size={14} />
|
|
||||||
继续
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canCancelExport && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="export-progress-btn danger"
|
|
||||||
onClick={handleCancelSnsExport}
|
|
||||||
disabled={exportTaskStatus === 'cancel_requested'}
|
|
||||||
>
|
|
||||||
<Square size={14} />
|
|
||||||
{exportTaskStatus === 'cancel_requested' ? '取消中' : '取消'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2474,14 +2211,47 @@ export default function SnsPage() {
|
|||||||
<button
|
<button
|
||||||
className="export-cancel-btn"
|
className="export-cancel-btn"
|
||||||
onClick={() => setShowExportDialog(false)}
|
onClick={() => setShowExportDialog(false)}
|
||||||
disabled={isExportLocked}
|
disabled={isExporting}
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="export-start-btn"
|
className="export-start-btn"
|
||||||
disabled={!canStartExport}
|
disabled={!canStartExport}
|
||||||
onClick={handleStartSnsExport}
|
onClick={async () => {
|
||||||
|
setIsExporting(true)
|
||||||
|
setExportProgress({ current: 0, total: 0, status: '准备导出...' })
|
||||||
|
setExportResult(null)
|
||||||
|
|
||||||
|
// 监听进度
|
||||||
|
const removeProgress = window.electronAPI.sns.onExportProgress((progress: any) => {
|
||||||
|
setExportProgress(progress)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.sns.exportTimeline({
|
||||||
|
outputDir: exportFolder,
|
||||||
|
format: exportFormat,
|
||||||
|
usernames: exportScope.kind === 'selected' ? exportScope.usernames : undefined,
|
||||||
|
keyword: searchKeyword || undefined,
|
||||||
|
exportImages,
|
||||||
|
exportLivePhotos,
|
||||||
|
exportVideos,
|
||||||
|
startTime: exportDateRangeSelection.useAllTime
|
||||||
|
? undefined
|
||||||
|
: Math.floor(exportDateRangeSelection.dateRange.start.getTime() / 1000),
|
||||||
|
endTime: exportDateRangeSelection.useAllTime
|
||||||
|
? undefined
|
||||||
|
: Math.floor(exportDateRangeSelection.dateRange.end.getTime() / 1000)
|
||||||
|
})
|
||||||
|
setExportResult(result)
|
||||||
|
} catch (e: any) {
|
||||||
|
setExportResult({ success: false, error: e.message || String(e) })
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false)
|
||||||
|
removeProgress()
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isExporting ? '导出中...' : '开始导出'}
|
{isExporting ? '导出中...' : '开始导出'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export const CONFIG_KEYS = {
|
|||||||
WINDOW_BOUNDS: 'windowBounds',
|
WINDOW_BOUNDS: 'windowBounds',
|
||||||
CACHE_PATH: 'cachePath',
|
CACHE_PATH: 'cachePath',
|
||||||
LAUNCH_AT_STARTUP: 'launchAtStartup',
|
LAUNCH_AT_STARTUP: 'launchAtStartup',
|
||||||
SILENT_STARTUP: 'silentStartup',
|
|
||||||
|
|
||||||
EXPORT_PATH: 'exportPath',
|
EXPORT_PATH: 'exportPath',
|
||||||
AGREEMENT_ACCEPTED: 'agreementAccepted',
|
AGREEMENT_ACCEPTED: 'agreementAccepted',
|
||||||
@@ -322,17 +321,6 @@ export async function setLaunchAtStartup(enabled: boolean): Promise<void> {
|
|||||||
await config.set(CONFIG_KEYS.LAUNCH_AT_STARTUP, enabled)
|
await config.set(CONFIG_KEYS.LAUNCH_AT_STARTUP, enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取静默启动偏好
|
|
||||||
export async function getSilentStartup(): Promise<boolean> {
|
|
||||||
const value = await config.get(CONFIG_KEYS.SILENT_STARTUP)
|
|
||||||
return value === true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置静默启动偏好
|
|
||||||
export async function setSilentStartup(enabled: boolean): Promise<void> {
|
|
||||||
await config.set(CONFIG_KEYS.SILENT_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)
|
||||||
|
|||||||
25
src/types/electron.d.ts
vendored
25
src/types/electron.d.ts
vendored
@@ -35,17 +35,6 @@ export interface BackupOptions {
|
|||||||
includeFiles?: boolean
|
includeFiles?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackupImageDatMeta {
|
|
||||||
version?: number
|
|
||||||
aesSize?: number
|
|
||||||
aes_size?: number
|
|
||||||
xorSize?: number
|
|
||||||
xor_size?: number
|
|
||||||
rawSize?: number
|
|
||||||
raw_size?: number
|
|
||||||
flag?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BackupManifest {
|
export interface BackupManifest {
|
||||||
version: 1
|
version: 1
|
||||||
type: 'weflow-db-snapshots'
|
type: 'weflow-db-snapshots'
|
||||||
@@ -58,7 +47,7 @@ export interface BackupManifest {
|
|||||||
options?: BackupOptions
|
options?: BackupOptions
|
||||||
databases: Array<{
|
databases: Array<{
|
||||||
id: string
|
id: string
|
||||||
kind: 'session' | 'contact' | 'emoticon' | 'message' | 'media' | 'sns' | 'hardlink'
|
kind: 'session' | 'contact' | 'emoticon' | 'message' | 'media' | 'sns'
|
||||||
dbPath: string
|
dbPath: string
|
||||||
relativePath: string
|
relativePath: string
|
||||||
tables: Array<{
|
tables: Array<{
|
||||||
@@ -81,7 +70,6 @@ export interface BackupManifest {
|
|||||||
targetRelativePath: string
|
targetRelativePath: string
|
||||||
ext?: string
|
ext?: string
|
||||||
size?: number
|
size?: number
|
||||||
datMeta?: BackupImageDatMeta
|
|
||||||
}>
|
}>
|
||||||
videos?: Array<{
|
videos?: Array<{
|
||||||
kind: 'image' | 'video' | 'file'
|
kind: 'image' | 'video' | 'file'
|
||||||
@@ -271,7 +259,6 @@ export interface ElectronAPI {
|
|||||||
chat: {
|
chat: {
|
||||||
connect: () => Promise<{ success: boolean; error?: string }>
|
connect: () => Promise<{ success: boolean; error?: string }>
|
||||||
getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
|
getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
|
||||||
getAntiRevokeSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
|
|
||||||
getSessionStatuses: (usernames: string[]) => Promise<{
|
getSessionStatuses: (usernames: string[]) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
map?: Record<string, { isFolded?: boolean; isMuted?: boolean }>
|
map?: Record<string, { isFolded?: boolean; isMuted?: boolean }>
|
||||||
@@ -1092,21 +1079,16 @@ export interface ElectronAPI {
|
|||||||
estimatedSeconds: number
|
estimatedSeconds: number
|
||||||
sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }>
|
sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }>
|
||||||
}>
|
}>
|
||||||
exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions, controlOptions?: { taskId?: string }) => Promise<{
|
exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
successCount?: number
|
successCount?: number
|
||||||
failCount?: number
|
failCount?: number
|
||||||
paused?: boolean
|
|
||||||
stopped?: boolean
|
|
||||||
pendingSessionIds?: string[]
|
pendingSessionIds?: string[]
|
||||||
successSessionIds?: string[]
|
successSessionIds?: string[]
|
||||||
failedSessionIds?: string[]
|
failedSessionIds?: string[]
|
||||||
sessionOutputPaths?: Record<string, string>
|
sessionOutputPaths?: Record<string, string>
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
pauseTask: (taskId: string) => Promise<{ success: boolean; error?: string }>
|
|
||||||
resumeTask: (taskId: string) => Promise<{ success: boolean; error?: string }>
|
|
||||||
cancelTask: (taskId: string) => Promise<{ success: boolean; error?: string }>
|
|
||||||
exportSession: (sessionId: string, outputPath: string, options: ExportOptions) => Promise<{
|
exportSession: (sessionId: string, outputPath: string, options: ExportOptions) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
error?: string
|
error?: string
|
||||||
@@ -1179,8 +1161,7 @@ export interface ElectronAPI {
|
|||||||
exportVideos?: boolean
|
exportVideos?: boolean
|
||||||
startTime?: number
|
startTime?: number
|
||||||
endTime?: number
|
endTime?: number
|
||||||
taskId?: string
|
}) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }>
|
||||||
}) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }>
|
|
||||||
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
|
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
|
||||||
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
|
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
|
||||||
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
|
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
|
||||||
|
|||||||
Reference in New Issue
Block a user