🐛 fix(export-workbench): 修正未知总数进度展示并优化 XLSX 收尾阶段

- 修正总行数为 0 时仍被当作已知总数的问题,避免导出进度百分比失真
- 调整导出进度条判定逻辑,未知总数时改为展示实时写入进度
- 统一 Sidebar、TableOverview 和导出工作台的预计行数口径,仅在总数大于 0 时视为已知
- 优化 XLSX 收尾阶段的 ZIP 压缩策略和拷贝缓冲,降低 Windows 大文件导出封装耗时
- 细化 finalizing 阶段文案,明确显示 XLSX 正在封装压缩
- 补充导出进度状态与零总数场景的回归测试
This commit is contained in:
Syngnat
2026-06-18 10:15:00 +08:00
parent 9613f6b624
commit 2a8ae05363
10 changed files with 116 additions and 22 deletions

View File

@@ -30,7 +30,7 @@ export const ExportProgressBar: React.FC<ExportProgressBarProps> = ({
total,
totalRowsKnown,
}) => {
const isIndeterminate = shouldUseIndeterminateExportProgress(status, totalRowsKnown);
const isIndeterminate = shouldUseIndeterminateExportProgress(status, total, totalRowsKnown);
const progressStatus = status === 'error'
? 'exception'
: (status === 'done' ? 'success' : 'active');

View File

@@ -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) => {

View File

@@ -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;

View File

@@ -541,7 +541,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ 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<TableOverviewProps> = ({ 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,
}));

View File

@@ -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);
});
});

View File

@@ -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: '',

View File

@@ -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', () => {

View File

@@ -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)));

View File

@@ -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) {

View File

@@ -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
}