mirror of
https://fastgit.cc/github.com/hicccc77/WeFlow
synced 2026-04-20 12:51:02 +08:00
图片解密再次优化
This commit is contained in:
@@ -2644,6 +2644,9 @@ function registerIpcHandlers() {
|
||||
force?: boolean
|
||||
preferFilePath?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
suppressEvents?: boolean
|
||||
}) => {
|
||||
return imageDecryptService.decryptImage(payload)
|
||||
})
|
||||
@@ -2656,6 +2659,7 @@ function registerIpcHandlers() {
|
||||
hardlinkOnly?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
suppressEvents?: boolean
|
||||
}) => {
|
||||
return imageDecryptService.resolveCachedImage(payload)
|
||||
})
|
||||
@@ -2663,19 +2667,84 @@ function registerIpcHandlers() {
|
||||
'image:resolveCacheBatch',
|
||||
async (
|
||||
_,
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>,
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean }
|
||||
payloads: Array<{
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
preferFilePath?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
suppressEvents?: boolean
|
||||
}>,
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
|
||||
) => {
|
||||
const list = Array.isArray(payloads) ? payloads : []
|
||||
const rows = await Promise.all(list.map(async (payload) => {
|
||||
return imageDecryptService.resolveCachedImage({
|
||||
...payload,
|
||||
preferFilePath: payload.preferFilePath ?? options?.preferFilePath === true,
|
||||
hardlinkOnly: payload.hardlinkOnly ?? options?.hardlinkOnly === true,
|
||||
disableUpdateCheck: options?.disableUpdateCheck === true,
|
||||
allowCacheIndex: options?.allowCacheIndex !== false
|
||||
})
|
||||
}))
|
||||
if (list.length === 0) return { success: true, rows: [] }
|
||||
|
||||
const maxConcurrentRaw = Number(process.env.WEFLOW_IMAGE_RESOLVE_BATCH_CONCURRENCY || 10)
|
||||
const maxConcurrent = Number.isFinite(maxConcurrentRaw)
|
||||
? Math.max(1, Math.min(Math.floor(maxConcurrentRaw), 48))
|
||||
: 10
|
||||
const workerCount = Math.min(maxConcurrent, list.length)
|
||||
|
||||
const rows: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }> = new Array(list.length)
|
||||
let cursor = 0
|
||||
const dedupe = new Map<string, Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>>()
|
||||
|
||||
const makeDedupeKey = (payload: typeof list[number]): string => {
|
||||
const sessionId = String(payload.sessionId || '').trim().toLowerCase()
|
||||
const imageMd5 = String(payload.imageMd5 || '').trim().toLowerCase()
|
||||
const imageDatName = String(payload.imageDatName || '').trim().toLowerCase()
|
||||
const createTime = Number(payload.createTime || 0) || 0
|
||||
const preferFilePath = payload.preferFilePath ?? options?.preferFilePath === true
|
||||
const hardlinkOnly = payload.hardlinkOnly ?? options?.hardlinkOnly === true
|
||||
const allowCacheIndex = options?.allowCacheIndex !== false
|
||||
const disableUpdateCheck = options?.disableUpdateCheck === true
|
||||
const suppressEvents = payload.suppressEvents ?? options?.suppressEvents === true
|
||||
return [
|
||||
sessionId,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
String(createTime),
|
||||
preferFilePath ? 'pf1' : 'pf0',
|
||||
hardlinkOnly ? 'hl1' : 'hl0',
|
||||
allowCacheIndex ? 'ci1' : 'ci0',
|
||||
disableUpdateCheck ? 'du1' : 'du0',
|
||||
suppressEvents ? 'se1' : 'se0'
|
||||
].join('|')
|
||||
}
|
||||
|
||||
const resolveOne = (payload: typeof list[number]) => imageDecryptService.resolveCachedImage({
|
||||
...payload,
|
||||
preferFilePath: payload.preferFilePath ?? options?.preferFilePath === true,
|
||||
hardlinkOnly: payload.hardlinkOnly ?? options?.hardlinkOnly === true,
|
||||
disableUpdateCheck: options?.disableUpdateCheck === true,
|
||||
allowCacheIndex: options?.allowCacheIndex !== false,
|
||||
suppressEvents: payload.suppressEvents ?? options?.suppressEvents === true
|
||||
})
|
||||
|
||||
const worker = async () => {
|
||||
while (true) {
|
||||
const index = cursor
|
||||
cursor += 1
|
||||
if (index >= list.length) return
|
||||
const payload = list[index]
|
||||
const key = makeDedupeKey(payload)
|
||||
const existing = dedupe.get(key)
|
||||
if (existing) {
|
||||
rows[index] = await existing
|
||||
continue
|
||||
}
|
||||
const task = resolveOne(payload).catch((error) => ({
|
||||
success: false,
|
||||
error: String(error)
|
||||
}))
|
||||
dedupe.set(key, task)
|
||||
rows[index] = await task
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: workerCount }, () => worker()))
|
||||
return { success: true, rows }
|
||||
}
|
||||
)
|
||||
@@ -2689,6 +2758,13 @@ function registerIpcHandlers() {
|
||||
imagePreloadService.enqueue(payloads || [], options)
|
||||
return true
|
||||
})
|
||||
ipcMain.handle(
|
||||
'image:preloadHardlinkMd5s',
|
||||
async (_, md5List?: string[]) => {
|
||||
await imageDecryptService.preloadImageHardlinkMd5s(Array.isArray(md5List) ? md5List : [])
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
||||
// Windows Hello
|
||||
ipcMain.handle('auth:hello', async (event, message?: string) => {
|
||||
|
||||
@@ -286,7 +286,18 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
|
||||
// 图片解密
|
||||
image: {
|
||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; force?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean }) =>
|
||||
decrypt: (payload: {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
force?: boolean
|
||||
preferFilePath?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
suppressEvents?: boolean
|
||||
}) =>
|
||||
ipcRenderer.invoke('image:decrypt', payload),
|
||||
resolveCache: (payload: {
|
||||
sessionId?: string
|
||||
@@ -297,16 +308,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
hardlinkOnly?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
suppressEvents?: boolean
|
||||
}) =>
|
||||
ipcRenderer.invoke('image:resolveCache', payload),
|
||||
resolveCacheBatch: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>,
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean }
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
|
||||
) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options),
|
||||
preload: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
|
||||
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||
) => ipcRenderer.invoke('image:preload', payloads, options),
|
||||
preloadHardlinkMd5s: (md5List: string[]) =>
|
||||
ipcRenderer.invoke('image:preloadHardlinkMd5s', md5List),
|
||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
|
||||
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
|
||||
ipcRenderer.on('image:updateAvailable', listener)
|
||||
|
||||
@@ -442,8 +442,8 @@ class ExportService {
|
||||
let lastSessionId = ''
|
||||
let lastCollected = 0
|
||||
let lastExported = 0
|
||||
const MIN_PROGRESS_EMIT_INTERVAL_MS = 250
|
||||
const MESSAGE_PROGRESS_DELTA_THRESHOLD = 500
|
||||
const MIN_PROGRESS_EMIT_INTERVAL_MS = 400
|
||||
const MESSAGE_PROGRESS_DELTA_THRESHOLD = 1200
|
||||
|
||||
const commit = (progress: ExportProgress) => {
|
||||
onProgress(progress)
|
||||
@@ -3682,18 +3682,28 @@ class ExportService {
|
||||
createTime: msg.createTime,
|
||||
force: true, // 导出优先高清,失败再回退缩略图
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
allowCacheIndex: !imageMd5,
|
||||
suppressEvents: true
|
||||
})
|
||||
|
||||
if (!result.success || !result.localPath) {
|
||||
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
|
||||
if (result.failureKind === 'decrypt_failed') {
|
||||
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
|
||||
} else {
|
||||
console.log(`[Export] 图片本地无数据 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
|
||||
}
|
||||
// 尝试获取缩略图
|
||||
const thumbResult = await imageDecryptService.resolveCachedImage({
|
||||
sessionId,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
createTime: msg.createTime,
|
||||
preferFilePath: true
|
||||
preferFilePath: true,
|
||||
disableUpdateCheck: true,
|
||||
allowCacheIndex: !imageMd5,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (thumbResult.success && thumbResult.localPath) {
|
||||
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
|
||||
|
||||
@@ -1257,7 +1257,9 @@ class HttpService {
|
||||
createTime: msg.createTime,
|
||||
force: true,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
|
||||
let imagePath = result.success ? result.localPath : undefined
|
||||
@@ -1269,7 +1271,9 @@ class HttpService {
|
||||
imageDatName: msg.imageDatName,
|
||||
createTime: msg.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (cached.success && cached.localPath) {
|
||||
imagePath = cached.localPath
|
||||
|
||||
@@ -53,6 +53,7 @@ type DecryptResult = {
|
||||
success: boolean
|
||||
localPath?: string
|
||||
error?: string
|
||||
failureKind?: 'not_found' | 'decrypt_failed'
|
||||
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
|
||||
}
|
||||
|
||||
@@ -67,6 +68,7 @@ type CachedImagePayload = {
|
||||
hardlinkOnly?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
suppressEvents?: boolean
|
||||
}
|
||||
|
||||
type DecryptImagePayload = CachedImagePayload & {
|
||||
@@ -81,6 +83,21 @@ export class ImageDecryptService {
|
||||
private nativeLogged = false
|
||||
private datNameScanMissAt = new Map<string, number>()
|
||||
private readonly datNameScanMissTtlMs = 1200
|
||||
private readonly accountDirCache = new Map<string, string>()
|
||||
private cacheRootPath: string | null = null
|
||||
private readonly ensuredDirs = new Set<string>()
|
||||
|
||||
private shouldEmitImageEvents(payload?: { suppressEvents?: boolean }): boolean {
|
||||
if (payload?.suppressEvents === true) return false
|
||||
// 导出 worker 场景不需要向渲染层广播逐条图片事件,避免事件风暴拖慢主界面。
|
||||
if (process.env.WEFLOW_WORKER === '1') return false
|
||||
return true
|
||||
}
|
||||
|
||||
private shouldCheckImageUpdate(payload?: { disableUpdateCheck?: boolean; suppressEvents?: boolean }): boolean {
|
||||
if (payload?.disableUpdateCheck === true) return false
|
||||
return this.shouldEmitImageEvents(payload)
|
||||
}
|
||||
|
||||
private logInfo(message: string, meta?: Record<string, unknown>): void {
|
||||
if (!this.configService.get('logEnabled')) return
|
||||
@@ -122,7 +139,7 @@ export class ImageDecryptService {
|
||||
const cacheKeys = this.getCacheKeys(payload)
|
||||
const cacheKey = cacheKeys[0]
|
||||
if (!cacheKey) {
|
||||
return { success: false, error: '缺少图片标识' }
|
||||
return { success: false, error: '缺少图片标识', failureKind: 'not_found' }
|
||||
}
|
||||
for (const key of cacheKeys) {
|
||||
const cached = this.resolvedCache.get(key)
|
||||
@@ -135,7 +152,7 @@ export class ImageDecryptService {
|
||||
const isThumb = this.isThumbnailPath(finalPath)
|
||||
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
||||
if (isThumb) {
|
||||
if (!payload.disableUpdateCheck) {
|
||||
if (this.shouldCheckImageUpdate(payload)) {
|
||||
this.triggerUpdateCheck(payload, key, finalPath)
|
||||
}
|
||||
} else {
|
||||
@@ -160,7 +177,8 @@ export class ImageDecryptService {
|
||||
{
|
||||
allowThumbnail: true,
|
||||
skipResolvedCache: false,
|
||||
hardlinkOnly: true
|
||||
hardlinkOnly: true,
|
||||
allowDatNameScanFallback: payload.allowCacheIndex !== false
|
||||
}
|
||||
)
|
||||
if (datPath) {
|
||||
@@ -175,7 +193,7 @@ export class ImageDecryptService {
|
||||
const isThumb = this.isThumbnailPath(finalPath)
|
||||
const hasUpdate = isThumb ? (this.updateFlags.get(cacheKey) ?? false) : false
|
||||
if (isThumb) {
|
||||
if (!payload.disableUpdateCheck) {
|
||||
if (this.shouldCheckImageUpdate(payload)) {
|
||||
this.triggerUpdateCheck(payload, cacheKey, finalPath)
|
||||
}
|
||||
} else {
|
||||
@@ -187,14 +205,14 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
return { success: false, error: '未找到缓存图片' }
|
||||
return { success: false, error: '未找到缓存图片', failureKind: 'not_found' }
|
||||
}
|
||||
|
||||
async decryptImage(payload: DecryptImagePayload): Promise<DecryptResult> {
|
||||
const cacheKeys = this.getCacheKeys(payload)
|
||||
const cacheKey = cacheKeys[0]
|
||||
if (!cacheKey) {
|
||||
return { success: false, error: '缺少图片标识' }
|
||||
return { success: false, error: '缺少图片标识', failureKind: 'not_found' }
|
||||
}
|
||||
this.emitDecryptProgress(payload, cacheKey, 'queued', 4, 'running')
|
||||
|
||||
@@ -296,14 +314,14 @@ export class ImageDecryptService {
|
||||
if (!wxid || !dbPath) {
|
||||
this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '配置缺失')
|
||||
return { success: false, error: '未配置账号或数据库路径' }
|
||||
return { success: false, error: '未配置账号或数据库路径', failureKind: 'not_found' }
|
||||
}
|
||||
|
||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||
if (!accountDir) {
|
||||
this.logError('未找到账号目录', undefined, { dbPath, wxid })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '账号目录缺失')
|
||||
return { success: false, error: '未找到账号目录' }
|
||||
return { success: false, error: '未找到账号目录', failureKind: 'not_found' }
|
||||
}
|
||||
|
||||
let datPath: string | null = null
|
||||
@@ -321,8 +339,9 @@ export class ImageDecryptService {
|
||||
payload.createTime,
|
||||
{
|
||||
allowThumbnail: false,
|
||||
skipResolvedCache: true,
|
||||
hardlinkOnly: payload.hardlinkOnly === true
|
||||
skipResolvedCache: false,
|
||||
hardlinkOnly: payload.hardlinkOnly === true,
|
||||
allowDatNameScanFallback: payload.allowCacheIndex !== false
|
||||
}
|
||||
)
|
||||
if (!datPath) {
|
||||
@@ -334,8 +353,9 @@ export class ImageDecryptService {
|
||||
payload.createTime,
|
||||
{
|
||||
allowThumbnail: true,
|
||||
skipResolvedCache: true,
|
||||
hardlinkOnly: payload.hardlinkOnly === true
|
||||
skipResolvedCache: false,
|
||||
hardlinkOnly: payload.hardlinkOnly === true,
|
||||
allowDatNameScanFallback: payload.allowCacheIndex !== false
|
||||
}
|
||||
)
|
||||
fallbackToThumbnail = Boolean(datPath)
|
||||
@@ -356,7 +376,8 @@ export class ImageDecryptService {
|
||||
{
|
||||
allowThumbnail: true,
|
||||
skipResolvedCache: false,
|
||||
hardlinkOnly: payload.hardlinkOnly === true
|
||||
hardlinkOnly: payload.hardlinkOnly === true,
|
||||
allowDatNameScanFallback: payload.allowCacheIndex !== false
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -365,9 +386,9 @@ export class ImageDecryptService {
|
||||
this.logError('未找到DAT文件', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '未找到DAT文件')
|
||||
if (usedHdAttempt) {
|
||||
return { success: false, error: '未找到图片文件,请在微信中点开该图片后重试' }
|
||||
return { success: false, error: '未找到图片文件,请在微信中点开该图片后重试', failureKind: 'not_found' }
|
||||
}
|
||||
return { success: false, error: '未找到图片文件' }
|
||||
return { success: false, error: '未找到图片文件', failureKind: 'not_found' }
|
||||
}
|
||||
|
||||
this.logInfo('找到DAT文件', { datPath })
|
||||
@@ -414,7 +435,7 @@ export class ImageDecryptService {
|
||||
}
|
||||
if (Number.isNaN(xorKey) || (!xorKey && xorKey !== 0)) {
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '缺少解密密钥')
|
||||
return { success: false, error: '未配置图片解密密钥' }
|
||||
return { success: false, error: '未配置图片解密密钥', failureKind: 'not_found' }
|
||||
}
|
||||
|
||||
const aesKeyRaw = imageKeys.aesKey
|
||||
@@ -426,7 +447,7 @@ export class ImageDecryptService {
|
||||
const nativeResult = this.tryDecryptDatWithNative(datPath, xorKey, aesKeyForNative)
|
||||
if (!nativeResult) {
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', 'Rust原生解密不可用')
|
||||
return { success: false, error: 'Rust原生解密不可用或解密失败,请检查 native 模块与密钥配置' }
|
||||
return { success: false, error: 'Rust原生解密不可用或解密失败,请检查 native 模块与密钥配置', failureKind: 'not_found' }
|
||||
}
|
||||
let decrypted: Buffer = nativeResult.data
|
||||
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 78, 'running')
|
||||
@@ -435,35 +456,34 @@ export class ImageDecryptService {
|
||||
const wxgfResult = await this.unwrapWxgf(decrypted)
|
||||
decrypted = wxgfResult.data
|
||||
|
||||
let ext = this.detectImageExtension(decrypted)
|
||||
const detectedExt = this.detectImageExtension(decrypted)
|
||||
|
||||
// 如果是 wxgf 格式且没检测到扩展名
|
||||
if (wxgfResult.isWxgf && !ext) {
|
||||
ext = '.hevc'
|
||||
// 如果解密产物无法识别为图片,归类为“解密失败”。
|
||||
if (!detectedExt) {
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '解密后不是有效图片')
|
||||
return {
|
||||
success: false,
|
||||
error: '解密后不是有效图片',
|
||||
failureKind: 'decrypt_failed',
|
||||
isThumb: this.isThumbnailPath(datPath)
|
||||
}
|
||||
}
|
||||
|
||||
const finalExt = ext || '.jpg'
|
||||
const finalExt = detectedExt
|
||||
|
||||
const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId)
|
||||
this.emitDecryptProgress(payload, cacheKey, 'writing', 90, 'running')
|
||||
await writeFile(outputPath, decrypted)
|
||||
this.logInfo('解密成功', { outputPath, size: decrypted.length })
|
||||
|
||||
if (finalExt === '.hevc') {
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', 'wxgf转换失败')
|
||||
return {
|
||||
success: false,
|
||||
error: '此图片为微信新格式(wxgf),ffmpeg 转换失败,请检查日志',
|
||||
isThumb: this.isThumbnailPath(datPath)
|
||||
}
|
||||
}
|
||||
|
||||
const isThumb = this.isThumbnailPath(datPath)
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
|
||||
if (!isThumb) {
|
||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||
} else {
|
||||
this.triggerUpdateCheck(payload, cacheKey, outputPath)
|
||||
if (this.shouldCheckImageUpdate(payload)) {
|
||||
this.triggerUpdateCheck(payload, cacheKey, outputPath)
|
||||
}
|
||||
}
|
||||
const localPath = payload.preferFilePath
|
||||
? outputPath
|
||||
@@ -475,18 +495,30 @@ export class ImageDecryptService {
|
||||
} catch (e) {
|
||||
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', String(e))
|
||||
return { success: false, error: String(e) }
|
||||
return { success: false, error: String(e), failureKind: 'not_found' }
|
||||
}
|
||||
}
|
||||
|
||||
private resolveAccountDir(dbPath: string, wxid: string): string | null {
|
||||
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||
const normalized = dbPath.replace(/[\\/]+$/, '')
|
||||
const cacheKey = `${normalized}|${cleanedWxid.toLowerCase()}`
|
||||
const cached = this.accountDirCache.get(cacheKey)
|
||||
if (cached && existsSync(cached)) return cached
|
||||
if (cached && !existsSync(cached)) {
|
||||
this.accountDirCache.delete(cacheKey)
|
||||
}
|
||||
|
||||
const direct = join(normalized, cleanedWxid)
|
||||
if (existsSync(direct)) return direct
|
||||
if (existsSync(direct)) {
|
||||
this.accountDirCache.set(cacheKey, direct)
|
||||
return direct
|
||||
}
|
||||
|
||||
if (this.isAccountDir(normalized)) return normalized
|
||||
if (this.isAccountDir(normalized)) {
|
||||
this.accountDirCache.set(cacheKey, normalized)
|
||||
return normalized
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = readdirSync(normalized)
|
||||
@@ -496,7 +528,10 @@ export class ImageDecryptService {
|
||||
if (!this.isDirectory(entryPath)) continue
|
||||
const lowerEntry = entry.toLowerCase()
|
||||
if (lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)) {
|
||||
if (this.isAccountDir(entryPath)) return entryPath
|
||||
if (this.isAccountDir(entryPath)) {
|
||||
this.accountDirCache.set(cacheKey, entryPath)
|
||||
return entryPath
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
@@ -574,23 +609,35 @@ export class ImageDecryptService {
|
||||
imageDatName?: string,
|
||||
sessionId?: string,
|
||||
createTime?: number,
|
||||
options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean; hardlinkOnly?: boolean }
|
||||
options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean; hardlinkOnly?: boolean; allowDatNameScanFallback?: boolean }
|
||||
): Promise<string | null> {
|
||||
const allowThumbnail = options?.allowThumbnail ?? true
|
||||
const skipResolvedCache = options?.skipResolvedCache ?? false
|
||||
const hardlinkOnly = options?.hardlinkOnly ?? false
|
||||
const allowDatNameScanFallback = options?.allowDatNameScanFallback ?? true
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath', {
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
createTime,
|
||||
allowThumbnail,
|
||||
skipResolvedCache,
|
||||
hardlinkOnly
|
||||
hardlinkOnly,
|
||||
allowDatNameScanFallback
|
||||
})
|
||||
|
||||
const lookupMd5s = this.collectHardlinkLookupMd5s(imageMd5, imageDatName)
|
||||
const fallbackDatName = String(imageDatName || imageMd5 || '').trim().toLowerCase() || undefined
|
||||
if (lookupMd5s.length === 0) {
|
||||
const packedDatFallback = this.resolveDatPathFromParsedDatName(accountDir, imageDatName, sessionId, createTime, allowThumbnail)
|
||||
if (!allowDatNameScanFallback) {
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath skip datName scan (no hardlink md5)', {
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
sessionId,
|
||||
createTime
|
||||
})
|
||||
return null
|
||||
}
|
||||
const packedDatFallback = this.resolveDatPathFromParsedDatName(accountDir, fallbackDatName, sessionId, createTime, allowThumbnail)
|
||||
if (packedDatFallback) {
|
||||
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, packedDatFallback)
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, packedDatFallback)
|
||||
@@ -637,7 +684,18 @@ export class ImageDecryptService {
|
||||
return hardlinkPath
|
||||
}
|
||||
|
||||
const packedDatFallback = this.resolveDatPathFromParsedDatName(accountDir, imageDatName, sessionId, createTime, allowThumbnail)
|
||||
if (!allowDatNameScanFallback) {
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath skip datName fallback after hardlink miss', {
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
sessionId,
|
||||
createTime,
|
||||
lookupMd5s
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const packedDatFallback = this.resolveDatPathFromParsedDatName(accountDir, fallbackDatName, sessionId, createTime, allowThumbnail)
|
||||
if (packedDatFallback) {
|
||||
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, packedDatFallback)
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, packedDatFallback)
|
||||
@@ -680,7 +738,7 @@ export class ImageDecryptService {
|
||||
payload.imageDatName,
|
||||
payload.sessionId,
|
||||
payload.createTime,
|
||||
{ allowThumbnail: false, skipResolvedCache: true, hardlinkOnly: true }
|
||||
{ allowThumbnail: false, skipResolvedCache: true, hardlinkOnly: true, allowDatNameScanFallback: false }
|
||||
)
|
||||
return Boolean(hdPath)
|
||||
}
|
||||
@@ -703,7 +761,7 @@ export class ImageDecryptService {
|
||||
payload.imageDatName,
|
||||
payload.sessionId,
|
||||
payload.createTime,
|
||||
{ allowThumbnail: false, skipResolvedCache: true, hardlinkOnly: true }
|
||||
{ allowThumbnail: false, skipResolvedCache: true, hardlinkOnly: true, allowDatNameScanFallback: false }
|
||||
)
|
||||
if (!hdDatPath) return null
|
||||
|
||||
@@ -761,10 +819,11 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
private triggerUpdateCheck(
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number },
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; disableUpdateCheck?: boolean; suppressEvents?: boolean },
|
||||
cacheKey: string,
|
||||
cachedPath: string
|
||||
): void {
|
||||
if (!this.shouldCheckImageUpdate(payload)) return
|
||||
if (this.updateFlags.get(cacheKey)) return
|
||||
void this.checkHasUpdate(payload, cacheKey, cachedPath).then((hasUpdate) => {
|
||||
if (!hasUpdate) return
|
||||
@@ -1082,6 +1141,16 @@ export class ImageDecryptService {
|
||||
const priorityB = this.getHardlinkCandidatePriority(nameB, baseMd5)
|
||||
if (priorityA !== priorityB) return priorityA - priorityB
|
||||
|
||||
let sizeA = 0
|
||||
let sizeB = 0
|
||||
try {
|
||||
sizeA = statSync(a).size
|
||||
} catch { }
|
||||
try {
|
||||
sizeB = statSync(b).size
|
||||
} catch { }
|
||||
if (sizeA !== sizeB) return sizeB - sizeA
|
||||
|
||||
let mtimeA = 0
|
||||
let mtimeB = 0
|
||||
try {
|
||||
@@ -1096,13 +1165,6 @@ export class ImageDecryptService {
|
||||
return list
|
||||
}
|
||||
|
||||
private isPlainMd5DatName(fileName: string): boolean {
|
||||
const lower = String(fileName || '').trim().toLowerCase()
|
||||
if (!lower.endsWith('.dat')) return false
|
||||
const base = lower.slice(0, -4)
|
||||
return this.looksLikeMd5(base)
|
||||
}
|
||||
|
||||
private isHardlinkCandidateName(fileName: string, baseMd5: string): boolean {
|
||||
const lower = String(fileName || '').trim().toLowerCase()
|
||||
if (!lower.endsWith('.dat')) return false
|
||||
@@ -1113,57 +1175,33 @@ export class ImageDecryptService {
|
||||
return this.normalizeDatBase(base) === baseMd5
|
||||
}
|
||||
|
||||
private getHardlinkCandidatePriority(fileName: string, baseMd5: string): number {
|
||||
private getHardlinkCandidatePriority(fileName: string, _baseMd5: string): number {
|
||||
const lower = String(fileName || '').trim().toLowerCase()
|
||||
if (!lower.endsWith('.dat')) return 999
|
||||
|
||||
const base = lower.slice(0, -4)
|
||||
|
||||
// 无后缀 DAT 最后兜底;优先尝试变体 DAT。
|
||||
if (base === baseMd5) return 20
|
||||
// _t / .t / _thumb 等缩略图 DAT 仅作次级回退。
|
||||
if (this.isThumbnailDat(lower)) return 10
|
||||
// 其他非缩略图变体优先。
|
||||
return 0
|
||||
}
|
||||
|
||||
private resolveHardlinkDatVariants(fullPath: string, baseMd5: string): string[] {
|
||||
const dirPath = dirname(fullPath)
|
||||
try {
|
||||
const entries = readdirSync(dirPath, { withFileTypes: true })
|
||||
const candidates = entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name)
|
||||
.filter((name) => this.isHardlinkCandidateName(name, baseMd5))
|
||||
.map((name) => join(dirPath, name))
|
||||
.filter((candidatePath) => existsSync(candidatePath))
|
||||
return this.sortDatCandidatePaths(candidates, baseMd5)
|
||||
} catch {
|
||||
return []
|
||||
if (
|
||||
base.endsWith('_h') ||
|
||||
base.endsWith('.h') ||
|
||||
base.endsWith('_hd') ||
|
||||
base.endsWith('.hd')
|
||||
) {
|
||||
return 0
|
||||
}
|
||||
if (base.endsWith('_b') || base.endsWith('.b')) return 1
|
||||
if (this.isThumbnailDat(lower)) return 3
|
||||
return 2
|
||||
}
|
||||
|
||||
private normalizeHardlinkDatPathByFileName(fullPath: string, fileName: string): string {
|
||||
const normalizedPath = String(fullPath || '').trim()
|
||||
const normalizedFileName = String(fileName || '').trim().toLowerCase()
|
||||
if (!normalizedPath || !normalizedFileName.endsWith('.dat')) {
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
// hardlink 记录到具体后缀时(如 _b/.b/_t),直接按记录路径解密。
|
||||
if (!this.isPlainMd5DatName(normalizedFileName)) {
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
const base = normalizedFileName.slice(0, -4)
|
||||
if (!this.looksLikeMd5(base)) {
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
const candidates = this.resolveHardlinkDatVariants(normalizedPath, base)
|
||||
if (candidates.length > 0) {
|
||||
return candidates[0]
|
||||
}
|
||||
if (!normalizedPath || !normalizedFileName) return normalizedPath
|
||||
if (!normalizedFileName.endsWith('.dat')) return normalizedPath
|
||||
const normalizedBase = this.normalizeDatBase(normalizedFileName.slice(0, -4))
|
||||
if (!this.looksLikeMd5(normalizedBase)) return ''
|
||||
|
||||
// 最新策略:只要 hardlink 有记录,始终直接使用其记录路径(包括无后缀 DAT)。
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
@@ -1197,6 +1235,7 @@ export class ImageDecryptService {
|
||||
this.logInfo('[ImageDecrypt] hardlink path hit', { md5: normalizedMd5, fileName, fullPath, selectedPath })
|
||||
return selectedPath
|
||||
}
|
||||
|
||||
this.logInfo('[ImageDecrypt] hardlink path miss', { md5: normalizedMd5, fileName, fullPath, selectedPath })
|
||||
return null
|
||||
} catch {
|
||||
@@ -1272,9 +1311,7 @@ export class ImageDecryptService {
|
||||
const contactDir = this.sanitizeDirName(sessionId || 'unknown')
|
||||
const timeDir = this.resolveTimeDir(datPath)
|
||||
const outputDir = join(this.getCacheRoot(), contactDir, timeDir)
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
}
|
||||
this.ensureDir(outputDir)
|
||||
|
||||
return join(outputDir, `${normalizedBase}${suffix}${ext}`)
|
||||
}
|
||||
@@ -1384,7 +1421,8 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string): void {
|
||||
private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; suppressEvents?: boolean }, cacheKey: string): void {
|
||||
if (!this.shouldEmitImageEvents(payload)) return
|
||||
const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName }
|
||||
for (const win of this.getActiveWindowsSafely()) {
|
||||
if (!win.isDestroyed()) {
|
||||
@@ -1393,7 +1431,8 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
private emitCacheResolved(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string, localPath: string): void {
|
||||
private emitCacheResolved(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; suppressEvents?: boolean }, cacheKey: string, localPath: string): void {
|
||||
if (!this.shouldEmitImageEvents(payload)) return
|
||||
const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName, localPath }
|
||||
for (const win of this.getActiveWindowsSafely()) {
|
||||
if (!win.isDestroyed()) {
|
||||
@@ -1403,13 +1442,14 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
private emitDecryptProgress(
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string },
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; suppressEvents?: boolean },
|
||||
cacheKey: string,
|
||||
stage: DecryptProgressStage,
|
||||
progress: number,
|
||||
status: 'running' | 'done' | 'error',
|
||||
message?: string
|
||||
): void {
|
||||
if (!this.shouldEmitImageEvents(payload)) return
|
||||
const safeProgress = Math.max(0, Math.min(100, Math.floor(progress)))
|
||||
const event = {
|
||||
cacheKey,
|
||||
@@ -1428,16 +1468,27 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
private getCacheRoot(): string {
|
||||
const configured = this.configService.get('cachePath')
|
||||
const root = configured
|
||||
? join(configured, 'Images')
|
||||
: join(this.getDocumentsPath(), 'WeFlow', 'Images')
|
||||
if (!existsSync(root)) {
|
||||
mkdirSync(root, { recursive: true })
|
||||
let root = this.cacheRootPath
|
||||
if (!root) {
|
||||
const configured = this.configService.get('cachePath')
|
||||
root = configured
|
||||
? join(configured, 'Images')
|
||||
: join(this.getDocumentsPath(), 'WeFlow', 'Images')
|
||||
this.cacheRootPath = root
|
||||
}
|
||||
this.ensureDir(root)
|
||||
return root
|
||||
}
|
||||
|
||||
private ensureDir(dirPath: string): void {
|
||||
if (!dirPath) return
|
||||
if (this.ensuredDirs.has(dirPath) && existsSync(dirPath)) return
|
||||
if (!existsSync(dirPath)) {
|
||||
mkdirSync(dirPath, { recursive: true })
|
||||
}
|
||||
this.ensuredDirs.add(dirPath)
|
||||
}
|
||||
|
||||
private tryDecryptDatWithNative(
|
||||
datPath: string,
|
||||
xorKey: number,
|
||||
@@ -1788,6 +1839,9 @@ export class ImageDecryptService {
|
||||
this.resolvedCache.clear()
|
||||
this.pending.clear()
|
||||
this.updateFlags.clear()
|
||||
this.accountDirCache.clear()
|
||||
this.ensuredDirs.clear()
|
||||
this.cacheRootPath = null
|
||||
|
||||
const configured = this.configService.get('cachePath')
|
||||
const root = configured
|
||||
|
||||
@@ -79,7 +79,8 @@ export class ImagePreloadService {
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: !task.allowDecrypt,
|
||||
allowCacheIndex: task.allowCacheIndex
|
||||
allowCacheIndex: task.allowCacheIndex,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (cached.success) return
|
||||
if (!task.allowDecrypt) return
|
||||
@@ -89,7 +90,9 @@ export class ImagePreloadService {
|
||||
imageDatName: task.imageDatName,
|
||||
createTime: task.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
} catch {
|
||||
// ignore preload failures
|
||||
|
||||
@@ -478,8 +478,6 @@ export class KeyServiceMac {
|
||||
'return "WF_ERR::" & errNum & "::" & errMsg & "::" & (pr as text)',
|
||||
'end try'
|
||||
]
|
||||
onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0)
|
||||
|
||||
let stdout = ''
|
||||
try {
|
||||
const result = await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), {
|
||||
|
||||
@@ -39,8 +39,6 @@ import UpdateDialog from './components/UpdateDialog'
|
||||
import UpdateProgressCapsule from './components/UpdateProgressCapsule'
|
||||
import LockScreen from './components/LockScreen'
|
||||
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
||||
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
|
||||
import WindowCloseDialog from './components/WindowCloseDialog'
|
||||
|
||||
function RouteStateRedirect({ to }: { to: string }) {
|
||||
@@ -554,10 +552,6 @@ function App() {
|
||||
{/* 全局会话监听与通知 */}
|
||||
<GlobalSessionMonitor />
|
||||
|
||||
{/* 全局批量转写进度浮窗 */}
|
||||
<BatchTranscribeGlobal />
|
||||
<BatchImageDecryptGlobal />
|
||||
|
||||
{/* 用户协议弹窗 */}
|
||||
{showAgreement && !agreementLoading && (
|
||||
<div className="agreement-overlay">
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
onSingleExportDialogStatus,
|
||||
requestExportSessionStatus
|
||||
} from '../services/exportBridge'
|
||||
import '../styles/batchTranscribe.scss'
|
||||
import './ChatPage.scss'
|
||||
|
||||
// 系统消息类型常量
|
||||
@@ -1370,34 +1371,30 @@ function ChatPage(props: ChatPageProps) {
|
||||
const {
|
||||
isBatchTranscribing,
|
||||
runningBatchVoiceTaskType,
|
||||
batchTranscribeProgress,
|
||||
startTranscribe,
|
||||
updateProgress,
|
||||
finishTranscribe,
|
||||
setShowBatchProgress
|
||||
updateTranscribeTaskStatus,
|
||||
finishTranscribe
|
||||
} = useBatchTranscribeStore(useShallow((state) => ({
|
||||
isBatchTranscribing: state.isBatchTranscribing,
|
||||
runningBatchVoiceTaskType: state.taskType,
|
||||
batchTranscribeProgress: state.progress,
|
||||
startTranscribe: state.startTranscribe,
|
||||
updateProgress: state.updateProgress,
|
||||
finishTranscribe: state.finishTranscribe,
|
||||
setShowBatchProgress: state.setShowToast
|
||||
updateTranscribeTaskStatus: state.setTaskStatus,
|
||||
finishTranscribe: state.finishTranscribe
|
||||
})))
|
||||
const {
|
||||
isBatchDecrypting,
|
||||
batchDecryptProgress,
|
||||
startDecrypt,
|
||||
updateDecryptProgress,
|
||||
finishDecrypt,
|
||||
setShowBatchDecryptToast
|
||||
updateDecryptTaskStatus,
|
||||
finishDecrypt
|
||||
} = useBatchImageDecryptStore(useShallow((state) => ({
|
||||
isBatchDecrypting: state.isBatchDecrypting,
|
||||
batchDecryptProgress: state.progress,
|
||||
startDecrypt: state.startDecrypt,
|
||||
updateDecryptProgress: state.updateProgress,
|
||||
finishDecrypt: state.finishDecrypt,
|
||||
setShowBatchDecryptToast: state.setShowToast
|
||||
updateDecryptTaskStatus: state.setTaskStatus,
|
||||
finishDecrypt: state.finishDecrypt
|
||||
})))
|
||||
const [showBatchConfirm, setShowBatchConfirm] = useState(false)
|
||||
const [batchVoiceCount, setBatchVoiceCount] = useState(0)
|
||||
@@ -5730,22 +5727,74 @@ function ChatPage(props: ChatPageProps) {
|
||||
if (!session) return
|
||||
|
||||
const taskType = batchVoiceTaskType
|
||||
startTranscribe(voiceMessages.length, session.displayName || session.username, taskType)
|
||||
|
||||
if (taskType === 'transcribe') {
|
||||
// 检查模型状态
|
||||
const modelStatus = await window.electronAPI.whisper.getModelStatus()
|
||||
if (!modelStatus?.exists) {
|
||||
alert('SenseVoice 模型未下载,请先在设置中下载模型')
|
||||
finishTranscribe(0, 0)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const totalVoices = voiceMessages.length
|
||||
const taskVerb = taskType === 'decrypt' ? '语音解密' : '语音转写'
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
let completedCount = 0
|
||||
const concurrency = taskType === 'decrypt' ? 12 : 10
|
||||
const controlState = {
|
||||
cancelRequested: false,
|
||||
pauseRequested: false,
|
||||
pauseAnnounced: false,
|
||||
resumeWaiters: [] as Array<() => void>
|
||||
}
|
||||
const resolveResumeWaiters = () => {
|
||||
const waiters = [...controlState.resumeWaiters]
|
||||
controlState.resumeWaiters.length = 0
|
||||
waiters.forEach(resolve => resolve())
|
||||
}
|
||||
const waitIfPaused = async () => {
|
||||
while (controlState.pauseRequested && !controlState.cancelRequested) {
|
||||
if (!controlState.pauseAnnounced) {
|
||||
controlState.pauseAnnounced = true
|
||||
updateTranscribeTaskStatus(
|
||||
`${taskVerb}任务已中断,等待继续...`,
|
||||
`${completedCount} / ${totalVoices}`,
|
||||
'paused'
|
||||
)
|
||||
}
|
||||
await new Promise<void>(resolve => {
|
||||
controlState.resumeWaiters.push(resolve)
|
||||
})
|
||||
}
|
||||
if (controlState.pauseAnnounced && !controlState.cancelRequested) {
|
||||
controlState.pauseAnnounced = false
|
||||
updateTranscribeTaskStatus(
|
||||
`继续${taskVerb}(${completedCount}/${totalVoices})`,
|
||||
`${completedCount} / ${totalVoices}`,
|
||||
'running'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
startTranscribe(totalVoices, session.displayName || session.username, taskType, 'chat', {
|
||||
cancelable: true,
|
||||
resumable: true,
|
||||
onPause: () => {
|
||||
controlState.pauseRequested = true
|
||||
updateTranscribeTaskStatus(
|
||||
`${taskVerb}中断请求已发出,当前处理完成后暂停...`,
|
||||
`${completedCount} / ${totalVoices}`,
|
||||
'pause_requested'
|
||||
)
|
||||
},
|
||||
onResume: () => {
|
||||
controlState.pauseRequested = false
|
||||
resolveResumeWaiters()
|
||||
},
|
||||
onCancel: () => {
|
||||
controlState.cancelRequested = true
|
||||
controlState.pauseRequested = false
|
||||
resolveResumeWaiters()
|
||||
updateTranscribeTaskStatus(
|
||||
`${taskVerb}停止请求已发出,当前处理完成后结束...`,
|
||||
`${completedCount} / ${totalVoices}`,
|
||||
'cancel_requested'
|
||||
)
|
||||
}
|
||||
})
|
||||
updateTranscribeTaskStatus(`正在准备${taskVerb}任务...`, `0 / ${totalVoices}`, 'running')
|
||||
|
||||
const runOne = async (msg: Message) => {
|
||||
try {
|
||||
@@ -5769,20 +5818,74 @@ function ChatPage(props: ChatPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < voiceMessages.length; i += concurrency) {
|
||||
const batch = voiceMessages.slice(i, i + concurrency)
|
||||
const results = await Promise.all(batch.map(msg => runOne(msg)))
|
||||
try {
|
||||
if (taskType === 'transcribe') {
|
||||
updateTranscribeTaskStatus('正在检查转写模型...', `0 / ${totalVoices}`)
|
||||
const modelStatus = await window.electronAPI.whisper.getModelStatus()
|
||||
if (!modelStatus?.exists) {
|
||||
alert('SenseVoice 模型未下载,请先在设置中下载模型')
|
||||
updateTranscribeTaskStatus('转写模型缺失,任务已停止', `0 / ${totalVoices}`)
|
||||
finishTranscribe(0, totalVoices)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
results.forEach(result => {
|
||||
updateTranscribeTaskStatus(`正在${taskVerb}(0/${totalVoices})`, `0 / ${totalVoices}`)
|
||||
const pool = new Set<Promise<void>>()
|
||||
|
||||
const runOneTracked = async (msg: Message) => {
|
||||
if (controlState.cancelRequested) return
|
||||
const result = await runOne(msg)
|
||||
if (result.success) successCount++
|
||||
else failCount++
|
||||
completedCount++
|
||||
updateProgress(completedCount, voiceMessages.length)
|
||||
})
|
||||
}
|
||||
updateProgress(completedCount, totalVoices)
|
||||
}
|
||||
|
||||
finishTranscribe(successCount, failCount)
|
||||
}, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages, batchVoiceTaskType, startTranscribe, updateProgress, finishTranscribe])
|
||||
for (const msg of voiceMessages) {
|
||||
if (controlState.cancelRequested) break
|
||||
await waitIfPaused()
|
||||
if (controlState.cancelRequested) break
|
||||
if (pool.size >= concurrency) {
|
||||
await Promise.race(pool)
|
||||
if (controlState.cancelRequested) break
|
||||
await waitIfPaused()
|
||||
if (controlState.cancelRequested) break
|
||||
}
|
||||
let p: Promise<void> = Promise.resolve()
|
||||
p = runOneTracked(msg).finally(() => {
|
||||
pool.delete(p)
|
||||
})
|
||||
pool.add(p)
|
||||
}
|
||||
|
||||
while (pool.size > 0) {
|
||||
await Promise.race(pool)
|
||||
}
|
||||
|
||||
if (controlState.cancelRequested) {
|
||||
const remaining = Math.max(0, totalVoices - completedCount)
|
||||
finishTranscribe(successCount, failCount, {
|
||||
status: 'canceled',
|
||||
detail: `${taskVerb}任务已中断:已完成 ${completedCount}/${totalVoices}(成功 ${successCount},失败 ${failCount},未处理 ${remaining})`,
|
||||
progressText: `${completedCount} / ${totalVoices}`
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
finishTranscribe(successCount, failCount, {
|
||||
status: failCount > 0 ? 'failed' : 'completed'
|
||||
})
|
||||
} catch (error) {
|
||||
const remaining = Math.max(0, totalVoices - completedCount)
|
||||
failCount += remaining
|
||||
updateTranscribeTaskStatus(`${taskVerb}过程中发生异常,正在结束任务...`, `${completedCount} / ${totalVoices}`)
|
||||
finishTranscribe(successCount, failCount, {
|
||||
status: 'failed'
|
||||
})
|
||||
alert(`批量${taskVerb}失败:${String(error)}`)
|
||||
}
|
||||
}, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages, batchVoiceTaskType, startTranscribe, updateTranscribeTaskStatus, updateProgress, finishTranscribe])
|
||||
|
||||
// 批量转写:按日期的消息数量
|
||||
const batchCountByDate = useMemo(() => {
|
||||
@@ -5845,14 +5948,114 @@ function ChatPage(props: ChatPageProps) {
|
||||
setBatchImageDates([])
|
||||
setBatchImageSelectedDates(new Set())
|
||||
|
||||
startDecrypt(images.length, session.displayName || session.username)
|
||||
|
||||
const totalImages = images.length
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
let notFoundCount = 0
|
||||
let decryptFailedCount = 0
|
||||
let completed = 0
|
||||
const controlState = {
|
||||
cancelRequested: false,
|
||||
pauseRequested: false,
|
||||
pauseAnnounced: false,
|
||||
resumeWaiters: [] as Array<() => void>
|
||||
}
|
||||
const resolveResumeWaiters = () => {
|
||||
const waiters = [...controlState.resumeWaiters]
|
||||
controlState.resumeWaiters.length = 0
|
||||
waiters.forEach(resolve => resolve())
|
||||
}
|
||||
const waitIfPaused = async () => {
|
||||
while (controlState.pauseRequested && !controlState.cancelRequested) {
|
||||
if (!controlState.pauseAnnounced) {
|
||||
controlState.pauseAnnounced = true
|
||||
updateDecryptTaskStatus(
|
||||
'图片批量解密任务已中断,等待继续...',
|
||||
`${completed} / ${totalImages}`,
|
||||
'paused'
|
||||
)
|
||||
}
|
||||
await new Promise<void>(resolve => {
|
||||
controlState.resumeWaiters.push(resolve)
|
||||
})
|
||||
}
|
||||
if (controlState.pauseAnnounced && !controlState.cancelRequested) {
|
||||
controlState.pauseAnnounced = false
|
||||
updateDecryptTaskStatus(
|
||||
`继续批量解密图片(${completed}/${totalImages})`,
|
||||
`${completed} / ${totalImages}`,
|
||||
'running'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
startDecrypt(totalImages, session.displayName || session.username, 'chat', {
|
||||
cancelable: true,
|
||||
resumable: true,
|
||||
onPause: () => {
|
||||
controlState.pauseRequested = true
|
||||
updateDecryptTaskStatus(
|
||||
'图片解密中断请求已发出,当前处理完成后暂停...',
|
||||
`${completed} / ${totalImages}`,
|
||||
'pause_requested'
|
||||
)
|
||||
},
|
||||
onResume: () => {
|
||||
controlState.pauseRequested = false
|
||||
resolveResumeWaiters()
|
||||
},
|
||||
onCancel: () => {
|
||||
controlState.cancelRequested = true
|
||||
controlState.pauseRequested = false
|
||||
resolveResumeWaiters()
|
||||
updateDecryptTaskStatus(
|
||||
'图片解密停止请求已发出,当前处理完成后结束...',
|
||||
`${completed} / ${totalImages}`,
|
||||
'cancel_requested'
|
||||
)
|
||||
}
|
||||
})
|
||||
updateDecryptTaskStatus('正在准备批量图片解密任务...', `0 / ${totalImages}`, 'running')
|
||||
|
||||
const hardlinkMd5Set = new Set<string>()
|
||||
for (const img of images) {
|
||||
const imageMd5 = String(img.imageMd5 || '').trim().toLowerCase()
|
||||
if (imageMd5) {
|
||||
hardlinkMd5Set.add(imageMd5)
|
||||
continue
|
||||
}
|
||||
const imageDatName = String(img.imageDatName || '').trim().toLowerCase()
|
||||
if (/^[a-f0-9]{32}$/i.test(imageDatName)) {
|
||||
hardlinkMd5Set.add(imageDatName)
|
||||
}
|
||||
}
|
||||
if (hardlinkMd5Set.size > 0) {
|
||||
await waitIfPaused()
|
||||
if (controlState.cancelRequested) {
|
||||
const remaining = Math.max(0, totalImages - completed)
|
||||
finishDecrypt(successCount, failCount, {
|
||||
status: 'canceled',
|
||||
detail: `图片批量解密已中断:已处理 ${completed}/${totalImages}(成功 ${successCount},未找到 ${notFoundCount},解密失败 ${decryptFailedCount},未处理 ${remaining})`,
|
||||
progressText: `成功 ${successCount} / 未找到 ${notFoundCount} / 解密失败 ${decryptFailedCount}`
|
||||
})
|
||||
return
|
||||
}
|
||||
updateDecryptTaskStatus(
|
||||
`正在预热图片索引(${hardlinkMd5Set.size} 个标识)...`,
|
||||
`0 / ${totalImages}`
|
||||
)
|
||||
try {
|
||||
await window.electronAPI.image.preloadHardlinkMd5s(Array.from(hardlinkMd5Set))
|
||||
} catch {
|
||||
// ignore preload failures and continue decrypt
|
||||
}
|
||||
}
|
||||
updateDecryptTaskStatus(`开始批量解密图片(0/${totalImages})`, `0 / ${totalImages}`)
|
||||
|
||||
const concurrency = batchDecryptConcurrency
|
||||
|
||||
const decryptOne = async (img: typeof images[0]) => {
|
||||
if (controlState.cancelRequested) return
|
||||
try {
|
||||
const r = await window.electronAPI.image.decrypt({
|
||||
sessionId: session.username,
|
||||
@@ -5861,32 +6064,59 @@ function ChatPage(props: ChatPageProps) {
|
||||
createTime: img.createTime,
|
||||
force: true,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (r?.success) successCount++
|
||||
else failCount++
|
||||
else {
|
||||
failCount++
|
||||
if (r?.failureKind === 'decrypt_failed') decryptFailedCount++
|
||||
else notFoundCount++
|
||||
}
|
||||
} catch {
|
||||
failCount++
|
||||
notFoundCount++
|
||||
}
|
||||
completed++
|
||||
updateDecryptProgress(completed, images.length)
|
||||
updateDecryptProgress(completed, totalImages)
|
||||
}
|
||||
|
||||
// 并发池:同时跑 concurrency 个任务
|
||||
const pool = new Set<Promise<void>>()
|
||||
for (const img of images) {
|
||||
const p = decryptOne(img).then(() => { pool.delete(p) })
|
||||
pool.add(p)
|
||||
if (controlState.cancelRequested) break
|
||||
await waitIfPaused()
|
||||
if (controlState.cancelRequested) break
|
||||
if (pool.size >= concurrency) {
|
||||
await Promise.race(pool)
|
||||
if (controlState.cancelRequested) break
|
||||
await waitIfPaused()
|
||||
if (controlState.cancelRequested) break
|
||||
}
|
||||
let p: Promise<void> = Promise.resolve()
|
||||
p = decryptOne(img).then(() => { pool.delete(p) })
|
||||
pool.add(p)
|
||||
}
|
||||
if (pool.size > 0) {
|
||||
await Promise.all(pool)
|
||||
while (pool.size > 0) {
|
||||
await Promise.race(pool)
|
||||
}
|
||||
|
||||
finishDecrypt(successCount, failCount)
|
||||
}, [batchImageMessages, batchImageSelectedDates, batchDecryptConcurrency, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress])
|
||||
if (controlState.cancelRequested) {
|
||||
const remaining = Math.max(0, totalImages - completed)
|
||||
finishDecrypt(successCount, failCount, {
|
||||
status: 'canceled',
|
||||
detail: `图片批量解密已中断:已处理 ${completed}/${totalImages}(成功 ${successCount},未找到 ${notFoundCount},解密失败 ${decryptFailedCount},未处理 ${remaining})`,
|
||||
progressText: `成功 ${successCount} / 未找到 ${notFoundCount} / 解密失败 ${decryptFailedCount}`
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
finishDecrypt(successCount, failCount, {
|
||||
status: decryptFailedCount > 0 ? 'failed' : 'completed',
|
||||
detail: `图片批量解密完成:成功 ${successCount},未找到 ${notFoundCount},解密失败 ${decryptFailedCount}`,
|
||||
progressText: `成功 ${successCount} / 未找到 ${notFoundCount} / 解密失败 ${decryptFailedCount}`
|
||||
})
|
||||
}, [batchImageMessages, batchImageSelectedDates, batchDecryptConcurrency, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptTaskStatus, updateDecryptProgress])
|
||||
|
||||
const batchImageCountByDate = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
@@ -6621,16 +6851,10 @@ function ChatPage(props: ChatPageProps) {
|
||||
{!standaloneSessionWindow && (
|
||||
<button
|
||||
className={`icon-btn batch-transcribe-btn${isBatchTranscribing ? ' transcribing' : ''}`}
|
||||
onClick={() => {
|
||||
if (isBatchTranscribing) {
|
||||
setShowBatchProgress(true)
|
||||
} else {
|
||||
handleBatchTranscribe()
|
||||
}
|
||||
}}
|
||||
onClick={handleBatchTranscribe}
|
||||
disabled={!currentSessionId}
|
||||
title={isBatchTranscribing
|
||||
? `${runningBatchVoiceTaskType === 'decrypt' ? '批量语音解密' : '批量转写'}中 (${batchTranscribeProgress.current}/${batchTranscribeProgress.total}),点击查看进度`
|
||||
? `${runningBatchVoiceTaskType === 'decrypt' ? '批量语音解密' : '批量转写'}中,可在导出页任务中心查看进度`
|
||||
: '批量语音处理(解密/转文字)'}
|
||||
>
|
||||
{isBatchTranscribing ? (
|
||||
@@ -6643,16 +6867,10 @@ function ChatPage(props: ChatPageProps) {
|
||||
{!standaloneSessionWindow && (
|
||||
<button
|
||||
className={`icon-btn batch-decrypt-btn${isBatchDecrypting ? ' transcribing' : ''}`}
|
||||
onClick={() => {
|
||||
if (isBatchDecrypting) {
|
||||
setShowBatchDecryptToast(true)
|
||||
} else {
|
||||
handleBatchDecrypt()
|
||||
}
|
||||
}}
|
||||
onClick={handleBatchDecrypt}
|
||||
disabled={!currentSessionId}
|
||||
title={isBatchDecrypting
|
||||
? `批量解密中 (${batchDecryptProgress.current}/${batchDecryptProgress.total}),点击查看进度`
|
||||
? '批量解密中,可在导出页任务中心查看进度'
|
||||
: '批量解密图片'}
|
||||
>
|
||||
{isBatchDecrypting ? (
|
||||
@@ -7330,8 +7548,8 @@ function ChatPage(props: ChatPageProps) {
|
||||
<AlertCircle size={16} />
|
||||
<span>
|
||||
{batchVoiceTaskType === 'decrypt'
|
||||
? '批量解密会预先缓存语音数据,之后播放和转写会更快。解密过程中可以继续使用其他功能。'
|
||||
: '批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过。'}
|
||||
? '批量解密会预先缓存语音数据,之后播放和转写会更快。解密过程中可以继续使用其他功能,进度会写入导出页任务中心。'
|
||||
: '批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过,进度会写入导出页任务中心。'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -7398,17 +7616,17 @@ function ChatPage(props: ChatPageProps) {
|
||||
className={`batch-concurrency-trigger ${showConcurrencyDropdown ? 'open' : ''}`}
|
||||
onClick={() => setShowConcurrencyDropdown(!showConcurrencyDropdown)}
|
||||
>
|
||||
<span>{batchDecryptConcurrency === 1 ? '1(最慢,最稳)' : batchDecryptConcurrency === 6 ? '6(推荐)' : batchDecryptConcurrency === 20 ? '20(最快,可能卡顿)' : String(batchDecryptConcurrency)}</span>
|
||||
<span>{batchDecryptConcurrency === 1 ? '1' : batchDecryptConcurrency === 6 ? '6' : batchDecryptConcurrency === 20 ? '20' : String(batchDecryptConcurrency)}</span>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{showConcurrencyDropdown && (
|
||||
<div className="batch-concurrency-dropdown">
|
||||
{[
|
||||
{ value: 1, label: '1(最慢,最稳)' },
|
||||
{ value: 1, label: '1' },
|
||||
{ value: 3, label: '3' },
|
||||
{ value: 6, label: '6(推荐)' },
|
||||
{ value: 6, label: '6' },
|
||||
{ value: 10, label: '10' },
|
||||
{ value: 20, label: '20(最快,可能卡顿)' },
|
||||
{ value: 20, label: '20' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
@@ -7426,7 +7644,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
</div>
|
||||
<div className="batch-warning">
|
||||
<AlertCircle size={16} />
|
||||
<span>批量解密可能需要较长时间,进行中会在右下角显示非阻塞进度浮层。</span>
|
||||
<span>批量解密可能需要较长时间,进度会自动写入导出页任务中心(含准备阶段状态)。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="batch-modal-footer">
|
||||
@@ -7789,7 +8007,13 @@ const emojiDataUrlCache = new Map<string, string>()
|
||||
const imageDataUrlCache = new Map<string, string>()
|
||||
const voiceDataUrlCache = new Map<string, string>()
|
||||
const voiceTranscriptCache = new Map<string, string>()
|
||||
type SharedImageDecryptResult = { success: boolean; localPath?: string; liveVideoPath?: string; error?: string }
|
||||
type SharedImageDecryptResult = {
|
||||
success: boolean
|
||||
localPath?: string
|
||||
liveVideoPath?: string
|
||||
error?: string
|
||||
failureKind?: 'not_found' | 'decrypt_failed'
|
||||
}
|
||||
const imageDecryptInFlight = new Map<string, Promise<SharedImageDecryptResult>>()
|
||||
const senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: string }>()
|
||||
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
|
||||
|
||||
@@ -410,6 +410,17 @@
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
}
|
||||
|
||||
&.status-pause_requested,
|
||||
&.status-paused {
|
||||
color: #b45309;
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
}
|
||||
|
||||
&.status-canceled {
|
||||
color: #64748b;
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
&.status-completed {
|
||||
color: #166534;
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
@@ -5817,8 +5828,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 终止时间选择器 */
|
||||
.automation-stopat-picker {
|
||||
/* 首次触发/终止时间选择器 */
|
||||
.automation-stopat-picker,
|
||||
.automation-first-trigger-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -5884,4 +5896,4 @@
|
||||
font-size: 11px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
MessageSquare,
|
||||
MessageSquareText,
|
||||
Mic,
|
||||
Pause,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Square,
|
||||
@@ -48,6 +50,8 @@ import {
|
||||
import {
|
||||
requestCancelBackgroundTask,
|
||||
requestCancelBackgroundTasks,
|
||||
requestPauseBackgroundTask,
|
||||
requestResumeBackgroundTask,
|
||||
subscribeBackgroundTasks
|
||||
} from '../services/backgroundTaskMonitor'
|
||||
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
|
||||
@@ -208,6 +212,8 @@ interface AutomationTaskDraft {
|
||||
dateRangeConfig: ExportAutomationDateRangeConfig | string | null
|
||||
intervalDays: number
|
||||
intervalHours: number
|
||||
firstTriggerAtEnabled: boolean
|
||||
firstTriggerAtValue: string
|
||||
stopAtEnabled: boolean
|
||||
stopAtValue: string
|
||||
maxRunsEnabled: boolean
|
||||
@@ -217,6 +223,7 @@ interface AutomationTaskDraft {
|
||||
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
||||
const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000
|
||||
const TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS = 900
|
||||
const EXPORT_PROGRESS_UI_FLUSH_INTERVAL_MS = 320
|
||||
const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10
|
||||
const SESSION_MEDIA_METRIC_BATCH_SIZE = 8
|
||||
const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48
|
||||
@@ -248,6 +255,8 @@ const backgroundTaskSourceLabels: Record<string, string> = {
|
||||
|
||||
const backgroundTaskStatusLabels: Record<BackgroundTaskRecord['status'], string> = {
|
||||
running: '运行中',
|
||||
pause_requested: '中断中',
|
||||
paused: '已中断',
|
||||
cancel_requested: '停止中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
@@ -321,6 +330,69 @@ const createEmptyProgress = (): TaskProgress => ({
|
||||
mediaBytesWritten: 0
|
||||
})
|
||||
|
||||
const areStringArraysEqual = (left: string[], right: string[]): boolean => {
|
||||
if (left === right) return true
|
||||
if (left.length !== right.length) return false
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const areTaskProgressEqual = (left: TaskProgress, right: TaskProgress): boolean => (
|
||||
left.current === right.current &&
|
||||
left.total === right.total &&
|
||||
left.currentName === right.currentName &&
|
||||
left.phase === right.phase &&
|
||||
left.phaseLabel === right.phaseLabel &&
|
||||
left.phaseProgress === right.phaseProgress &&
|
||||
left.phaseTotal === right.phaseTotal &&
|
||||
left.exportedMessages === right.exportedMessages &&
|
||||
left.estimatedTotalMessages === right.estimatedTotalMessages &&
|
||||
left.collectedMessages === right.collectedMessages &&
|
||||
left.writtenFiles === right.writtenFiles &&
|
||||
left.mediaDoneFiles === right.mediaDoneFiles &&
|
||||
left.mediaCacheHitFiles === right.mediaCacheHitFiles &&
|
||||
left.mediaCacheMissFiles === right.mediaCacheMissFiles &&
|
||||
left.mediaCacheFillFiles === right.mediaCacheFillFiles &&
|
||||
left.mediaDedupReuseFiles === right.mediaDedupReuseFiles &&
|
||||
left.mediaBytesWritten === right.mediaBytesWritten
|
||||
)
|
||||
|
||||
const normalizeProgressFloat = (value: unknown, digits = 3): number => {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 0
|
||||
const factor = 10 ** digits
|
||||
return Math.round(parsed * factor) / factor
|
||||
}
|
||||
|
||||
const normalizeProgressInt = (value: unknown): number => {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 0
|
||||
return Math.max(0, Math.floor(parsed))
|
||||
}
|
||||
|
||||
const buildProgressPayloadSignature = (payload: ExportProgress): string => ([
|
||||
String(payload.phase || ''),
|
||||
String(payload.currentSessionId || ''),
|
||||
String(payload.currentSession || ''),
|
||||
String(payload.phaseLabel || ''),
|
||||
normalizeProgressFloat(payload.current, 4),
|
||||
normalizeProgressFloat(payload.total, 4),
|
||||
normalizeProgressFloat(payload.phaseProgress, 2),
|
||||
normalizeProgressFloat(payload.phaseTotal, 2),
|
||||
normalizeProgressInt(payload.collectedMessages),
|
||||
normalizeProgressInt(payload.exportedMessages),
|
||||
normalizeProgressInt(payload.estimatedTotalMessages),
|
||||
normalizeProgressInt(payload.writtenFiles),
|
||||
normalizeProgressInt(payload.mediaDoneFiles),
|
||||
normalizeProgressInt(payload.mediaCacheHitFiles),
|
||||
normalizeProgressInt(payload.mediaCacheMissFiles),
|
||||
normalizeProgressInt(payload.mediaCacheFillFiles),
|
||||
normalizeProgressInt(payload.mediaDedupReuseFiles),
|
||||
normalizeProgressInt(payload.mediaBytesWritten)
|
||||
].join('|'))
|
||||
|
||||
const createEmptyTaskPerformance = (): TaskPerformance => ({
|
||||
stages: {
|
||||
collect: 0,
|
||||
@@ -508,6 +580,35 @@ const getTaskStatusLabel = (task: ExportTask): string => {
|
||||
return '失败'
|
||||
}
|
||||
|
||||
const resolveBackgroundTaskCardClass = (status: BackgroundTaskRecord['status']): 'running' | 'paused' | 'stopped' | 'success' | 'error' => {
|
||||
if (status === 'running') return 'running'
|
||||
if (status === 'pause_requested' || status === 'paused') return 'paused'
|
||||
if (status === 'cancel_requested' || status === 'canceled') return 'stopped'
|
||||
if (status === 'completed') return 'success'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
const parseBackgroundTaskProgress = (progressText?: string): { current: number; total: number; ratio: number | null } => {
|
||||
const normalized = String(progressText || '').trim()
|
||||
if (!normalized) {
|
||||
return { current: 0, total: 0, ratio: null }
|
||||
}
|
||||
const match = normalized.match(/(\d+)\s*\/\s*(\d+)/)
|
||||
if (!match) {
|
||||
return { current: 0, total: 0, ratio: null }
|
||||
}
|
||||
const current = Math.max(0, Math.floor(Number(match[1]) || 0))
|
||||
const total = Math.max(0, Math.floor(Number(match[2]) || 0))
|
||||
if (total <= 0) {
|
||||
return { current, total, ratio: null }
|
||||
}
|
||||
return {
|
||||
current,
|
||||
total,
|
||||
ratio: Math.max(0, Math.min(1, current / total))
|
||||
}
|
||||
}
|
||||
|
||||
const formatAbsoluteDate = (timestamp: number): string => {
|
||||
const d = new Date(timestamp)
|
||||
const y = d.getFullYear()
|
||||
@@ -643,6 +744,11 @@ type ContactsDataSource = 'cache' | 'network' | null
|
||||
|
||||
const normalizeAutomationIntervalDays = (value: unknown): number => Math.max(0, Math.floor(Number(value) || 0))
|
||||
const normalizeAutomationIntervalHours = (value: unknown): number => Math.max(0, Math.min(23, Math.floor(Number(value) || 0)))
|
||||
const normalizeAutomationFirstTriggerAt = (value: unknown): number => {
|
||||
const numeric = Math.floor(Number(value) || 0)
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return 0
|
||||
return numeric
|
||||
}
|
||||
|
||||
const resolveAutomationIntervalMs = (schedule: ExportAutomationSchedule): number => {
|
||||
const days = normalizeAutomationIntervalDays(schedule.intervalDays)
|
||||
@@ -652,6 +758,16 @@ const resolveAutomationIntervalMs = (schedule: ExportAutomationSchedule): number
|
||||
return totalHours * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
const resolveAutomationInitialTriggerAt = (task: ExportAutomationTask): number | null => {
|
||||
const intervalMs = resolveAutomationIntervalMs(task.schedule)
|
||||
if (intervalMs <= 0) return null
|
||||
const firstTriggerAt = normalizeAutomationFirstTriggerAt(task.schedule.firstTriggerAt)
|
||||
if (firstTriggerAt > 0) return firstTriggerAt
|
||||
const createdAt = Math.max(0, Math.floor(Number(task.createdAt || 0)))
|
||||
if (!createdAt) return null
|
||||
return createdAt + intervalMs
|
||||
}
|
||||
|
||||
const formatAutomationScheduleLabel = (schedule: ExportAutomationSchedule): string => {
|
||||
const days = normalizeAutomationIntervalDays(schedule.intervalDays)
|
||||
const hours = normalizeAutomationIntervalHours(schedule.intervalHours)
|
||||
@@ -665,12 +781,60 @@ const resolveAutomationDueScheduleKey = (task: ExportAutomationTask, now: Date):
|
||||
const intervalMs = resolveAutomationIntervalMs(task.schedule)
|
||||
if (intervalMs <= 0) return null
|
||||
const nowMs = now.getTime()
|
||||
const anchorAt = Math.max(
|
||||
0,
|
||||
Number(task.runState?.lastTriggeredAt || 0) || Number(task.createdAt || 0)
|
||||
)
|
||||
if (nowMs < anchorAt + intervalMs) return null
|
||||
return `interval:${anchorAt}:${Math.floor((nowMs - anchorAt) / intervalMs)}`
|
||||
const lastTriggeredAt = Math.max(0, Math.floor(Number(task.runState?.lastTriggeredAt || 0)))
|
||||
if (lastTriggeredAt > 0) {
|
||||
if (nowMs < lastTriggeredAt + intervalMs) return null
|
||||
return `interval:${lastTriggeredAt}:${Math.floor((nowMs - lastTriggeredAt) / intervalMs)}`
|
||||
}
|
||||
const initialTriggerAt = resolveAutomationInitialTriggerAt(task)
|
||||
if (!initialTriggerAt) return null
|
||||
if (nowMs < initialTriggerAt) return null
|
||||
return `first:${initialTriggerAt}`
|
||||
}
|
||||
|
||||
const resolveAutomationFirstTriggerSummary = (task: ExportAutomationTask): string => {
|
||||
const firstTriggerAt = normalizeAutomationFirstTriggerAt(task.schedule.firstTriggerAt)
|
||||
if (firstTriggerAt <= 0) return '未指定(默认按创建时间+间隔)'
|
||||
return new Date(firstTriggerAt).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const buildAutomationSchedule = (
|
||||
intervalDays: number,
|
||||
intervalHours: number,
|
||||
firstTriggerAt: number
|
||||
): ExportAutomationSchedule => ({
|
||||
type: 'interval',
|
||||
intervalDays,
|
||||
intervalHours,
|
||||
firstTriggerAt: firstTriggerAt > 0 ? firstTriggerAt : undefined
|
||||
})
|
||||
|
||||
const buildAutomationDatePart = (timestamp: number): string => {
|
||||
const date = new Date(timestamp)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
const year = date.getFullYear()
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${date.getDate()}`.padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
const buildAutomationTodayDatePart = (): string => buildAutomationDatePart(Date.now())
|
||||
|
||||
const normalizeAutomationDatePart = (value: string): string => {
|
||||
const text = String(value || '').trim()
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : ''
|
||||
}
|
||||
|
||||
const normalizeAutomationTimePart = (value: string): string => {
|
||||
const text = String(value || '').trim()
|
||||
if (!/^\d{2}:\d{2}$/.test(text)) return '00:00'
|
||||
const [hoursText, minutesText] = text.split(':')
|
||||
const hours = Math.floor(Number(hoursText))
|
||||
const minutes = Math.floor(Number(minutesText))
|
||||
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return '00:00'
|
||||
const safeHours = Math.min(23, Math.max(0, hours))
|
||||
const safeMinutes = Math.min(59, Math.max(0, minutes))
|
||||
return `${`${safeHours}`.padStart(2, '0')}:${`${safeMinutes}`.padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const toDateTimeLocalValue = (timestamp: number): string => {
|
||||
@@ -811,9 +975,9 @@ const formatAutomationStopCondition = (task: ExportAutomationTask): string => {
|
||||
const resolveAutomationNextTriggerAt = (task: ExportAutomationTask): number | null => {
|
||||
const intervalMs = resolveAutomationIntervalMs(task.schedule)
|
||||
if (intervalMs <= 0) return null
|
||||
const anchorAt = Math.max(0, Number(task.runState?.lastTriggeredAt || 0) || Number(task.createdAt || 0))
|
||||
if (!anchorAt) return null
|
||||
return anchorAt + intervalMs
|
||||
const lastTriggeredAt = Math.max(0, Math.floor(Number(task.runState?.lastTriggeredAt || 0)))
|
||||
if (lastTriggeredAt > 0) return lastTriggeredAt + intervalMs
|
||||
return resolveAutomationInitialTriggerAt(task)
|
||||
}
|
||||
|
||||
const formatAutomationCurrentState = (
|
||||
@@ -1597,25 +1761,40 @@ const SectionInfoTooltip = memo(function SectionInfoTooltip({
|
||||
interface TaskCenterModalProps {
|
||||
isOpen: boolean
|
||||
tasks: ExportTask[]
|
||||
chatBackgroundTasks: BackgroundTaskRecord[]
|
||||
taskRunningCount: number
|
||||
taskQueuedCount: number
|
||||
expandedPerfTaskId: string | null
|
||||
nowTick: number
|
||||
onClose: () => void
|
||||
onTogglePerfTask: (taskId: string) => void
|
||||
onPauseBackgroundTask: (taskId: string) => void
|
||||
onResumeBackgroundTask: (taskId: string) => void
|
||||
onCancelBackgroundTask: (taskId: string) => void
|
||||
}
|
||||
|
||||
const TaskCenterModal = memo(function TaskCenterModal({
|
||||
isOpen,
|
||||
tasks,
|
||||
chatBackgroundTasks,
|
||||
taskRunningCount,
|
||||
taskQueuedCount,
|
||||
expandedPerfTaskId,
|
||||
nowTick,
|
||||
onClose,
|
||||
onTogglePerfTask
|
||||
onTogglePerfTask,
|
||||
onPauseBackgroundTask,
|
||||
onResumeBackgroundTask,
|
||||
onCancelBackgroundTask
|
||||
}: TaskCenterModalProps) {
|
||||
if (!isOpen) return null
|
||||
const chatActiveTaskCount = chatBackgroundTasks.filter(task => (
|
||||
task.status === 'running' ||
|
||||
task.status === 'pause_requested' ||
|
||||
task.status === 'paused' ||
|
||||
task.status === 'cancel_requested'
|
||||
)).length
|
||||
const totalTaskCount = tasks.length + chatBackgroundTasks.length
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
@@ -1632,7 +1811,7 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
<div className="task-center-modal-header">
|
||||
<div className="task-center-modal-title">
|
||||
<h3>任务中心</h3>
|
||||
<span>进行中 {taskRunningCount} · 排队 {taskQueuedCount} · 总计 {tasks.length}</span>
|
||||
<span>导出进行中 {taskRunningCount} · 排队 {taskQueuedCount} · 聊天后台 {chatActiveTaskCount} · 总计 {totalTaskCount}</span>
|
||||
</div>
|
||||
<button
|
||||
className="close-icon-btn"
|
||||
@@ -1644,8 +1823,8 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
</button>
|
||||
</div>
|
||||
<div className="task-center-modal-body">
|
||||
{tasks.length === 0 ? (
|
||||
<div className="task-empty">暂无任务。点击会话导出或卡片导出后会在这里创建任务。</div>
|
||||
{totalTaskCount === 0 ? (
|
||||
<div className="task-empty">暂无任务。导出任务和聊天页批量语音/图片任务都会显示在这里。</div>
|
||||
) : (
|
||||
<div className="task-list">
|
||||
{tasks.map(task => {
|
||||
@@ -1833,6 +2012,70 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{chatBackgroundTasks.map(task => {
|
||||
const taskCardClass = resolveBackgroundTaskCardClass(task.status)
|
||||
const progress = parseBackgroundTaskProgress(task.progressText)
|
||||
const canPause = task.resumable && task.status === 'running'
|
||||
const canResume = task.resumable && (task.status === 'paused' || task.status === 'pause_requested')
|
||||
const canCancel = task.cancelable && (
|
||||
task.status === 'running' ||
|
||||
task.status === 'pause_requested' ||
|
||||
task.status === 'paused' ||
|
||||
task.status === 'cancel_requested'
|
||||
)
|
||||
return (
|
||||
<div key={task.id} className={`task-card ${taskCardClass}`}>
|
||||
<div className="task-main">
|
||||
<div className="task-title">{task.title}</div>
|
||||
<div className="task-meta">
|
||||
<span className={`task-status ${taskCardClass}`}>{backgroundTaskStatusLabels[task.status]}</span>
|
||||
<span>{backgroundTaskSourceLabels[task.sourcePage] || backgroundTaskSourceLabels.other}</span>
|
||||
<span>{new Date(task.startedAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
{progress.ratio !== null && (
|
||||
<div className="task-progress-bar">
|
||||
<div
|
||||
className="task-progress-fill"
|
||||
style={{ width: `${progress.ratio * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="task-progress-text">
|
||||
{task.detail || '任务进行中'}
|
||||
{task.progressText ? ` · ${task.progressText}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="task-actions">
|
||||
{canPause && (
|
||||
<button
|
||||
className="task-action-btn"
|
||||
type="button"
|
||||
onClick={() => onPauseBackgroundTask(task.id)}
|
||||
>
|
||||
<Pause size={14} /> 中断
|
||||
</button>
|
||||
)}
|
||||
{canResume && (
|
||||
<button
|
||||
className="task-action-btn primary"
|
||||
type="button"
|
||||
onClick={() => onResumeBackgroundTask(task.id)}
|
||||
>
|
||||
<Play size={14} /> 继续
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="task-action-btn danger"
|
||||
type="button"
|
||||
onClick={() => onCancelBackgroundTask(task.id)}
|
||||
disabled={!canCancel || task.status === 'cancel_requested'}
|
||||
>
|
||||
{task.status === 'cancel_requested' ? '停止中' : '停止'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -4857,6 +5100,7 @@ function ExportPage() {
|
||||
|
||||
const openEditAutomationTaskDraft = useCallback((task: ExportAutomationTask) => {
|
||||
const schedule = task.schedule
|
||||
const firstTriggerAt = normalizeAutomationFirstTriggerAt(schedule.firstTriggerAt)
|
||||
const stopAt = Number(task.stopCondition?.endAt || 0)
|
||||
const maxRuns = Number(task.stopCondition?.maxRuns || 0)
|
||||
const resolvedRange = resolveAutomationDateRangeSelection(task.template.dateRangeConfig as any, new Date())
|
||||
@@ -4877,6 +5121,8 @@ function ExportPage() {
|
||||
dateRangeConfig: task.template.dateRangeConfig,
|
||||
intervalDays: normalizeAutomationIntervalDays(schedule.intervalDays),
|
||||
intervalHours: normalizeAutomationIntervalHours(schedule.intervalHours),
|
||||
firstTriggerAtEnabled: firstTriggerAt > 0,
|
||||
firstTriggerAtValue: firstTriggerAt > 0 ? toDateTimeLocalValue(firstTriggerAt) : '',
|
||||
stopAtEnabled: stopAt > 0,
|
||||
stopAtValue: stopAt > 0 ? toDateTimeLocalValue(stopAt) : '',
|
||||
maxRunsEnabled: maxRuns > 0,
|
||||
@@ -4982,7 +5228,18 @@ function ExportPage() {
|
||||
window.alert('执行间隔不能为 0,请至少设置天数或小时')
|
||||
return
|
||||
}
|
||||
const schedule: ExportAutomationSchedule = { type: 'interval', intervalDays, intervalHours }
|
||||
const firstTriggerAtTimestamp = automationTaskDraft.firstTriggerAtEnabled
|
||||
? parseDateTimeLocalValue(automationTaskDraft.firstTriggerAtValue)
|
||||
: null
|
||||
if (automationTaskDraft.firstTriggerAtEnabled && !firstTriggerAtTimestamp) {
|
||||
window.alert('请填写有效的首次触发时间')
|
||||
return
|
||||
}
|
||||
const schedule = buildAutomationSchedule(
|
||||
intervalDays,
|
||||
intervalHours,
|
||||
firstTriggerAtTimestamp && firstTriggerAtTimestamp > 0 ? firstTriggerAtTimestamp : 0
|
||||
)
|
||||
const stopAtTimestamp = automationTaskDraft.stopAtEnabled
|
||||
? parseDateTimeLocalValue(automationTaskDraft.stopAtValue)
|
||||
: null
|
||||
@@ -5169,14 +5426,10 @@ function ExportPage() {
|
||||
const settledSessionIdsFromProgress = new Set<string>()
|
||||
const sessionMessageProgress = new Map<string, { exported: number; total: number; knownTotal: boolean }>()
|
||||
let queuedProgressPayload: ExportProgress | null = null
|
||||
let queuedProgressRaf: number | null = null
|
||||
let queuedProgressSignature = ''
|
||||
let queuedProgressTimer: number | null = null
|
||||
|
||||
const clearQueuedProgress = () => {
|
||||
if (queuedProgressRaf !== null) {
|
||||
window.cancelAnimationFrame(queuedProgressRaf)
|
||||
queuedProgressRaf = null
|
||||
}
|
||||
if (queuedProgressTimer !== null) {
|
||||
window.clearTimeout(queuedProgressTimer)
|
||||
queuedProgressTimer = null
|
||||
@@ -5228,6 +5481,7 @@ function ExportPage() {
|
||||
if (!queuedProgressPayload) return
|
||||
const payload = queuedProgressPayload
|
||||
queuedProgressPayload = null
|
||||
queuedProgressSignature = ''
|
||||
const now = Date.now()
|
||||
const currentSessionId = String(payload.currentSessionId || '').trim()
|
||||
updateTask(next.id, task => {
|
||||
@@ -5284,77 +5538,71 @@ function ExportPage() {
|
||||
const mediaBytesWritten = Number.isFinite(payload.mediaBytesWritten)
|
||||
? Math.max(prevMediaBytesWritten, Math.max(0, Math.floor(Number(payload.mediaBytesWritten || 0))))
|
||||
: prevMediaBytesWritten
|
||||
const nextProgress: TaskProgress = {
|
||||
current: payload.current,
|
||||
total: payload.total,
|
||||
currentName: payload.currentSession || '',
|
||||
phase: payload.phase,
|
||||
phaseLabel: payload.phaseLabel || '',
|
||||
phaseProgress: payload.phaseProgress || 0,
|
||||
phaseTotal: payload.phaseTotal || 0,
|
||||
exportedMessages: Math.max(task.progress.exportedMessages, aggregatedMessageProgress.exported),
|
||||
estimatedTotalMessages: aggregatedMessageProgress.estimated > 0
|
||||
? Math.max(task.progress.estimatedTotalMessages, aggregatedMessageProgress.estimated)
|
||||
: (task.progress.estimatedTotalMessages > 0 ? task.progress.estimatedTotalMessages : 0),
|
||||
collectedMessages: Math.max(task.progress.collectedMessages, collectedMessages),
|
||||
writtenFiles,
|
||||
mediaDoneFiles,
|
||||
mediaCacheHitFiles,
|
||||
mediaCacheMissFiles,
|
||||
mediaCacheFillFiles,
|
||||
mediaDedupReuseFiles,
|
||||
mediaBytesWritten
|
||||
}
|
||||
const hasSettledListChanged = !areStringArraysEqual(settledSessionIds, nextSettledSessionIds)
|
||||
const hasProgressChanged = !areTaskProgressEqual(task.progress, nextProgress)
|
||||
const hasPerformanceChanged = performance !== task.performance
|
||||
if (!hasSettledListChanged && !hasProgressChanged && !hasPerformanceChanged) {
|
||||
return task
|
||||
}
|
||||
return {
|
||||
...task,
|
||||
progress: {
|
||||
current: payload.current,
|
||||
total: payload.total,
|
||||
currentName: payload.currentSession,
|
||||
phase: payload.phase,
|
||||
phaseLabel: payload.phaseLabel || '',
|
||||
phaseProgress: payload.phaseProgress || 0,
|
||||
phaseTotal: payload.phaseTotal || 0,
|
||||
exportedMessages: Math.max(task.progress.exportedMessages, aggregatedMessageProgress.exported),
|
||||
estimatedTotalMessages: aggregatedMessageProgress.estimated > 0
|
||||
? Math.max(task.progress.estimatedTotalMessages, aggregatedMessageProgress.estimated)
|
||||
: (task.progress.estimatedTotalMessages > 0 ? task.progress.estimatedTotalMessages : 0),
|
||||
collectedMessages: Math.max(task.progress.collectedMessages, collectedMessages),
|
||||
writtenFiles,
|
||||
mediaDoneFiles,
|
||||
mediaCacheHitFiles,
|
||||
mediaCacheMissFiles,
|
||||
mediaCacheFillFiles,
|
||||
mediaDedupReuseFiles,
|
||||
mediaBytesWritten
|
||||
},
|
||||
settledSessionIds: nextSettledSessionIds,
|
||||
performance
|
||||
progress: hasProgressChanged ? nextProgress : task.progress,
|
||||
settledSessionIds: hasSettledListChanged ? nextSettledSessionIds : settledSessionIds,
|
||||
performance: hasPerformanceChanged ? performance : task.performance
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const queueProgressUpdate = (payload: ExportProgress) => {
|
||||
const signature = buildProgressPayloadSignature(payload)
|
||||
if (queuedProgressPayload && signature === queuedProgressSignature) {
|
||||
return
|
||||
}
|
||||
queuedProgressPayload = payload
|
||||
queuedProgressSignature = signature
|
||||
if (payload.phase === 'complete') {
|
||||
clearQueuedProgress()
|
||||
flushQueuedProgress()
|
||||
return
|
||||
}
|
||||
if (queuedProgressRaf !== null || queuedProgressTimer !== null) return
|
||||
queuedProgressRaf = window.requestAnimationFrame(() => {
|
||||
queuedProgressRaf = null
|
||||
queuedProgressTimer = window.setTimeout(() => {
|
||||
queuedProgressTimer = null
|
||||
flushQueuedProgress()
|
||||
}, 180)
|
||||
})
|
||||
if (queuedProgressTimer !== null) return
|
||||
queuedProgressTimer = window.setTimeout(() => {
|
||||
queuedProgressTimer = null
|
||||
flushQueuedProgress()
|
||||
}, EXPORT_PROGRESS_UI_FLUSH_INTERVAL_MS)
|
||||
}
|
||||
if (next.payload.scope === 'sns') {
|
||||
progressUnsubscribeRef.current = window.electronAPI.sns.onExportProgress((payload) => {
|
||||
updateTask(next.id, task => {
|
||||
if (task.status !== 'running') return task
|
||||
return {
|
||||
...task,
|
||||
progress: {
|
||||
current: payload.current || 0,
|
||||
total: payload.total || 0,
|
||||
currentName: '',
|
||||
phase: 'exporting',
|
||||
phaseLabel: payload.status || '',
|
||||
phaseProgress: payload.total > 0 ? payload.current : 0,
|
||||
phaseTotal: payload.total || 0,
|
||||
exportedMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.current || 0)) : task.progress.exportedMessages,
|
||||
estimatedTotalMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.total || 0)) : task.progress.estimatedTotalMessages,
|
||||
collectedMessages: task.progress.collectedMessages,
|
||||
writtenFiles: task.progress.writtenFiles,
|
||||
mediaDoneFiles: task.progress.mediaDoneFiles,
|
||||
mediaCacheHitFiles: task.progress.mediaCacheHitFiles,
|
||||
mediaCacheMissFiles: task.progress.mediaCacheMissFiles,
|
||||
mediaCacheFillFiles: task.progress.mediaCacheFillFiles,
|
||||
mediaDedupReuseFiles: task.progress.mediaDedupReuseFiles,
|
||||
mediaBytesWritten: task.progress.mediaBytesWritten
|
||||
}
|
||||
}
|
||||
queueProgressUpdate({
|
||||
current: Number(payload.current || 0),
|
||||
total: Number(payload.total || 0),
|
||||
currentSession: '',
|
||||
currentSessionId: '',
|
||||
phase: 'exporting',
|
||||
phaseLabel: String(payload.status || ''),
|
||||
phaseProgress: payload.total > 0 ? Number(payload.current || 0) : 0,
|
||||
phaseTotal: Number(payload.total || 0)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
@@ -5679,6 +5927,8 @@ function ExportPage() {
|
||||
dateRangeConfig: serializeExportDateRangeConfig(normalizedRangeSelection),
|
||||
intervalDays: 1,
|
||||
intervalHours: 0,
|
||||
firstTriggerAtEnabled: false,
|
||||
firstTriggerAtValue: '',
|
||||
stopAtEnabled: false,
|
||||
stopAtValue: '',
|
||||
maxRunsEnabled: false,
|
||||
@@ -7357,11 +7607,23 @@ function ExportPage() {
|
||||
const handleCancelBackgroundTask = useCallback((taskId: string) => {
|
||||
requestCancelBackgroundTask(taskId)
|
||||
}, [])
|
||||
const handlePauseBackgroundTask = useCallback((taskId: string) => {
|
||||
requestPauseBackgroundTask(taskId)
|
||||
}, [])
|
||||
const handleResumeBackgroundTask = useCallback((taskId: string) => {
|
||||
requestResumeBackgroundTask(taskId)
|
||||
}, [])
|
||||
const handleCancelAllNonExportTasks = useCallback(() => {
|
||||
requestCancelBackgroundTasks(task => (
|
||||
task.sourcePage !== 'export' &&
|
||||
task.sourcePage !== 'chat' &&
|
||||
task.cancelable &&
|
||||
(task.status === 'running' || task.status === 'cancel_requested')
|
||||
(
|
||||
task.status === 'running' ||
|
||||
task.status === 'pause_requested' ||
|
||||
task.status === 'paused' ||
|
||||
task.status === 'cancel_requested'
|
||||
)
|
||||
))
|
||||
}, [])
|
||||
|
||||
@@ -7509,7 +7771,18 @@ function ExportPage() {
|
||||
const isSnsCardStatsLoading = !hasSeededSnsStats
|
||||
const taskRunningCount = tasks.filter(task => task.status === 'running').length
|
||||
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
|
||||
const taskCenterAlertCount = taskRunningCount + taskQueuedCount
|
||||
const chatBackgroundTasks = useMemo(() => (
|
||||
backgroundTasks.filter(task => task.sourcePage === 'chat')
|
||||
), [backgroundTasks])
|
||||
const chatBackgroundActiveTaskCount = useMemo(() => (
|
||||
chatBackgroundTasks.filter(task => (
|
||||
task.status === 'running' ||
|
||||
task.status === 'pause_requested' ||
|
||||
task.status === 'paused' ||
|
||||
task.status === 'cancel_requested'
|
||||
)).length
|
||||
), [chatBackgroundTasks])
|
||||
const taskCenterAlertCount = taskRunningCount + taskQueuedCount + chatBackgroundActiveTaskCount
|
||||
const hasFilteredContacts = filteredContacts.length > 0
|
||||
const optionalMetricColumnCount = (shouldShowSnsColumn ? 1 : 0) + (shouldShowMutualFriendsColumn ? 1 : 0)
|
||||
const contactsMetricColumnCount = 4 + optionalMetricColumnCount
|
||||
@@ -7524,15 +7797,25 @@ function ExportPage() {
|
||||
width: `${Math.max(contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth)}px`
|
||||
}), [contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth])
|
||||
const nonExportBackgroundTasks = useMemo(() => (
|
||||
backgroundTasks.filter(task => task.sourcePage !== 'export')
|
||||
backgroundTasks.filter(task => task.sourcePage !== 'export' && task.sourcePage !== 'chat')
|
||||
), [backgroundTasks])
|
||||
const runningNonExportTaskCount = useMemo(() => (
|
||||
nonExportBackgroundTasks.filter(task => task.status === 'running' || task.status === 'cancel_requested').length
|
||||
nonExportBackgroundTasks.filter(task => (
|
||||
task.status === 'running' ||
|
||||
task.status === 'pause_requested' ||
|
||||
task.status === 'paused' ||
|
||||
task.status === 'cancel_requested'
|
||||
)).length
|
||||
), [nonExportBackgroundTasks])
|
||||
const cancelableNonExportTaskCount = useMemo(() => (
|
||||
nonExportBackgroundTasks.filter(task => (
|
||||
task.cancelable &&
|
||||
(task.status === 'running' || task.status === 'cancel_requested')
|
||||
(
|
||||
task.status === 'running' ||
|
||||
task.status === 'pause_requested' ||
|
||||
task.status === 'paused' ||
|
||||
task.status === 'cancel_requested'
|
||||
)
|
||||
)).length
|
||||
), [nonExportBackgroundTasks])
|
||||
const nonExportBackgroundTasksUpdatedAt = useMemo(() => (
|
||||
@@ -8152,12 +8435,16 @@ function ExportPage() {
|
||||
<TaskCenterModal
|
||||
isOpen={isTaskCenterOpen}
|
||||
tasks={tasks}
|
||||
chatBackgroundTasks={chatBackgroundTasks}
|
||||
taskRunningCount={taskRunningCount}
|
||||
taskQueuedCount={taskQueuedCount}
|
||||
expandedPerfTaskId={expandedPerfTaskId}
|
||||
nowTick={nowTick}
|
||||
onClose={closeTaskCenter}
|
||||
onTogglePerfTask={toggleTaskPerfDetail}
|
||||
onPauseBackgroundTask={handlePauseBackgroundTask}
|
||||
onResumeBackgroundTask={handleResumeBackgroundTask}
|
||||
onCancelBackgroundTask={handleCancelBackgroundTask}
|
||||
/>
|
||||
|
||||
{isAutomationModalOpen && createPortal(
|
||||
@@ -8233,6 +8520,7 @@ function ExportPage() {
|
||||
{queueState === 'queued' && <span className="automation-task-status queued">排队中</span>}
|
||||
</div>
|
||||
<p>{formatAutomationScheduleLabel(task.schedule)}</p>
|
||||
<p>首次触发:{resolveAutomationFirstTriggerSummary(task)}</p>
|
||||
<p>时间范围:{formatAutomationRangeLabel(task.template.dateRangeConfig as any)}</p>
|
||||
<p>会话范围:{task.sessionIds.length} 个</p>
|
||||
<p>导出目录:{task.outputDir || `${exportFolder || '未设置'}(全局)`}</p>
|
||||
@@ -8346,6 +8634,52 @@ function ExportPage() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="automation-form-field">
|
||||
<span>首次触发时间(可选)</span>
|
||||
<label className="automation-inline-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={automationTaskDraft.firstTriggerAtEnabled}
|
||||
onChange={(event) => setAutomationTaskDraft((prev) => prev ? {
|
||||
...prev,
|
||||
firstTriggerAtEnabled: event.target.checked
|
||||
} : prev)}
|
||||
/>
|
||||
指定第一次触发时间
|
||||
</label>
|
||||
{automationTaskDraft.firstTriggerAtEnabled && (
|
||||
<div className="automation-first-trigger-picker">
|
||||
<input
|
||||
type="date"
|
||||
className="automation-stopat-date"
|
||||
value={automationTaskDraft.firstTriggerAtValue ? automationTaskDraft.firstTriggerAtValue.slice(0, 10) : ''}
|
||||
onChange={(event) => {
|
||||
const datePart = normalizeAutomationDatePart(event.target.value)
|
||||
const timePart = normalizeAutomationTimePart(automationTaskDraft.firstTriggerAtValue?.slice(11) || '00:00')
|
||||
setAutomationTaskDraft((prev) => prev ? {
|
||||
...prev,
|
||||
firstTriggerAtValue: datePart ? `${datePart}T${timePart}` : ''
|
||||
} : prev)
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
className="automation-stopat-time"
|
||||
value={automationTaskDraft.firstTriggerAtValue ? normalizeAutomationTimePart(automationTaskDraft.firstTriggerAtValue.slice(11)) : '00:00'}
|
||||
onChange={(event) => {
|
||||
const timePart = normalizeAutomationTimePart(event.target.value)
|
||||
const datePart = normalizeAutomationDatePart(automationTaskDraft.firstTriggerAtValue?.slice(0, 10))
|
||||
|| buildAutomationTodayDatePart()
|
||||
setAutomationTaskDraft((prev) => prev ? {
|
||||
...prev,
|
||||
firstTriggerAtValue: `${datePart}T${timePart}`
|
||||
} : prev)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="automation-form-field">
|
||||
<span>导出时间范围(按触发时间动态计算)</span>
|
||||
<div className="automation-segment-row">
|
||||
@@ -8486,7 +8820,11 @@ function ExportPage() {
|
||||
</label>
|
||||
|
||||
<div className="automation-draft-summary">
|
||||
会话:{automationTaskDraft.sessionIds.length} 个 · 间隔:{automationTaskDraft.intervalDays} 天 {automationTaskDraft.intervalHours} 小时 · 时间:{formatAutomationRangeLabel(automationTaskDraft.dateRangeConfig as any, automationRangeSelection)} · 条件:有新消息才导出
|
||||
会话:{automationTaskDraft.sessionIds.length} 个 · 间隔:{automationTaskDraft.intervalDays} 天 {automationTaskDraft.intervalHours} 小时 · 首次:{
|
||||
automationTaskDraft.firstTriggerAtEnabled
|
||||
? (automationTaskDraft.firstTriggerAtValue ? automationTaskDraft.firstTriggerAtValue.replace('T', ' ') : '未设置')
|
||||
: '默认按创建时间+间隔'
|
||||
} · 时间:{formatAutomationRangeLabel(automationTaskDraft.dateRangeConfig as any, automationRangeSelection)} · 条件:有新消息才导出
|
||||
</div>
|
||||
</div>
|
||||
<div className="automation-editor-actions">
|
||||
@@ -8959,7 +9297,12 @@ function ExportPage() {
|
||||
type="button"
|
||||
className="session-load-detail-task-stop-btn"
|
||||
onClick={() => handleCancelBackgroundTask(task.id)}
|
||||
disabled={!task.cancelable || (task.status !== 'running' && task.status !== 'cancel_requested')}
|
||||
disabled={!task.cancelable || (
|
||||
task.status !== 'running' &&
|
||||
task.status !== 'pause_requested' &&
|
||||
task.status !== 'paused' &&
|
||||
task.status !== 'cancel_requested'
|
||||
)}
|
||||
>
|
||||
停止
|
||||
</button>
|
||||
|
||||
@@ -44,6 +44,7 @@ const INITIAL_IMAGE_PRELOAD_END = 48
|
||||
const INITIAL_IMAGE_RESOLVE_END = 12
|
||||
const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250
|
||||
const TASK_PROGRESS_UPDATE_MAX_STEPS = 100
|
||||
const BATCH_IMAGE_DECRYPT_CONCURRENCY = 8
|
||||
|
||||
const GridList = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function GridList(props, ref) {
|
||||
const { className = '', ...rest } = props
|
||||
@@ -71,6 +72,20 @@ function getRangeTimestampEnd(date: string): number | undefined {
|
||||
return Number.isFinite(n) ? n : undefined
|
||||
}
|
||||
|
||||
function normalizeMediaToken(value?: string): string {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function getSafeImageDatName(item: Pick<MediaStreamItem, 'imageDatName' | 'imageMd5'>): string {
|
||||
const datName = normalizeMediaToken(item.imageDatName)
|
||||
if (!datName) return ''
|
||||
return datName
|
||||
}
|
||||
|
||||
function hasImageLocator(item: Pick<MediaStreamItem, 'imageDatName' | 'imageMd5'>): boolean {
|
||||
return Boolean(normalizeMediaToken(item.imageMd5) || getSafeImageDatName(item))
|
||||
}
|
||||
|
||||
function getItemKey(item: MediaStreamItem): string {
|
||||
const sessionId = String(item.sessionId || '').trim().toLowerCase()
|
||||
const localId = Number(item.localId || 0)
|
||||
@@ -84,7 +99,7 @@ function getItemKey(item: MediaStreamItem): string {
|
||||
const mediaId = String(
|
||||
item.mediaType === 'video'
|
||||
? (item.videoMd5 || '')
|
||||
: (item.imageMd5 || item.imageDatName || '')
|
||||
: (item.imageMd5 || getSafeImageDatName(item) || '')
|
||||
).trim().toLowerCase()
|
||||
return `${sessionId}|${createTime}|${localType}|${serverId}|${mediaId}`
|
||||
}
|
||||
@@ -658,19 +673,20 @@ function ResourcesPage() {
|
||||
const to = Math.min(displayItems.length - 1, end)
|
||||
if (to < from) return
|
||||
const now = Date.now()
|
||||
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = []
|
||||
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }> = []
|
||||
const itemKeys: string[] = []
|
||||
for (let i = from; i <= to; i += 1) {
|
||||
const item = displayItems[i]
|
||||
if (!item || item.mediaType !== 'image') continue
|
||||
const itemKey = getItemKey(item)
|
||||
if (previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey]) continue
|
||||
if (!item.imageMd5 && !item.imageDatName) continue
|
||||
if (!hasImageLocator(item)) continue
|
||||
if ((imageCacheMissUntilRef.current[itemKey] || 0) > now) continue
|
||||
payloads.push({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined
|
||||
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
|
||||
imageDatName: getSafeImageDatName(item) || undefined,
|
||||
createTime: Number(item.createTime || 0) || undefined
|
||||
})
|
||||
itemKeys.push(itemKey)
|
||||
if (payloads.length >= MAX_IMAGE_CACHE_RESOLVE_PER_TICK) break
|
||||
@@ -686,7 +702,10 @@ function ResourcesPage() {
|
||||
try {
|
||||
const result = await window.electronAPI.image.resolveCacheBatch(payloads, {
|
||||
disableUpdateCheck: true,
|
||||
allowCacheIndex: false
|
||||
allowCacheIndex: true,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
const rows = Array.isArray(result?.rows) ? result.rows : []
|
||||
const pathPatch: Record<string, string> = {}
|
||||
@@ -733,30 +752,31 @@ function ResourcesPage() {
|
||||
if (to < from) return
|
||||
|
||||
const now = Date.now()
|
||||
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = []
|
||||
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }> = []
|
||||
const dedup = new Set<string>()
|
||||
for (let i = from; i <= to; i += 1) {
|
||||
const item = displayItems[i]
|
||||
if (!item || item.mediaType !== 'image') continue
|
||||
const itemKey = getItemKey(item)
|
||||
if (previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey]) continue
|
||||
if (!item.imageMd5 && !item.imageDatName) continue
|
||||
if (!hasImageLocator(item)) continue
|
||||
if ((imagePreloadUntilRef.current[itemKey] || 0) > now) continue
|
||||
const dedupKey = `${item.sessionId || ''}|${item.imageMd5 || ''}|${item.imageDatName || ''}`
|
||||
const dedupKey = `${item.sessionId || ''}|${normalizeMediaToken(item.imageMd5)}|${getSafeImageDatName(item)}`
|
||||
if (dedup.has(dedupKey)) continue
|
||||
dedup.add(dedupKey)
|
||||
imagePreloadUntilRef.current[itemKey] = now + 12000
|
||||
payloads.push({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined
|
||||
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
|
||||
imageDatName: getSafeImageDatName(item) || undefined,
|
||||
createTime: Number(item.createTime || 0) || undefined
|
||||
})
|
||||
if (payloads.length >= MAX_IMAGE_CACHE_PRELOAD_PER_TICK) break
|
||||
}
|
||||
if (payloads.length === 0) return
|
||||
void window.electronAPI.image.preload(payloads, {
|
||||
allowDecrypt: false,
|
||||
allowCacheIndex: false
|
||||
allowCacheIndex: true
|
||||
})
|
||||
}, [displayItems])
|
||||
|
||||
@@ -954,11 +974,14 @@ function ResourcesPage() {
|
||||
}, '批量删除确认')
|
||||
}, [batchBusy, selectedItems, showAlert, showConfirm])
|
||||
|
||||
const decryptImage = useCallback(async (item: MediaStreamItem): Promise<string | undefined> => {
|
||||
const decryptImage = useCallback(async (
|
||||
item: MediaStreamItem,
|
||||
options?: { allowCacheIndex?: boolean }
|
||||
): Promise<string | undefined> => {
|
||||
if (item.mediaType !== 'image') return
|
||||
|
||||
const key = getItemKey(item)
|
||||
if (!item.imageMd5 && !item.imageDatName) {
|
||||
if (!hasImageLocator(item)) {
|
||||
showAlert('当前图片缺少解密所需字段(imageMd5/imageDatName)', '无法解密')
|
||||
return
|
||||
}
|
||||
@@ -972,12 +995,21 @@ function ResourcesPage() {
|
||||
try {
|
||||
const result = await window.electronAPI.image.decrypt({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined,
|
||||
force: true
|
||||
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
|
||||
imageDatName: getSafeImageDatName(item) || undefined,
|
||||
createTime: Number(item.createTime || 0) || undefined,
|
||||
force: true,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
allowCacheIndex: options?.allowCacheIndex ?? true,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (!result?.success) {
|
||||
showAlert(`解密失败:${result?.error || '未知错误'}`, '解密失败')
|
||||
if (result?.failureKind === 'decrypt_failed') {
|
||||
showAlert(`解密失败:${result?.error || '解密后不是有效图片'}`, '解密失败')
|
||||
} else {
|
||||
showAlert(`本地无数据:${result?.error || '未找到原始 DAT 文件'}`, '未找到本地数据')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -991,8 +1023,13 @@ function ResourcesPage() {
|
||||
try {
|
||||
const resolved = await window.electronAPI.image.resolveCache({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined
|
||||
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
|
||||
imageDatName: getSafeImageDatName(item) || undefined,
|
||||
createTime: Number(item.createTime || 0) || undefined,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
allowCacheIndex: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (resolved?.success && resolved.localPath) {
|
||||
const localPath = resolved.localPath
|
||||
@@ -1007,7 +1044,7 @@ function ResourcesPage() {
|
||||
setActionMessage('图片解密完成')
|
||||
return undefined
|
||||
} catch (e) {
|
||||
showAlert(`解密失败:${String(e)}`, '解密失败')
|
||||
showAlert(`本地无数据:${String(e)}`, '未找到本地数据')
|
||||
return undefined
|
||||
} finally {
|
||||
setDecryptingKeys((prev) => {
|
||||
@@ -1027,8 +1064,13 @@ function ResourcesPage() {
|
||||
try {
|
||||
const resolved = await window.electronAPI.image.resolveCache({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined
|
||||
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
|
||||
imageDatName: getSafeImageDatName(item) || undefined,
|
||||
createTime: Number(item.createTime || 0) || undefined,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
allowCacheIndex: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (resolved?.success && resolved.localPath) {
|
||||
localPath = resolved.localPath
|
||||
@@ -1046,8 +1088,13 @@ function ResourcesPage() {
|
||||
try {
|
||||
const resolved = await window.electronAPI.image.resolveCache({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined
|
||||
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
|
||||
imageDatName: getSafeImageDatName(item) || undefined,
|
||||
createTime: Number(item.createTime || 0) || undefined,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
allowCacheIndex: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (resolved?.success && resolved.localPath) {
|
||||
localPath = resolved.localPath
|
||||
@@ -1077,7 +1124,8 @@ function ResourcesPage() {
|
||||
|
||||
setBatchBusy(true)
|
||||
let success = 0
|
||||
let failed = 0
|
||||
let notFound = 0
|
||||
let decryptFailed = 0
|
||||
const previewPatch: Record<string, string> = {}
|
||||
const updatePatch: Record<string, boolean> = {}
|
||||
const taskId = registerBackgroundTask({
|
||||
@@ -1105,32 +1153,71 @@ function ResourcesPage() {
|
||||
lastProgressBucket = bucket
|
||||
lastProgressUpdateAt = now
|
||||
}
|
||||
const hardlinkMd5Set = new Set<string>()
|
||||
for (const item of imageItems) {
|
||||
if (!item.imageMd5 && !item.imageDatName) {
|
||||
failed += 1
|
||||
completed += 1
|
||||
updateTaskProgress()
|
||||
if (!hasImageLocator(item)) continue
|
||||
const imageMd5 = normalizeMediaToken(item.imageMd5)
|
||||
if (imageMd5) {
|
||||
hardlinkMd5Set.add(imageMd5)
|
||||
continue
|
||||
}
|
||||
const result = await window.electronAPI.image.decrypt({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined,
|
||||
force: true
|
||||
})
|
||||
if (!result?.success) {
|
||||
failed += 1
|
||||
} else {
|
||||
success += 1
|
||||
if (result.localPath) {
|
||||
const key = getItemKey(item)
|
||||
previewPatch[key] = result.localPath
|
||||
updatePatch[key] = isLikelyThumbnailPreview(result.localPath)
|
||||
const imageDatName = getSafeImageDatName(item)
|
||||
if (/^[a-f0-9]{32}$/i.test(imageDatName)) {
|
||||
hardlinkMd5Set.add(imageDatName)
|
||||
}
|
||||
}
|
||||
if (hardlinkMd5Set.size > 0) {
|
||||
try {
|
||||
await window.electronAPI.image.preloadHardlinkMd5s(Array.from(hardlinkMd5Set))
|
||||
} catch {
|
||||
// ignore preload failures and continue decrypt
|
||||
}
|
||||
}
|
||||
|
||||
const concurrency = Math.max(1, Math.min(BATCH_IMAGE_DECRYPT_CONCURRENCY, imageItems.length))
|
||||
let cursor = 0
|
||||
const worker = async () => {
|
||||
while (true) {
|
||||
const index = cursor
|
||||
cursor += 1
|
||||
if (index >= imageItems.length) return
|
||||
const item = imageItems[index]
|
||||
try {
|
||||
if (!hasImageLocator(item)) {
|
||||
notFound += 1
|
||||
continue
|
||||
}
|
||||
const result = await window.electronAPI.image.decrypt({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
|
||||
imageDatName: getSafeImageDatName(item) || undefined,
|
||||
createTime: Number(item.createTime || 0) || undefined,
|
||||
force: true,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
allowCacheIndex: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (!result?.success) {
|
||||
if (result?.failureKind === 'decrypt_failed') decryptFailed += 1
|
||||
else notFound += 1
|
||||
} else {
|
||||
success += 1
|
||||
if (result.localPath) {
|
||||
const key = getItemKey(item)
|
||||
previewPatch[key] = result.localPath
|
||||
updatePatch[key] = isLikelyThumbnailPreview(result.localPath)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
notFound += 1
|
||||
} finally {
|
||||
completed += 1
|
||||
updateTaskProgress()
|
||||
}
|
||||
}
|
||||
completed += 1
|
||||
updateTaskProgress()
|
||||
}
|
||||
await Promise.all(Array.from({ length: concurrency }, () => worker()))
|
||||
updateTaskProgress(true)
|
||||
|
||||
if (Object.keys(previewPatch).length > 0) {
|
||||
@@ -1139,11 +1226,11 @@ function ResourcesPage() {
|
||||
if (Object.keys(updatePatch).length > 0) {
|
||||
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
|
||||
}
|
||||
setActionMessage(`批量解密完成:成功 ${success},失败 ${failed}`)
|
||||
showAlert(`批量解密完成:成功 ${success},失败 ${failed}`, '批量解密完成')
|
||||
finishBackgroundTask(taskId, success > 0 || failed === 0 ? 'completed' : 'failed', {
|
||||
detail: `资源页图片批量解密完成:成功 ${success},失败 ${failed}`,
|
||||
progressText: `成功 ${success} / 失败 ${failed}`
|
||||
setActionMessage(`批量解密完成:成功 ${success},未找到 ${notFound},解密失败 ${decryptFailed}`)
|
||||
showAlert(`批量解密完成:成功 ${success},未找到 ${notFound},解密失败 ${decryptFailed}`, '批量解密完成')
|
||||
finishBackgroundTask(taskId, decryptFailed > 0 ? 'failed' : 'completed', {
|
||||
detail: `资源页图片批量解密完成:成功 ${success},未找到 ${notFound},解密失败 ${decryptFailed}`,
|
||||
progressText: `成功 ${success} / 未找到 ${notFound} / 解密失败 ${decryptFailed}`
|
||||
})
|
||||
} catch (e) {
|
||||
finishBackgroundTask(taskId, 'failed', {
|
||||
|
||||
@@ -56,43 +56,28 @@ const normalizeDbKeyStatusMessage = (message: string): string => {
|
||||
return message
|
||||
}
|
||||
|
||||
const isDbKeyReadyMessage = (message: string): boolean => (
|
||||
message.includes('现在可以登录')
|
||||
|| message.includes('Hook安装成功')
|
||||
|| message.includes('已准备就绪,现在登录微信或退出登录后重新登录微信')
|
||||
)
|
||||
const isDbKeyReadyMessage = (message: string): boolean => {
|
||||
if (isWindows) {
|
||||
return message.includes('现在可以登录')
|
||||
|| message.includes('Hook安装成功')
|
||||
|| message.includes('已准备就绪,现在登录微信或退出登录后重新登录微信')
|
||||
}
|
||||
return message.includes('现在可以登录')
|
||||
}
|
||||
|
||||
const pickWxidByAnchorTime = (
|
||||
wxids: Array<{ wxid: string; modifiedTime: number }>,
|
||||
anchorTime?: number
|
||||
const pickLatestWxid = (
|
||||
wxids: Array<{ wxid: string; modifiedTime: number }>
|
||||
): string => {
|
||||
if (!Array.isArray(wxids) || wxids.length === 0) return ''
|
||||
const fallbackWxid = wxids[0]?.wxid || ''
|
||||
if (!anchorTime || !Number.isFinite(anchorTime)) return fallbackWxid
|
||||
|
||||
const valid = wxids.filter(item => Number.isFinite(item.modifiedTime) && item.modifiedTime > 0)
|
||||
if (valid.length === 0) return fallbackWxid
|
||||
|
||||
const anchor = Number(anchorTime)
|
||||
const nearWindowMs = 10 * 60 * 1000
|
||||
|
||||
const near = valid
|
||||
.filter(item => Math.abs(item.modifiedTime - anchor) <= nearWindowMs)
|
||||
.sort((a, b) => {
|
||||
const diffGap = Math.abs(a.modifiedTime - anchor) - Math.abs(b.modifiedTime - anchor)
|
||||
if (diffGap !== 0) return diffGap
|
||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||
return a.wxid.localeCompare(b.wxid)
|
||||
})
|
||||
if (near.length > 0) return near[0].wxid
|
||||
|
||||
const closest = valid.sort((a, b) => {
|
||||
const diffGap = Math.abs(a.modifiedTime - anchor) - Math.abs(b.modifiedTime - anchor)
|
||||
if (diffGap !== 0) return diffGap
|
||||
const latest = [...valid].sort((a, b) => {
|
||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||
return a.wxid.localeCompare(b.wxid)
|
||||
})
|
||||
return closest[0]?.wxid || fallbackWxid
|
||||
return latest[0]?.wxid || fallbackWxid
|
||||
}
|
||||
|
||||
function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
@@ -434,7 +419,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleScanWxid = async (silent = false, anchorTime?: number) => {
|
||||
const handleScanWxid = async (silent = false) => {
|
||||
if (!dbPath) {
|
||||
if (!silent) setError('请先选择数据库目录')
|
||||
return
|
||||
@@ -446,9 +431,8 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
|
||||
setWxidOptions(wxids)
|
||||
if (wxids.length > 0) {
|
||||
// 密钥成功后使用成功时刻作为锚点,自动选择最接近该时刻的活跃账号;
|
||||
// 其余场景保持“时间最新”优先。
|
||||
const selectedWxid = pickWxidByAnchorTime(wxids, anchorTime)
|
||||
// 自动获取密钥后,始终优先选择最近活跃(modifiedTime 最新)的账号。
|
||||
const selectedWxid = pickLatestWxid(wxids)
|
||||
setWxid(selectedWxid || wxids[0].wxid)
|
||||
if (!silent) setError('')
|
||||
} else {
|
||||
@@ -501,8 +485,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
setHasReacquiredDbKey(true)
|
||||
setDbKeyStatus('密钥获取成功')
|
||||
setError('')
|
||||
const keySuccessAt = Date.now()
|
||||
await handleScanWxid(true, keySuccessAt)
|
||||
await handleScanWxid(true)
|
||||
} else {
|
||||
if (isAddAccountMode) {
|
||||
setHasReacquiredDbKey(false)
|
||||
|
||||
@@ -9,10 +9,12 @@ type BackgroundTaskListener = (tasks: BackgroundTaskRecord[]) => void
|
||||
|
||||
const tasks = new Map<string, BackgroundTaskRecord>()
|
||||
const cancelHandlers = new Map<string, () => void | Promise<void>>()
|
||||
const pauseHandlers = new Map<string, () => void | Promise<void>>()
|
||||
const resumeHandlers = new Map<string, () => void | Promise<void>>()
|
||||
const listeners = new Set<BackgroundTaskListener>()
|
||||
let taskSequence = 0
|
||||
|
||||
const ACTIVE_STATUSES = new Set<BackgroundTaskStatus>(['running', 'cancel_requested'])
|
||||
const ACTIVE_STATUSES = new Set<BackgroundTaskStatus>(['running', 'pause_requested', 'paused', 'cancel_requested'])
|
||||
const MAX_SETTLED_TASKS = 24
|
||||
|
||||
const buildTaskId = (): string => {
|
||||
@@ -34,6 +36,9 @@ const pruneSettledTasks = () => {
|
||||
|
||||
for (const staleTask of settledTasks.slice(MAX_SETTLED_TASKS)) {
|
||||
tasks.delete(staleTask.id)
|
||||
cancelHandlers.delete(staleTask.id)
|
||||
pauseHandlers.delete(staleTask.id)
|
||||
resumeHandlers.delete(staleTask.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +69,9 @@ export const registerBackgroundTask = (input: BackgroundTaskInput): string => {
|
||||
detail: input.detail,
|
||||
progressText: input.progressText,
|
||||
cancelable: input.cancelable !== false,
|
||||
resumable: input.resumable === true,
|
||||
cancelRequested: false,
|
||||
pauseRequested: false,
|
||||
status: 'running',
|
||||
startedAt: now,
|
||||
updatedAt: now
|
||||
@@ -72,6 +79,12 @@ export const registerBackgroundTask = (input: BackgroundTaskInput): string => {
|
||||
if (input.onCancel) {
|
||||
cancelHandlers.set(taskId, input.onCancel)
|
||||
}
|
||||
if (input.onPause) {
|
||||
pauseHandlers.set(taskId, input.onPause)
|
||||
}
|
||||
if (input.onResume) {
|
||||
resumeHandlers.set(taskId, input.onResume)
|
||||
}
|
||||
pruneSettledTasks()
|
||||
notifyListeners()
|
||||
return taskId
|
||||
@@ -87,6 +100,9 @@ export const updateBackgroundTask = (taskId: string, patch: BackgroundTaskUpdate
|
||||
...patch,
|
||||
status: nextStatus,
|
||||
updatedAt: nextUpdatedAt,
|
||||
pauseRequested: nextStatus === 'paused' || nextStatus === 'pause_requested'
|
||||
? true
|
||||
: (nextStatus === 'running' ? false : existing.pauseRequested),
|
||||
finishedAt: ACTIVE_STATUSES.has(nextStatus) ? undefined : (existing.finishedAt || nextUpdatedAt)
|
||||
})
|
||||
pruneSettledTasks()
|
||||
@@ -107,9 +123,12 @@ export const finishBackgroundTask = (
|
||||
status,
|
||||
updatedAt: now,
|
||||
finishedAt: now,
|
||||
cancelRequested: status === 'canceled' ? true : existing.cancelRequested
|
||||
cancelRequested: status === 'canceled' ? true : existing.cancelRequested,
|
||||
pauseRequested: false
|
||||
})
|
||||
cancelHandlers.delete(taskId)
|
||||
pauseHandlers.delete(taskId)
|
||||
resumeHandlers.delete(taskId)
|
||||
pruneSettledTasks()
|
||||
notifyListeners()
|
||||
}
|
||||
@@ -121,6 +140,7 @@ export const requestCancelBackgroundTask = (taskId: string): boolean => {
|
||||
...existing,
|
||||
status: 'cancel_requested',
|
||||
cancelRequested: true,
|
||||
pauseRequested: false,
|
||||
detail: existing.detail || '停止请求已发出,当前查询完成后会结束后续加载',
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
@@ -132,6 +152,46 @@ export const requestCancelBackgroundTask = (taskId: string): boolean => {
|
||||
return true
|
||||
}
|
||||
|
||||
export const requestPauseBackgroundTask = (taskId: string): boolean => {
|
||||
const existing = tasks.get(taskId)
|
||||
if (!existing || !existing.resumable) return false
|
||||
if (existing.status !== 'running' && existing.status !== 'pause_requested') return false
|
||||
tasks.set(taskId, {
|
||||
...existing,
|
||||
status: 'pause_requested',
|
||||
pauseRequested: true,
|
||||
detail: existing.detail || '中断请求已发出,当前处理完成后会暂停',
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
const pauseHandler = pauseHandlers.get(taskId)
|
||||
if (pauseHandler) {
|
||||
void Promise.resolve(pauseHandler()).catch(() => {})
|
||||
}
|
||||
notifyListeners()
|
||||
return true
|
||||
}
|
||||
|
||||
export const requestResumeBackgroundTask = (taskId: string): boolean => {
|
||||
const existing = tasks.get(taskId)
|
||||
if (!existing || !existing.resumable) return false
|
||||
if (existing.status !== 'paused' && existing.status !== 'pause_requested') return false
|
||||
tasks.set(taskId, {
|
||||
...existing,
|
||||
status: 'running',
|
||||
cancelRequested: false,
|
||||
pauseRequested: false,
|
||||
detail: existing.detail || '任务已继续',
|
||||
updatedAt: Date.now(),
|
||||
finishedAt: undefined
|
||||
})
|
||||
const resumeHandler = resumeHandlers.get(taskId)
|
||||
if (resumeHandler) {
|
||||
void Promise.resolve(resumeHandler()).catch(() => {})
|
||||
}
|
||||
notifyListeners()
|
||||
return true
|
||||
}
|
||||
|
||||
export const requestCancelBackgroundTasks = (predicate: (task: BackgroundTaskRecord) => boolean): number => {
|
||||
let canceledCount = 0
|
||||
for (const task of tasks.values()) {
|
||||
@@ -147,3 +207,8 @@ export const isBackgroundTaskCancelRequested = (taskId: string): boolean => {
|
||||
const task = tasks.get(taskId)
|
||||
return Boolean(task?.cancelRequested)
|
||||
}
|
||||
|
||||
export const isBackgroundTaskPauseRequested = (taskId: string): boolean => {
|
||||
const task = tasks.get(taskId)
|
||||
return Boolean(task?.pauseRequested)
|
||||
}
|
||||
|
||||
@@ -689,11 +689,17 @@ const normalizeAutomationTask = (raw: unknown): ExportAutomationTask | null => {
|
||||
if (scheduleType === 'interval') {
|
||||
const rawDays = Math.max(0, normalizeAutomationNumeric(scheduleObj.intervalDays, 0))
|
||||
const rawHours = Math.max(0, normalizeAutomationNumeric(scheduleObj.intervalHours, 0))
|
||||
const rawFirstTriggerAt = Math.max(0, normalizeAutomationNumeric(scheduleObj.firstTriggerAt, 0))
|
||||
const totalHours = (rawDays * 24) + rawHours
|
||||
if (totalHours <= 0) return null
|
||||
const intervalDays = Math.floor(totalHours / 24)
|
||||
const intervalHours = totalHours % 24
|
||||
schedule = { type: 'interval', intervalDays, intervalHours }
|
||||
schedule = {
|
||||
type: 'interval',
|
||||
intervalDays,
|
||||
intervalHours,
|
||||
firstTriggerAt: rawFirstTriggerAt > 0 ? rawFirstTriggerAt : undefined
|
||||
}
|
||||
}
|
||||
if (!schedule) return null
|
||||
|
||||
|
||||
@@ -4,7 +4,21 @@ import {
|
||||
registerBackgroundTask,
|
||||
updateBackgroundTask
|
||||
} from '../services/backgroundTaskMonitor'
|
||||
import type { BackgroundTaskSourcePage } from '../types/backgroundTask'
|
||||
import type { BackgroundTaskSourcePage, BackgroundTaskStatus } from '../types/backgroundTask'
|
||||
|
||||
interface BatchDecryptTaskControls {
|
||||
cancelable?: boolean
|
||||
resumable?: boolean
|
||||
onCancel?: () => void | Promise<void>
|
||||
onPause?: () => void | Promise<void>
|
||||
onResume?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
interface BatchDecryptFinishOptions {
|
||||
status?: Extract<BackgroundTaskStatus, 'completed' | 'failed' | 'canceled'>
|
||||
detail?: string
|
||||
progressText?: string
|
||||
}
|
||||
|
||||
export interface BatchImageDecryptState {
|
||||
isBatchDecrypting: boolean
|
||||
@@ -16,9 +30,15 @@ export interface BatchImageDecryptState {
|
||||
sessionName: string
|
||||
taskId: string | null
|
||||
|
||||
startDecrypt: (total: number, sessionName: string, sourcePage?: BackgroundTaskSourcePage) => void
|
||||
startDecrypt: (
|
||||
total: number,
|
||||
sessionName: string,
|
||||
sourcePage?: BackgroundTaskSourcePage,
|
||||
controls?: BatchDecryptTaskControls
|
||||
) => void
|
||||
updateProgress: (current: number, total: number) => void
|
||||
finishDecrypt: (success: number, fail: number) => void
|
||||
setTaskStatus: (detail: string, progressText?: string, status?: BackgroundTaskStatus) => void
|
||||
finishDecrypt: (success: number, fail: number, options?: BatchDecryptFinishOptions) => void
|
||||
setShowToast: (show: boolean) => void
|
||||
setShowResultToast: (show: boolean) => void
|
||||
reset: () => void
|
||||
@@ -53,7 +73,7 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, ge
|
||||
sessionName: '',
|
||||
taskId: null,
|
||||
|
||||
startDecrypt: (total, sessionName, sourcePage = 'chat') => {
|
||||
startDecrypt: (total, sessionName, sourcePage = 'chat', controls) => {
|
||||
const previousTaskId = get().taskId
|
||||
if (previousTaskId) {
|
||||
taskProgressUpdateMeta.delete(previousTaskId)
|
||||
@@ -73,7 +93,11 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, ge
|
||||
title,
|
||||
detail: `正在解密图片(${normalizedProgress.current}/${normalizedProgress.total})`,
|
||||
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`,
|
||||
cancelable: false
|
||||
cancelable: controls?.cancelable !== false,
|
||||
resumable: controls?.resumable === true,
|
||||
onCancel: controls?.onCancel,
|
||||
onPause: controls?.onPause,
|
||||
onResume: controls?.onResume
|
||||
})
|
||||
taskProgressUpdateMeta.set(taskId, {
|
||||
lastAt: Date.now(),
|
||||
@@ -97,6 +121,7 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, ge
|
||||
const previousProgress = get().progress
|
||||
const normalizedProgress = clampProgress(current, total)
|
||||
const taskId = get().taskId
|
||||
let shouldCommitUi = true
|
||||
if (taskId) {
|
||||
const now = Date.now()
|
||||
const meta = taskProgressUpdateMeta.get(taskId)
|
||||
@@ -105,7 +130,9 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, ge
|
||||
const intervalReached = !meta || (now - meta.lastAt >= TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS)
|
||||
const crossedBucket = !meta || bucket !== meta.lastBucket
|
||||
const isFinal = normalizedProgress.total > 0 && normalizedProgress.current >= normalizedProgress.total
|
||||
if (crossedBucket || intervalReached || isFinal) {
|
||||
const shouldPublish = crossedBucket || intervalReached || isFinal
|
||||
shouldCommitUi = shouldPublish
|
||||
if (shouldPublish) {
|
||||
updateBackgroundTask(taskId, {
|
||||
detail: `正在解密图片(${normalizedProgress.current}/${normalizedProgress.total})`,
|
||||
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`
|
||||
@@ -117,26 +144,38 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, ge
|
||||
})
|
||||
}
|
||||
}
|
||||
if (
|
||||
if (shouldCommitUi && (
|
||||
previousProgress.current !== normalizedProgress.current ||
|
||||
previousProgress.total !== normalizedProgress.total
|
||||
) {
|
||||
)) {
|
||||
set({
|
||||
progress: normalizedProgress
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
finishDecrypt: (success, fail) => {
|
||||
setTaskStatus: (detail, progressText, status) => {
|
||||
const taskId = get().taskId
|
||||
if (!taskId) return
|
||||
const normalizedDetail = String(detail || '').trim()
|
||||
if (!normalizedDetail) return
|
||||
updateBackgroundTask(taskId, {
|
||||
detail: normalizedDetail,
|
||||
progressText,
|
||||
status
|
||||
})
|
||||
},
|
||||
|
||||
finishDecrypt: (success, fail, options) => {
|
||||
const taskId = get().taskId
|
||||
const normalizedSuccess = Number.isFinite(success) ? Math.max(0, Math.floor(success)) : 0
|
||||
const normalizedFail = Number.isFinite(fail) ? Math.max(0, Math.floor(fail)) : 0
|
||||
if (taskId) {
|
||||
taskProgressUpdateMeta.delete(taskId)
|
||||
const status = normalizedSuccess > 0 || normalizedFail === 0 ? 'completed' : 'failed'
|
||||
const status = options?.status || (normalizedSuccess > 0 || normalizedFail === 0 ? 'completed' : 'failed')
|
||||
finishBackgroundTask(taskId, status, {
|
||||
detail: `图片批量解密完成:成功 ${normalizedSuccess},失败 ${normalizedFail}`,
|
||||
progressText: `成功 ${normalizedSuccess} / 失败 ${normalizedFail}`
|
||||
detail: options?.detail || `图片批量解密完成:成功 ${normalizedSuccess},失败 ${normalizedFail}`,
|
||||
progressText: options?.progressText || `成功 ${normalizedSuccess} / 失败 ${normalizedFail}`
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
import { create } from 'zustand'
|
||||
import {
|
||||
finishBackgroundTask,
|
||||
registerBackgroundTask,
|
||||
updateBackgroundTask
|
||||
} from '../services/backgroundTaskMonitor'
|
||||
import type { BackgroundTaskSourcePage, BackgroundTaskStatus } from '../types/backgroundTask'
|
||||
|
||||
export type BatchVoiceTaskType = 'transcribe' | 'decrypt'
|
||||
|
||||
interface BatchVoiceTaskControls {
|
||||
cancelable?: boolean
|
||||
resumable?: boolean
|
||||
onCancel?: () => void | Promise<void>
|
||||
onPause?: () => void | Promise<void>
|
||||
onResume?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
interface BatchVoiceTaskFinishOptions {
|
||||
status?: Extract<BackgroundTaskStatus, 'completed' | 'failed' | 'canceled'>
|
||||
detail?: string
|
||||
progressText?: string
|
||||
}
|
||||
|
||||
export interface BatchTranscribeState {
|
||||
/** 是否正在批量转写 */
|
||||
isBatchTranscribing: boolean
|
||||
@@ -18,17 +38,44 @@ export interface BatchTranscribeState {
|
||||
/** 当前转写的会话名 */
|
||||
startTime: number
|
||||
sessionName: string
|
||||
taskId: string | null
|
||||
|
||||
// Actions
|
||||
startTranscribe: (total: number, sessionName: string, taskType?: BatchVoiceTaskType) => void
|
||||
startTranscribe: (
|
||||
total: number,
|
||||
sessionName: string,
|
||||
taskType?: BatchVoiceTaskType,
|
||||
sourcePage?: BackgroundTaskSourcePage,
|
||||
controls?: BatchVoiceTaskControls
|
||||
) => void
|
||||
updateProgress: (current: number, total: number) => void
|
||||
finishTranscribe: (success: number, fail: number) => void
|
||||
setTaskStatus: (detail: string, progressText?: string, status?: BackgroundTaskStatus) => void
|
||||
finishTranscribe: (success: number, fail: number, options?: BatchVoiceTaskFinishOptions) => void
|
||||
setShowToast: (show: boolean) => void
|
||||
setShowResult: (show: boolean) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({
|
||||
const clampProgress = (current: number, total: number): { current: number; total: number } => {
|
||||
const normalizedTotal = Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0
|
||||
const normalizedCurrentRaw = Number.isFinite(current) ? Math.max(0, Math.floor(current)) : 0
|
||||
const normalizedCurrent = normalizedTotal > 0
|
||||
? Math.min(normalizedCurrentRaw, normalizedTotal)
|
||||
: normalizedCurrentRaw
|
||||
return { current: normalizedCurrent, total: normalizedTotal }
|
||||
}
|
||||
|
||||
const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250
|
||||
const TASK_PROGRESS_UPDATE_MAX_STEPS = 100
|
||||
|
||||
const taskProgressUpdateMeta = new Map<string, { lastAt: number; lastBucket: number; step: number }>()
|
||||
|
||||
const calcProgressStep = (total: number): number => {
|
||||
if (total <= 0) return 1
|
||||
return Math.max(1, Math.floor(total / TASK_PROGRESS_UPDATE_MAX_STEPS))
|
||||
}
|
||||
|
||||
export const useBatchTranscribeStore = create<BatchTranscribeState>((set, get) => ({
|
||||
isBatchTranscribing: false,
|
||||
taskType: 'transcribe',
|
||||
progress: { current: 0, total: 0 },
|
||||
@@ -37,41 +84,151 @@ export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({
|
||||
result: { success: 0, fail: 0 },
|
||||
sessionName: '',
|
||||
startTime: 0,
|
||||
taskId: null,
|
||||
|
||||
startTranscribe: (total, sessionName, taskType = 'transcribe') => set({
|
||||
isBatchTranscribing: true,
|
||||
taskType,
|
||||
showToast: true,
|
||||
progress: { current: 0, total },
|
||||
showResult: false,
|
||||
result: { success: 0, fail: 0 },
|
||||
sessionName,
|
||||
startTime: Date.now()
|
||||
}),
|
||||
startTranscribe: (total, sessionName, taskType = 'transcribe', sourcePage = 'chat', controls) => {
|
||||
const previousTaskId = get().taskId
|
||||
if (previousTaskId) {
|
||||
taskProgressUpdateMeta.delete(previousTaskId)
|
||||
finishBackgroundTask(previousTaskId, 'canceled', {
|
||||
detail: '已被新的语音批量任务替换',
|
||||
progressText: '已替换'
|
||||
})
|
||||
}
|
||||
|
||||
updateProgress: (current, total) => set({
|
||||
progress: { current, total }
|
||||
}),
|
||||
const normalizedProgress = clampProgress(0, total)
|
||||
const normalizedSessionName = String(sessionName || '').trim()
|
||||
const taskLabel = taskType === 'decrypt' ? '语音批量解密' : '语音批量转写'
|
||||
const title = normalizedSessionName
|
||||
? `${taskLabel}(${normalizedSessionName})`
|
||||
: taskLabel
|
||||
const taskId = registerBackgroundTask({
|
||||
sourcePage,
|
||||
title,
|
||||
detail: `正在准备${taskType === 'decrypt' ? '语音解密' : '语音转写'}任务...`,
|
||||
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`,
|
||||
cancelable: controls?.cancelable !== false,
|
||||
resumable: controls?.resumable === true,
|
||||
onCancel: controls?.onCancel,
|
||||
onPause: controls?.onPause,
|
||||
onResume: controls?.onResume
|
||||
})
|
||||
taskProgressUpdateMeta.set(taskId, {
|
||||
lastAt: Date.now(),
|
||||
lastBucket: 0,
|
||||
step: calcProgressStep(normalizedProgress.total)
|
||||
})
|
||||
|
||||
finishTranscribe: (success, fail) => set({
|
||||
isBatchTranscribing: false,
|
||||
showToast: false,
|
||||
showResult: true,
|
||||
result: { success, fail },
|
||||
startTime: 0
|
||||
}),
|
||||
set({
|
||||
isBatchTranscribing: true,
|
||||
taskType,
|
||||
showToast: false,
|
||||
progress: normalizedProgress,
|
||||
showResult: false,
|
||||
result: { success: 0, fail: 0 },
|
||||
sessionName: normalizedSessionName,
|
||||
startTime: Date.now(),
|
||||
taskId
|
||||
})
|
||||
},
|
||||
|
||||
updateProgress: (current, total) => {
|
||||
const previousProgress = get().progress
|
||||
const normalizedProgress = clampProgress(current, total)
|
||||
const taskId = get().taskId
|
||||
let shouldCommitUi = true
|
||||
if (taskId) {
|
||||
const now = Date.now()
|
||||
const meta = taskProgressUpdateMeta.get(taskId)
|
||||
const step = meta?.step || calcProgressStep(normalizedProgress.total)
|
||||
const bucket = Math.floor(normalizedProgress.current / step)
|
||||
const intervalReached = !meta || (now - meta.lastAt >= TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS)
|
||||
const crossedBucket = !meta || bucket !== meta.lastBucket
|
||||
const isFinal = normalizedProgress.total > 0 && normalizedProgress.current >= normalizedProgress.total
|
||||
const shouldPublish = crossedBucket || intervalReached || isFinal
|
||||
shouldCommitUi = shouldPublish
|
||||
if (shouldPublish) {
|
||||
const taskVerb = get().taskType === 'decrypt' ? '解密语音' : '转写语音'
|
||||
updateBackgroundTask(taskId, {
|
||||
detail: `正在${taskVerb}(${normalizedProgress.current}/${normalizedProgress.total})`,
|
||||
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`
|
||||
})
|
||||
taskProgressUpdateMeta.set(taskId, {
|
||||
lastAt: now,
|
||||
lastBucket: bucket,
|
||||
step
|
||||
})
|
||||
}
|
||||
}
|
||||
if (shouldCommitUi && (
|
||||
previousProgress.current !== normalizedProgress.current ||
|
||||
previousProgress.total !== normalizedProgress.total
|
||||
)) {
|
||||
set({
|
||||
progress: normalizedProgress
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
setTaskStatus: (detail, progressText, status) => {
|
||||
const taskId = get().taskId
|
||||
if (!taskId) return
|
||||
const normalizedDetail = String(detail || '').trim()
|
||||
if (!normalizedDetail) return
|
||||
updateBackgroundTask(taskId, {
|
||||
detail: normalizedDetail,
|
||||
progressText,
|
||||
status
|
||||
})
|
||||
},
|
||||
|
||||
finishTranscribe: (success, fail, options) => {
|
||||
const taskId = get().taskId
|
||||
const normalizedSuccess = Number.isFinite(success) ? Math.max(0, Math.floor(success)) : 0
|
||||
const normalizedFail = Number.isFinite(fail) ? Math.max(0, Math.floor(fail)) : 0
|
||||
const taskType = get().taskType
|
||||
if (taskId) {
|
||||
taskProgressUpdateMeta.delete(taskId)
|
||||
const status = options?.status || (normalizedSuccess > 0 || normalizedFail === 0 ? 'completed' : 'failed')
|
||||
const taskLabel = taskType === 'decrypt' ? '语音批量解密' : '语音批量转写'
|
||||
finishBackgroundTask(taskId, status, {
|
||||
detail: options?.detail || `${taskLabel}完成:成功 ${normalizedSuccess},失败 ${normalizedFail}`,
|
||||
progressText: options?.progressText || `成功 ${normalizedSuccess} / 失败 ${normalizedFail}`
|
||||
})
|
||||
}
|
||||
|
||||
set({
|
||||
isBatchTranscribing: false,
|
||||
showToast: false,
|
||||
showResult: false,
|
||||
result: { success: normalizedSuccess, fail: normalizedFail },
|
||||
startTime: 0,
|
||||
taskId: null
|
||||
})
|
||||
},
|
||||
|
||||
setShowToast: (show) => set({ showToast: show }),
|
||||
setShowResult: (show) => set({ showResult: show }),
|
||||
|
||||
reset: () => set({
|
||||
isBatchTranscribing: false,
|
||||
taskType: 'transcribe',
|
||||
progress: { current: 0, total: 0 },
|
||||
showToast: false,
|
||||
showResult: false,
|
||||
result: { success: 0, fail: 0 },
|
||||
sessionName: '',
|
||||
startTime: 0
|
||||
})
|
||||
reset: () => {
|
||||
const taskId = get().taskId
|
||||
if (taskId) {
|
||||
taskProgressUpdateMeta.delete(taskId)
|
||||
finishBackgroundTask(taskId, 'canceled', {
|
||||
detail: '语音批量任务已重置',
|
||||
progressText: '已停止'
|
||||
})
|
||||
}
|
||||
set({
|
||||
isBatchTranscribing: false,
|
||||
taskType: 'transcribe',
|
||||
progress: { current: 0, total: 0 },
|
||||
showToast: false,
|
||||
showResult: false,
|
||||
result: { success: 0, fail: 0 },
|
||||
sessionName: '',
|
||||
startTime: 0,
|
||||
taskId: null
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -9,6 +9,8 @@ export type BackgroundTaskSourcePage =
|
||||
|
||||
export type BackgroundTaskStatus =
|
||||
| 'running'
|
||||
| 'pause_requested'
|
||||
| 'paused'
|
||||
| 'cancel_requested'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
@@ -21,7 +23,9 @@ export interface BackgroundTaskRecord {
|
||||
detail?: string
|
||||
progressText?: string
|
||||
cancelable: boolean
|
||||
resumable: boolean
|
||||
cancelRequested: boolean
|
||||
pauseRequested: boolean
|
||||
status: BackgroundTaskStatus
|
||||
startedAt: number
|
||||
updatedAt: number
|
||||
@@ -34,7 +38,10 @@ export interface BackgroundTaskInput {
|
||||
detail?: string
|
||||
progressText?: string
|
||||
cancelable?: boolean
|
||||
resumable?: boolean
|
||||
onCancel?: () => void | Promise<void>
|
||||
onPause?: () => void | Promise<void>
|
||||
onResume?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export interface BackgroundTaskUpdate {
|
||||
|
||||
13
src/types/electron.d.ts
vendored
13
src/types/electron.d.ts
vendored
@@ -499,7 +499,10 @@ export interface ElectronAPI {
|
||||
force?: boolean
|
||||
preferFilePath?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
}) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
suppressEvents?: boolean
|
||||
}) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string; failureKind?: 'not_found' | 'decrypt_failed' }>
|
||||
resolveCache: (payload: {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
@@ -509,19 +512,21 @@ export interface ElectronAPI {
|
||||
hardlinkOnly?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
}) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
|
||||
suppressEvents?: boolean
|
||||
}) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string; failureKind?: 'not_found' | 'decrypt_failed' }>
|
||||
resolveCacheBatch: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>,
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean }
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
|
||||
rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string; failureKind?: 'not_found' | 'decrypt_failed' }>
|
||||
error?: string
|
||||
}>
|
||||
preload: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
|
||||
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||
) => Promise<boolean>
|
||||
preloadHardlinkMd5s: (md5List: string[]) => Promise<boolean>
|
||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
|
||||
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
|
||||
onDecryptProgress: (callback: (payload: {
|
||||
|
||||
@@ -8,6 +8,7 @@ export type ExportAutomationSchedule =
|
||||
type: 'interval'
|
||||
intervalDays: number
|
||||
intervalHours: number
|
||||
firstTriggerAt?: number
|
||||
}
|
||||
|
||||
export interface ExportAutomationCondition {
|
||||
|
||||
Reference in New Issue
Block a user