🐛 fix(driver): 修复驱动安装进度回退

驱动安装进度按单次会话做单调归一,避免旧下载事件覆盖新进度。

有单文件预编译资产时跳过驱动总包兜底,减少进度回退和安装失败面。

补充前端进度状态机与后端总包兜底回归测试。
This commit is contained in:
Syngnat
2026-06-04 16:57:00 +08:00
parent 30d1c080a0
commit 455813e53c
5 changed files with 223 additions and 45 deletions

View File

@@ -4,6 +4,7 @@ import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, FolderOpenOutline
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { useStore } from '../store';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { normalizeDriverProgressUpdate, type DriverProgressState } from '../utils/driverProgress';
import { buildDriverManagerWorkbenchTheme } from '../utils/driverManagerWorkbenchTheme';
import {
DRIVER_LOCAL_IMPORT_BUTTON_LABEL,
@@ -55,12 +56,6 @@ type DriverProgressEvent = {
percent?: number;
};
type ProgressState = {
status: 'start' | 'downloading' | 'done' | 'error';
message: string;
percent: number;
};
type DriverActionKind = '' | 'install' | 'remove' | 'local';
type DriverBatchActionKind = '' | 'install-all' | 'reinstall-updates' | 'remove-all';
@@ -227,7 +222,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
const [actionState, setActionState] = useState<{ driverType: string; kind: DriverActionKind }>({ driverType: '', kind: '' });
const [batchAction, setBatchAction] = useState<DriverBatchActionKind>('');
const [batchProgress, setBatchProgress] = useState<DriverBatchProgressState | null>(null);
const [progressMap, setProgressMap] = useState<Record<string, ProgressState>>({});
const [progressMap, setProgressMap] = useState<Record<string, DriverProgressState>>({});
const [operationLogMap, setOperationLogMap] = useState<Record<string, DriverLogEntry[]>>({});
const [logDriverType, setLogDriverType] = useState('');
const [logModalOpen, setLogModalOpen] = useState(false);
@@ -238,12 +233,38 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
const [versionLoadingMap, setVersionLoadingMap] = useState<Record<string, boolean>>({});
const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState<Record<string, boolean>>({});
const downloadDirRef = useRef(downloadDir);
const progressMapRef = useRef<Record<string, DriverProgressState>>({});
const batchBusy = batchDirectoryImporting || batchAction !== '' || actionState.kind !== '';
useEffect(() => {
downloadDirRef.current = downloadDir;
}, [downloadDir]);
const updateDriverProgress = useCallback((driverType: string, incoming: DriverProgressState) => {
const normalized = String(driverType || '').trim().toLowerCase();
if (!normalized) {
return undefined;
}
const nextProgress = normalizeDriverProgressUpdate(progressMapRef.current[normalized], incoming);
progressMapRef.current = {
...progressMapRef.current,
[normalized]: nextProgress,
};
setProgressMap(progressMapRef.current);
return nextProgress;
}, []);
const clearDriverProgress = useCallback((driverType: string) => {
const normalized = String(driverType || '').trim().toLowerCase();
if (!normalized) {
return;
}
const next = { ...progressMapRef.current };
delete next[normalized];
progressMapRef.current = next;
setProgressMap(next);
}, []);
const modalBodyStyle = useMemo<React.CSSProperties>(() => ({
maxHeight: 'calc(100vh - 220px)',
overflowY: 'auto',
@@ -635,18 +656,19 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
}
const messageText = String(event.message || '').trim();
const percent = Math.max(0, Math.min(100, Number(event.percent || 0)));
setProgressMap((prev) => ({
...prev,
[driverType]: {
status,
message: messageText,
percent,
},
}));
const progressText = `${Math.round(percent)}%`;
const statusText = String(status || '').toUpperCase();
const lineText = `[${statusText}] ${messageText || '-'} (${progressText})`;
const lineSignature = `${statusText}|${messageText || '-'}`;
const nextProgress = updateDriverProgress(driverType, {
status,
message: messageText,
percent,
});
if (!nextProgress) {
return;
}
const progressText = `${Math.round(nextProgress.percent)}%`;
const statusText = String(nextProgress.status || '').toUpperCase();
const logMessageText = nextProgress.message || '-';
const lineText = `[${statusText}] ${logMessageText} (${progressText})`;
const lineSignature = `${statusText}|${logMessageText}`;
appendOperationLog(driverType, lineText, lineSignature, 'update-last');
});
} catch (error) {
@@ -657,7 +679,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
off();
}
};
}, [appendOperationLog]);
}, [appendOperationLog, updateDriverProgress]);
const resolveLocalImportVersion = useCallback((row: DriverStatusRow) => {
const options = versionMap[row.type] || [];
@@ -674,14 +696,11 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
actionOptions?: { silentToast?: boolean; skipRefresh?: boolean },
) => {
setActionState({ driverType: row.type, kind: 'install' });
setProgressMap((prev) => ({
...prev,
[row.type]: {
status: 'start',
message: '开始安装',
percent: 0,
},
}));
updateDriverProgress(row.type, {
status: 'start',
message: '开始安装',
percent: 0,
});
appendOperationLog(row.type, '[START] 开始自动安装');
try {
let versionOptions = versionMap[row.type] || [];
@@ -717,7 +736,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
} finally {
setActionState({ driverType: '', kind: '' });
}
}, [appendOperationLog, downloadDir, loadVersionOptions, refreshStatus, selectedVersionMap, versionMap]);
}, [appendOperationLog, downloadDir, loadVersionOptions, refreshStatus, selectedVersionMap, updateDriverProgress, versionMap]);
const installDriverFromLocalPath = useCallback(async (
row: DriverStatusRow,
@@ -734,14 +753,11 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
}
setActionState({ driverType: row.type, kind: 'local' });
setProgressMap((prev) => ({
...prev,
[row.type]: {
status: 'start',
message: '开始导入本地驱动包',
percent: 0,
},
}));
updateDriverProgress(row.type, {
status: 'start',
message: '开始导入本地驱动包',
percent: 0,
});
const selectedVersion = resolveLocalImportVersion(row);
const versionTip = selectedVersion ? `${selectedVersion}` : '';
appendOperationLog(row.type, `[START] 开始本地导入${versionTip}${sourceLabel}${pathText}`);
@@ -766,7 +782,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
} finally {
setActionState({ driverType: '', kind: '' });
}
}, [appendOperationLog, downloadDir, refreshStatus, resolveLocalImportVersion]);
}, [appendOperationLog, downloadDir, refreshStatus, resolveLocalImportVersion, updateDriverProgress]);
const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => {
const fileRes = await SelectDriverPackageFile(downloadDir);
@@ -901,11 +917,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
if (!options?.silentToast) {
message.success(`${row.name} 已移除`);
}
setProgressMap((prev) => {
const next = { ...prev };
delete next[row.type];
return next;
});
clearDriverProgress(row.type);
if (!options?.skipRefresh) {
await refreshStatus(false);
}
@@ -913,7 +925,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
} finally {
setActionState({ driverType: '', kind: '' });
}
}, [appendOperationLog, downloadDir, refreshStatus]);
}, [appendOperationLog, clearDriverProgress, downloadDir, refreshStatus]);
const resolvePackageSizeText = (row: DriverStatusRow): string => {
if (row.builtIn) {

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { normalizeDriverProgressUpdate, type DriverProgressState } from './driverProgress';
describe('normalizeDriverProgressUpdate', () => {
it('keeps downloading progress monotonic within one install session', () => {
const previous: DriverProgressState = {
status: 'downloading',
message: '写入驱动元数据',
percent: 90,
};
const actual = normalizeDriverProgressUpdate(previous, {
status: 'downloading',
message: '下载驱动总包',
percent: 30,
});
expect(actual).toEqual({
status: 'downloading',
message: '下载驱动总包',
percent: 90,
});
});
it('allows start to reset progress for a new install session', () => {
const actual = normalizeDriverProgressUpdate({
status: 'error',
message: '安装失败',
percent: 90,
}, {
status: 'start',
message: '开始安装',
percent: 0,
});
expect(actual).toEqual({
status: 'start',
message: '开始安装',
percent: 0,
});
});
it('does not let stale downloading events overwrite terminal states', () => {
const done = normalizeDriverProgressUpdate({
status: 'downloading',
message: '写入驱动元数据',
percent: 95,
}, {
status: 'done',
message: '驱动代理安装完成',
percent: 100,
});
expect(normalizeDriverProgressUpdate(done, {
status: 'downloading',
message: '下载驱动总包',
percent: 40,
})).toBe(done);
const failed = normalizeDriverProgressUpdate({
status: 'downloading',
message: '写入驱动元数据',
percent: 95,
}, {
status: 'error',
message: '安装失败',
percent: 0,
});
expect(failed).toEqual({
status: 'error',
message: '安装失败',
percent: 95,
});
expect(normalizeDriverProgressUpdate(failed, {
status: 'downloading',
message: '下载驱动总包',
percent: 40,
})).toBe(failed);
});
});

View File

@@ -0,0 +1,59 @@
export type DriverProgressStatus = 'start' | 'downloading' | 'done' | 'error';
export type DriverProgressState = {
status: DriverProgressStatus;
message: string;
percent: number;
};
const clampDriverProgressPercent = (value: number): number => {
if (!Number.isFinite(value)) {
return 0;
}
return Math.max(0, Math.min(100, value));
};
export const normalizeDriverProgressUpdate = (
previous: DriverProgressState | undefined,
incoming: DriverProgressState,
): DriverProgressState => {
const next: DriverProgressState = {
status: incoming.status,
message: String(incoming.message || '').trim(),
percent: clampDriverProgressPercent(Number(incoming.percent || 0)),
};
if (next.status === 'start') {
return {
...next,
percent: 0,
};
}
if (next.status === 'done') {
return {
...next,
percent: 100,
};
}
if (next.status === 'error') {
return {
...next,
percent: Math.max(clampDriverProgressPercent(previous?.percent || 0), next.percent),
};
}
if (previous?.status === 'done' || previous?.status === 'error') {
return previous;
}
if (previous?.status === 'start' || previous?.status === 'downloading') {
return {
...next,
percent: Math.max(clampDriverProgressPercent(previous.percent || 0), next.percent),
};
}
return next;
};

View File

@@ -3346,7 +3346,7 @@ func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, execut
bundleURLs := []string{}
if !forceSourceBuild {
downloadURLs = resolveOptionalDriverAgentDownloadURLs(definition, downloadURL, selectedVersion)
if !restrictToExplicitArtifact {
if shouldUseOptionalDriverBundleFallback(driverType, restrictToExplicitArtifact, len(downloadURLs)) {
bundleURLs = resolveOptionalDriverBundleDownloadURLs()
}
}
@@ -3501,6 +3501,16 @@ func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, execut
return "", "", errors.New(strings.Join(parts, ""))
}
func shouldUseOptionalDriverBundleFallback(driverType string, restrictToExplicitArtifact bool, directURLCount int) bool {
if restrictToExplicitArtifact {
return false
}
if shouldSkipDirectOptionalDriverDownloads(driverType) {
return true
}
return directURLCount == 0
}
func downloadOptionalDriverAgentBinary(a *App, definition driverDefinition, urlText string, executablePath string) (string, error) {
driverType := normalizeDriverType(definition.Type)
displayName := resolveDriverDisplayName(definition)

View File

@@ -212,6 +212,21 @@ func TestResolveOptionalDriverAgentDownloadURLsSkipsBundleOnlyDamengAsset(t *tes
}
}
func TestShouldUseOptionalDriverBundleFallbackSkipsWhenDirectAssetExists(t *testing.T) {
if shouldUseOptionalDriverBundleFallback("sqlserver", false, 1) {
t.Fatal("expected published single-file driver asset to avoid 497MB bundle fallback")
}
}
func TestShouldUseOptionalDriverBundleFallbackKeepsBundleWhenDirectAssetMissing(t *testing.T) {
if !shouldUseOptionalDriverBundleFallback("dameng", false, 0) {
t.Fatal("expected missing single-file driver asset to keep bundle fallback")
}
if shouldUseOptionalDriverBundleFallback("dameng", true, 0) {
t.Fatal("expected explicit version artifact installs to skip bundle fallback")
}
}
func TestResolveDriverInstallVersionUsesPinnedVersionForBuiltinActivateURL(t *testing.T) {
definition, ok := resolveDriverDefinition("sqlserver")
if !ok {