diff --git a/frontend/src/components/ExportProgressBar.tsx b/frontend/src/components/ExportProgressBar.tsx index 01bc8ae..8021169 100644 --- a/frontend/src/components/ExportProgressBar.tsx +++ b/frontend/src/components/ExportProgressBar.tsx @@ -30,7 +30,7 @@ export const ExportProgressBar: React.FC = ({ total, totalRowsKnown, }) => { - const isIndeterminate = shouldUseIndeterminateExportProgress(status, totalRowsKnown); + const isIndeterminate = shouldUseIndeterminateExportProgress(status, total, totalRowsKnown); const progressStatus = status === 'error' ? 'exception' : (status === 'done' ? 'success' : 'active'); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index a5feea2..9d355bd 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -3796,7 +3796,7 @@ const Sidebar: React.FC<{ const handleExport = async (node: any, options: { format: string; xlsxMaxRowsPerSheet?: number }) => { const { config, dbName, tableName } = node.dataRef; const rowCount = Number(node?.dataRef?.rowCount); - const totalRowsKnown = Number.isFinite(rowCount) && rowCount >= 0; + const totalRowsKnown = Number.isFinite(rowCount) && rowCount > 0; await runExportWithProgress({ title: `导出 ${tableName}`, targetName: tableName, @@ -3832,10 +3832,10 @@ const Sidebar: React.FC<{ objectType: node?.type === 'view' ? 'view' : (node?.type === 'materialized-view' ? 'materialized-view' : 'table'), schemaName: typeof node?.dataRef?.schemaName === 'string' ? node.dataRef.schemaName : undefined, sidebarLocateKey: typeof node?.key === 'string' ? node.key : undefined, - rowCountByScope: Number.isFinite(Number(node?.dataRef?.rowCount)) && Number(node?.dataRef?.rowCount) >= 0 + rowCountByScope: Number.isFinite(Number(node?.dataRef?.rowCount)) && Number(node?.dataRef?.rowCount) > 0 ? { all: Math.trunc(Number(node.dataRef.rowCount)) } : undefined, - })); + })); }; const handleCopyTableAsInsert = async (node: any) => { diff --git a/frontend/src/components/TableExportWorkbench.tsx b/frontend/src/components/TableExportWorkbench.tsx index a6a2e37..6d8de17 100644 --- a/frontend/src/components/TableExportWorkbench.tsx +++ b/frontend/src/components/TableExportWorkbench.tsx @@ -454,7 +454,7 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => { ); const singleScopeRowCount = useMemo(() => { const raw = tab.tableExportRowCountByScope?.[scope]; - return Number.isFinite(Number(raw)) && Number(raw) >= 0 ? Number(raw) : undefined; + return Number.isFinite(Number(raw)) && Number(raw) > 0 ? Number(raw) : undefined; }, [scope, tab.tableExportRowCountByScope]); const singleTotalRowsKnown = typeof singleScopeRowCount === 'number'; const singleScopeLabel = activeScopeOption?.label || scope; diff --git a/frontend/src/components/TableOverview.tsx b/frontend/src/components/TableOverview.tsx index 23e1d8f..d6aa631 100644 --- a/frontend/src/components/TableOverview.tsx +++ b/frontend/src/components/TableOverview.tsx @@ -541,7 +541,7 @@ const TableOverview: React.FC = ({ tab }) => { const handleExport = useCallback(async (tableName: string, options: { format: string; xlsxMaxRowsPerSheet?: number }, totalRows?: number) => { const config = buildConfig(); if (!config) return; - const totalRowsKnown = Number.isFinite(totalRows) && Number(totalRows) >= 0; + const totalRowsKnown = Number.isFinite(totalRows) && Number(totalRows) > 0; await runExportWithProgress({ title: `导出 ${tableName}`, targetName: tableName, @@ -568,7 +568,7 @@ const TableOverview: React.FC = ({ tab }) => { tableName, title: `导出 ${tableName}`, objectType: 'table', - rowCountByScope: Number.isFinite(Number(totalRows)) && Number(totalRows) >= 0 + rowCountByScope: Number.isFinite(Number(totalRows)) && Number(totalRows) > 0 ? { all: Math.trunc(Number(totalRows)) } : undefined, })); diff --git a/frontend/src/components/useExportProgressRunner.test.tsx b/frontend/src/components/useExportProgressRunner.test.tsx index 382b52b..1d17a8e 100644 --- a/frontend/src/components/useExportProgressRunner.test.tsx +++ b/frontend/src/components/useExportProgressRunner.test.tsx @@ -126,4 +126,55 @@ describe('useExportProgressRunner', () => { expect(runner?.state.startedAt).toBe(8_000); expect(runner?.state.finishedAt).toBe(13_000); }); + + it('treats zero total row hints as unknown progress', async () => { + renderRunner(); + + let resolveRun!: (value: { success: boolean; message: string }) => void; + const pendingRun = new Promise<{ success: boolean; message: string }>((resolve) => { + resolveRun = resolve; + }); + + let runPromise: Promise<{ success: boolean; message: string } | null> | null = null; + await act(async () => { + runPromise = runner?.runExportWithProgress({ + title: '导出 messages', + targetName: 'messages', + format: 'xlsx', + totalRows: 0, + run: async () => pendingRun, + }) || null; + await Promise.resolve(); + }); + + expect(runner?.state.totalRowsKnown).toBe(false); + expect(runner?.state.total).toBe(0); + + const jobId = runner?.state.jobId || ''; + now = 5_000; + act(() => { + runtimeApi.emitExportProgress({ + jobId, + status: 'running', + stage: '正在写入文件', + current: 754000, + total: 0, + totalRowsKnown: true, + }); + }); + + expect(runner?.state.status).toBe('running'); + expect(runner?.state.current).toBe(754000); + expect(runner?.state.totalRowsKnown).toBe(false); + expect(runner?.state.total).toBe(0); + + now = 7_000; + await act(async () => { + resolveRun({ success: true, message: '导出完成' }); + await runPromise; + }); + + expect(runner?.state.status).toBe('done'); + expect(runner?.state.totalRowsKnown).toBe(false); + }); }); diff --git a/frontend/src/components/useExportProgressRunner.ts b/frontend/src/components/useExportProgressRunner.ts index 0403ec2..1c6865c 100644 --- a/frontend/src/components/useExportProgressRunner.ts +++ b/frontend/src/components/useExportProgressRunner.ts @@ -75,6 +75,13 @@ const normalizeCount = (value: unknown): number => { return Math.trunc(next); }; +const hasUsableTotalRows = (known: boolean, total: unknown): boolean => { + if (!known) { + return false; + } + return normalizeCount(total) > 0; +}; + const buildExportJobId = (): string => `export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const isActiveExportStatus = (status: ExportProgressStatus): boolean => @@ -99,10 +106,10 @@ export function useExportProgressRunner(options?: UseExportProgressRunnerOptions const nextStartedAt = prev.startedAt > 0 || !isActiveExportStatus(nextStatus) ? prev.startedAt : Date.now(); - const nextTotalRowsKnown = typeof event.totalRowsKnown === 'boolean' ? event.totalRowsKnown : prev.totalRowsKnown; - const nextTotal = nextTotalRowsKnown - ? normalizeCount(typeof event.total === 'number' ? event.total : prev.total) - : prev.total; + const rawNextTotal = normalizeCount(typeof event.total === 'number' ? event.total : prev.total); + const requestedTotalRowsKnown = typeof event.totalRowsKnown === 'boolean' ? event.totalRowsKnown : prev.totalRowsKnown; + const nextTotalRowsKnown = hasUsableTotalRows(requestedTotalRowsKnown, rawNextTotal); + const nextTotal = nextTotalRowsKnown ? rawNextTotal : 0; return { ...prev, open: true, @@ -144,7 +151,11 @@ export function useExportProgressRunner(options?: UseExportProgressRunnerOptions } const jobId = buildExportJobId(); - const totalRowsKnown = Number.isFinite(runOptions.totalRows) && Number(runOptions.totalRows) >= 0; + const requestedTotal = normalizeCount(runOptions.totalRows); + const totalRowsKnown = hasUsableTotalRows( + Number.isFinite(runOptions.totalRows) && Number(runOptions.totalRows) >= 0, + requestedTotal, + ); activeJobIdRef.current = jobId; setState({ open: true, @@ -157,7 +168,7 @@ export function useExportProgressRunner(options?: UseExportProgressRunnerOptions status: 'start', stage: '等待选择导出文件', current: 0, - total: totalRowsKnown ? normalizeCount(runOptions.totalRows) : 0, + total: totalRowsKnown ? requestedTotal : 0, totalRowsKnown, filePath: '', message: '', diff --git a/frontend/src/utils/exportProgress.test.ts b/frontend/src/utils/exportProgress.test.ts index dc104ce..47e946e 100644 --- a/frontend/src/utils/exportProgress.test.ts +++ b/frontend/src/utils/exportProgress.test.ts @@ -17,7 +17,14 @@ describe('exportProgress', () => { expect(resolveExportProgressPercent('running', 5000, 0, false)).toBe(0); expect(resolveExportProgressPercent('finalizing', 5000, 0, false)).toBe(0); expect(shouldUseExactExportProgress('running', 0, false)).toBe(false); - expect(shouldUseIndeterminateExportProgress('running', false)).toBe(true); + expect(shouldUseIndeterminateExportProgress('running', 0, false)).toBe(true); + }); + + it('falls back to indeterminate progress when total row hint is zero', () => { + expect(resolveExportProgressPercent('running', 754000, 0, true)).toBe(0); + expect(shouldUseExactExportProgress('running', 0, true)).toBe(false); + expect(shouldUseIndeterminateExportProgress('running', 0, true)).toBe(true); + expect(formatExportProgressRows(754000, 0, true)).toBe('已写入 754,000 行'); }); it('formats row summary for known and unknown totals', () => { diff --git a/frontend/src/utils/exportProgress.ts b/frontend/src/utils/exportProgress.ts index 0cd94a2..7c1752f 100644 --- a/frontend/src/utils/exportProgress.ts +++ b/frontend/src/utils/exportProgress.ts @@ -1,5 +1,10 @@ export type ExportProgressStatus = 'idle' | 'start' | 'running' | 'finalizing' | 'done' | 'error'; +const hasUsableExportTotal = (total: number, totalRowsKnown: boolean): boolean => { + const normalizedTotal = Number.isFinite(total) ? Math.max(0, Math.trunc(total)) : 0; + return totalRowsKnown && normalizedTotal > 0; +}; + const clampPercent = (value: number): number => { if (!Number.isFinite(value)) return 0; if (value <= 0) return 0; @@ -12,11 +17,10 @@ export const shouldUseExactExportProgress = ( total: number, totalRowsKnown: boolean, ): boolean => { - const normalizedTotal = Number.isFinite(total) ? Math.max(0, Math.trunc(total)) : 0; - if (totalRowsKnown && normalizedTotal > 0) { + if (hasUsableExportTotal(total, totalRowsKnown)) { return true; } - if ((status === 'done' || status === 'error') && totalRowsKnown) { + if ((status === 'done' || status === 'error') && hasUsableExportTotal(total, totalRowsKnown)) { return true; } return false; @@ -24,8 +28,9 @@ export const shouldUseExactExportProgress = ( export const shouldUseIndeterminateExportProgress = ( status: ExportProgressStatus, + total: number, totalRowsKnown: boolean, -): boolean => !totalRowsKnown && status !== 'idle' && status !== 'done' && status !== 'error'; +): boolean => !hasUsableExportTotal(total, totalRowsKnown) && status !== 'idle' && status !== 'done' && status !== 'error'; export const resolveExportProgressPercent = ( status: ExportProgressStatus, @@ -35,7 +40,7 @@ export const resolveExportProgressPercent = ( ): number => { const normalizedCurrent = Number.isFinite(current) ? Math.max(0, current) : 0; const normalizedTotal = Number.isFinite(total) ? Math.max(0, total) : 0; - if (totalRowsKnown && normalizedTotal > 0) { + if (hasUsableExportTotal(total, totalRowsKnown)) { return clampPercent((normalizedCurrent / normalizedTotal) * 100); } if ((status === 'done' || status === 'error') && (totalRowsKnown || normalizedCurrent >= 0)) { @@ -51,7 +56,7 @@ export const formatExportProgressRows = ( ): string => { const formatter = new Intl.NumberFormat('zh-CN'); const safeCurrent = formatter.format(Math.max(0, Math.trunc(Number(current) || 0))); - if (!totalRowsKnown) { + if (!hasUsableExportTotal(total, totalRowsKnown)) { return `已写入 ${safeCurrent} 行`; } const safeTotal = formatter.format(Math.max(0, Math.trunc(Number(total) || 0))); diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 3342cde..6bcc206 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -210,7 +210,16 @@ func (r *exportProgressReporter) ForceRunning(current int64, stage string) { } func (r *exportProgressReporter) Finalizing(current int64) { - r.emit("finalizing", "正在完成文件写入", current, "", true) + stage := "正在完成文件写入" + if r != nil { + switch strings.ToLower(strings.TrimSpace(r.format)) { + case "xlsx": + stage = "正在封装并压缩 XLSX 文件" + case "csv": + stage = "正在完成 CSV 写入" + } + } + r.emit("finalizing", stage, current, "", true) } func (r *exportProgressReporter) Done(current int64) { diff --git a/internal/app/xlsx_stream_writer.go b/internal/app/xlsx_stream_writer.go index ea50be6..c0e4b13 100644 --- a/internal/app/xlsx_stream_writer.go +++ b/internal/app/xlsx_stream_writer.go @@ -3,6 +3,7 @@ package app import ( "archive/zip" "bufio" + "compress/flate" "encoding/xml" "fmt" "io" @@ -33,6 +34,11 @@ var ( xlsxTemplatePartsOnce sync.Once xlsxTemplateParts map[string][]byte xlsxTemplatePartsErr error + xlsxZipCopyBufferPool = sync.Pool{ + New: func() interface{} { + return make([]byte, 1024*1024) + }, + } ) type xlsxExportTempSheet struct { @@ -181,6 +187,9 @@ func (w *xlsxExportFileWriter) Close() error { } zw := zip.NewWriter(w.file) + zw.RegisterCompressor(zip.Deflate, func(out io.Writer) (io.WriteCloser, error) { + return flate.NewWriter(out, flate.BestSpeed) + }) defer w.cleanupTempSheets() if err := writeXLSXZipFile(zw, w.sheets); err != nil { @@ -329,7 +338,9 @@ func writeZipFileFromPath(zw *zip.Writer, name string, path string) error { return err } defer file.Close() - _, err = io.Copy(writer, file) + buffer := xlsxZipCopyBufferPool.Get().([]byte) + defer xlsxZipCopyBufferPool.Put(buffer) + _, err = io.CopyBuffer(writer, file, buffer) return err }