feat(driver-manager): 增强驱动管理本地导入并统一滚动交互体验

- 新增驱动目录批量导入入口,支持覆盖已安装开关与去重处理
- 行内本地导入聚焦单文件场景,目录导入与单文件导入流程统一
- 已安装驱动版本选择锁定,避免安装后误改版本
- 补充驱动下载网络检测与日志可见性,提升问题定位效率
- 重构驱动管理横向滚动条实现,修复双滚动条/消失/位置异常问题
This commit is contained in:
Syngnat
2026-02-28 11:33:21 +08:00
parent 587ed3444b
commit 98c1600e13
5 changed files with 656 additions and 97 deletions

View File

@@ -92,3 +92,53 @@ body[data-theme='dark'] {
background-color: #ff4d4f !important;
color: #fff !important;
}
/* 驱动管理:统一关闭 antd sticky 横向条,仅保留自定义独立横向条 */
.driver-manager-table .ant-table-sticky-scroll {
display: none !important;
}
/* 仅在独立横向条激活时隐藏表格自身横向滚动条,避免出现双横向条 */
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-content,
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-body {
overflow-x: auto !important;
-ms-overflow-style: none;
scrollbar-width: none;
}
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-content::-webkit-scrollbar:horizontal,
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-body::-webkit-scrollbar:horizontal {
height: 0 !important;
}
.driver-manager-table-wrap {
width: 100%;
max-width: 100%;
overflow-x: hidden;
}
.driver-manager-footer {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.driver-manager-footer-actions {
width: 100%;
display: flex;
justify-content: flex-end;
}
.driver-manager-hscroll {
width: 100%;
height: 12px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-gutter: stable;
background: transparent;
}
.driver-manager-hscroll-inner {
height: 1px;
}

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Button, Collapse, Modal, Progress, Select, Space, Table, Tag, Typography, message } from 'antd';
import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, ReloadOutlined } from '@ant-design/icons';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Button, Collapse, Modal, Progress, Select, Space, Switch, Table, Tag, Typography, message } from 'antd';
import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, FolderOpenOutlined, ReloadOutlined } from '@ant-design/icons';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import {
CheckDriverNetworkStatus,
@@ -10,6 +10,7 @@ import {
GetDriverStatusList,
InstallLocalDriverPackage,
RemoveDriverPackage,
SelectDriverPackageDirectory,
SelectDriverPackageFile,
} from '../../wailsjs/go/app/App';
@@ -46,6 +47,8 @@ type ProgressState = {
percent: number;
};
type DriverActionKind = '' | 'install' | 'remove' | 'local';
type DriverLogEntry = {
time: string;
text: string;
@@ -84,6 +87,7 @@ type DriverVersionOption = {
const buildVersionOptionKey = (option: DriverVersionOption) => `${option.version}@@${option.downloadUrl}`;
const buildVersionSizeLoadingKey = (driverType: string, optionKey: string) => `${driverType}@@${optionKey}`;
const DRIVER_TABLE_SCROLL_X = 1450;
const buildVersionSelectOptions = (options: DriverVersionOption[]) => {
type SelectOption = { value: string; label: string };
@@ -132,20 +136,28 @@ const buildVersionSelectOptions = (options: DriverVersionOption[]) => {
};
const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
const modalContentRef = useRef<HTMLDivElement | null>(null);
const tableContainerRef = useRef<HTMLDivElement | null>(null);
const tableScrollTargetsRef = useRef<HTMLElement[]>([]);
const externalHScrollRef = useRef<HTMLDivElement | null>(null);
const horizontalSyncSourceRef = useRef<'table' | 'external' | ''>('');
const [loading, setLoading] = useState(false);
const [downloadDir, setDownloadDir] = useState('');
const [networkChecking, setNetworkChecking] = useState(false);
const [networkStatus, setNetworkStatus] = useState<DriverNetworkStatus | null>(null);
const [rows, setRows] = useState<DriverStatusRow[]>([]);
const [actionDriver, setActionDriver] = useState('');
const [actionState, setActionState] = useState<{ driverType: string; kind: DriverActionKind }>({ driverType: '', kind: '' });
const [progressMap, setProgressMap] = useState<Record<string, ProgressState>>({});
const [operationLogMap, setOperationLogMap] = useState<Record<string, DriverLogEntry[]>>({});
const [logDriverType, setLogDriverType] = useState('');
const [logModalOpen, setLogModalOpen] = useState(false);
const [batchDirectoryImporting, setBatchDirectoryImporting] = useState(false);
const [forceOverwriteInstalled, setForceOverwriteInstalled] = useState(false);
const [versionMap, setVersionMap] = useState<Record<string, DriverVersionOption[]>>({});
const [selectedVersionMap, setSelectedVersionMap] = useState<Record<string, string>>({});
const [versionLoadingMap, setVersionLoadingMap] = useState<Record<string, boolean>>({});
const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState<Record<string, boolean>>({});
const [horizontalScrollWidth, setHorizontalScrollWidth] = useState(DRIVER_TABLE_SCROLL_X);
const appendOperationLog = useCallback((
driverType: string,
@@ -193,6 +205,76 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
});
}, []);
const refreshHorizontalScrollState = useCallback(() => {
const tableContainer = tableContainerRef.current;
const targets = tableContainer
? [
...new Set(
[
...Array.from(tableContainer.querySelectorAll('.ant-table-content')),
...Array.from(tableContainer.querySelectorAll('.ant-table-body')),
].filter((node): node is HTMLElement => node instanceof HTMLElement),
),
]
: tableScrollTargetsRef.current;
if (!targets || targets.length === 0) {
setHorizontalScrollWidth(DRIVER_TABLE_SCROLL_X);
return;
}
const nextWidth = Math.max(
DRIVER_TABLE_SCROLL_X,
...targets.map((target) => Math.max(0, target.scrollWidth)),
);
setHorizontalScrollWidth((prev) => (prev === nextWidth ? prev : nextWidth));
const externalScroll = externalHScrollRef.current;
if (!externalScroll || horizontalSyncSourceRef.current === 'external') {
return;
}
const preferredTarget =
targets.find((target) => target.scrollWidth > target.clientWidth + 1) ||
targets[0];
const targetScrollLeft = preferredTarget?.scrollLeft || 0;
if (Math.abs(externalScroll.scrollLeft - targetScrollLeft) > 1) {
externalScroll.scrollLeft = targetScrollLeft;
}
}, []);
const applyExternalScrollToTableTargets = useCallback(() => {
const tableContainer = tableContainerRef.current;
const externalScroll = externalHScrollRef.current;
if (!(tableContainer instanceof HTMLElement) || !(externalScroll instanceof HTMLDivElement)) {
return;
}
if (horizontalSyncSourceRef.current === 'table') {
return;
}
const liveTargets = [
...new Set(
[
...Array.from(tableContainer.querySelectorAll('.ant-table-content')),
...Array.from(tableContainer.querySelectorAll('.ant-table-body')),
].filter((node): node is HTMLElement => node instanceof HTMLElement),
),
];
if (liveTargets.length === 0) {
return;
}
horizontalSyncSourceRef.current = 'external';
liveTargets.forEach((target) => {
if (target.scrollWidth <= target.clientWidth + 1) {
return;
}
if (Math.abs(target.scrollLeft - externalScroll.scrollLeft) > 1) {
target.scrollLeft = externalScroll.scrollLeft;
}
});
horizontalSyncSourceRef.current = '';
}, []);
const refreshStatus = useCallback(async (toastOnError = true) => {
setLoading(true);
try {
@@ -429,12 +511,125 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
useEffect(() => {
if (!open) {
setHorizontalScrollWidth(DRIVER_TABLE_SCROLL_X);
tableScrollTargetsRef.current = [];
return;
}
refreshStatus(false);
checkNetworkStatus(false);
}, [checkNetworkStatus, open, refreshStatus]);
useEffect(() => {
if (!open) {
return;
}
const tableContainer = tableContainerRef.current;
const externalScroll = externalHScrollRef.current;
if (!(tableContainer instanceof HTMLElement) || !(externalScroll instanceof HTMLDivElement)) {
return;
}
let currentTargets: HTMLElement[] = [];
let rafId: number | null = null;
let bodyResizeObserver: ResizeObserver | null = null;
let containerResizeObserver: ResizeObserver | null = null;
const pickSyncTarget = () => {
if (currentTargets.length === 0) {
return null;
}
return currentTargets.find((target) => target.scrollWidth > target.clientWidth + 1) || currentTargets[0];
};
const syncFromTableTarget = (event?: Event) => {
const source = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null;
const activeTarget = source || pickSyncTarget();
if (!activeTarget) {
return;
}
if (horizontalSyncSourceRef.current === 'external') {
return;
}
horizontalSyncSourceRef.current = 'table';
if (Math.abs(externalScroll.scrollLeft - activeTarget.scrollLeft) > 1) {
externalScroll.scrollLeft = activeTarget.scrollLeft;
}
horizontalSyncSourceRef.current = '';
};
const bindCurrentTableTargets = () => {
const nextTargets = [
...new Set(
[
...Array.from(tableContainer.querySelectorAll('.ant-table-content')),
...Array.from(tableContainer.querySelectorAll('.ant-table-body')),
].filter((node): node is HTMLElement => node instanceof HTMLElement),
),
];
const sameTargets =
nextTargets.length === currentTargets.length &&
nextTargets.every((target, index) => target === currentTargets[index]);
if (sameTargets) {
return;
}
currentTargets.forEach((target) => {
target.removeEventListener('scroll', syncFromTableTarget);
bodyResizeObserver?.unobserve(target);
});
currentTargets = nextTargets;
tableScrollTargetsRef.current = nextTargets;
currentTargets.forEach((target) => {
target.addEventListener('scroll', syncFromTableTarget, { passive: true });
bodyResizeObserver?.observe(target);
});
refreshHorizontalScrollState();
syncFromTableTarget();
};
const scheduleRefresh = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
bindCurrentTableTargets();
refreshHorizontalScrollState();
});
};
const mutationObserver = new MutationObserver(scheduleRefresh);
mutationObserver.observe(tableContainer, { childList: true, subtree: true });
bodyResizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(scheduleRefresh) : null;
containerResizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(scheduleRefresh) : null;
containerResizeObserver?.observe(tableContainer);
if (typeof ResizeObserver !== 'undefined') {
modalContentRef.current && containerResizeObserver?.observe(modalContentRef.current);
}
window.addEventListener('resize', scheduleRefresh);
scheduleRefresh();
return () => {
mutationObserver.disconnect();
window.removeEventListener('resize', scheduleRefresh);
currentTargets.forEach((target) => {
target.removeEventListener('scroll', syncFromTableTarget);
});
if (bodyResizeObserver) {
bodyResizeObserver.disconnect();
}
if (containerResizeObserver) {
containerResizeObserver.disconnect();
}
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
};
}, [open, refreshHorizontalScrollState]);
useEffect(() => {
if (!open) {
return;
@@ -470,7 +665,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
}, [appendOperationLog, open]);
const installDriver = useCallback(async (row: DriverStatusRow) => {
setActionDriver(row.type);
setActionState({ driverType: row.type, kind: 'install' });
setProgressMap((prev) => ({
...prev,
[row.type]: {
@@ -505,25 +700,25 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
message.success(`${row.name}${versionTip} 已安装启用`);
refreshStatus(false);
} finally {
setActionDriver('');
setActionState({ driverType: '', kind: '' });
}
}, [appendOperationLog, downloadDir, loadVersionOptions, refreshStatus, selectedVersionMap, versionMap]);
const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => {
const fileRes = await SelectDriverPackageFile(downloadDir);
if (!fileRes?.success) {
if (String(fileRes?.message || '') !== 'Cancelled') {
message.error(fileRes?.message || '选择本地驱动包失败');
const installDriverFromLocalPath = useCallback(async (
row: DriverStatusRow,
sourcePath: string,
sourceLabel: '文件' | '目录',
options?: { silentToast?: boolean; skipRefresh?: boolean },
) => {
const pathText = String(sourcePath || '').trim();
if (!pathText) {
if (!options?.silentToast) {
message.error(`未选择有效的本地导入${sourceLabel}`);
}
return;
}
const filePath = String((fileRes?.data as any)?.path || '').trim();
if (!filePath) {
message.error('未选择有效的驱动包文件');
return;
return false;
}
setActionDriver(row.type);
setActionState({ driverType: row.type, kind: 'local' });
setProgressMap((prev) => ({
...prev,
[row.type]: {
@@ -532,23 +727,122 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
percent: 0,
},
}));
appendOperationLog(row.type, `[START] 开始本地导入${filePath}`);
appendOperationLog(row.type, `[START] 开始本地导入${sourceLabel}${pathText}`);
try {
const result = await InstallLocalDriverPackage(row.type, filePath, downloadDir);
const result = await InstallLocalDriverPackage(row.type, pathText, downloadDir);
if (!result?.success) {
const errText = result?.message || `导入 ${row.name} 本地驱动包失败`;
appendOperationLog(row.type, `[ERROR] ${errText}`);
message.error(errText);
return;
if (!options?.silentToast) {
message.error(errText);
}
return false;
}
appendOperationLog(row.type, '[DONE] 本地导入安装完成');
message.success(`${row.name} 本地驱动包已安装启用`);
refreshStatus(false);
if (!options?.silentToast) {
message.success(`${row.name} 本地驱动包已安装启用`);
}
if (!options?.skipRefresh) {
await refreshStatus(false);
}
return true;
} finally {
setActionDriver('');
setActionState({ driverType: '', kind: '' });
}
}, [appendOperationLog, downloadDir, refreshStatus]);
const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => {
const fileRes = await SelectDriverPackageFile(downloadDir);
if (!fileRes?.success) {
if (String(fileRes?.message || '') !== 'Cancelled') {
message.error(fileRes?.message || '选择本地驱动包文件失败');
}
return;
}
const filePath = String((fileRes?.data as any)?.path || '').trim();
if (!filePath) {
message.error('未选择有效的驱动包文件');
return;
}
await installDriverFromLocalPath(row, filePath, '文件');
}, [downloadDir, installDriverFromLocalPath]);
const installDriversFromDirectory = useCallback(async () => {
const directoryRes = await SelectDriverPackageDirectory(downloadDir);
if (!directoryRes?.success) {
if (String(directoryRes?.message || '') !== 'Cancelled') {
message.error(directoryRes?.message || '选择本地驱动包目录失败');
}
return;
}
const directoryPath = String((directoryRes?.data as any)?.path || '').trim();
if (!directoryPath) {
message.error('未选择有效的驱动包目录');
return;
}
const optionalRows = rows.filter((item) => !item.builtIn);
if (optionalRows.length === 0) {
message.info('当前没有可导入的外置驱动');
return;
}
let successCount = 0;
let failCount = 0;
let dedupeSkipCount = 0;
let slimSkipCount = 0;
setBatchDirectoryImporting(true);
try {
for (const row of optionalRows) {
const alreadyInstalled = row.packageInstalled || row.connectable;
if (alreadyInstalled && !forceOverwriteInstalled) {
dedupeSkipCount += 1;
appendOperationLog(row.type, '[SKIP] 已检测到驱动已安装,目录导入去重跳过');
continue;
}
if (alreadyInstalled && forceOverwriteInstalled) {
appendOperationLog(row.type, '[INFO] 已启用覆盖已安装模式,执行重装导入');
}
const isSlimBuildUnavailable = (row.message || '').includes('精简构建') && !row.packageInstalled;
if (isSlimBuildUnavailable) {
slimSkipCount += 1;
appendOperationLog(row.type, '[WARN] 当前发行包为精简构建,已跳过目录导入');
continue;
}
const ok = await installDriverFromLocalPath(row, directoryPath, '目录', { silentToast: true, skipRefresh: true });
if (ok) {
successCount += 1;
} else {
failCount += 1;
}
}
await refreshStatus(false);
} finally {
setBatchDirectoryImporting(false);
}
const skipParts: string[] = [];
if (dedupeSkipCount > 0) {
skipParts.push(`去重跳过 ${dedupeSkipCount}`);
}
if (slimSkipCount > 0) {
skipParts.push(`精简版跳过 ${slimSkipCount}`);
}
const skipTip = skipParts.length > 0 ? `${skipParts.join('')}` : '';
const forceTip = forceOverwriteInstalled ? '(覆盖已安装)' : '';
if (failCount === 0) {
message.success(`目录导入完成${forceTip}:成功 ${successCount}${skipTip}`);
return;
}
if (successCount > 0) {
message.warning(`目录导入完成${forceTip}:成功 ${successCount},失败 ${failCount}${skipTip}`);
return;
}
message.error(`目录导入失败${forceTip}:失败 ${failCount}${skipTip}`);
}, [appendOperationLog, downloadDir, forceOverwriteInstalled, installDriverFromLocalPath, refreshStatus, rows]);
const openDriverLog = useCallback((driverType: string) => {
const normalized = String(driverType || '').trim().toLowerCase();
if (!normalized) {
@@ -559,7 +853,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
}, []);
const removeDriver = useCallback(async (row: DriverStatusRow) => {
setActionDriver(row.type);
setActionState({ driverType: row.type, kind: 'remove' });
appendOperationLog(row.type, '[START] 开始移除驱动');
try {
const result = await RemoveDriverPackage(row.type, downloadDir);
@@ -578,7 +872,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
});
refreshStatus(false);
} finally {
setActionDriver('');
setActionState({ driverType: '', kind: '' });
}
}, [appendOperationLog, downloadDir, refreshStatus]);
@@ -590,25 +884,6 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
key: 'name',
width: 150,
},
{
title: '安装位置',
key: 'installPath',
width: 260,
render: (_: string, row: DriverStatusRow) => {
if (row.builtIn) {
return <Text type="secondary"></Text>;
}
const installPath = row.executablePath || row.installDir || '-';
if (installPath === '-') {
return <Text type="secondary">-</Text>;
}
return (
<Text copyable={{ text: installPath }} style={{ fontSize: 12 }}>
{installPath}
</Text>
);
},
},
{
title: '安装包大小',
dataIndex: 'packageSizeText',
@@ -688,6 +963,14 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
if (row.builtIn) {
return <Text type="secondary">-</Text>;
}
const versionLocked = row.packageInstalled || row.connectable;
if (versionLocked) {
const installedVersion = String(row.installedVersion || '').trim();
if (installedVersion) {
return <Text type="secondary">{installedVersion}</Text>;
}
return <Text type="secondary"></Text>;
}
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
const selectOptions = buildVersionSelectOptions(options);
@@ -696,7 +979,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
size="small"
style={{ width: '100%' }}
loading={!!versionLoadingMap[row.type]}
disabled={actionDriver === row.type}
disabled={actionState.driverType === row.type}
placeholder={options.length > 0 ? '选择驱动版本' : '点击展开加载版本'}
value={selectedKey}
options={selectOptions as any}
@@ -726,7 +1009,9 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
return <Text type="secondary">-</Text>;
}
const isSlimBuildUnavailable = (row.message || '').includes('精简构建');
const loadingAction = actionDriver === row.type;
const loadingInstallOrRemove =
actionState.driverType === row.type && (actionState.kind === 'install' || actionState.kind === 'remove');
const loadingLocal = actionState.driverType === row.type && actionState.kind === 'local';
if (isSlimBuildUnavailable && !row.packageInstalled) {
return <Text type="secondary"> Full </Text>;
}
@@ -738,7 +1023,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
<Button
danger
icon={<DeleteOutlined />}
loading={loadingAction}
loading={loadingInstallOrRemove}
onClick={() => removeDriver(row)}
>
@@ -747,7 +1032,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
<Button
type="primary"
icon={<DownloadOutlined />}
loading={loadingAction}
loading={loadingInstallOrRemove}
onClick={() => installDriver(row)}
>
@@ -759,7 +1044,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
{mainAction}
<Button
icon={<FileSearchOutlined />}
loading={loadingAction}
loading={loadingLocal}
onClick={() => installDriverFromLocalFile(row)}
>
@@ -776,7 +1061,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
},
},
];
}, [actionDriver, installDriver, installDriverFromLocalFile, loadVersionOptions, loadVersionPackageSize, openDriverLog, operationLogMap, progressMap, removeDriver, selectedVersionMap, versionLoadingMap, versionMap, versionSizeLoadingMap]);
}, [actionState, installDriver, installDriverFromLocalFile, loadVersionOptions, loadVersionPackageSize, openDriverLog, operationLogMap, progressMap, removeDriver, selectedVersionMap, versionLoadingMap, versionMap, versionSizeLoadingMap]);
const activeLogRow = useMemo(() => {
if (!logDriverType) {
@@ -805,18 +1090,31 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
},
}}
destroyOnClose
footer={[
<Button key="refresh" icon={<ReloadOutlined />} onClick={() => refreshStatus(true)} loading={loading}>
</Button>,
<Button key="network" onClick={() => checkNetworkStatus(true)} loading={networkChecking}>
</Button>,
<Button key="close" type="primary" onClick={onClose}>
</Button>,
]}
footer={(
<div className="driver-manager-footer">
<div
ref={externalHScrollRef}
className="driver-manager-hscroll"
aria-hidden={false}
onScroll={applyExternalScrollToTableTargets}
>
<div className="driver-manager-hscroll-inner" style={{ width: `${Math.max(horizontalScrollWidth, 1)}px` }} />
</div>
<Space className="driver-manager-footer-actions" size={8}>
<Button key="refresh" icon={<ReloadOutlined />} onClick={() => refreshStatus(true)} loading={loading}>
</Button>
<Button key="network" onClick={() => checkNetworkStatus(true)} loading={networkChecking}>
</Button>
<Button key="close" type="primary" onClick={onClose}>
</Button>
</Space>
</div>
)}
>
<div ref={modalContentRef}>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Text type="secondary"> MySQL / Redis / Oracle / PostgreSQL </Text>
{networkStatus ? (
@@ -868,7 +1166,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
description={(
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text type="secondary"></Text>
<Text type="secondary"> `mariadb-driver-agent` / `mariadb-driver-agent.exe` `GoNavi-DriverAgents.zip`</Text>
<Text type="secondary">/ `mariadb-driver-agent``mariadb-driver-agent.exe``GoNavi-DriverAgents.zip`使</Text>
<Paragraph copyable={{ text: downloadDir || '-' }} style={{ marginBottom: 0 }}>
{downloadDir || '-'}
</Paragraph>
@@ -881,16 +1179,42 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
)}
/>
<Table
rowKey="type"
loading={loading}
columns={columns as any}
dataSource={rows}
pagination={false}
size="middle"
scroll={{ x: 1450 }}
/>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Space size={8}>
<Text type="secondary"></Text>
<Switch
checked={forceOverwriteInstalled}
onChange={(checked) => setForceOverwriteInstalled(checked)}
disabled={batchDirectoryImporting}
/>
</Space>
<Button
icon={<FolderOpenOutlined />}
loading={batchDirectoryImporting}
onClick={() => void installDriversFromDirectory()}
>
</Button>
</Space>
<div
ref={tableContainerRef}
className="driver-manager-table-wrap driver-manager-table-wrap-external-active"
>
<Table
className="driver-manager-table"
rowKey="type"
loading={loading}
columns={columns as any}
dataSource={rows}
pagination={false}
size="middle"
sticky={false}
scroll={{ x: DRIVER_TABLE_SCROLL_X }}
/>
</div>
</Space>
</div>
<Modal
title={`驱动日志 - ${activeLogRow?.name || logDriverType}`}
open={logModalOpen}

View File

@@ -164,6 +164,8 @@ export function ResolveDriverRepositoryURL(arg1:string):Promise<connection.Query
export function SelectDriverDownloadDirectory(arg1:string):Promise<connection.QueryResult>;
export function SelectDriverPackageDirectory(arg1:string):Promise<connection.QueryResult>;
export function SelectDriverPackageFile(arg1:string):Promise<connection.QueryResult>;
export function SelectSSHKeyFile(arg1:string):Promise<connection.QueryResult>;

View File

@@ -322,6 +322,10 @@ export function SelectDriverDownloadDirectory(arg1) {
return window['go']['app']['App']['SelectDriverDownloadDirectory'](arg1);
}
export function SelectDriverPackageDirectory(arg1) {
return window['go']['app']['App']['SelectDriverPackageDirectory'](arg1);
}
export function SelectDriverPackageFile(arg1) {
return window['go']['app']['App']['SelectDriverPackageFile'](arg1);
}

View File

@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"net"
"net/http"
"net/url"
@@ -200,6 +201,7 @@ const (
driverBundleIndexMaxSize = 1 << 20
driverManifestMaxSize = 2 << 20
driverNetworkProbeTimeout = 4 * time.Second
localDriverDirectoryScanMaxEntries = 20000
driverChecksumPolicyStrict = "strict"
driverChecksumPolicyWarn = "warn"
driverChecksumPolicyOff = "off"
@@ -228,18 +230,19 @@ const builtinDriverManifestJSON = `{
}`
var (
driverManifestCacheMu sync.RWMutex
driverManifestCache = make(map[string]driverManifestCacheEntry)
driverReleaseSizeMu sync.RWMutex
driverReleaseSizeMap = make(map[string]driverReleaseAssetSizeCacheEntry)
driverReleaseListMu sync.RWMutex
driverReleaseList = driverManifestReleaseListCache{}
driverModuleLatestMu sync.RWMutex
driverModuleLatestMap = make(map[string]goModuleLatestVersionCacheEntry)
driverModuleVersionMu sync.RWMutex
driverModuleVersionMap = make(map[string]goModuleVersionListCacheEntry)
driverVersionWarmupMu sync.Mutex
driverVersionWarmup = driverVersionWarmupState{}
driverManifestCacheMu sync.RWMutex
driverManifestCache = make(map[string]driverManifestCacheEntry)
driverReleaseSizeMu sync.RWMutex
driverReleaseSizeMap = make(map[string]driverReleaseAssetSizeCacheEntry)
driverReleaseListMu sync.RWMutex
driverReleaseList = driverManifestReleaseListCache{}
driverModuleLatestMu sync.RWMutex
driverModuleLatestMap = make(map[string]goModuleLatestVersionCacheEntry)
driverModuleVersionMu sync.RWMutex
driverModuleVersionMap = make(map[string]goModuleVersionListCacheEntry)
driverVersionWarmupMu sync.Mutex
driverVersionWarmup = driverVersionWarmupState{}
errLocalDriverDirScanLimit = errors.New("local_driver_directory_scan_limit_exceeded")
)
type driverVersionWarmupState struct {
@@ -360,9 +363,6 @@ func (a *App) SelectDriverPackageFile(currentPath string) connection.QueryResult
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "选择驱动包文件",
DefaultDirectory: defaultDir,
Filters: []runtime.FileFilter{
{DisplayName: "所有文件", Pattern: "*"},
},
})
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
@@ -377,6 +377,36 @@ func (a *App) SelectDriverPackageFile(currentPath string) connection.QueryResult
return connection.QueryResult{Success: true, Data: map[string]interface{}{"path": selection}}
}
func (a *App) SelectDriverPackageDirectory(currentPath string) connection.QueryResult {
defaultDir := strings.TrimSpace(currentPath)
if defaultDir == "" {
defaultDir = defaultDriverDownloadDirectory()
}
if filepath.Ext(defaultDir) != "" {
defaultDir = filepath.Dir(defaultDir)
}
if !filepath.IsAbs(defaultDir) {
if abs, err := filepath.Abs(defaultDir); err == nil {
defaultDir = abs
}
}
selection, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
Title: "选择驱动包目录",
DefaultDirectory: defaultDir,
})
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if strings.TrimSpace(selection) == "" {
return connection.QueryResult{Success: false, Message: "Cancelled"}
}
if abs, err := filepath.Abs(selection); err == nil {
selection = abs
}
return connection.QueryResult{Success: true, Data: map[string]interface{}{"path": selection}}
}
func (a *App) ResolveDriverDownloadDirectory(directory string) connection.QueryResult {
resolved, err := resolveDriverDownloadDirectory(directory)
if err != nil {
@@ -684,7 +714,7 @@ func (a *App) InstallLocalDriverPackage(driverType string, filePath string, down
a.emitDriverDownloadProgress(definition.Type, "start", 0, 100, "开始安装本地驱动包")
selectedVersion := resolveDriverInstallVersion(definition.PinnedVersion, "local://manual", definition)
meta, installErr := installOptionalDriverAgentFromLocalFile(definition, filePath, resolvedDir, selectedVersion)
meta, installErr := installOptionalDriverAgentFromLocalPath(definition, filePath, resolvedDir, selectedVersion)
if installErr != nil {
errText := normalizeErrorMessage(installErr)
a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, errText)
@@ -2194,7 +2224,7 @@ func installOptionalDriverAgentPackage(a *App, definition driverDefinition, sele
}, nil
}
func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePath string, resolvedDir string, selectedVersion string) (installedDriverPackage, error) {
func installOptionalDriverAgentFromLocalPath(definition driverDefinition, filePath string, resolvedDir string, selectedVersion string) (installedDriverPackage, error) {
driverType := normalizeDriverType(definition.Type)
displayName := resolveDriverDisplayName(definition)
pathText := strings.TrimSpace(filePath)
@@ -2208,9 +2238,6 @@ func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePa
if statErr != nil {
return installedDriverPackage{}, fmt.Errorf("读取本地驱动包失败:%w", statErr)
}
if info.IsDir() {
return installedDriverPackage{}, fmt.Errorf("本地驱动包路径为目录:%s", pathText)
}
executablePath, err := db.ResolveOptionalDriverAgentExecutablePath(resolvedDir, driverType)
if err != nil {
@@ -2220,8 +2247,23 @@ func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePa
return installedDriverPackage{}, fmt.Errorf("创建 %s 驱动目录失败:%w", displayName, mkErr)
}
sourcePath := pathText
sourceName := filepath.Base(pathText)
downloadSource := fmt.Sprintf("local://manual/%s", filepath.Base(pathText))
if strings.EqualFold(filepath.Ext(pathText), ".zip") {
if info.IsDir() {
matchedPath, matchedEntry, resolveErr := resolveLocalDriverAgentFromDirectory(pathText, driverType)
if resolveErr != nil {
return installedDriverPackage{}, resolveErr
}
sourcePath = matchedPath
sourceName = filepath.Base(matchedPath)
downloadSource = fmt.Sprintf("local://manual-dir/%s", filepath.Base(pathText))
if strings.TrimSpace(matchedEntry) != "" {
downloadSource = downloadSource + "#" + matchedEntry
}
}
if !info.IsDir() && strings.EqualFold(filepath.Ext(pathText), ".zip") {
entryName, extractErr := installOptionalDriverAgentFromLocalZip(pathText, definition, executablePath)
if extractErr != nil {
return installedDriverPackage{}, extractErr
@@ -2230,7 +2272,7 @@ func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePa
downloadSource = downloadSource + "#" + entryName
}
} else {
if copyErr := copyAgentBinary(pathText, executablePath); copyErr != nil {
if copyErr := copyAgentBinary(sourcePath, executablePath); copyErr != nil {
return installedDriverPackage{}, fmt.Errorf("导入本地驱动代理失败:%w", copyErr)
}
}
@@ -2242,8 +2284,8 @@ func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePa
return installedDriverPackage{
DriverType: driverType,
Version: strings.TrimSpace(selectedVersion),
FilePath: pathText,
FileName: filepath.Base(pathText),
FilePath: sourcePath,
FileName: sourceName,
ExecutablePath: executablePath,
DownloadURL: downloadSource,
SHA256: hash,
@@ -2251,6 +2293,143 @@ func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePa
}, nil
}
type localDriverCandidate struct {
absPath string
relativePath string
depth int
inPlatformDir bool
}
func resolveLocalDriverAgentFromDirectory(directoryPath string, driverType string) (string, string, error) {
root := strings.TrimSpace(directoryPath)
if root == "" {
return "", "", fmt.Errorf("本地驱动目录路径为空")
}
if absPath, absErr := filepath.Abs(root); absErr == nil {
root = absPath
}
info, statErr := os.Stat(root)
if statErr != nil {
return "", "", fmt.Errorf("读取本地驱动目录失败:%w", statErr)
}
if !info.IsDir() {
return "", "", fmt.Errorf("本地驱动目录路径不是目录:%s", root)
}
normalizedType := normalizeDriverType(driverType)
displayDefinition, found := resolveDriverDefinition(normalizedType)
if !found {
displayDefinition = driverDefinition{Type: normalizedType, Name: normalizedType}
}
displayName := resolveDriverDisplayName(displayDefinition)
platformDir := optionalDriverBundlePlatformDir(stdRuntime.GOOS)
assetName := optionalDriverReleaseAssetName(normalizedType)
baseName := optionalDriverExecutableBaseName(normalizedType)
exactRelativePath := filepath.ToSlash(filepath.Join(platformDir, assetName))
exactPath := filepath.Join(root, platformDir, assetName)
if exactInfo, err := os.Stat(exactPath); err == nil && !exactInfo.IsDir() {
return exactPath, exactRelativePath, nil
}
rootAssetPath := filepath.Join(root, assetName)
if rootAssetInfo, err := os.Stat(rootAssetPath); err == nil && !rootAssetInfo.IsDir() {
return rootAssetPath, filepath.ToSlash(assetName), nil
}
assetCandidates := make([]localDriverCandidate, 0, 8)
baseCandidates := make([]localDriverCandidate, 0, 8)
visited := 0
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
visited++
if visited > localDriverDirectoryScanMaxEntries {
return errLocalDriverDirScanLimit
}
if d.IsDir() {
return nil
}
name := strings.TrimSpace(d.Name())
if name == "" {
return nil
}
relative, relErr := filepath.Rel(root, path)
if relErr != nil {
relative = name
}
normalizedRelative := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(relative), "./"))
if normalizedRelative == "" {
normalizedRelative = name
}
normalizedLower := strings.ToLower(normalizedRelative)
platformPrefix := strings.ToLower(platformDir) + "/"
inPlatformDir := normalizedLower == strings.ToLower(platformDir) || strings.HasPrefix(normalizedLower, platformPrefix)
depth := strings.Count(normalizedRelative, "/")
candidate := localDriverCandidate{
absPath: path,
relativePath: normalizedRelative,
depth: depth,
inPlatformDir: inPlatformDir,
}
if strings.EqualFold(name, assetName) {
assetCandidates = append(assetCandidates, candidate)
return nil
}
if strings.EqualFold(name, baseName) {
baseCandidates = append(baseCandidates, candidate)
}
return nil
})
if errors.Is(walkErr, errLocalDriverDirScanLimit) {
return "", "", fmt.Errorf("本地驱动目录条目过多(超过 %d请缩小目录范围或直接选择 zip/单文件", localDriverDirectoryScanMaxEntries)
}
if walkErr != nil {
return "", "", fmt.Errorf("扫描本地驱动目录失败:%w", walkErr)
}
selectBest := func(candidates []localDriverCandidate) (localDriverCandidate, bool) {
if len(candidates) == 0 {
return localDriverCandidate{}, false
}
sort.Slice(candidates, func(i, j int) bool {
left := candidates[i]
right := candidates[j]
if left.inPlatformDir != right.inPlatformDir {
return left.inPlatformDir
}
if left.depth != right.depth {
return left.depth < right.depth
}
leftRelative := strings.ToLower(left.relativePath)
rightRelative := strings.ToLower(right.relativePath)
if leftRelative != rightRelative {
return leftRelative < rightRelative
}
return strings.ToLower(left.absPath) < strings.ToLower(right.absPath)
})
return candidates[0], true
}
if candidate, ok := selectBest(assetCandidates); ok {
return candidate.absPath, candidate.relativePath, nil
}
if candidate, ok := selectBest(baseCandidates); ok {
return candidate.absPath, candidate.relativePath, nil
}
return "", "", fmt.Errorf(
"目录中未找到 %s 代理文件(优先路径 %s候选文件名 %s / %s",
displayName,
exactRelativePath,
assetName,
baseName,
)
}
func installOptionalDriverAgentFromLocalZip(zipPath string, definition driverDefinition, executablePath string) (string, error) {
driverType := normalizeDriverType(definition.Type)
displayName := resolveDriverDisplayName(definition)