feat: 支持导出自定义目录并优化聊天记录读取

接入导出保存目录选择功能
修复导出路径摘要未同步更新
按时间范围查询聊天记录,避免全量扫描历史消息
增加跨分片消息查询支持验证
This commit is contained in:
电摇小子
2026-08-15 02:44:07 +08:00
parent 900e4c504c
commit 1fc39678ff
9 changed files with 29 additions and 16 deletions
+1
View File
@@ -44,6 +44,7 @@ const api = {
startExport: (request) => electron.ipcRenderer.invoke("export:start", request),
cancelExport: (jobId) => electron.ipcRenderer.invoke("export:cancel", jobId),
revealExport: (path) => electron.ipcRenderer.invoke("export:reveal", path),
selectExportDirectory: () => electron.ipcRenderer.invoke("export:selectDirectory"),
onExportProgress: (callback) => {
const listener = (_event, progress) => callback(progress);
electron.ipcRenderer.on("export:progress", listener);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 158 KiB

+2 -2
View File
@@ -928,7 +928,7 @@ async function runSingleExport(
request.format === 'html'
? options.outputFolderName || safeFilePart(request.outputName)
: `${safeFilePart(request.outputName)}_${exportStamp()}`
const root = options.outputRoot || (await resolveDefaultExportRoot(outputFolder))
const root = options.outputRoot || request.outputDirectory || (await resolveDefaultExportRoot(outputFolder))
await fs.mkdir(root, { recursive: true })
const outputDir = join(root, outputFolder)
const outputPath =
@@ -1607,7 +1607,7 @@ async function runAllExport(
}
const outputFolder = safeFilePart(request.outputName)
const exportRoot = await resolveDefaultExportRoot(outputFolder)
const exportRoot = request.outputDirectory || (await resolveDefaultExportRoot(outputFolder))
outputDir = join(exportRoot, outputFolder)
const folderNames = conversationFolderNames(targets)
let lastProgressAt = 0
+5
View File
@@ -1164,6 +1164,11 @@ app.whenReady().then(async () => {
if (!window) return { success: false, error: '窗口不可用' }
return runExport(request, window, voiceRecognition || undefined)
})
ipcMain.handle('export:selectDirectory', async (event) => {
const window = BrowserWindow.fromWebContents(event.sender)
const result = await dialog.showOpenDialog(window!, { properties: ['openDirectory', 'createDirectory'] })
return result.canceled ? { canceled: true } : { canceled: false, path: result.filePaths[0] }
})
ipcMain.handle('export:cancel', (_, jobId: string) => {
cancelExport(jobId)
return { success: true }
+5 -11
View File
@@ -216,17 +216,11 @@ export class WechatDb {
if (!username) return []
if (startTime && endTime && startTime > endTime) return []
// A bounded native cursor can omit rows stored across a message shard boundary.
// Scan without bounds first, then apply the requested range in application code.
const rows = await this.wcdb4Client.getMessagesAsync(username)
const messages = rows
.map((message) => ({ ...message.raw, ...message }))
.filter((message) => {
const createTime = Number(message.msgCreateTime || 0)
if (startTime && createTime < startTime) return false
if (endTime && createTime > endTime) return false
return true
})
// The async client merges cursor and shard-table results while applying the
// time range in SQLite. Avoid loading the entire conversation when exporting
// a narrow range; this is especially important for chats with long history.
const rows = await this.wcdb4Client.getMessagesAsync(username, startTime, endTime)
const messages = rows.map((message) => ({ ...message.raw, ...message }))
console.log(
`[WechatDb] export scan username=${username} raw=${rows.length} filtered=${messages.length} start=${startTime || 0} end=${endTime || 0}`
)
+1
View File
@@ -316,6 +316,7 @@ declare global {
httpStatus?: number
}>
startExport: (request: ExportRequest) => Promise<ExportResult>
selectExportDirectory: () => Promise<{ canceled: boolean; path?: string }>
cancelExport: (jobId: string) => Promise<{ success: boolean }>
revealExport: (path: string) => Promise<{ success: boolean; error?: string }>
onExportProgress: (callback: (progress: ExportJobProgress) => void) => () => void
+1
View File
@@ -209,6 +209,7 @@ const api = {
startExport: (request: ExportRequest) => ipcRenderer.invoke('export:start', request),
cancelExport: (jobId: string) => ipcRenderer.invoke('export:cancel', jobId),
revealExport: (path: string) => ipcRenderer.invoke('export:reveal', path),
selectExportDirectory: () => ipcRenderer.invoke('export:selectDirectory'),
onExportProgress: (callback: (progress: ExportJobProgress) => void) => {
const listener = (_event: Electron.IpcRendererEvent, progress: ExportJobProgress): void =>
callback(progress)
@@ -75,6 +75,7 @@ export function ExportWorkspace({
const [format, setFormat] = useState<ExportFormat>(() => runningAllTask?.format || 'csv')
const [zip, setZip] = useState(() => runningAllTask?.zip === true)
const [fileName, setFileName] = useState('')
const [outputDirectory, setOutputDirectory] = useState('')
const [status, setStatus] = useState<ExportStatus>('idle')
const [jobId, setJobId] = useState('')
const [progress, setProgress] = useState<ExportJobProgress | null>(null)
@@ -334,6 +335,7 @@ export function ExportWorkspace({
targets,
format: exportFormat,
outputName,
outputDirectory: outputDirectory || undefined,
startTime: exportAll
? undefined
: startOfRange
@@ -439,6 +441,10 @@ export function ExportWorkspace({
: `文稿/TraceMemo/导出/${outputName}/`
: `文稿/TraceMemo/导出/${outputName}.${format === 'markdown' ? 'md' : format}`
const selectedTargetPath = outputDirectory
? `${outputDirectory}/${outputName}${format === 'html' ? (zip ? '.zip' : '/') : `.${format === 'markdown' ? 'md' : format}`}`
: targetPath
return (
<div className="export-workspace">
<ExportContactPanel
@@ -757,8 +763,12 @@ export function ExportWorkspace({
</label>
<div className="export-target-path">
<span></span>
<strong>{targetPath}</strong>
<button type="button"></button>
<strong>{selectedTargetPath}</strong>
<button type="button" onClick={() => {
void window.api.selectExportDirectory().then((result) => {
if (!result.canceled && result.path) setOutputDirectory(result.path)
})
}}></button>
</div>
{format === 'html' && (
<p className="export-helper-text">
@@ -776,7 +786,7 @@ export function ExportWorkspace({
? '导出完成'
: '准备就绪'}
</span>
<span className="export-target-summary">{targetPath}</span>
<span className="export-target-summary">{selectedTargetPath}</span>
<button type="button" className="export-reset-button" onClick={resetDefaults}>
</button>
+1
View File
@@ -32,6 +32,7 @@ export interface ExportRequest {
targets: ExportTarget[]
format: ExportFormat
outputName: string
outputDirectory?: string
startTime?: number
endTime?: number
kinds: ExportMessageKind[]