Files
MyGoNavi/frontend/src/components/DriverManagerModal.tsx
Syngnat 89c81823bc Release/0.5.4 (#199)
* 🐛 fix(data-viewer): 修复ClickHouse尾部分页异常并增强DuckDB复杂类型兼容

- DataViewer 新增 ClickHouse 反向分页策略,修复最后页与倒数页查询失败
- DuckDB 查询失败时按列类型生成安全 SELECT,复杂类型转 VARCHAR 重试
- 分页状态统一使用 currentPage 回填,避免页码与总数推导不一致
- 增强查询异常日志与重试路径,降低大表场景卡顿与误报

*  feat(frontend-driver): 驱动管理支持快速搜索并优化信息展示

- 新增搜索框,支持按 DuckDB/ClickHouse 等关键字快速定位驱动
- 显示“匹配 x / y”统计与无结果提示
- 优化头部区域排版,提升透明/暗色场景下的视觉对齐

* 🔧 fix(connection-modal): 修复多数据源URI导入解析并校正Oracle服务名校验

- 新增单主机URI解析映射,兼容 postgres/postgresql、sqlserver、redis、tdengine、dameng(dm)、kingbase、highgo、vastbase、clickhouse、oracle
- 抽取 parseSingleHostUri 复用逻辑,统一 host/port/user/password/database 回填行为
- Oracle 连接新增服务名必填校验,移除“服务名为空回退用户名”的隐式逻辑
- 连接弹窗补充 Oracle 服务名输入项与 URI 示例

* 🐛 fix(query-export): 修复查询结果导出卡住并统一按数据源能力控制导出路径

- 查询结果页导出增加稳定兜底,异常时确保 loading 关闭避免持续转圈
- DataGrid 导出逻辑按数据源能力分流,优先走后端 ExportQuery 并保留结果集导出降级
- QueryEditor 传递结果导出 SQL,保证查询结果导出范围与当前结果一致
- 后端补充 ExportData/ExportQuery 关键日志,提升导出链路可观测性

* 🐛 fix(precision): 修复查询链路与分页统计的大整数精度丢失

- 代理响应数据解码改为 UseNumber,避免默认 float64 吞精度
- 统一归一化 json.Number 与超界整数,超出 JS 安全范围转字符串
- 修复 DataViewer 总数解析,超大值不再误转 Number 参与分页
- refs #142

* 🐛 fix(driver-manager): 修复驱动管理网络告警重复并强化代理引导

- 新增下载链路域名探测,区分“GitHub可达但驱动下载链路不可达”
- 网络不可达场景仅保留红色强提醒,移除重复二级告警
- 强提醒增加“打开全局代理设置”入口,优先引导使用 GoNavi 全局代理
- 统一网络检测与目录说明提示图标尺寸,修复加载期视觉不一致
- refs #141

* ♻️ refactor(frontend-interaction): 统一标签拖拽与暗色主题交互实现

- 重构Tab拖拽排序实现,统一为可配置拖拽引擎
- 规范拖拽与点击事件边界,提升交互一致性
- 统一多组件暗色透明样式策略,减少硬编码色值
- 提升Redis/表格/连接面板在透明模式下的观感一致性
- refs #144

* ♻️ refactor(update-state): 重构在线更新状态流并按版本统一进度展示

- 重构更新检查与下载状态同步流程,减少前后端状态分叉
- 进度展示严格绑定 latestVersion,避免跨版本状态串用
- 优化 about 打开场景的静默检查状态回填逻辑
- 统一下载弹窗关闭/后台隐藏行为
- 保持现有安装流程并补齐目录打开能力

* 🎨 style(sidebar-log): 将SQL执行日志入口调整为悬浮胶囊样式

- 移除侧栏底部整条日志入口容器
- 新增悬浮按钮阴影/边框/透明背景并适配明暗主题
- 为树区域预留底部空间避免入口遮挡内容

*  feat(redis-cluster): 支持集群模式逻辑多库隔离与 0-15 库切换

- 前端恢复 Redis 集群场景下 db0-db15 的数据库选择与展示
- 后端新增集群逻辑库命名空间前缀映射,统一 key/pattern 读写隔离
- 覆盖扫描、读取、写入、删除、重命名等核心操作的键映射规则
- 集群命令通道支持 SELECT 逻辑切库与 FLUSHDB 逻辑库清空
- refs #145

*  feat(DataGrid): 大数据表虚拟滚动性能优化及UI一致性修复

- 启用动态虚拟滚动(数据量≥500行自动切换),解决万行数据表卡顿问题
- 虚拟模式下EditableCell改用div渲染,CSS选择器从元素级改为类级适配虚拟DOM
- 修复虚拟模式双水平滚动条:样式化rc-virtual-list内置滚动条为胶囊外观,禁用自定义外部滚动条
- 为rc-virtual-list水平滚动条添加鼠标滚轮支持(MutationObserver + marginLeft驱动)
- 修复白色主题透明模式下列名悬浮Tooltip对比度不足的问题
- 新增白色主题全局滚动条样式适配透明模式(App.css)
- App.tsx主题token与组件样式优化
- refs #147

* 🔧 chore(app): 清理 App.tsx 类型告警并收敛前端壳层实现

- 清除未使用代码和冗余状态
- 替换弃用 API 以消除 IDE 提示
- 显式处理浮动 Promise 避免告警
- 保持现有更新检查和代理设置行为不变

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建工具链

- 将 DuckDB 编译链从 MINGW64 切换为 MSYS2 UCRT64
- 修正 Windows AMD64 的 gcc 和 g++ 探测路径
- 增加 DuckDB 编译器版本校验步骤

* 📝 docs(contributing): 补充中英文贡献指南并统一 README 入口

- 新增英文版 CONTRIBUTING.md 作为正式贡献文档
- 新增中文版 CONTRIBUTING.zh-CN.md 作为中文贡献说明
- 调整 README 和 README.zh-CN 的贡献入口指向对应语言文档

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188) (#190)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>

*  feat(release-notes): 支持自动生成 Release 更新说明并区分配置文件命名

* 🔁 chore(sync): 回灌 main 到 dev (#192)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* 🐛 fix(branch-sync): 修复 main 回灌 dev 时 mergeable 异步计算导致漏开自动合并

- 增加 mergeable 状态轮询,避免新建同步 PR 后立即返回 UNKNOWN
- 在合并状态未稳定时输出中文告警与执行摘要
- 保持冲突分支、待计算分支与自动合并分支的处理路径清晰

* 🔁 chore(sync): 回灌 main 到 dev (#195)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

* - chore(ci): 新增全平台测试包手动构建工作流 tianqijiuyun-latiao 今天 下午4:26 (#194)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* fix(kingbase): 补齐主键识别并优化宽表卡顿 refs #176 refs #178

* fix(query-execution): 支持带前置注释的读查询结果识别

* chore(ci): 新增全平台测试包手动构建工作流

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* ♻️ refactor(frontend-sync): 优化桌面交互细节并移除 main 回灌 dev 自动化

- 优化新建连接、主题设置、侧边栏工具区与 SQL 日志的界面表现
- 调整分页、筛选、透明模式与弹窗样式,统一整体交互层次
- 收口外观参数生效逻辑并补齐多组件适配
- 删除 sync-main-to-dev 工作流并同步维护者手动回灌说明

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
2026-03-07 17:15:30 +08:00

1447 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Button, Collapse, Input, Modal, Progress, Select, Space, Switch, Table, Tag, Typography, message } from 'antd';
import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, FolderOpenOutlined, InfoCircleFilled, ReloadOutlined } from '@ant-design/icons';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { useStore } from '../store';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import {
CheckDriverNetworkStatus,
DownloadDriverPackage,
GetDriverVersionList,
GetDriverVersionPackageSize,
GetDriverStatusList,
InstallLocalDriverPackage,
RemoveDriverPackage,
SelectDriverPackageDirectory,
SelectDriverPackageFile,
} from '../../wailsjs/go/app/App';
const { Paragraph, Text } = Typography;
type DriverStatusRow = {
type: string;
name: string;
builtIn: boolean;
pinnedVersion?: string;
installedVersion?: string;
packageSizeText?: string;
runtimeAvailable: boolean;
packageInstalled: boolean;
connectable: boolean;
defaultDownloadUrl?: string;
installDir?: string;
packagePath?: string;
executablePath?: string;
downloadedAt?: string;
message?: string;
};
type DriverProgressEvent = {
driverType?: string;
status?: 'start' | 'downloading' | 'done' | 'error';
message?: string;
percent?: number;
};
type ProgressState = {
status: 'start' | 'downloading' | 'done' | 'error';
message: string;
percent: number;
};
type DriverActionKind = '' | 'install' | 'remove' | 'local';
type DriverLogEntry = {
time: string;
text: string;
signature: string;
};
type DriverNetworkProbe = {
name: string;
url: string;
reachable: boolean;
httpStatus?: number;
latencyMs?: number;
tcpLatencyMs?: number;
httpLatencyMs?: number;
method?: string;
error?: string;
};
type DriverNetworkStatus = {
reachable: boolean;
summary: string;
recommendedProxy: boolean;
proxyConfigured: boolean;
downloadChainReachable?: boolean;
downloadRequiredHosts?: string[];
proxyEnv?: Record<string, string>;
checks: DriverNetworkProbe[];
checkedAt?: string;
logPath?: string;
};
const parseOptionalLatency = (value: unknown): number | undefined => {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) return undefined;
return parsed;
};
const sharedInfoAlertIcon = <InfoCircleFilled style={{ fontSize: 24 }} />;
type DriverVersionOption = {
version: string;
downloadUrl: string;
packageSizeText?: string;
recommended?: boolean;
source?: string;
year?: string;
displayLabel?: string;
};
const buildVersionOptionKey = (option: DriverVersionOption) => `${option.version}@@${option.downloadUrl}`;
const buildVersionSizeLoadingKey = (driverType: string, optionKey: string) => `${driverType}@@${optionKey}`;
const DRIVER_TABLE_SCROLL_X = 1450;
const DRIVER_STATUS_CACHE_TTL_MS = 60 * 1000;
const DRIVER_NETWORK_CACHE_TTL_MS = 5 * 60 * 1000;
const normalizeDriverSearchText = (value: string) => String(value || '').trim().toLowerCase();
let driverStatusSnapshotCache: { rows: DriverStatusRow[]; downloadDir: string; cachedAt: number } | null = null;
let driverNetworkSnapshotCache: { status: DriverNetworkStatus; cachedAt: number } | null = null;
const isFreshCache = (cachedAt: number, ttlMs: number): boolean => Date.now() - cachedAt <= ttlMs;
const buildVersionSelectOptions = (options: DriverVersionOption[]) => {
type SelectOption = { value: string; label: string };
type SelectGroup = { label: string; options: SelectOption[] };
if (options.length === 0) {
return [] as Array<SelectOption | SelectGroup>;
}
const yearGroups = new Map<string, SelectOption[]>();
const others: SelectOption[] = [];
options.forEach((option) => {
const selectOption: SelectOption = {
value: buildVersionOptionKey(option),
label: option.displayLabel || option.version || '默认版本',
};
const year = String(option.year || '').trim();
if (!year) {
others.push(selectOption);
return;
}
const group = yearGroups.get(year) || [];
group.push(selectOption);
yearGroups.set(year, group);
});
const sortedYears = Array.from(yearGroups.keys()).sort((a, b) => {
const left = Number.parseInt(a, 10);
const right = Number.parseInt(b, 10);
const leftValid = Number.isFinite(left);
const rightValid = Number.isFinite(right);
if (leftValid && rightValid) {
return right - left;
}
return b.localeCompare(a);
});
const grouped: SelectGroup[] = sortedYears.map((year) => ({
label: `${year}`,
options: yearGroups.get(year) || [],
}));
if (others.length > 0) {
grouped.push({ label: '其他', options: others });
}
return grouped;
};
const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenGlobalProxySettings?: () => void }> = ({
open,
onClose,
onOpenGlobalProxySettings,
}) => {
const theme = useStore((state) => state.theme);
const appearance = useStore((state) => state.appearance);
const darkMode = theme === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
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 [searchKeyword, setSearchKeyword] = useState('');
const [rows, setRows] = useState<DriverStatusRow[]>([]);
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 downloadDirRef = useRef(downloadDir);
useEffect(() => {
downloadDirRef.current = downloadDir;
}, [downloadDir]);
const appendOperationLog = useCallback((
driverType: string,
text: string,
signature?: string,
mode: 'append' | 'update-last' = 'append',
) => {
const normalized = String(driverType || '').trim().toLowerCase();
const content = String(text || '').trim();
if (!normalized || !content) {
return;
}
const sign = String(signature || content).trim() || content;
const now = new Date().toLocaleTimeString();
setOperationLogMap((prev) => {
const history = prev[normalized] || [];
if (history.length > 0) {
const last = history[history.length - 1];
if (last.signature === sign) {
if (mode === 'update-last') {
if (last.text === content) {
return prev;
}
const nextHistory = [...history];
nextHistory[nextHistory.length - 1] = {
...last,
text: content,
time: now,
};
return { ...prev, [normalized]: nextHistory };
}
return prev;
}
}
const nextHistory = [
...history,
{
time: now,
text: content,
signature: sign,
},
];
const sliced = nextHistory.length > 200 ? nextHistory.slice(nextHistory.length - 200) : nextHistory;
return { ...prev, [normalized]: sliced };
});
}, []);
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,
options?: { showLoading?: boolean },
) => {
const showLoading = options?.showLoading ?? true;
if (showLoading) {
setLoading(true);
}
try {
const res = await GetDriverStatusList(downloadDirRef.current, '');
if (!res?.success) {
if (toastOnError) {
message.error(res?.message || '拉取驱动状态失败');
}
return;
}
const data = (res?.data || {}) as any;
const resolvedDir = String(data.downloadDir || '').trim();
const drivers = Array.isArray(data.drivers) ? data.drivers : [];
const effectiveDownloadDir = resolvedDir || downloadDirRef.current;
if (resolvedDir) {
setDownloadDir(resolvedDir);
}
const nextRows: DriverStatusRow[] = drivers.map((item: any) => ({
type: String(item.type || '').trim(),
name: String(item.name || item.type || '').trim(),
builtIn: !!item.builtIn,
pinnedVersion: String(item.pinnedVersion || '').trim() || undefined,
installedVersion: String(item.installedVersion || '').trim() || undefined,
packageSizeText: String(item.packageSizeText || '').trim() || undefined,
runtimeAvailable: !!item.runtimeAvailable,
packageInstalled: !!item.packageInstalled,
connectable: !!item.connectable,
defaultDownloadUrl: String(item.defaultDownloadUrl || '').trim() || undefined,
installDir: String(item.installDir || '').trim() || undefined,
packagePath: String(item.packagePath || '').trim() || undefined,
executablePath: String(item.executablePath || '').trim() || undefined,
downloadedAt: String(item.downloadedAt || '').trim() || undefined,
message: String(item.message || '').trim() || undefined,
}));
setRows(nextRows);
driverStatusSnapshotCache = {
rows: nextRows,
downloadDir: effectiveDownloadDir,
cachedAt: Date.now(),
};
} catch (err: any) {
if (toastOnError) {
message.error(`拉取驱动状态失败:${err?.message || String(err)}`);
}
} finally {
if (showLoading) {
setLoading(false);
}
}
}, []);
const checkNetworkStatus = useCallback(async (
toastOnError = false,
options?: { showLoading?: boolean },
) => {
const showLoading = options?.showLoading ?? true;
if (showLoading) {
setNetworkChecking(true);
}
try {
const res = await CheckDriverNetworkStatus();
if (!res?.success) {
if (toastOnError) {
message.error(res?.message || '驱动网络检测失败');
}
return;
}
const data = (res?.data || {}) as any;
const checks = Array.isArray(data.checks) ? data.checks : [];
const normalizedChecks: DriverNetworkProbe[] = checks.map((item: any) => ({
name: String(item.name || '').trim(),
url: String(item.url || '').trim(),
reachable: !!item.reachable,
httpStatus: parseOptionalLatency(item.httpStatus),
latencyMs: parseOptionalLatency(item.latencyMs),
tcpLatencyMs: parseOptionalLatency(item.tcpLatencyMs),
httpLatencyMs: parseOptionalLatency(item.httpLatencyMs),
method: String(item.method || '').trim().toUpperCase() || undefined,
error: String(item.error || '').trim() || undefined,
}));
const nextStatus: DriverNetworkStatus = {
reachable: !!data.reachable,
summary: String(data.summary || '').trim() || '驱动网络检测已完成',
recommendedProxy: !!data.recommendedProxy,
proxyConfigured: !!data.proxyConfigured,
downloadChainReachable: typeof data.downloadChainReachable === 'boolean' ? data.downloadChainReachable : undefined,
downloadRequiredHosts: Array.isArray(data.downloadRequiredHosts)
? data.downloadRequiredHosts.map((item: unknown) => String(item || '').trim()).filter(Boolean)
: undefined,
proxyEnv: (data.proxyEnv || {}) as Record<string, string>,
checkedAt: String(data.checkedAt || '').trim() || undefined,
checks: normalizedChecks,
logPath: String(data.logPath || '').trim() || undefined,
};
setNetworkStatus(nextStatus);
driverNetworkSnapshotCache = {
status: nextStatus,
cachedAt: Date.now(),
};
} catch (err: any) {
if (toastOnError) {
message.error(`驱动网络检测失败:${err?.message || String(err)}`);
}
} finally {
if (showLoading) {
setNetworkChecking(false);
}
}
}, []);
const loadVersionOptions = useCallback(async (row: DriverStatusRow, toastOnError = false) => {
if (row.builtIn) {
return [] as DriverVersionOption[];
}
const driverType = String(row.type || '').trim();
if (!driverType) {
return [] as DriverVersionOption[];
}
setVersionLoadingMap((prev) => ({ ...prev, [driverType]: true }));
try {
const res = await GetDriverVersionList(driverType, '');
if (!res?.success) {
if (toastOnError) {
message.error(res?.message || `${row.name} 版本列表加载失败`);
}
return [] as DriverVersionOption[];
}
const data = (res?.data || {}) as any;
const rawVersions = Array.isArray(data.versions) ? data.versions : [];
const options: DriverVersionOption[] = rawVersions
.map((item: any) => {
const version = String(item.version || '').trim();
const downloadUrl = String(item.downloadUrl || '').trim();
if (!version && !downloadUrl) {
return null;
}
return {
version,
downloadUrl,
packageSizeText: String(item.packageSizeText || '').trim() || undefined,
recommended: !!item.recommended,
source: String(item.source || '').trim() || undefined,
year: String(item.year || '').trim() || undefined,
displayLabel: String(item.displayLabel || '').trim() || undefined,
} as DriverVersionOption;
})
.filter((item: DriverVersionOption | null): item is DriverVersionOption => !!item);
if (options.length === 0) {
const fallbackVersion = String(row.pinnedVersion || '').trim();
const fallbackURL = String(row.defaultDownloadUrl || '').trim();
if (fallbackVersion || fallbackURL) {
options.push({
version: fallbackVersion,
downloadUrl: fallbackURL,
recommended: true,
source: 'fallback',
displayLabel: fallbackVersion || '默认版本',
});
}
}
setVersionMap((prev) => ({ ...prev, [driverType]: options }));
setSelectedVersionMap((prev) => {
const currentKey = prev[driverType];
if (currentKey && options.some((option) => buildVersionOptionKey(option) === currentKey)) {
return prev;
}
const preferred =
options.find((option) => option.version === row.installedVersion) ||
options.find((option) => option.version === row.pinnedVersion) ||
options.find((option) => option.recommended) ||
options[0];
if (!preferred) {
return prev;
}
return { ...prev, [driverType]: buildVersionOptionKey(preferred) };
});
return options;
} catch (err: any) {
if (toastOnError) {
message.error(`加载 ${row.name} 版本列表失败:${err?.message || String(err)}`);
}
return [] as DriverVersionOption[];
} finally {
setVersionLoadingMap((prev) => ({ ...prev, [driverType]: false }));
}
}, []);
const loadVersionPackageSize = useCallback(async (row: DriverStatusRow, optionKey: string) => {
if (row.builtIn) {
return;
}
const driverType = String(row.type || '').trim();
if (!driverType || !optionKey) {
return;
}
const options = versionMap[driverType] || [];
const selectedOption = options.find((item) => buildVersionOptionKey(item) === optionKey);
if (!selectedOption) {
return;
}
if (String(selectedOption.packageSizeText || '').trim()) {
return;
}
const versionText = String(selectedOption.version || '').trim();
if (!versionText) {
return;
}
const loadingKey = buildVersionSizeLoadingKey(driverType, optionKey);
if (versionSizeLoadingMap[loadingKey]) {
return;
}
setVersionSizeLoadingMap((prev) => ({ ...prev, [loadingKey]: true }));
try {
const res = await GetDriverVersionPackageSize(driverType, versionText);
if (!res?.success) {
return;
}
const data = (res?.data || {}) as any;
const sizeText = String(data.packageSizeText || '').trim();
if (!sizeText) {
return;
}
setVersionMap((prev) => {
const current = prev[driverType] || [];
let changed = false;
const next = current.map((item) => {
if (buildVersionOptionKey(item) !== optionKey) {
return item;
}
if (String(item.packageSizeText || '').trim() === sizeText) {
return item;
}
changed = true;
return { ...item, packageSizeText: sizeText };
});
if (!changed) {
return prev;
}
return { ...prev, [driverType]: next };
});
} finally {
setVersionSizeLoadingMap((prev) => {
if (!prev[loadingKey]) {
return prev;
}
const next = { ...prev };
delete next[loadingKey];
return next;
});
}
}, [versionMap, versionSizeLoadingMap]);
useEffect(() => {
if (!open) {
setHorizontalScrollWidth(DRIVER_TABLE_SCROLL_X);
tableScrollTargetsRef.current = [];
return;
}
const cachedStatus = driverStatusSnapshotCache;
const hasCachedStatus = !!cachedStatus;
if (cachedStatus) {
setRows(cachedStatus.rows);
if (cachedStatus.downloadDir) {
setDownloadDir(cachedStatus.downloadDir);
}
}
const shouldRefreshStatus = !cachedStatus || !isFreshCache(cachedStatus.cachedAt, DRIVER_STATUS_CACHE_TTL_MS);
if (shouldRefreshStatus) {
void refreshStatus(false, { showLoading: !hasCachedStatus });
}
const cachedNetwork = driverNetworkSnapshotCache;
const hasCachedNetwork = !!cachedNetwork;
if (cachedNetwork) {
setNetworkStatus(cachedNetwork.status);
}
const shouldRefreshNetwork = !cachedNetwork || !isFreshCache(cachedNetwork.cachedAt, DRIVER_NETWORK_CACHE_TTL_MS);
if (shouldRefreshNetwork) {
void checkNetworkStatus(false, { showLoading: !hasCachedNetwork });
}
}, [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;
}
const off = EventsOn('driver:download-progress', (event: DriverProgressEvent) => {
if (!event) {
return;
}
const driverType = String(event.driverType || '').trim().toLowerCase();
const status = event.status;
if (!driverType || !status) {
return;
}
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 || '-'}`;
appendOperationLog(driverType, lineText, lineSignature, 'update-last');
});
return () => {
off();
};
}, [appendOperationLog, open]);
const installDriver = useCallback(async (row: DriverStatusRow) => {
setActionState({ driverType: row.type, kind: 'install' });
setProgressMap((prev) => ({
...prev,
[row.type]: {
status: 'start',
message: '开始安装',
percent: 0,
},
}));
appendOperationLog(row.type, '[START] 开始自动安装');
try {
let options = versionMap[row.type] || [];
if (options.length === 0) {
options = await loadVersionOptions(row, true);
}
const selectedKey = selectedVersionMap[row.type];
const selectedOption =
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
options.find((item) => item.recommended) ||
options[0];
const selectedVersion = selectedOption?.version || row.pinnedVersion || '';
const selectedDownloadURL = selectedOption?.downloadUrl || row.defaultDownloadUrl || '';
const result = await DownloadDriverPackage(row.type, selectedVersion, selectedDownloadURL, downloadDir);
if (!result?.success) {
const errText = result?.message || `安装 ${row.name} 失败`;
appendOperationLog(row.type, `[ERROR] ${errText}`);
message.error(errText);
return;
}
const versionTip = selectedVersion ? `${selectedVersion}` : '';
appendOperationLog(row.type, `[DONE] 自动安装完成 ${versionTip}`);
message.success(`${row.name}${versionTip} 已安装启用`);
refreshStatus(false);
} finally {
setActionState({ driverType: '', kind: '' });
}
}, [appendOperationLog, downloadDir, loadVersionOptions, refreshStatus, selectedVersionMap, versionMap]);
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 false;
}
setActionState({ driverType: row.type, kind: 'local' });
setProgressMap((prev) => ({
...prev,
[row.type]: {
status: 'start',
message: '开始导入本地驱动包',
percent: 0,
},
}));
appendOperationLog(row.type, `[START] 开始本地导入(${sourceLabel}${pathText}`);
try {
const result = await InstallLocalDriverPackage(row.type, pathText, downloadDir);
if (!result?.success) {
const errText = result?.message || `导入 ${row.name} 本地驱动包失败`;
appendOperationLog(row.type, `[ERROR] ${errText}`);
if (!options?.silentToast) {
message.error(errText);
}
return false;
}
appendOperationLog(row.type, '[DONE] 本地导入安装完成');
if (!options?.silentToast) {
message.success(`${row.name} 本地驱动包已安装启用`);
}
if (!options?.skipRefresh) {
await refreshStatus(false);
}
return true;
} finally {
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) {
return;
}
setLogDriverType(normalized);
setLogModalOpen(true);
}, []);
const removeDriver = useCallback(async (row: DriverStatusRow) => {
setActionState({ driverType: row.type, kind: 'remove' });
appendOperationLog(row.type, '[START] 开始移除驱动');
try {
const result = await RemoveDriverPackage(row.type, downloadDir);
if (!result?.success) {
const errText = result?.message || `移除 ${row.name} 失败`;
appendOperationLog(row.type, `[ERROR] ${errText}`);
message.error(errText);
return;
}
appendOperationLog(row.type, '[DONE] 驱动移除完成');
message.success(`${row.name} 已移除`);
setProgressMap((prev) => {
const next = { ...prev };
delete next[row.type];
return next;
});
refreshStatus(false);
} finally {
setActionState({ driverType: '', kind: '' });
}
}, [appendOperationLog, downloadDir, refreshStatus]);
const columns = useMemo(() => {
return [
{
title: '数据源',
dataIndex: 'name',
key: 'name',
width: 150,
},
{
title: '安装包大小',
dataIndex: 'packageSizeText',
key: 'packageSizeText',
width: 120,
render: (_: string | undefined, row: DriverStatusRow) => {
if (row.builtIn) {
return row.packageSizeText || '-';
}
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
const loadingKey = buildVersionSizeLoadingKey(row.type, selectedKey || '');
const selectedOption =
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
options.find((item) => item.recommended) ||
options[0];
const anyKnownSize = options.find((item) => String(item.packageSizeText || '').trim())?.packageSizeText;
if (selectedKey && versionSizeLoadingMap[loadingKey]) {
return '计算中...';
}
return selectedOption?.packageSizeText || anyKnownSize || row.packageSizeText || '-';
},
},
{
title: '状态',
key: 'status',
width: 140,
render: (_: string, row: DriverStatusRow) => {
if (row.builtIn) {
return <Tag color="success"></Tag>;
}
const progress = progressMap[row.type];
if (progress && (progress.status === 'start' || progress.status === 'downloading')) {
return <Tag color="processing"> {Math.round(progress.percent)}%</Tag>;
}
if (row.connectable) {
return <Tag color="success"></Tag>;
}
if (row.packageInstalled) {
return <Tag color="warning"></Tag>;
}
return <Tag color="default"></Tag>;
},
},
{
title: '安装进度',
key: 'progress',
width: 170,
render: (_: string, row: DriverStatusRow) => {
if (row.builtIn) {
return <Text type="secondary">-</Text>;
}
const progress = progressMap[row.type];
let percent = 0;
let status: 'normal' | 'exception' | 'active' | 'success' = 'normal';
if (progress?.status === 'error') {
percent = Math.max(0, Math.min(100, Math.round(progress.percent || 0)));
status = 'exception';
} else if (progress && (progress.status === 'start' || progress.status === 'downloading')) {
percent = Math.max(1, Math.min(99, Math.round(progress.percent || 0)));
status = 'active';
} else if (row.connectable || row.packageInstalled) {
percent = 100;
status = 'success';
}
return <Progress percent={percent} status={status} size="small" />;
},
},
{
title: '驱动版本',
key: 'driverVersion',
width: 230,
render: (_: string, row: DriverStatusRow) => {
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);
return (
<Select
size="small"
style={{ width: '100%' }}
loading={!!versionLoadingMap[row.type]}
disabled={actionState.driverType === row.type}
placeholder={options.length > 0 ? '选择驱动版本' : '点击展开加载版本'}
value={selectedKey}
options={selectOptions as any}
onOpenChange={(open) => {
if (open && options.length === 0 && !versionLoadingMap[row.type]) {
void loadVersionOptions(row, true);
return;
}
if (open && selectedKey) {
void loadVersionPackageSize(row, selectedKey);
}
}}
onChange={(value) => {
setSelectedVersionMap((prev) => ({ ...prev, [row.type]: value }));
void loadVersionPackageSize(row, value);
}}
/>
);
},
},
{
title: '操作',
key: 'actions',
width: 320,
render: (_: string, row: DriverStatusRow) => {
if (row.builtIn) {
return <Text type="secondary">-</Text>;
}
const isSlimBuildUnavailable = (row.message || '').includes('精简构建');
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>;
}
const logs = operationLogMap[row.type] || [];
const hasLogs = logs.length > 0;
const mainAction = row.connectable ? (
<Button
danger
icon={<DeleteOutlined />}
loading={loadingInstallOrRemove}
onClick={() => removeDriver(row)}
>
</Button>
) : (
<Button
type="primary"
icon={<DownloadOutlined />}
loading={loadingInstallOrRemove}
onClick={() => installDriver(row)}
>
</Button>
);
return (
<Space size={8} wrap>
{mainAction}
<Button
icon={<FileSearchOutlined />}
loading={loadingLocal}
onClick={() => installDriverFromLocalFile(row)}
>
</Button>
<Button
type={hasLogs ? 'default' : 'text'}
disabled={!hasLogs}
onClick={() => openDriverLog(row.type)}
>
</Button>
</Space>
);
},
},
];
}, [actionState, installDriver, installDriverFromLocalFile, loadVersionOptions, loadVersionPackageSize, openDriverLog, operationLogMap, progressMap, removeDriver, selectedVersionMap, versionLoadingMap, versionMap, versionSizeLoadingMap]);
const activeLogRow = useMemo(() => {
if (!logDriverType) {
return undefined;
}
return rows.find((item) => item.type === logDriverType);
}, [logDriverType, rows]);
const normalizedSearchKeyword = useMemo(() => normalizeDriverSearchText(searchKeyword), [searchKeyword]);
const filteredRows = useMemo(() => {
if (!normalizedSearchKeyword) {
return rows;
}
return rows.filter((row) => {
const searchableParts = [
row.name,
row.type,
row.pinnedVersion,
row.installedVersion,
row.message,
row.builtIn ? '内置' : '外置',
row.connectable ? '已启用' : row.packageInstalled ? '已安装' : '未启用',
];
const searchableText = normalizeDriverSearchText(searchableParts.filter(Boolean).join(' '));
return searchableText.includes(normalizedSearchKeyword);
});
}, [normalizedSearchKeyword, rows]);
const filterSummaryText = useMemo(() => {
if (normalizedSearchKeyword) {
return `匹配 ${filteredRows.length} / ${rows.length}`;
}
return `${rows.length} 个驱动`;
}, [filteredRows.length, normalizedSearchKeyword, rows.length]);
const activeDriverLogs = operationLogMap[logDriverType] || [];
const activeDriverLogLines = activeDriverLogs.map((item) => `[${item.time}] ${item.text}`);
const proxyEnvEntries = Object.entries(networkStatus?.proxyEnv || {});
const downloadRequiredHosts = (networkStatus?.downloadRequiredHosts || []).filter(Boolean);
const showDownloadChainAlert = networkStatus?.downloadChainReachable === false;
const networkUnreachable = networkStatus?.reachable === false;
const downloadRequiredHostText = (downloadRequiredHosts.length > 0
? downloadRequiredHosts
: ['github.com', 'api.github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com', 'raw.githubusercontent.com']).join('、');
const githubConnectivityProbe = networkStatus?.checks.find((item) => item.name === 'GitHub API')
|| networkStatus?.checks.find((item) => item.name === 'GitHub 驱动发布')
|| null;
const githubConnectivityLatencyMs = githubConnectivityProbe
? (githubConnectivityProbe.httpLatencyMs ?? githubConnectivityProbe.latencyMs ?? githubConnectivityProbe.tcpLatencyMs)
: undefined;
const logBlockBackground = darkMode
? `rgba(28, 28, 28, ${Math.max(opacity, 0.82)})`
: `rgba(255, 255, 255, ${Math.max(opacity, 0.92)})`;
const logBlockBorderColor = darkMode ? 'rgba(255, 255, 255, 0.16)' : 'rgba(0, 0, 0, 0.12)';
const logBlockTextColor = darkMode ? 'rgba(255, 255, 255, 0.88)' : 'rgba(0, 0, 0, 0.88)';
return (
<Modal
title="驱动管理"
open={open}
onCancel={onClose}
width={980}
style={{ top: 24 }}
styles={{
body: {
maxHeight: 'calc(100vh - 220px)',
overflowY: 'auto',
overflowX: 'hidden',
paddingRight: 18,
},
}}
destroyOnHidden
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 ? (
networkUnreachable ? (
<Alert
type="error"
showIcon
message={showDownloadChainAlert ? '重要提醒:驱动下载链路域名不可达' : '重要提醒:驱动下载网络不可达'}
description={(
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{showDownloadChainAlert ? (
<>
<Text>
访 GitHub
GoNavi
</Text>
{onOpenGlobalProxySettings ? (
<Button size="small" onClick={onOpenGlobalProxySettings}></Button>
) : null}
<Text>
{downloadRequiredHostText} TUN
</Text>
</>
) : (
<Text>{networkStatus.summary}</Text>
)}
{proxyEnvEntries.length > 0 ? (
<Text type="secondary">
{proxyEnvEntries.map(([key]) => key).join('、')}
</Text>
) : null}
</Space>
)}
/>
) : (
<Alert
type="success"
showIcon
message={networkStatus.summary}
description={(
<Collapse
size="small"
items={[
{
key: 'checks',
label: '查看网络检测明细',
children: (
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Text type="secondary">
GitHub {githubConnectivityProbe ? (githubConnectivityProbe.reachable ? '可达' : '不可达') : '暂无结果'}
{githubConnectivityLatencyMs !== undefined ? `${githubConnectivityLatencyMs}ms` : ''}
{githubConnectivityProbe?.error ? `${githubConnectivityProbe.error}` : ''}
</Text>
{proxyEnvEntries.length > 0 ? (
<Text type="secondary">
{proxyEnvEntries.map(([key]) => key).join('、')}
</Text>
) : (
<Text type="secondary"></Text>
)}
</Space>
),
},
]}
/>
)}
/>
)
) : (
<Alert
type="info"
showIcon
icon={sharedInfoAlertIcon}
message={networkChecking ? '正在检测驱动下载网络...' : '尚未完成网络检测'}
/>
)}
<Alert
type="info"
showIcon
icon={sharedInfoAlertIcon}
message="驱动目录与复用说明"
description={(
<Collapse
size="small"
items={[
{
key: 'driver-directory',
label: '查看驱动目录与复用说明',
children: (
<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>
<Paragraph copyable={{ text: downloadDir || '-' }} style={{ marginBottom: 0 }}>
{downloadDir || '-'}
</Paragraph>
{networkStatus?.logPath ? (
<Paragraph copyable={{ text: networkStatus.logPath }} style={{ marginBottom: 0 }}>
{networkStatus.logPath}
</Paragraph>
) : null}
</Space>
),
},
]}
/>
)}
/>
<div style={{ width: '100%', display: 'flex', gap: 12, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Input.Search
allowClear
placeholder="搜索驱动名称/类型(如 DuckDB、clickhouse"
value={searchKeyword}
onChange={(event) => setSearchKeyword(event.target.value)}
style={{ minWidth: 300, flex: '1 1 360px' }}
/>
<Space size={8}>
<Text type="secondary"></Text>
<Switch
checked={forceOverwriteInstalled}
onChange={(checked) => setForceOverwriteInstalled(checked)}
disabled={batchDirectoryImporting}
/>
<Button
icon={<FolderOpenOutlined />}
loading={batchDirectoryImporting}
onClick={() => void installDriversFromDirectory()}
>
</Button>
</Space>
</div>
<Text type="secondary">{filterSummaryText}</Text>
<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={filteredRows}
pagination={false}
size="middle"
sticky={false}
scroll={{ x: DRIVER_TABLE_SCROLL_X }}
locale={{
emptyText: normalizedSearchKeyword
? `未找到匹配“${String(searchKeyword || '').trim()}”的驱动`
: '暂无驱动数据',
}}
/>
</div>
</Space>
</div>
<Modal
title={`驱动日志 - ${activeLogRow?.name || logDriverType}`}
open={logModalOpen}
onCancel={() => setLogModalOpen(false)}
footer={[
<Button key="close-log" type="primary" onClick={() => setLogModalOpen(false)}>
</Button>,
]}
width={780}
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{activeLogRow?.installDir ? (
<Paragraph copyable={{ text: activeLogRow.installDir }} style={{ marginBottom: 0 }}>
{activeLogRow.installDir}
</Paragraph>
) : null}
{activeLogRow?.executablePath ? (
<Paragraph copyable={{ text: activeLogRow.executablePath }} style={{ marginBottom: 0 }}>
{activeLogRow.executablePath}
</Paragraph>
) : null}
{activeDriverLogLines.length > 0 ? (
<pre style={{ margin: 0, maxHeight: 360, overflow: 'auto', padding: 12, background: logBlockBackground, color: logBlockTextColor, borderRadius: 8, border: `1px solid ${logBlockBorderColor}`, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{activeDriverLogLines.join('\n')}
</pre>
) : (
<Text type="secondary"></Text>
)}
</Space>
</Modal>
</Modal>
);
};
export default DriverManagerModal;