mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-28 09:21:38 +08:00
🐛 fix(export-workbench): 修正未知总数进度展示并优化 XLSX 收尾阶段
- 修正总行数为 0 时仍被当作已知总数的问题,避免导出进度百分比失真 - 调整导出进度条判定逻辑,未知总数时改为展示实时写入进度 - 统一 Sidebar、TableOverview 和导出工作台的预计行数口径,仅在总数大于 0 时视为已知 - 优化 XLSX 收尾阶段的 ZIP 压缩策略和拷贝缓冲,降低 Windows 大文件导出封装耗时 - 细化 finalizing 阶段文案,明确显示 XLSX 正在封装压缩 - 补充导出进度状态与零总数场景的回归测试
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user