mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
计划优化 P3/5
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
import { parentPort, workerData } from 'worker_threads'
|
import { parentPort, workerData } from 'worker_threads'
|
||||||
import { wcdbService } from './services/wcdbService'
|
import type { ExportOptions } from './services/exportService'
|
||||||
import { exportService, ExportOptions } from './services/exportService'
|
|
||||||
|
|
||||||
interface ExportWorkerConfig {
|
interface ExportWorkerConfig {
|
||||||
sessionIds: string[]
|
sessionIds: string[]
|
||||||
@@ -16,11 +15,21 @@ process.env.WEFLOW_WORKER = '1'
|
|||||||
if (config.resourcesPath) {
|
if (config.resourcesPath) {
|
||||||
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
|
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
|
||||||
}
|
}
|
||||||
|
if (config.userDataPath) {
|
||||||
|
process.env.WEFLOW_USER_DATA_PATH = config.userDataPath
|
||||||
|
process.env.WEFLOW_CONFIG_CWD = config.userDataPath
|
||||||
|
}
|
||||||
|
process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow'
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const [{ wcdbService }, { exportService }] = await Promise.all([
|
||||||
|
import('./services/wcdbService'),
|
||||||
|
import('./services/exportService')
|
||||||
|
])
|
||||||
|
|
||||||
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
|
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
|
||||||
wcdbService.setLogEnabled(config.logEnabled === true)
|
wcdbService.setLogEnabled(config.logEnabled === true)
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const result = await exportService.exportSessions(
|
const result = await exportService.exportSessions(
|
||||||
Array.isArray(config.sessionIds) ? config.sessionIds : [],
|
Array.isArray(config.sessionIds) ? config.sessionIds : [],
|
||||||
String(config.outputDir || ''),
|
String(config.outputDir || ''),
|
||||||
|
|||||||
@@ -84,9 +84,7 @@ export class ConfigService {
|
|||||||
return ConfigService.instance
|
return ConfigService.instance
|
||||||
}
|
}
|
||||||
ConfigService.instance = this
|
ConfigService.instance = this
|
||||||
this.store = new Store<ConfigSchema>({
|
const defaults: ConfigSchema = {
|
||||||
name: 'WeFlow-config',
|
|
||||||
defaults: {
|
|
||||||
dbPath: '',
|
dbPath: '',
|
||||||
decryptKey: '',
|
decryptKey: '',
|
||||||
myWxid: '',
|
myWxid: '',
|
||||||
@@ -122,7 +120,35 @@ export class ConfigService {
|
|||||||
windowCloseBehavior: 'ask',
|
windowCloseBehavior: 'ask',
|
||||||
wordCloudExcludeWords: []
|
wordCloudExcludeWords: []
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
const storeOptions: any = {
|
||||||
|
name: 'WeFlow-config',
|
||||||
|
defaults
|
||||||
|
}
|
||||||
|
const runningInWorker = process.env.WEFLOW_WORKER === '1'
|
||||||
|
if (runningInWorker) {
|
||||||
|
const cwd = String(process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || '').trim()
|
||||||
|
if (cwd) {
|
||||||
|
storeOptions.cwd = cwd
|
||||||
|
}
|
||||||
|
storeOptions.projectName = String(process.env.WEFLOW_PROJECT_NAME || 'WeFlow').trim() || 'WeFlow'
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.store = new Store<ConfigSchema>(storeOptions)
|
||||||
|
} catch (error) {
|
||||||
|
const message = String((error as Error)?.message || error || '')
|
||||||
|
if (message.includes('projectName')) {
|
||||||
|
const fallbackOptions = {
|
||||||
|
...storeOptions,
|
||||||
|
projectName: 'WeFlow',
|
||||||
|
cwd: storeOptions.cwd || process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || process.cwd()
|
||||||
|
}
|
||||||
|
this.store = new Store<ConfigSchema>(fallbackOptions)
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
this.migrateAuthFields()
|
this.migrateAuthFields()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -421,6 +421,34 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isCloneUnsupportedError(code: string | undefined): boolean {
|
||||||
|
return code === 'ENOTSUP' || code === 'ENOSYS' || code === 'EINVAL' || code === 'EXDEV' || code === 'ENOTTY'
|
||||||
|
}
|
||||||
|
|
||||||
|
private async copyFileOptimized(sourcePath: string, destPath: string): Promise<{ success: boolean; code?: string }> {
|
||||||
|
const cloneFlag = typeof fs.constants.COPYFILE_FICLONE === 'number' ? fs.constants.COPYFILE_FICLONE : 0
|
||||||
|
try {
|
||||||
|
if (cloneFlag) {
|
||||||
|
await fs.promises.copyFile(sourcePath, destPath, cloneFlag)
|
||||||
|
} else {
|
||||||
|
await fs.promises.copyFile(sourcePath, destPath)
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
const code = (e as NodeJS.ErrnoException | undefined)?.code
|
||||||
|
if (!this.isCloneUnsupportedError(code)) {
|
||||||
|
return { success: false, code }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.promises.copyFile(sourcePath, destPath)
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, code: (e as NodeJS.ErrnoException | undefined)?.code }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private isMediaExportEnabled(options: ExportOptions): boolean {
|
private isMediaExportEnabled(options: ExportOptions): boolean {
|
||||||
return options.exportMedia === true &&
|
return options.exportMedia === true &&
|
||||||
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
|
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
|
||||||
@@ -2387,14 +2415,18 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 复制文件
|
// 复制文件
|
||||||
if (!(await this.pathExists(sourcePath))) {
|
|
||||||
console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const ext = path.extname(sourcePath) || '.jpg'
|
const ext = path.extname(sourcePath) || '.jpg'
|
||||||
const fileName = `${messageId}_${imageKey}${ext}`
|
const fileName = `${messageId}_${imageKey}${ext}`
|
||||||
const destPath = path.join(imagesDir, fileName)
|
const destPath = path.join(imagesDir, fileName)
|
||||||
await fs.promises.copyFile(sourcePath, destPath)
|
const copied = await this.copyFileOptimized(sourcePath, destPath)
|
||||||
|
if (!copied.success) {
|
||||||
|
if (copied.code === 'ENOENT') {
|
||||||
|
console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`)
|
||||||
|
} else {
|
||||||
|
console.log(`[Export] 复制图片失败 (localId=${msg.localId}): ${sourcePath}, code=${copied.code || 'UNKNOWN'} → 将显示 [图片] 占位符`)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
|
relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
|
||||||
@@ -2598,7 +2630,7 @@ class ExportService {
|
|||||||
// 使用 chatService 下载表情包 (利用其重试和 fallback 逻辑)
|
// 使用 chatService 下载表情包 (利用其重试和 fallback 逻辑)
|
||||||
const localPath = await chatService.downloadEmojiFile(msg)
|
const localPath = await chatService.downloadEmojiFile(msg)
|
||||||
|
|
||||||
if (!localPath || !(await this.pathExists(localPath))) {
|
if (!localPath) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2607,8 +2639,8 @@ class ExportService {
|
|||||||
const key = msg.emojiMd5 || String(msg.localId)
|
const key = msg.emojiMd5 || String(msg.localId)
|
||||||
const fileName = `${key}${ext}`
|
const fileName = `${key}${ext}`
|
||||||
const destPath = path.join(emojisDir, fileName)
|
const destPath = path.join(emojisDir, fileName)
|
||||||
|
const copied = await this.copyFileOptimized(localPath, destPath)
|
||||||
await fs.promises.copyFile(localPath, destPath)
|
if (!copied.success) return null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
|
relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
|
||||||
@@ -2649,7 +2681,8 @@ class ExportService {
|
|||||||
const fileName = path.basename(sourcePath)
|
const fileName = path.basename(sourcePath)
|
||||||
const destPath = path.join(videosDir, fileName)
|
const destPath = path.join(videosDir, fileName)
|
||||||
|
|
||||||
await fs.promises.copyFile(sourcePath, destPath)
|
const copied = await this.copyFileOptimized(sourcePath, destPath)
|
||||||
|
if (!copied.success) return null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
relativePath: path.posix.join(mediaRelativePrefix, 'videos', fileName),
|
relativePath: path.posix.join(mediaRelativePrefix, 'videos', fileName),
|
||||||
|
|||||||
@@ -162,21 +162,22 @@ class VideoService {
|
|||||||
new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean))
|
new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean))
|
||||||
)
|
)
|
||||||
const resolvedMap = new Map<string, string>()
|
const resolvedMap = new Map<string, string>()
|
||||||
let unresolved = [...normalizedList]
|
const unresolvedSet = new Set(normalizedList)
|
||||||
|
|
||||||
for (const md5 of normalizedList) {
|
for (const md5 of normalizedList) {
|
||||||
const cacheKey = `${scopeKey}|${md5}`
|
const cacheKey = `${scopeKey}|${md5}`
|
||||||
const cached = this.readTimedCache(this.hardlinkResolveCache, cacheKey)
|
const cached = this.readTimedCache(this.hardlinkResolveCache, cacheKey)
|
||||||
if (cached === undefined) continue
|
if (cached === undefined) continue
|
||||||
if (cached) resolvedMap.set(md5, cached)
|
if (cached) resolvedMap.set(md5, cached)
|
||||||
unresolved = unresolved.filter((item) => item !== md5)
|
unresolvedSet.delete(md5)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unresolved.length === 0) return resolvedMap
|
if (unresolvedSet.size === 0) return resolvedMap
|
||||||
|
|
||||||
const encryptedDbPaths = this.getHardlinkDbPaths(dbPath, wxid, cleanedWxid)
|
const encryptedDbPaths = this.getHardlinkDbPaths(dbPath, wxid, cleanedWxid)
|
||||||
for (const p of encryptedDbPaths) {
|
for (const p of encryptedDbPaths) {
|
||||||
if (!existsSync(p) || unresolved.length === 0) continue
|
if (!existsSync(p) || unresolvedSet.size === 0) continue
|
||||||
|
const unresolved = Array.from(unresolvedSet)
|
||||||
const requests = unresolved.map((md5) => ({ md5, dbPath: p }))
|
const requests = unresolved.map((md5) => ({ md5, dbPath: p }))
|
||||||
try {
|
try {
|
||||||
const batchResult = await wcdbService.resolveVideoHardlinkMd5Batch(requests)
|
const batchResult = await wcdbService.resolveVideoHardlinkMd5Batch(requests)
|
||||||
@@ -194,6 +195,7 @@ class VideoService {
|
|||||||
const cacheKey = `${scopeKey}|${inputMd5}`
|
const cacheKey = `${scopeKey}|${inputMd5}`
|
||||||
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
||||||
resolvedMap.set(inputMd5, resolvedMd5)
|
resolvedMap.set(inputMd5, resolvedMd5)
|
||||||
|
unresolvedSet.delete(inputMd5)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 兼容不支持批量接口的版本,回退单条请求。
|
// 兼容不支持批量接口的版本,回退单条请求。
|
||||||
@@ -207,17 +209,16 @@ class VideoService {
|
|||||||
const cacheKey = `${scopeKey}|${req.md5}`
|
const cacheKey = `${scopeKey}|${req.md5}`
|
||||||
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
||||||
resolvedMap.set(req.md5, resolvedMd5)
|
resolvedMap.set(req.md5, resolvedMd5)
|
||||||
|
unresolvedSet.delete(req.md5)
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.log('resolveVideoHardlinks 批量查询失败', { path: p, error: String(e) })
|
this.log('resolveVideoHardlinks 批量查询失败', { path: p, error: String(e) })
|
||||||
}
|
}
|
||||||
|
|
||||||
unresolved = unresolved.filter((md5) => !resolvedMap.has(md5))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const md5 of unresolved) {
|
for (const md5 of unresolvedSet) {
|
||||||
const cacheKey = `${scopeKey}|${md5}`
|
const cacheKey = `${scopeKey}|${md5}`
|
||||||
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, null, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, null, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ export class WcdbCore {
|
|||||||
private wcdbGetMessageTableColumns: any = null
|
private wcdbGetMessageTableColumns: any = null
|
||||||
private wcdbGetMessageTableTimeRange: any = null
|
private wcdbGetMessageTableTimeRange: any = null
|
||||||
private wcdbResolveImageHardlink: any = null
|
private wcdbResolveImageHardlink: any = null
|
||||||
|
private wcdbResolveImageHardlinkBatch: any = null
|
||||||
private wcdbResolveVideoHardlinkMd5: any = null
|
private wcdbResolveVideoHardlinkMd5: any = null
|
||||||
|
private wcdbResolveVideoHardlinkMd5Batch: any = null
|
||||||
private wcdbInstallSnsBlockDeleteTrigger: any = null
|
private wcdbInstallSnsBlockDeleteTrigger: any = null
|
||||||
private wcdbUninstallSnsBlockDeleteTrigger: any = null
|
private wcdbUninstallSnsBlockDeleteTrigger: any = null
|
||||||
private wcdbCheckSnsBlockDeleteTrigger: any = null
|
private wcdbCheckSnsBlockDeleteTrigger: any = null
|
||||||
@@ -111,6 +113,7 @@ export class WcdbCore {
|
|||||||
private imageHardlinkCache: Map<string, { result: { success: boolean; data?: any; error?: string }; updatedAt: number }> = new Map()
|
private imageHardlinkCache: Map<string, { result: { success: boolean; data?: any; error?: string }; updatedAt: number }> = new Map()
|
||||||
private videoHardlinkCache: Map<string, { result: { success: boolean; data?: any; error?: string }; updatedAt: number }> = new Map()
|
private videoHardlinkCache: Map<string, { result: { success: boolean; data?: any; error?: string }; updatedAt: number }> = new Map()
|
||||||
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
|
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
|
||||||
|
private readonly hardlinkCacheMaxEntries = 20000
|
||||||
private logTimer: NodeJS.Timeout | null = null
|
private logTimer: NodeJS.Timeout | null = null
|
||||||
private lastLogTail: string | null = null
|
private lastLogTail: string | null = null
|
||||||
private lastResolvedLogPath: string | null = null
|
private lastResolvedLogPath: string | null = null
|
||||||
@@ -962,11 +965,21 @@ export class WcdbCore {
|
|||||||
} catch {
|
} catch {
|
||||||
this.wcdbResolveImageHardlink = null
|
this.wcdbResolveImageHardlink = null
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
this.wcdbResolveImageHardlinkBatch = this.lib.func('int32 wcdb_resolve_image_hardlink_batch(int64 handle, const char* requestsJson, _Out_ void** outJson)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbResolveImageHardlinkBatch = null
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
this.wcdbResolveVideoHardlinkMd5 = this.lib.func('int32 wcdb_resolve_video_hardlink_md5(int64 handle, const char* md5, const char* dbPath, _Out_ void** outJson)')
|
this.wcdbResolveVideoHardlinkMd5 = this.lib.func('int32 wcdb_resolve_video_hardlink_md5(int64 handle, const char* md5, const char* dbPath, _Out_ void** outJson)')
|
||||||
} catch {
|
} catch {
|
||||||
this.wcdbResolveVideoHardlinkMd5 = null
|
this.wcdbResolveVideoHardlinkMd5 = null
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
this.wcdbResolveVideoHardlinkMd5Batch = this.lib.func('int32 wcdb_resolve_video_hardlink_md5_batch(int64 handle, const char* requestsJson, _Out_ void** outJson)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbResolveVideoHardlinkMd5Batch = null
|
||||||
|
}
|
||||||
|
|
||||||
// wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error)
|
// wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error)
|
||||||
try {
|
try {
|
||||||
@@ -1312,6 +1325,20 @@ export class WcdbCore {
|
|||||||
result: this.cloneHardlinkResult(result),
|
result: this.cloneHardlinkResult(result),
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
})
|
})
|
||||||
|
if (cache.size <= this.hardlinkCacheMaxEntries) return
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [cacheKey, entry] of cache) {
|
||||||
|
if (now - entry.updatedAt > this.hardlinkCacheTtlMs) {
|
||||||
|
cache.delete(cacheKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (cache.size > this.hardlinkCacheMaxEntries) {
|
||||||
|
const oldestKey = cache.keys().next().value as string | undefined
|
||||||
|
if (!oldestKey) break
|
||||||
|
cache.delete(oldestKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private cloneHardlinkResult(result: { success: boolean; data?: any; error?: string }): { success: boolean; data?: any; error?: string } {
|
private cloneHardlinkResult(result: { success: boolean; data?: any; error?: string }): { success: boolean; data?: any; error?: string } {
|
||||||
@@ -2853,22 +2880,98 @@ export class WcdbCore {
|
|||||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
if (!Array.isArray(requests)) return { success: false, error: '参数错误: requests 必须是数组' }
|
if (!Array.isArray(requests)) return { success: false, error: '参数错误: requests 必须是数组' }
|
||||||
try {
|
try {
|
||||||
const rows: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }> = []
|
const normalizedRequests = requests.map((req) => ({
|
||||||
for (let i = 0; i < requests.length; i += 1) {
|
md5: String(req?.md5 || '').trim().toLowerCase(),
|
||||||
const req = requests[i] || { md5: '' }
|
accountDir: String(req?.accountDir || '').trim()
|
||||||
const normalizedMd5 = String(req.md5 || '').trim().toLowerCase()
|
}))
|
||||||
if (!normalizedMd5) {
|
const rows: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }> = new Array(normalizedRequests.length)
|
||||||
rows.push({ index: i, md5: '', success: false, error: 'md5 为空' })
|
const unresolved: Array<{ index: number; md5: string; accountDir: string }> = []
|
||||||
|
|
||||||
|
for (let i = 0; i < normalizedRequests.length; i += 1) {
|
||||||
|
const req = normalizedRequests[i]
|
||||||
|
if (!req.md5) {
|
||||||
|
rows[i] = { index: i, md5: '', success: false, error: 'md5 为空' }
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const result = await this.resolveImageHardlink(normalizedMd5, req.accountDir)
|
const cacheKey = this.makeHardlinkCacheKey(req.md5, req.accountDir)
|
||||||
rows.push({
|
const cached = this.readHardlinkCache(this.imageHardlinkCache, cacheKey)
|
||||||
|
if (cached) {
|
||||||
|
rows[i] = {
|
||||||
index: i,
|
index: i,
|
||||||
md5: normalizedMd5,
|
md5: req.md5,
|
||||||
|
success: cached.success === true,
|
||||||
|
data: cached.data,
|
||||||
|
error: cached.error
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
unresolved.push({ index: i, md5: req.md5, accountDir: req.accountDir })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unresolved.length === 0) {
|
||||||
|
return { success: true, rows }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.wcdbResolveImageHardlinkBatch) {
|
||||||
|
try {
|
||||||
|
const outPtr = [null as any]
|
||||||
|
const payload = JSON.stringify(unresolved.map((req) => ({
|
||||||
|
md5: req.md5,
|
||||||
|
account_dir: req.accountDir || undefined
|
||||||
|
})))
|
||||||
|
const result = this.wcdbResolveImageHardlinkBatch(this.handle, payload, outPtr)
|
||||||
|
if (result === 0 && outPtr[0]) {
|
||||||
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
|
if (jsonStr) {
|
||||||
|
const nativeRows = JSON.parse(jsonStr)
|
||||||
|
const mappedRows = Array.isArray(nativeRows) ? nativeRows.map((row: any, index: number) => {
|
||||||
|
const rowIndexRaw = Number(row?.index)
|
||||||
|
const rowIndex = Number.isFinite(rowIndexRaw) ? Math.floor(rowIndexRaw) : index
|
||||||
|
const fallbackReq = rowIndex >= 0 && rowIndex < unresolved.length ? unresolved[rowIndex] : { md5: '', accountDir: '', index: -1 }
|
||||||
|
const rowMd5 = String(row?.md5 || fallbackReq.md5 || '').trim().toLowerCase()
|
||||||
|
const success = row?.success === true || row?.success === 1 || row?.success === '1'
|
||||||
|
const data = row?.data && typeof row.data === 'object' ? row.data : {}
|
||||||
|
const error = row?.error ? String(row.error) : undefined
|
||||||
|
if (success && rowMd5) {
|
||||||
|
const cacheKey = this.makeHardlinkCacheKey(rowMd5, fallbackReq.accountDir)
|
||||||
|
this.writeHardlinkCache(this.imageHardlinkCache, cacheKey, { success: true, data })
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
index: rowIndex,
|
||||||
|
md5: rowMd5,
|
||||||
|
success,
|
||||||
|
data,
|
||||||
|
error
|
||||||
|
}
|
||||||
|
}) : []
|
||||||
|
for (const row of mappedRows) {
|
||||||
|
const fallbackReq = row.index >= 0 && row.index < unresolved.length ? unresolved[row.index] : null
|
||||||
|
if (!fallbackReq) continue
|
||||||
|
rows[fallbackReq.index] = {
|
||||||
|
index: fallbackReq.index,
|
||||||
|
md5: row.md5 || fallbackReq.md5,
|
||||||
|
success: row.success,
|
||||||
|
data: row.data,
|
||||||
|
error: row.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 回退到单条循环实现
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const req of unresolved) {
|
||||||
|
if (rows[req.index]) continue
|
||||||
|
const result = await this.resolveImageHardlink(req.md5, req.accountDir)
|
||||||
|
rows[req.index] = {
|
||||||
|
index: req.index,
|
||||||
|
md5: req.md5,
|
||||||
success: result.success === true,
|
success: result.success === true,
|
||||||
data: result.data,
|
data: result.data,
|
||||||
error: result.error
|
error: result.error
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
return { success: true, rows }
|
return { success: true, rows }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -2882,22 +2985,98 @@ export class WcdbCore {
|
|||||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
if (!Array.isArray(requests)) return { success: false, error: '参数错误: requests 必须是数组' }
|
if (!Array.isArray(requests)) return { success: false, error: '参数错误: requests 必须是数组' }
|
||||||
try {
|
try {
|
||||||
const rows: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }> = []
|
const normalizedRequests = requests.map((req) => ({
|
||||||
for (let i = 0; i < requests.length; i += 1) {
|
md5: String(req?.md5 || '').trim().toLowerCase(),
|
||||||
const req = requests[i] || { md5: '' }
|
dbPath: String(req?.dbPath || '').trim()
|
||||||
const normalizedMd5 = String(req.md5 || '').trim().toLowerCase()
|
}))
|
||||||
if (!normalizedMd5) {
|
const rows: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }> = new Array(normalizedRequests.length)
|
||||||
rows.push({ index: i, md5: '', success: false, error: 'md5 为空' })
|
const unresolved: Array<{ index: number; md5: string; dbPath: string }> = []
|
||||||
|
|
||||||
|
for (let i = 0; i < normalizedRequests.length; i += 1) {
|
||||||
|
const req = normalizedRequests[i]
|
||||||
|
if (!req.md5) {
|
||||||
|
rows[i] = { index: i, md5: '', success: false, error: 'md5 为空' }
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const result = await this.resolveVideoHardlinkMd5(normalizedMd5, req.dbPath)
|
const cacheKey = this.makeHardlinkCacheKey(req.md5, req.dbPath)
|
||||||
rows.push({
|
const cached = this.readHardlinkCache(this.videoHardlinkCache, cacheKey)
|
||||||
|
if (cached) {
|
||||||
|
rows[i] = {
|
||||||
index: i,
|
index: i,
|
||||||
md5: normalizedMd5,
|
md5: req.md5,
|
||||||
|
success: cached.success === true,
|
||||||
|
data: cached.data,
|
||||||
|
error: cached.error
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
unresolved.push({ index: i, md5: req.md5, dbPath: req.dbPath })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unresolved.length === 0) {
|
||||||
|
return { success: true, rows }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.wcdbResolveVideoHardlinkMd5Batch) {
|
||||||
|
try {
|
||||||
|
const outPtr = [null as any]
|
||||||
|
const payload = JSON.stringify(unresolved.map((req) => ({
|
||||||
|
md5: req.md5,
|
||||||
|
db_path: req.dbPath || undefined
|
||||||
|
})))
|
||||||
|
const result = this.wcdbResolveVideoHardlinkMd5Batch(this.handle, payload, outPtr)
|
||||||
|
if (result === 0 && outPtr[0]) {
|
||||||
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
|
if (jsonStr) {
|
||||||
|
const nativeRows = JSON.parse(jsonStr)
|
||||||
|
const mappedRows = Array.isArray(nativeRows) ? nativeRows.map((row: any, index: number) => {
|
||||||
|
const rowIndexRaw = Number(row?.index)
|
||||||
|
const rowIndex = Number.isFinite(rowIndexRaw) ? Math.floor(rowIndexRaw) : index
|
||||||
|
const fallbackReq = rowIndex >= 0 && rowIndex < unresolved.length ? unresolved[rowIndex] : { md5: '', dbPath: '', index: -1 }
|
||||||
|
const rowMd5 = String(row?.md5 || fallbackReq.md5 || '').trim().toLowerCase()
|
||||||
|
const success = row?.success === true || row?.success === 1 || row?.success === '1'
|
||||||
|
const data = row?.data && typeof row.data === 'object' ? row.data : {}
|
||||||
|
const error = row?.error ? String(row.error) : undefined
|
||||||
|
if (success && rowMd5) {
|
||||||
|
const cacheKey = this.makeHardlinkCacheKey(rowMd5, fallbackReq.dbPath)
|
||||||
|
this.writeHardlinkCache(this.videoHardlinkCache, cacheKey, { success: true, data })
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
index: rowIndex,
|
||||||
|
md5: rowMd5,
|
||||||
|
success,
|
||||||
|
data,
|
||||||
|
error
|
||||||
|
}
|
||||||
|
}) : []
|
||||||
|
for (const row of mappedRows) {
|
||||||
|
const fallbackReq = row.index >= 0 && row.index < unresolved.length ? unresolved[row.index] : null
|
||||||
|
if (!fallbackReq) continue
|
||||||
|
rows[fallbackReq.index] = {
|
||||||
|
index: fallbackReq.index,
|
||||||
|
md5: row.md5 || fallbackReq.md5,
|
||||||
|
success: row.success,
|
||||||
|
data: row.data,
|
||||||
|
error: row.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 回退到单条循环实现
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const req of unresolved) {
|
||||||
|
if (rows[req.index]) continue
|
||||||
|
const result = await this.resolveVideoHardlinkMd5(req.md5, req.dbPath)
|
||||||
|
rows[req.index] = {
|
||||||
|
index: req.index,
|
||||||
|
md5: req.md5,
|
||||||
success: result.success === true,
|
success: result.success === true,
|
||||||
data: result.data,
|
data: result.data,
|
||||||
error: result.error
|
error: result.error
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
return { success: true, rows }
|
return { success: true, rows }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
Binary file not shown.
@@ -566,7 +566,8 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
background: var(--chat-pattern);
|
background: var(--chat-pattern);
|
||||||
background-color: var(--bg-secondary);
|
background-color: var(--bg-secondary);
|
||||||
padding: 20px 24px;
|
padding: 20px 24px 112px;
|
||||||
|
padding-bottom: calc(112px + env(safe-area-inset-bottom));
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
@@ -600,7 +601,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-wrapper {
|
.message-wrapper {
|
||||||
margin-bottom: 16px;
|
box-sizing: border-box;
|
||||||
|
padding-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-bubble {
|
.message-bubble {
|
||||||
@@ -1748,7 +1750,8 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 20px 24px;
|
padding: 20px 24px 112px;
|
||||||
|
padding-bottom: calc(112px + env(safe-area-inset-bottom));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@@ -1898,7 +1901,8 @@
|
|||||||
.message-wrapper {
|
.message-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin-bottom: 16px;
|
box-sizing: border-box;
|
||||||
|
padding-bottom: 16px;
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
|
|
||||||
&.sent {
|
&.sent {
|
||||||
|
|||||||
@@ -1021,11 +1021,25 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const sessionWindowCacheRef = useRef<Map<string, SessionWindowCacheEntry>>(new Map())
|
const sessionWindowCacheRef = useRef<Map<string, SessionWindowCacheEntry>>(new Map())
|
||||||
const previewPersistTimerRef = useRef<number | null>(null)
|
const previewPersistTimerRef = useRef<number | null>(null)
|
||||||
const sessionListPersistTimerRef = useRef<number | null>(null)
|
const sessionListPersistTimerRef = useRef<number | null>(null)
|
||||||
|
const scrollBottomButtonArmTimerRef = useRef<number | null>(null)
|
||||||
|
const suppressScrollToBottomButtonRef = useRef(false)
|
||||||
const pendingExportRequestIdRef = useRef<string | null>(null)
|
const pendingExportRequestIdRef = useRef<string | null>(null)
|
||||||
const exportPrepareLongWaitTimerRef = useRef<number | null>(null)
|
const exportPrepareLongWaitTimerRef = useRef<number | null>(null)
|
||||||
const jumpDatesRequestSeqRef = useRef(0)
|
const jumpDatesRequestSeqRef = useRef(0)
|
||||||
const jumpDateCountsRequestSeqRef = useRef(0)
|
const jumpDateCountsRequestSeqRef = useRef(0)
|
||||||
|
|
||||||
|
const suppressScrollToBottomButton = useCallback((delayMs = 180) => {
|
||||||
|
suppressScrollToBottomButtonRef.current = true
|
||||||
|
if (scrollBottomButtonArmTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(scrollBottomButtonArmTimerRef.current)
|
||||||
|
scrollBottomButtonArmTimerRef.current = null
|
||||||
|
}
|
||||||
|
scrollBottomButtonArmTimerRef.current = window.setTimeout(() => {
|
||||||
|
suppressScrollToBottomButtonRef.current = false
|
||||||
|
scrollBottomButtonArmTimerRef.current = null
|
||||||
|
}, delayMs)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const isGroupChatSession = useCallback((username: string) => {
|
const isGroupChatSession = useCallback((username: string) => {
|
||||||
return username.includes('@chatroom')
|
return username.includes('@chatroom')
|
||||||
}, [])
|
}, [])
|
||||||
@@ -2287,6 +2301,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setCurrentSession(null)
|
setCurrentSession(null)
|
||||||
setSessions([])
|
setSessions([])
|
||||||
setMessages([])
|
setMessages([])
|
||||||
|
setShowScrollToBottom(false)
|
||||||
|
suppressScrollToBottomButton(260)
|
||||||
setSearchKeyword('')
|
setSearchKeyword('')
|
||||||
setConnectionError(null)
|
setConnectionError(null)
|
||||||
setConnected(false)
|
setConnected(false)
|
||||||
@@ -2311,6 +2327,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setSessionDetail,
|
setSessionDetail,
|
||||||
setShowDetailPanel,
|
setShowDetailPanel,
|
||||||
setShowGroupMembersPanel,
|
setShowGroupMembersPanel,
|
||||||
|
suppressScrollToBottomButton,
|
||||||
setSessions
|
setSessions
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -2350,7 +2367,9 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
currentSessionRef.current = currentSessionId
|
currentSessionRef.current = currentSessionId
|
||||||
topRangeLoadLockRef.current = false
|
topRangeLoadLockRef.current = false
|
||||||
bottomRangeLoadLockRef.current = false
|
bottomRangeLoadLockRef.current = false
|
||||||
}, [currentSessionId])
|
setShowScrollToBottom(false)
|
||||||
|
suppressScrollToBottomButton(260)
|
||||||
|
}, [currentSessionId, suppressScrollToBottomButton])
|
||||||
|
|
||||||
const hydrateSessionStatuses = useCallback(async (sessionList: ChatSession[]) => {
|
const hydrateSessionStatuses = useCallback(async (sessionList: ChatSession[]) => {
|
||||||
const usernames = sessionList.map((s) => s.username).filter(Boolean)
|
const usernames = sessionList.map((s) => s.username).filter(Boolean)
|
||||||
@@ -2820,6 +2839,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
|
|
||||||
|
|
||||||
if (offset === 0) {
|
if (offset === 0) {
|
||||||
|
suppressScrollToBottomButton(260)
|
||||||
|
setShowScrollToBottom(false)
|
||||||
setLoadingMessages(true)
|
setLoadingMessages(true)
|
||||||
// 切会话时保留旧内容作为过渡,避免大面积闪烁
|
// 切会话时保留旧内容作为过渡,避免大面积闪烁
|
||||||
setHasInitialMessages(true)
|
setHasInitialMessages(true)
|
||||||
@@ -3903,10 +3924,6 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const remaining = (total - 1) - range.endIndex
|
|
||||||
const shouldShowScrollButton = remaining > 3
|
|
||||||
setShowScrollToBottom(prev => (prev === shouldShowScrollButton ? prev : shouldShowScrollButton))
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
range.startIndex <= 2 &&
|
range.startIndex <= 2 &&
|
||||||
!topRangeLoadLockRef.current &&
|
!topRangeLoadLockRef.current &&
|
||||||
@@ -3948,7 +3965,13 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
if (!atBottom) {
|
if (!atBottom) {
|
||||||
bottomRangeLoadLockRef.current = false
|
bottomRangeLoadLockRef.current = false
|
||||||
}
|
}
|
||||||
}, [])
|
if (messages.length <= 0 || isLoadingMessages || isSessionSwitching || suppressScrollToBottomButtonRef.current) {
|
||||||
|
setShowScrollToBottom(prev => (prev ? false : prev))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const shouldShow = !atBottom
|
||||||
|
setShowScrollToBottom(prev => (prev === shouldShow ? prev : shouldShow))
|
||||||
|
}, [messages.length, isLoadingMessages, isSessionSwitching])
|
||||||
|
|
||||||
const handleMessageAtTopStateChange = useCallback((atTop: boolean) => {
|
const handleMessageAtTopStateChange = useCallback((atTop: boolean) => {
|
||||||
if (!atTop) {
|
if (!atTop) {
|
||||||
@@ -4028,22 +4051,24 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
|
|
||||||
// 滚动到底部
|
// 滚动到底部
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
|
suppressScrollToBottomButton(220)
|
||||||
|
setShowScrollToBottom(false)
|
||||||
const lastIndex = messages.length - 1
|
const lastIndex = messages.length - 1
|
||||||
if (lastIndex >= 0 && messageVirtuosoRef.current) {
|
if (lastIndex >= 0 && messageVirtuosoRef.current) {
|
||||||
messageVirtuosoRef.current.scrollToIndex({
|
messageVirtuosoRef.current.scrollToIndex({
|
||||||
index: lastIndex,
|
index: lastIndex,
|
||||||
align: 'end',
|
align: 'end',
|
||||||
behavior: 'smooth'
|
behavior: 'auto'
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (messageListRef.current) {
|
if (messageListRef.current) {
|
||||||
messageListRef.current.scrollTo({
|
messageListRef.current.scrollTo({
|
||||||
top: messageListRef.current.scrollHeight,
|
top: messageListRef.current.scrollHeight,
|
||||||
behavior: 'smooth'
|
behavior: 'auto'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [messages.length])
|
}, [messages.length, suppressScrollToBottomButton])
|
||||||
|
|
||||||
// 拖动调节侧边栏宽度
|
// 拖动调节侧边栏宽度
|
||||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||||
@@ -4086,6 +4111,10 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
window.clearTimeout(sessionListPersistTimerRef.current)
|
window.clearTimeout(sessionListPersistTimerRef.current)
|
||||||
sessionListPersistTimerRef.current = null
|
sessionListPersistTimerRef.current = null
|
||||||
}
|
}
|
||||||
|
if (scrollBottomButtonArmTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(scrollBottomButtonArmTimerRef.current)
|
||||||
|
scrollBottomButtonArmTimerRef.current = null
|
||||||
|
}
|
||||||
if (contactUpdateTimerRef.current) {
|
if (contactUpdateTimerRef.current) {
|
||||||
clearTimeout(contactUpdateTimerRef.current)
|
clearTimeout(contactUpdateTimerRef.current)
|
||||||
}
|
}
|
||||||
@@ -5055,15 +5084,28 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
return `${y}年${m}月${d}日`
|
return `${y}年${m}月${d}日`
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const clampContextMenuPosition = useCallback((x: number, y: number) => {
|
||||||
|
const viewportPadding = 12
|
||||||
|
const estimatedMenuWidth = 180
|
||||||
|
const estimatedMenuHeight = 188
|
||||||
|
const maxLeft = Math.max(viewportPadding, window.innerWidth - estimatedMenuWidth - viewportPadding)
|
||||||
|
const maxTop = Math.max(viewportPadding, window.innerHeight - estimatedMenuHeight - viewportPadding)
|
||||||
|
return {
|
||||||
|
x: Math.min(Math.max(x, viewportPadding), maxLeft),
|
||||||
|
y: Math.min(Math.max(y, viewportPadding), maxTop)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// 消息右键菜单处理
|
// 消息右键菜单处理
|
||||||
const handleContextMenu = useCallback((e: React.MouseEvent, message: Message) => {
|
const handleContextMenu = useCallback((e: React.MouseEvent, message: Message) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
const nextPos = clampContextMenuPosition(e.clientX, e.clientY)
|
||||||
setContextMenu({
|
setContextMenu({
|
||||||
x: e.clientX,
|
x: nextPos.x,
|
||||||
y: e.clientY,
|
y: nextPos.y,
|
||||||
message
|
message
|
||||||
})
|
})
|
||||||
}, [])
|
}, [clampContextMenuPosition])
|
||||||
|
|
||||||
// 关闭右键菜单
|
// 关闭右键菜单
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -5916,6 +5958,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
customScrollParent={messageListScrollParent ?? undefined}
|
customScrollParent={messageListScrollParent ?? undefined}
|
||||||
data={messages}
|
data={messages}
|
||||||
overscan={360}
|
overscan={360}
|
||||||
|
followOutput={(isAtBottom) => (isAtBottom ? 'auto' : false)}
|
||||||
|
atBottomThreshold={80}
|
||||||
atBottomStateChange={handleMessageAtBottomStateChange}
|
atBottomStateChange={handleMessageAtBottomStateChange}
|
||||||
atTopStateChange={handleMessageAtTopStateChange}
|
atTopStateChange={handleMessageAtTopStateChange}
|
||||||
rangeChanged={handleMessageRangeChanged}
|
rangeChanged={handleMessageRangeChanged}
|
||||||
@@ -6464,14 +6508,16 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
{contextMenu && createPortal(
|
{contextMenu && createPortal(
|
||||||
<>
|
<>
|
||||||
<div className="context-menu-overlay" onClick={() => setContextMenu(null)}
|
<div className="context-menu-overlay" onClick={() => setContextMenu(null)}
|
||||||
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 9998 }} />
|
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 12040 }} />
|
||||||
<div
|
<div
|
||||||
className="context-menu"
|
className="context-menu"
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: contextMenu.y,
|
top: contextMenu.y,
|
||||||
left: contextMenu.x,
|
left: contextMenu.x,
|
||||||
zIndex: 9999
|
zIndex: 12050,
|
||||||
|
maxHeight: 'min(280px, calc(100vh - 24px))',
|
||||||
|
overflowY: 'auto'
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
@@ -6922,6 +6968,7 @@ function MessageBubble({
|
|||||||
const [imageInView, setImageInView] = useState(false)
|
const [imageInView, setImageInView] = useState(false)
|
||||||
const imageForceHdAttempted = useRef<string | null>(null)
|
const imageForceHdAttempted = useRef<string | null>(null)
|
||||||
const imageForceHdPending = useRef(false)
|
const imageForceHdPending = useRef(false)
|
||||||
|
const imageDecryptPendingRef = useRef(false)
|
||||||
const [imageLiveVideoPath, setImageLiveVideoPath] = useState<string | undefined>(undefined)
|
const [imageLiveVideoPath, setImageLiveVideoPath] = useState<string | undefined>(undefined)
|
||||||
const [voiceError, setVoiceError] = useState(false)
|
const [voiceError, setVoiceError] = useState(false)
|
||||||
const [voiceLoading, setVoiceLoading] = useState(false)
|
const [voiceLoading, setVoiceLoading] = useState(false)
|
||||||
@@ -7115,7 +7162,8 @@ function MessageBubble({
|
|||||||
|
|
||||||
const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false) => {
|
const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false) => {
|
||||||
if (!isImage) return
|
if (!isImage) return
|
||||||
if (imageLoading) return
|
if (imageLoading || imageDecryptPendingRef.current) return
|
||||||
|
imageDecryptPendingRef.current = true
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
setImageLoading(true)
|
setImageLoading(true)
|
||||||
setImageError(false)
|
setImageError(false)
|
||||||
@@ -7151,6 +7199,7 @@ function MessageBubble({
|
|||||||
if (!silent) setImageError(true)
|
if (!silent) setImageError(true)
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent) setImageLoading(false)
|
if (!silent) setImageLoading(false)
|
||||||
|
imageDecryptPendingRef.current = false
|
||||||
}
|
}
|
||||||
return { success: false } as any
|
return { success: false } as any
|
||||||
}, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64])
|
}, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64])
|
||||||
@@ -7342,19 +7391,6 @@ function MessageBubble({
|
|||||||
triggerForceHd()
|
triggerForceHd()
|
||||||
}, [isImage, imageHasUpdate, imageInView, imageCacheKey, triggerForceHd])
|
}, [isImage, imageHasUpdate, imageInView, imageCacheKey, triggerForceHd])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isImage || !imageHasUpdate) return
|
|
||||||
if (imageAutoHdTriggered.current === imageCacheKey) return
|
|
||||||
imageAutoHdTriggered.current = imageCacheKey
|
|
||||||
triggerForceHd()
|
|
||||||
}, [isImage, imageHasUpdate, imageCacheKey, triggerForceHd])
|
|
||||||
|
|
||||||
// 更激进:进入视野/打开预览时,无论 hasUpdate 与否都尝试强制高清
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isImage || !imageInView) return
|
|
||||||
triggerForceHd()
|
|
||||||
}, [isImage, imageInView, triggerForceHd])
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVoice) return
|
if (!isVoice) return
|
||||||
|
|||||||
@@ -1291,9 +1291,26 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
|||||||
)
|
)
|
||||||
const exportedMessages = Math.max(0, Math.floor(task.progress.exportedMessages || 0))
|
const exportedMessages = Math.max(0, Math.floor(task.progress.exportedMessages || 0))
|
||||||
const estimatedTotalMessages = Math.max(0, Math.floor(task.progress.estimatedTotalMessages || 0))
|
const estimatedTotalMessages = Math.max(0, Math.floor(task.progress.estimatedTotalMessages || 0))
|
||||||
|
const collectedMessages = Math.max(0, Math.floor(task.progress.collectedMessages || 0))
|
||||||
const messageProgressLabel = estimatedTotalMessages > 0
|
const messageProgressLabel = estimatedTotalMessages > 0
|
||||||
? `已导出 ${Math.min(exportedMessages, estimatedTotalMessages)}/${estimatedTotalMessages} 条`
|
? `已导出 ${Math.min(exportedMessages, estimatedTotalMessages)}/${estimatedTotalMessages} 条`
|
||||||
: `已导出 ${exportedMessages} 条`
|
: `已导出 ${exportedMessages} 条`
|
||||||
|
const effectiveMessageProgressLabel = (
|
||||||
|
exportedMessages > 0 || estimatedTotalMessages > 0 || collectedMessages <= 0 || task.progress.phase !== 'preparing'
|
||||||
|
)
|
||||||
|
? messageProgressLabel
|
||||||
|
: `已收集 ${collectedMessages.toLocaleString()} 条`
|
||||||
|
const phaseProgress = Math.max(0, Math.floor(task.progress.phaseProgress || 0))
|
||||||
|
const phaseTotal = Math.max(0, Math.floor(task.progress.phaseTotal || 0))
|
||||||
|
const phaseMetricLabel = phaseTotal > 0
|
||||||
|
? (
|
||||||
|
task.progress.phase === 'exporting-media'
|
||||||
|
? `媒体 ${Math.min(phaseProgress, phaseTotal)}/${phaseTotal}`
|
||||||
|
: task.progress.phase === 'exporting-voice'
|
||||||
|
? `语音 ${Math.min(phaseProgress, phaseTotal)}/${phaseTotal}`
|
||||||
|
: ''
|
||||||
|
)
|
||||||
|
: ''
|
||||||
const sessionProgressLabel = completedSessionTotal > 0
|
const sessionProgressLabel = completedSessionTotal > 0
|
||||||
? `会话 ${completedSessionCount}/${completedSessionTotal}`
|
? `会话 ${completedSessionCount}/${completedSessionTotal}`
|
||||||
: '会话处理中'
|
: '会话处理中'
|
||||||
@@ -1317,7 +1334,8 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="task-progress-text">
|
<div className="task-progress-text">
|
||||||
{`${sessionProgressLabel} · ${messageProgressLabel}`}
|
{`${sessionProgressLabel} · ${effectiveMessageProgressLabel}`}
|
||||||
|
{phaseMetricLabel ? ` · ${phaseMetricLabel}` : ''}
|
||||||
{task.status === 'running' && currentSessionRatio !== null
|
{task.status === 'running' && currentSessionRatio !== null
|
||||||
? `(当前会话 ${Math.round(currentSessionRatio * 100)}%)`
|
? `(当前会话 ${Math.round(currentSessionRatio * 100)}%)`
|
||||||
: ''}
|
: ''}
|
||||||
|
|||||||
@@ -81,13 +81,48 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
setMessages: (messages) => set({ messages }),
|
setMessages: (messages) => set({ messages }),
|
||||||
|
|
||||||
appendMessages: (newMessages, prepend = false) => set((state) => {
|
appendMessages: (newMessages, prepend = false) => set((state) => {
|
||||||
const getMsgKey = (m: Message) => {
|
const buildPrimaryKey = (m: Message): string => {
|
||||||
if (m.messageKey) return m.messageKey
|
if (m.messageKey) return String(m.messageKey)
|
||||||
return `fallback:${m.serverId || 0}:${m.createTime}:${m.sortSeq || 0}:${m.localId || 0}:${m.senderUsername || ''}:${m.localType || 0}`
|
return `fallback:${m.serverId || 0}:${m.createTime}:${m.sortSeq || 0}:${m.localId || 0}:${m.senderUsername || ''}:${m.localType || 0}`
|
||||||
}
|
}
|
||||||
|
const buildAliasKeys = (m: Message): string[] => {
|
||||||
|
const keys = [buildPrimaryKey(m)]
|
||||||
|
const localId = Math.max(0, Number(m.localId || 0))
|
||||||
|
const serverId = Math.max(0, Number(m.serverId || 0))
|
||||||
|
const createTime = Math.max(0, Number(m.createTime || 0))
|
||||||
|
const localType = Math.floor(Number(m.localType || 0))
|
||||||
|
const sender = String(m.senderUsername || '')
|
||||||
|
const isSend = Number(m.isSend ?? -1)
|
||||||
|
|
||||||
|
if (localId > 0) {
|
||||||
|
keys.push(`lid:${localId}`)
|
||||||
|
}
|
||||||
|
if (serverId > 0) {
|
||||||
|
keys.push(`sid:${serverId}`)
|
||||||
|
}
|
||||||
|
if (localType === 3) {
|
||||||
|
const imageIdentity = String(m.imageMd5 || m.imageDatName || '').trim()
|
||||||
|
if (imageIdentity) {
|
||||||
|
keys.push(`img:${createTime}:${sender}:${isSend}:${imageIdentity}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
const currentMessages = state.messages || []
|
const currentMessages = state.messages || []
|
||||||
const existingKeys = new Set(currentMessages.map(getMsgKey))
|
const existingAliases = new Set<string>()
|
||||||
const filtered = newMessages.filter(m => !existingKeys.has(getMsgKey(m)))
|
currentMessages.forEach((msg) => {
|
||||||
|
buildAliasKeys(msg).forEach((key) => existingAliases.add(key))
|
||||||
|
})
|
||||||
|
|
||||||
|
const filtered: Message[] = []
|
||||||
|
newMessages.forEach((msg) => {
|
||||||
|
const aliasKeys = buildAliasKeys(msg)
|
||||||
|
const exists = aliasKeys.some((key) => existingAliases.has(key))
|
||||||
|
if (exists) return
|
||||||
|
filtered.push(msg)
|
||||||
|
aliasKeys.forEach((key) => existingAliases.add(key))
|
||||||
|
})
|
||||||
|
|
||||||
if (filtered.length === 0) return state
|
if (filtered.length === 0) return state
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user