Merge remote-tracking branch 'origin/master'

master
汤全昆 2025-09-01 09:33:52 +08:00
commit 1f3e63a8a8
192 changed files with 54427 additions and 3838 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@ -1,194 +0,0 @@
// // src/grpc/BTGrpcClient.ts 调用后端服务
// import * as grpc from '@grpc/grpc-js';
// import * as protoLoader from '@grpc/proto-loader';
// import path from 'path';
// import { app } from 'electron';
// export interface DownloadRequest {
// torrent_url: string;
// item_name: string;
// item_id?: string;
// }
// export interface ProgressCallback {
// (progress: ProgressUpdate): void;
// }
// export interface ProgressUpdate {
// download_id: string;
// progress: number;
// download_speed: number;
// upload_speed: number;
// eta: number;
// total_size: number;
// downloaded_size: number;
// state: string;
// }
// export class BTGrpcClient {
// private client: any;
// private progressCallbacks: Map<string, ProgressCallback[]> = new Map();
// private progressStream: any = null;
// constructor() {
// this.initializeClient();
// }
// private initializeClient() {
// try {
// const PROTO_PATH = path.join(__dirname, 'protos', 'bittorrent.proto');
// const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
// keepCase: true,
// longs: String,
// enums: String,
// defaults: true,
// oneofs: true,
// });
// const protoDescriptor = grpc.loadPackageDefinition(packageDefinition);
// const bittorrent = protoDescriptor.bittorrent as any;
// // 连接到后端 Agent假设运行在 localhost:50051
// this.client = new bittorrent.BTDownloadService(
// 'localhost:50051',
// grpc.credentials.createInsecure()
// );
// console.log('gRPC客户端初始化成功');
// this.setupProgressStream();
// } catch (error) {
// console.error('gRPC客户端初始化失败:', error);
// }
// }
// // 设置进度流监听
// private setupProgressStream() {
// try {
// this.progressStream = this.client.SubscribeProgress({});
// // 注册数据事件监听器,接收进度更新
// this.progressStream.on('data', (progress: ProgressUpdate) => {
// console.log('收到进度数据:', progress); // 添加调试日志
// // BTGrpcClient 通过IPC将进度发送给主进程
// this.progressCallbacks.forEach((callbacks, downloadId) => {
// if (downloadId === 'all' || downloadId === progress.download_id) {
// callbacks.forEach(callback => callback(progress));
// }
// });
// // 如果有针对特定下载ID的回调也要通知
// const specificCallbacks = this.progressCallbacks.get(progress.download_id);
// if (specificCallbacks) {
// specificCallbacks.forEach(callback => callback(progress));
// }
// });
// this.progressStream.on('error', (error: Error) => {
// console.error('进度流错误:', error);
// });
// this.progressStream.on('end', () => {
// console.log('进度流结束');
// });
// } catch (error) {
// console.error('设置进度流失败:', error);
// }
// }
// // 开始下载
// startDownload(request: DownloadRequest): Promise<any> {
// return new Promise((resolve, reject) => {
// // MockBTService 接收请求,创建下载任务
// this.client.StartDownload(request, (error: grpc.ServiceError, response: any) => {
// if (error) {
// reject(error);
// } else {
// resolve(response);
// }
// });
// });
// }
// // 停止下载
// stopDownload(downloadId: string): Promise<any> {
// return new Promise((resolve, reject) => {
// this.client.StopDownload({ download_id: downloadId }, (error: grpc.ServiceError, response: any) => {
// if (error) {
// reject(error);
// } else {
// resolve(response);
// }
// });
// });
// }
// // 注册进度回调
// registerProgressCallback(downloadId: string, callback: ProgressCallback) {
// if (!this.progressCallbacks.has(downloadId)) {
// this.progressCallbacks.set(downloadId, []);
// }
// this.progressCallbacks.get(downloadId)!.push(callback);
// }
// // 移除进度回调
// removeProgressCallback(downloadId: string, callback: ProgressCallback) {
// const callbacks = this.progressCallbacks.get(downloadId);
// if (callbacks) {
// const index = callbacks.indexOf(callback);
// if (index > -1) {
// callbacks.splice(index, 1);
// }
// }
// }
// // 检查连接状态
// isConnected(): boolean {
// return this.client && this.client.getChannel().getConnectivityState(true) === grpc.connectivityState.READY;
// }
// // 重新连接
// reconnect() {
// try {
// this.client.close();
// this.initializeClient();
// } catch (error) {
// console.error('重新连接失败:', error);
// }
// }
// getConnectionState(): string {
// if (!this.client) return 'NOT_CREATED';
// const state = this.client.getChannel().getConnectivityState(false);
// const stateNames = {
// [grpc.connectivityState.IDLE]: 'IDLE',
// [grpc.connectivityState.CONNECTING]: 'CONNECTING',
// [grpc.connectivityState.READY]: 'READY',
// [grpc.connectivityState.TRANSIENT_FAILURE]: 'TRANSIENT_FAILURE',
// [grpc.connectivityState.SHUTDOWN]: 'SHUTDOWN'
// } as const;
// // 使用类型断言确保 state 是合法的 key
// return stateNames[state as keyof typeof stateNames] || 'UNKNOWN';
// }
// // 添加测试方法
// async testConnection(): Promise<boolean> {
// try {
// // 尝试调用一个简单的方法来测试连接
// await new Promise((resolve, reject) => {
// this.client.ListDownloads({}, (error: any, response: any) => {
// if (error && error.code !== grpc.status.UNIMPLEMENTED) {
// reject(error);
// } else {
// resolve(response);
// }
// });
// });
// return true;
// } catch (error) {
// console.error('连接测试失败:', error);
// return false;
// }
// }
// }

View File

@ -1,323 +0,0 @@
// // 本地测试用的 gRPC 服务器,用于模拟 BT 下载
// // src/grpc/MockBTService.ts
// import * as grpc from '@grpc/grpc-js';
// import * as protoLoader from '@grpc/proto-loader';
// import path from 'path';
// // 进度更新接口
// interface ProgressUpdate {
// download_id: string;
// progress: number;
// download_speed: number;
// item_name?: string; // 可选的文件名字段
// upload_speed: number;
// eta: number;
// total_size: number;
// downloaded_size: number;
// state: string;
// }
// // 模拟的下载任务
// interface MockDownloadTask {
// id: string;
// itemName: string;
// torrentUrl: string;
// progress: number;
// totalSize: number;
// downloadedSize: number;
// downloadSpeed: number;
// state: 'downloading' | 'completed' | 'error' | 'paused';
// startTime: number;
// }
// export class MockBTService {
// private server: grpc.Server;
// private activeDownloads: Map<string, MockDownloadTask> = new Map();
// private progressIntervals: Map<string, NodeJS.Timeout> = new Map();
// // 存储所有的进度回调函数,发送信息
// private progressCallbacks: ((progress: ProgressUpdate) => void)[] = [];
// constructor() {
// this.server = new grpc.Server();
// this.setupService();
// }
// private setupService() {
// // 设置gRPC服务,加载 bittorrent.proto 定义,实现所有gRPC服务方法
// const PROTO_PATH = path.join(__dirname, 'protos', 'bittorrent.proto');
// const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
// keepCase: true,
// longs: String,
// enums: String,
// defaults: true,
// oneofs: true,
// });
// const protoDescriptor = grpc.loadPackageDefinition(packageDefinition);
// const bittorrent = protoDescriptor.bittorrent as any;
// // 实现 gRPC 服务方法
// this.server.addService(bittorrent.BTDownloadService.service, {
// StartDownload: this.startDownload.bind(this),
// StopDownload: this.stopDownload.bind(this),
// GetDownloadStatus: this.getDownloadStatus.bind(this),
// ListDownloads: this.listDownloads.bind(this),
// SubscribeProgress: this.subscribeProgress.bind(this),
// });
// }
// // 开始下载
// // 在 startDownload 方法中,可以添加根据文件名设置不同大小的逻辑
// private startDownload(call: grpc.ServerUnaryCall<any, any>, callback: grpc.sendUnaryData<any>) {
// const { torrent_url, item_name, item_id } = call.request;
// const downloadId = item_id || `download-${Date.now()}`;
// console.log(`模拟开始下载: ${item_name}, ID: ${downloadId}`);
// // 根据文件名或ID设置不同大小的文件
// let fileSize = 1024 * 1024 * 100; // 默认100MB
// if (item_name.includes('large') || item_name.includes('大文件')) {
// fileSize = 4 * 1024 * 1024 * 1024; // 4GB
// } else if (item_name.includes('medium') || item_name.includes('中等')) {
// fileSize = 1024 * 1024 * 1024; // 1GB
// } else if (item_name.includes('small') || item_name.includes('小文件')) {
// fileSize = 100 * 1024 * 1024; // 100MB
// }
// // 创建模拟下载任务
// const task: MockDownloadTask = {
// id: downloadId,
// itemName: item_name,
// torrentUrl: torrent_url,
// progress: 0,
// totalSize: fileSize,
// downloadedSize: 0,
// downloadSpeed: 1024 * 1024 * 2, // 2MB/s
// state: 'downloading',
// startTime: Date.now(),
// };
// this.activeDownloads.set(downloadId, task);
// console.log(`已创建下载任务: ${downloadId}, 大小: ${fileSize} bytes`);
// // 启动进度模拟
// this.startProgressSimulation(downloadId);
// callback(null, {
// success: true,
// message: '下载已开始',
// download_id: downloadId,
// });
// }
// // 停止下载
// private stopDownload(call: grpc.ServerUnaryCall<any, any>, callback: grpc.sendUnaryData<any>) {
// const { download_id } = call.request;
// if (this.activeDownloads.has(download_id)) {
// const task = this.activeDownloads.get(download_id)!;
// task.state = 'paused';
// // 清除进度定时器
// if (this.progressIntervals.has(download_id)) {
// clearInterval(this.progressIntervals.get(download_id));
// this.progressIntervals.delete(download_id);
// }
// console.log(`模拟停止下载: ${download_id}`);
// callback(null, { success: true, message: '下载已停止' });
// } else {
// callback({
// code: grpc.status.NOT_FOUND,
// message: `下载任务不存在: ${download_id}`
// });
// }
// }
// // 获取下载状态
// private getDownloadStatus(call: grpc.ServerUnaryCall<any, any>, callback: grpc.sendUnaryData<any>) {
// const { download_id } = call.request;
// if (this.activeDownloads.has(download_id)) {
// const task = this.activeDownloads.get(download_id)!;
// callback(null, {
// download_id: task.id,
// progress: task.progress,
// download_speed: task.downloadSpeed,
// state: task.state,
// total_size: task.totalSize,
// downloaded_size: task.downloadedSize,
// });
// } else {
// callback({
// code: grpc.status.NOT_FOUND,
// message: `下载任务不存在: ${download_id}`
// });
// }
// }
// // 列出所有下载
// private listDownloads(call: grpc.ServerUnaryCall<any, any>, callback: grpc.sendUnaryData<any>) {
// const downloads = Array.from(this.activeDownloads.values()).map(task => ({
// download_id: task.id,
// item_name: task.itemName,
// progress: task.progress,
// download_speed: task.downloadSpeed,
// state: task.state,
// total_size: task.totalSize,
// downloaded_size: task.downloadedSize,
// }));
// callback(null, { downloads });
// }
// // 订阅进度更新(流式响应)
// private subscribeProgress(call: grpc.ServerWritableStream<any, any>) {
// console.log('客户端订阅了进度更新');
// // 存储回调以便发送进度更新
// const callback = (progress: ProgressUpdate) => {
// try {
// call.write(progress);
// } catch (error) {
// console.error('发送进度更新失败:', error);
// }
// };
// this.progressCallbacks.push(callback);
// // 当客户端断开连接时清理
// call.on('cancelled', () => {
// console.log('客户端取消了进度订阅');
// const index = this.progressCallbacks.indexOf(callback);
// if (index > -1) {
// this.progressCallbacks.splice(index, 1);
// }
// });
// call.on('error', (error) => {
// console.error('进度流错误:', error);
// const index = this.progressCallbacks.indexOf(callback);
// if (index > -1) {
// this.progressCallbacks.splice(index, 1);
// }
// });
// // 处理客户端断开连接
// call.on('end', () => {
// console.log('客户端断开连接');
// const index = this.progressCallbacks.indexOf(callback);
// if (index > -1) {
// this.progressCallbacks.splice(index, 1);
// }
// });
// }
// // 启动进度模拟
// // 在 MockBTService.ts 中修改 startProgressSimulation 方法
// private startProgressSimulation(downloadId: string) {
// const interval = setInterval(() => {
// if (this.activeDownloads.has(downloadId)) {
// const task = this.activeDownloads.get(downloadId)!;
// if (task.state === 'downloading' && task.progress < 100) {
// // // 更新进度但确保不超过100%
// // task.progress += Math.random() * 2;
// // 使用固定的进度增加而不是随机值,使下载更加稳定
// task.progress += 1.5; // 每秒增加1.5%的进度
// // 确保进度不超过100%
// if (task.progress >= 100) {
// task.progress = 100;
// task.state = 'completed';
// task.downloadedSize = task.totalSize; // 确保已完成时已下载大小等于总大小
// console.log(`下载完成: ${downloadId}`);
// // 清除定时器
// clearInterval(interval);
// this.progressIntervals.delete(downloadId);
// } else {
// // 根据进度计算已下载大小
// task.downloadedSize = (task.totalSize * task.progress) / 100;
// }
// // 计算剩余时间(秒)
// let eta = 0;
// if (task.progress < 100) {
// // 基于当前速度计算剩余时间
// const progressPerSecond = 1.5; // 每秒进度百分比
// eta = Math.round(((100 - task.progress) / progressPerSecond));
// }
// // 发送进度更新
// this.sendProgressUpdate({
// download_id: task.id,
// item_name: task.itemName,
// progress: task.progress,
// download_speed: task.downloadSpeed,
// upload_speed: 1024 * 512, // 512KB/s 上传速度
// eta: task.progress >= 100 ? 0 : Math.round(((100 - task.progress) / 2) * 1000),
// total_size: task.totalSize,
// downloaded_size: task.downloadedSize,
// state: task.state,
// });
// }
// } else {
// clearInterval(interval);
// this.progressIntervals.delete(downloadId);
// }
// }, 1000); // 每秒更新一次进度
// this.progressIntervals.set(downloadId, interval);
// }
// // 发送进度更新给所有订阅者
// private sendProgressUpdate(progress: ProgressUpdate) {
// console.log('发送进度更新:', progress);
// // 通过已注册的回调函数传递给 BTGrpcClient
// this.progressCallbacks.forEach((callback, index) => {
// try {
// callback(progress);
// } catch (error) {
// console.error(`发送进度更新给回调 ${index} 失败:`, error);
// }
// });
// }
// // 绑定端口并启动服务器
// start(port: number = 50051): Promise<void> {
// return new Promise((resolve, reject) => {
// this.server.bindAsync(
// `0.0.0.0:${port}`,
// grpc.ServerCredentials.createInsecure(),
// (error, port) => {
// if (error) {
// reject(error);
// } else {
// this.server.start();
// console.log(`Mock gRPC 服务器运行在端口 ${port}`);
// resolve();
// }
// }
// );
// });
// }
// // 停止服务器
// stop(): Promise<void> {
// return new Promise((resolve) => {
// // 清理所有定时器
// this.progressIntervals.forEach(interval => clearInterval(interval));
// this.progressIntervals.clear();
// this.activeDownloads.clear();
// this.progressCallbacks = [];
// this.server.tryShutdown(() => {
// console.log('Mock gRPC 服务器已停止');
// resolve();
// });
// });
// }
// }

View File

@ -1,219 +0,0 @@
梳理整个gRPC下载逻辑和模拟下载的执行流程。
## 1. 整体架构概览
整个gRPC下载系统分为以下几个主要部分
1. **前端页面** (`grpc.tsx`) - 用户交互界面
2. **预加载脚本** (`preload.ts`) - 桥接前端和主进程
3. **主进程** (`platform.ts`) - 管理gRPC客户端和服务端
4. **gRPC客户端** (`BTGrpcClient.ts`) - 调用后端服务
5. **gRPC服务端** (`MockBTService.ts`) - 模拟下载服务
6. **协议定义** (`bittorrent.proto`) - gRPC接口定义
## 2. 启动流程
### 2.1 应用启动时初始化gRPC
```mermaid
graph TD
A[应用启动] --> B[app.whenReady()]
B --> C[initializeGrpc()]
C --> D{IS_TEST_MODE}
D -->|true| E[创建MockBTService实例]
D -->|false| F[连接真实后端]
E --> G[启动Mock gRPC服务器]
G --> H[创建BTGrpcClient实例]
H --> I[注册进度回调]
I --> J[启动健康检查]
```
`platform.ts` 中:
- 应用启动时调用 `initializeGrpc()` 函数
- 根据 `IS_TEST_MODE` 决定是启动模拟服务还是连接真实后端
- 创建 `MockBTService` 实例并启动服务器
- 创建 `BTGrpcClient` 实例连接到gRPC服务
- 注册进度回调函数,用于接收下载进度更新
### 2.2 gRPC服务端启动 (MockBTService)
```mermaid
sequenceDiagram
participant M as MockBTService
participant S as gRPC Server
participant C as BTGrpcClient
M->>S: 创建gRPC服务器实例
M->>S: 加载proto定义
M->>S: 实现服务方法
M->>S: 绑定端口并启动
S-->>M: 服务器启动成功
C->>S: 连接服务器
S-->>C: 连接建立
```
`MockBTService.ts` 中:
1. 构造函数调用 [setupService()](file://d:\project\vdi\pc-fe\src\main\grpc\MockBTService.ts#L43-L65) 设置gRPC服务
2. 加载 `bittorrent.proto` 定义
3. 实现所有gRPC服务方法
- `StartDownload` - 开始下载
- `StopDownload` - 停止下载
- `GetDownloadStatus` - 获取下载状态
- `ListDownloads` - 列出所有下载
- `SubscribeProgress` - 订阅进度更新
4. 通过 `server.bindAsync()` 绑定端口并启动服务
## 3. 下载流程详解
### 3.1 用户触发下载
```mermaid
graph TD
A[用户点击下载按钮] --> B[handleStartDownload()]
B --> C[调用window.electronAPI.grpcStartDownload()]
C --> D[IPC调用grpc-start-download]
D --> E[主进程处理]
E --> F[调用btGrpcClient.startDownload()]
F --> G[gRPC调用StartDownload()]
G --> H[MockBTService处理]
H --> I[创建下载任务]
I --> J[启动进度模拟]
J --> K[返回下载ID]
K --> L[前端接收响应]
```
详细步骤:
1. 用户点击"开始gRPC下载测试"按钮
2. 调用 `handleStartDownload()` 函数
3. 通过 `window.electronAPI.grpcStartDownload()` 发起IPC调用
4. 主进程 `platform.ts` 接收 `grpc-start-download` 请求
5. 调用 `btGrpcClient.startDownload()` 方法
6. gRPC客户端向服务端发起 `StartDownload` 调用
7. [MockBTService](file://d:\project\vdi\pc-fe\src\main\grpc\MockBTService.ts#L32-L305) 接收请求,创建下载任务
8. 启动进度模拟定时器
9. 返回下载ID给前端
### 3.2 进度更新机制
```mermaid
sequenceDiagram
participant M as MockBTService
participant I as Interval
participant B as BTGrpcClient
participant P as Platform主进程
participant F as 前端
I->>M: 定时触发
M->>M: 更新任务进度
M->>M: 构造进度数据
M->>B: 发送进度更新
B->>P: 调用回调函数
P->>F: IPC发送grpc-progress-update
F->>F: 更新UI显示
```
详细步骤:
1. `startProgressSimulation()` 创建定时器
2. 定时器每秒触发一次,更新下载进度
3. 构造 `ProgressUpdate` 对象
4. 调用 `sendProgressUpdate()` 发送进度更新
5. 通过已注册的回调函数传递给 `BTGrpcClient`
6. `BTGrpcClient` 通过IPC将进度发送给主进程
7. 主进程通过 `webContents.send()` 将进度发送给前端
8. 前端接收并更新UI
## 4. 关键代码解析
### 4.1 前端监听进度更新
```typescript
// grpc.tsx
useEffect(() => {
// 设置进度监听
const handleProgress = (progress: DownloadProgress) => {
console.log('收到进度更新:', progress);
setDownloads(prev => {
const newMap = new Map(prev);
newMap.set(progress.download_id, progress);
return newMap;
});
};
window.electronAPI.onGrpcProgress(handleProgress);
return () => {
window.electronAPI.removeAllGrpcProgressListeners();
};
}, []);
```
### 4.2 主进程转发进度更新
```typescript
// platform.ts
btGrpcClient.registerProgressCallback('all', (progress) => {
const mainWindow = BrowserWindow.getFocusedWindow();
if (mainWindow) {
mainWindow.webContents.send('grpc-progress-update', progress);
}
});
```
### 4.3 Mock服务模拟进度
```typescript
// MockBTService.ts
private startProgressSimulation(downloadId: string) {
const interval = setInterval(() => {
if (this.activeDownloads.has(downloadId)) {
const task = this.activeDownloads.get(downloadId)!;
if (task.state === 'downloading' && task.progress < 100) {
// 更新进度
task.progress += Math.random() * 2;
task.downloadedSize = (task.totalSize * task.progress) / 100;
// 发送进度更新
this.sendProgressUpdate({
download_id: task.id,
item_name: task.itemName,
progress: task.progress,
download_speed: task.downloadSpeed,
upload_speed: 1024 * 512,
eta: Math.round(((100 - task.progress) / 2) * 1000),
total_size: task.totalSize,
downloaded_size: task.downloadedSize,
state: task.state,
});
}
}
}, 1000);
this.progressIntervals.set(downloadId, interval);
}
```
## 5. 数据流总结
```mermaid
graph LR
A[前端UI] --> B[preload.ts]
B --> C[主进程platform.ts]
C --> D[BTGrpcClient.ts]
D --> E[MockBTService.ts]
E --> F[定时模拟进度]
F --> G[发送进度更新]
G --> H[主进程转发]
H --> I[前端接收更新]
I --> A
```
整个流程形成了一个闭环:
1. 用户操作触发前端请求
2. 请求通过IPC传递到主进程
3. 主进程通过gRPC调用后端服务
4. Mock服务模拟下载过程
5. 进度更新通过相同路径返回前端
6. 前端更新UI显示最新进度
这就是整个gRPC下载逻辑的完整执行流程。

View File

@ -1,77 +0,0 @@
// bittorrent.proto gRPC
syntax = "proto3";
package bittorrent;
service BTDownloadService {
rpc StartDownload (DownloadRequest) returns (DownloadResponse) {}
rpc StopDownload (StopRequest) returns (StopResponse) {}
rpc GetDownloadStatus (StatusRequest) returns (StatusResponse) {}
rpc ListDownloads (ListRequest) returns (ListResponse) {}
rpc SubscribeProgress (SubscribeRequest) returns (stream ProgressUpdate) {}
}
message DownloadRequest {
string torrent_url = 1;
string item_name = 2;
string item_id = 3;
}
message DownloadResponse {
bool success = 1;
string message = 2;
string download_id = 3;
}
message StopRequest {
string download_id = 1;
}
message StopResponse {
bool success = 1;
string message = 2;
}
message ProgressUpdate {
string download_id = 1;
double progress = 2;
double download_speed = 3;
double upload_speed = 4;
int64 eta = 5;
int64 total_size = 6;
int64 downloaded_size = 7;
string state = 8;
}
message StatusRequest {
string download_id = 1;
}
message StatusResponse {
string download_id = 1;
double progress = 2;
double download_speed = 3;
string state = 4;
int64 total_size = 5;
int64 downloaded_size = 6;
}
message ListRequest {
}
message ListResponse {
repeated DownloadInfo downloads = 1;
}
message DownloadInfo {
string download_id = 1;
string item_name = 2;
double progress = 3;
double download_speed = 4;
string state = 5;
int64 total_size = 6;
int64 downloaded_size = 7;
}
message SubscribeRequest {
}

View File

@ -0,0 +1,105 @@
// bt下载
import { ipcMain } from 'electron';
import request from '../utils/request';
import { currentServerIp } from './sharedState'; // 修改导入
// 添加下载相关 IPC 处理
ipcMain.handle('start-download', async (event, downloadConfig) => {
// return {success:true,message:"开始下载"}
try {
console.log('开始下载:', downloadConfig);
// 检查服务器 IP 是否已设置
if (!currentServerIp) {
return {
success: false,
error: '服务器未连接,请先配置服务器'
};
}
// 使用保存的服务器 IP 构建 API 地址
const apiUrl = `http://${currentServerIp}:3001/api/downloads`;
// 发送 HTTP 请求到后端 API
const response = await request(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
data: JSON.stringify(downloadConfig)
});
let responseData;
try {
responseData = JSON.parse((response as any).data);
} catch (parseError) {
console.error('Failed to parse authentication response:', parseError);
return { success: false, message: '服务器响应格式错误' };
}
// const result = await response.json();
// 检查结果 - 只有code等于200时才算成功
if (responseData.code === '200' || responseData.code === 200) {
console.log('开始下载成功:', responseData);
return { success: true, message: `开始下载成功`, data: responseData };
} else {
// 认证失败,返回服务器提供的错误信息
const errorMessage = responseData.message || responseData.msg || responseData.error || '开始下载成功';
console.log('Authentication failed:', errorMessage);
return { success: false, message: errorMessage };
}
} catch (error) {
console.error('下载请求失败:', error);
return {
success: false,
message: error instanceof Error ? error.message : '未知错误'
};
}
});
ipcMain.handle('stop-download', async (event, downloadId) => {
// return {success:true,message:"停止下载成功"}
try {
console.log('停止下载:', downloadId);
// 检查服务器 IP 是否已设置
if (!currentServerIp) {
return {
success: false,
error: '服务器未连接,请先配置服务器'
};
}
// 使用保存的服务器 IP 构建 API 地址
const apiUrl = `http://${currentServerIp}:3001/api/downloads/${downloadId}`;
// 发送 HTTP 请求到后端 API
const response = await request(apiUrl, {
method: 'DELETE'
});
// const result = await response.json();
let responseData;
try {
responseData = JSON.parse((response as any).data);
} catch (parseError) {
console.error('Failed to parse authentication response:', parseError);
return { success: false, message: '服务器响应格式错误' };
}
// const result = await response.json();
// 检查结果 - 只有code等于200时才算成功
if (responseData.code === '200' || responseData.code === 200) {
console.log('停止下载成功:', responseData);
return { success: true, message: `停止下载成功`, data: responseData };
} else {
// 认证失败,返回服务器提供的错误信息
const errorMessage = responseData.message || responseData.msg || responseData.error || '停止下载失败';
console.log('Authentication failed:', errorMessage);
return { success: false, message: errorMessage };
}
} catch (error) {
console.error('停止下载失败:', error);
return {
success: false,
error: error instanceof Error ? error.message : '未知错误'
};
}
});

View File

@ -0,0 +1,111 @@
// 通用
import { ipcMain,app } from 'electron';
import { BrowserWindow } from 'electron';
import { getDeviceIdAndMac } from '../utils/utils';
// 服务器IP获取
ipcMain.handle('get-current-server-ip', () => {
return (global as any).currentServerIp;
});
// 终端信息获取
ipcMain.handle('get-device-info', async () => {
// return { success: true, deviceId: "fsfs" };
try {
const deviceInfo = await getDeviceIdAndMac();
console.log(`Using device Info: ${deviceInfo}`);
return { success: true, deviceInfo };
} catch (error) {
console.error('获取设备信息失败:', error);
return { success: false, error: error instanceof Error ? error.message : '未知错误' };
}
});
// 添加处理窗口调整
ipcMain.handle('adjust-window-for-normal', async (event) => {
try {
const window = BrowserWindow.fromWebContents(event.sender);
if (window) {
// 调整窗口大小和配置
window.setKiosk(false); // 退出全屏模式
window.setMinimumSize(1200, 800);
window.setSize(1200, 800);
window.setResizable(true);
window.setMaximizable(true);
window.setMinimizable(true);
// 保持无边框和隐藏标题栏的设置
window.setFullScreen(false);
}
return { success: true };
} catch (error) {
console.error('调整窗口失败:', error);
return { success: false, };
}
});
/**拖动窗口 */
ipcMain.handle('drag-window', (event) => {
const focusedWindow = BrowserWindow.getFocusedWindow();
if (focusedWindow) {
// 通知渲染进程开始拖拽
focusedWindow.webContents.send('start-drag');
}
});
// 监听渲染进程发送的消息
ipcMain.handle('getPlatform', () => {
return `hi, i'm from ${process.platform}`;
});
// 窗口控制:最小化,退出全屏,关闭,
// 获取窗口最大化状态
ipcMain.handle('get-window-maximized', (event) => {
const window = BrowserWindow.fromWebContents(event.sender);
return window?.isMaximized() || false;
});
ipcMain.on('close-app', () => {
app.quit();
});
// 最小化
ipcMain.on('minimize-app', () => {
const focusedWindow = BrowserWindow.getFocusedWindow();
if (focusedWindow) {
focusedWindow.minimize();
}
// window?.minimize();
});
// 退出全屏
ipcMain.on('restore-window', () => {
const focusedWindow = BrowserWindow.getFocusedWindow();
if (focusedWindow) {
focusedWindow.unmaximize();
}
// if (window) {
// window.setFullScreen(false);
// }
});
// 设置全屏
ipcMain.on('maximize-window', () => {
const focusedWindow = BrowserWindow.getFocusedWindow();
if (focusedWindow) {
focusedWindow.maximize();
}
})
// 监听窗口状态变化并通知渲染进程
ipcMain.on('register-window-state-listeners', (event) => {
const window = BrowserWindow.fromWebContents(event.sender);
if (window) {
window.on('maximize', () => {
event.sender.send('window-maximized');
});
window.on('unmaximize', () => {
event.sender.send('window-unmaximized');
});
}
});

View File

@ -1,5 +1,5 @@
import { ipcMain,app } from 'electron';
import { getDeviceId, getWiredConnectionName, netmaskToCidr,simulateUpdate,performRealUpdate } from '../utils/utils';
import { getDeviceIdAndMac, getWiredConnectionName, netmaskToCidr,simulateUpdate,performRealUpdate } from '../utils/utils';
import { BrowserWindow } from 'electron';
import { autoUpdater } from 'electron-updater';
import request from '../utils/request';
@ -8,151 +8,51 @@ const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
const window = getBrowserWindowRuntime();
let currentServerIp: string | undefined;
// 添加处理窗口调整
ipcMain.handle('adjust-window-for-normal', async (event) => {
try {
const window = BrowserWindow.fromWebContents(event.sender);
if (window) {
// 调整窗口大小和配置
window.setKiosk(false); // 退出全屏模式
window.setMinimumSize(1200, 800);
window.setSize(1200, 800);
window.setResizable(true);
window.setMaximizable(true);
window.setMinimizable(true);
// 保持无边框和隐藏标题栏的设置
window.setFullScreen(false);
}
return { success: true };
} catch (error) {
console.error('调整窗口失败:', error);
return { success: false, };
}
});
/**拖动窗口 */
ipcMain.handle('drag-window', (event) => {
const focusedWindow = BrowserWindow.getFocusedWindow();
if (focusedWindow) {
// 通知渲染进程开始拖拽
focusedWindow.webContents.send('start-drag');
}
});
// 监听渲染进程发送的消息
ipcMain.handle('getPlatform', () => {
return `hi, i'm from ${process.platform}`;
});
// 窗口控制:最小化,退出全屏,关闭,
// 获取窗口最大化状态
ipcMain.handle('get-window-maximized', (event) => {
const window = BrowserWindow.fromWebContents(event.sender);
return window?.isMaximized() || false;
});
ipcMain.on('close-app', () => {
app.quit();
});
// 最小化
ipcMain.on('minimize-app', () => {
const focusedWindow = BrowserWindow.getFocusedWindow();
if (focusedWindow) {
focusedWindow.minimize();
}
// window?.minimize();
});
// 退出全屏
ipcMain.on('restore-window', () => {
const focusedWindow = BrowserWindow.getFocusedWindow();
if (focusedWindow) {
focusedWindow.unmaximize();
}
// if (window) {
// window.setFullScreen(false);
// }
});
// 设置全屏
ipcMain.on('maximize-window', () => {
const focusedWindow = BrowserWindow.getFocusedWindow();
if (focusedWindow) {
focusedWindow.maximize();
}
})
// 监听窗口状态变化并通知渲染进程
ipcMain.on('register-window-state-listeners', (event) => {
const window = BrowserWindow.fromWebContents(event.sender);
if (window) {
window.on('maximize', () => {
event.sender.send('window-maximized');
});
window.on('unmaximize', () => {
event.sender.send('window-unmaximized');
});
}
});
ipcMain.handle('get-device-id',async()=>{
const deviceId = await getDeviceId();
console.log(`Using device ID: ${deviceId}`);
// TODO:传给后端
})
/* 1. 平台网络配置IPC 处理应用有线网络配置 */
ipcMain.handle('apply-wired-config',async(event,config)=>{
/* 1. 平台网络配置IPC 处理应用有线网络配置 */
ipcMain.handle('apply-wired-config', async (event, config) => {
// return {
// success: true,
// message: '网络配置已成功应用'
// };
try{
try {
console.log('应用网络配置:', config);
// 获取有线连接名称
const connectionName = await getWiredConnectionName();
console.log('有线连接名称:', connectionName);
if(config.method==='static'){
if (config.method === 'static') {
// 使用nmcli配置静态IP需要使用sudo权限一次性设置所有参数
let modifyCmd = `echo "unis@123" | sudo -S nmcli connection modify "${connectionName}" ipv4.method manual ipv4.addresses "${config.ipv4}/${netmaskToCidr(config.subnetMask)}" ipv4.gateway "${config.ipv4Gateway}"`;
const dnsServers = [config.primaryDns, config.secondaryDns].filter(Boolean).join(',');
modifyCmd += ` ipv4.dns "${dnsServers}"`;
// 添加 IPv6 配置(如果存在 ipv6Gateway????ipv6和长度需要吗ui只写了ipv6网关
// ipv6PrefixLength 是 IPv6 地址的前缀长度,类似于 IPv4 中的子网掩码。????
if (config.ipv6 && config.ipv6Gateway) {
const ipv6PrefixLength = config.ipv6PrefixLength &&
config.ipv6PrefixLength >= 0 &&
config.ipv6PrefixLength <= 128 ?
config.ipv6PrefixLength : 64; // 默认使用64
const ipv6PrefixLength = config.ipv6PrefixLength &&
config.ipv6PrefixLength >= 0 &&
config.ipv6PrefixLength <= 128 ?
config.ipv6PrefixLength : 64; // 默认使用64
modifyCmd += ` ipv6.method manual ipv6.addresses "${config.ipv6}/${ipv6PrefixLength}" ipv6.gateway "${config.ipv6Gateway}"`;
}else if (config.ipv6 || config.ipv6Gateway) {
} else if (config.ipv6 || config.ipv6Gateway) {
console.warn('IPv6配置不完整需要同时提供IPv6地址和网关');
}
// 执行配置命令
console.log('执行命令:', modifyCmd.replace('unis@123', '***'));
await execAsync(modifyCmd);
// 重新激活连接
await execAsync(`echo "unis@123" | sudo -S nmcli connection up "${connectionName}"`);
}else{
} else {
// DHCP配置一次性设置所有参数
const modifyCmd = `echo "unis@123" | sudo -S nmcli connection modify "${connectionName}" ipv4.method auto ipv4.addresses "" ipv4.gateway "" ipv4.dns ""`;
// 执行配置命令
console.log('执行命令:', modifyCmd.replace('unis@123', '***'));
await execAsync(modifyCmd);
// 重新激活连接
await execAsync(`echo "unis@123" | sudo -S nmcli connection up "${connectionName}"`);
}
@ -160,7 +60,7 @@ ipcMain.handle('apply-wired-config',async(event,config)=>{
success: true,
message: '网络配置已成功应用'
};
}catch(error:unknown){
} catch (error: unknown) {
console.error('应用网络配置失败:', error);
return {
success: false,
@ -169,14 +69,16 @@ ipcMain.handle('apply-wired-config',async(event,config)=>{
}
})
/**2. 服务器配置 */
/**2. 服务器配置认证 */
ipcMain.handle('connect-server', async (event, { serverIp }) => {
console.log(`Connecting to server: ${serverIp}`);
// (global as any).currentServerIp = serverIp;
// console.log('Authentication successful, server IP stored:', serverIp);
// return { success: true, message: `已连接到服务器 ${serverIp}`, serverIp: serverIp };
try {
// 获取设备ID
const deviceId = await getDeviceId();
console.log(`Using device ID: ${deviceId}`);
const deviceInfo = await getDeviceIdAndMac();
console.log(`Using device Info: ${JSON.stringify(deviceInfo)}`);
// 构建新的API地址使用POST请求
const apiUrl = `http://${serverIp}:8113/api/nex/v1/client/authentication`;
@ -189,7 +91,7 @@ ipcMain.handle('connect-server', async (event, { serverIp }) => {
headers: {
'Content-Type': 'application/json'
},
data: JSON.stringify({ device_id: deviceId })
data: JSON.stringify({ device_id: deviceInfo.deviceId,mac_addr:deviceInfo.macAddr })
});
console.log('API response received:', response);
@ -208,7 +110,7 @@ ipcMain.handle('connect-server', async (event, { serverIp }) => {
// 检查认证结果 - 只有code等于200时才算成功
if (responseData.code === '200' || responseData.code === 200) {
// 认证成功存储服务器IP
currentServerIp = serverIp;
(global as any).currentServerIp = serverIp;
console.log('Authentication successful, server IP stored:', serverIp);
return { success: true, message: `已连接到服务器 ${serverIp}`, serverIp: serverIp };
} else {
@ -251,10 +153,6 @@ ipcMain.handle('connect-server', async (event, { serverIp }) => {
};
}
});
// 服务器IP获取
ipcMain.handle('get-current-server-ip', () => {
return currentServerIp;
});
/**3. 侦测管理平台 */
// 下载并更新客户端
@ -325,4 +223,5 @@ ipcMain.on('install-update-and-restart', () => {
}
}
}
});
});

View File

@ -1,181 +0,0 @@
// import { ipcMain,app,BrowserWindow } from 'electron';
// // 模拟grpc服务端
// import { BTGrpcClient } from '../grpc/BTGrpcClient';
// import { MockBTService } from '../grpc/MockBTService';
// // 客户端和服务端 通信
// const IS_TEST_MODE = true; // 设置为 false 时连接真实后端
// const GRPC_SERVER_PORT = 50051;
// // 先声明变量,但不立即初始化
// let btGrpcClient: BTGrpcClient | null = null;
// let mockServer: MockBTService | null = null;
// let healthCheckInterval: NodeJS.Timeout | null = null; // 声明一个变量来存储定时器
// // 初始化 gRPC 客户端
// async function initializeGrpc() {
// try {
// if (IS_TEST_MODE) {
// console.log('启动模拟 gRPC 服务器...');
// mockServer = new MockBTService();
// await mockServer.start(GRPC_SERVER_PORT);
// // 给一点时间让服务器启动
// await new Promise(resolve => setTimeout(resolve, 1000));
// }
// // 创建 BTGrpcClient 实例连接到gRPC服务
// btGrpcClient = new BTGrpcClient();
// // 注册进度回调函数,用于接收下载进度更新
// btGrpcClient.registerProgressCallback('all', (progress) => {
// const mainWindow = BrowserWindow.getFocusedWindow();
// if (mainWindow) {
// mainWindow.webContents.send('grpc-progress-update', progress);
// }
// });
// // 测试连接
// setTimeout(async () => {
// try {
// const connected = await btGrpcClient!.testConnection();
// console.log('gRPC 连接状态:', connected ? '已连接' : '未连接');
// const mainWindow = BrowserWindow.getFocusedWindow();
// if (mainWindow) {
// mainWindow.webContents.send('grpc-connection-status', {
// connected,
// isMock: IS_TEST_MODE
// });
// }
// } catch (error) {
// console.error('连接测试失败:', error);
// }
// }, 2000);
// // 启动健康检查
// startHealthCheck();
// } catch (error) {
// console.error('gRPC 初始化失败:', error);
// }
// }
// // 启动健康检查
// function startHealthCheck() {
// if (healthCheckInterval) {
// clearInterval(healthCheckInterval);
// }
// healthCheckInterval = setInterval(async () => {
// try {
// if (!btGrpcClient) return;
// const isConnected = await btGrpcClient.testConnection();
// const mainWindow = BrowserWindow.getFocusedWindow();
// if (mainWindow) {
// mainWindow.webContents.send('grpc-connection-status', {
// connected: isConnected,
// state: btGrpcClient.getConnectionState(),
// isMock: IS_TEST_MODE
// });
// }
// } catch (error) {
// console.error('健康检查失败:', error);
// }
// }, 5000); // 每5秒检查一次
// }
// // 应用启动时初始化
// app.whenReady().then(() => {
// initializeGrpc();
// });
// // 应用退出时清理
// app.on('before-quit', async () => {
// if (healthCheckInterval) {
// clearInterval(healthCheckInterval);
// }
// if (mockServer) {
// await mockServer.stop();
// }
// });
// // 添加 gRPC 相关的 IPC 处理程序
// ipcMain.handle('grpc-start-download', async (event, config) => {
// try {
// if (!btGrpcClient) {
// return {
// success: false,
// error: 'gRPC客户端未初始化'
// };
// }
// console.log('开始gRPC下载:', config);
// // gRPC客户端向服务端发起 StartDownload 调用
// const result = await btGrpcClient.startDownload({
// torrent_url: config.torrentUrl,
// item_name: config.itemName,
// item_id: config.itemId
// });
// return { success: true, data: result };
// } catch (error: any) {
// console.error('gRPC下载失败:', error);
// return {
// success: false,
// error: error.message || '未知错误',
// details: error.details
// };
// }
// });
// ipcMain.handle('grpc-stop-download', async (event, downloadId) => {
// try {
// if (!btGrpcClient) {
// return {
// success: false,
// error: 'gRPC客户端未初始化'
// };
// }
// const result = await btGrpcClient.stopDownload(downloadId);
// return { success: true, data: result };
// } catch (error: any) {
// return {
// success: false,
// error: error.message || '未知错误'
// };
// }
// });
// ipcMain.handle('grpc-check-connection', async () => {
// if (!btGrpcClient) {
// return { connected: false, error: 'gRPC客户端未初始化' };
// }
// return {
// connected: btGrpcClient.isConnected(),
// state: btGrpcClient.getConnectionState()
// };
// });
// // 重新连接 gRPC
// ipcMain.handle('grpc-reconnect', async () => {
// try {
// if (btGrpcClient) {
// btGrpcClient.reconnect();
// return { success: true, message: '正在重新连接' };
// } else {
// await initializeGrpc();
// return { success: true, message: '正在初始化连接' };
// }
// } catch (error: any) {
// return {
// success: false,
// error: error.message || '重新连接失败'
// };
// }
// });

View File

@ -0,0 +1,126 @@
import { ipcMain } from 'electron';
import request from '../utils/request';
import { getDeviceIdAndMac } from '../utils/utils';
// import { setCurrentServerIp, getCurrentServerIp } from './sharedState'; // 修改导
// gRPC 连接请求
ipcMain.handle('connect-to-grpc', async (event, { serverIp, clientUser }: { serverIp?: string; clientUser?: string }) => {
console.log('gRPC 连接请求');
// return {
// success: true,
// message: 'gRPC 连接成功',
// data: []
// };
try {
// 如果没有传 serverIp则使用已存储的 serverIp
const ip = serverIp || (global as any).currentServerIp;
if (!ip) {
return {
success: false,
error: '未找到服务器IP地址'
};
}
// 获取设备序列号
const deviceInfo = await getDeviceIdAndMac();
if (!deviceInfo.deviceId) {
return {
success: false,
error: '无法获取设备序列号'
};
}
// 构建请求参数
const requestData = {
serverIp: ip,
serverPort: 50051,
clientSn:deviceInfo.deviceId, // 使用设备序列号作为 clientSn,
clientUser: clientUser || '' // 如果没有用户名,则传空字符串
};
console.log('连接 gRPC 服务器:', requestData);
// 发送请求到后端接口
const apiUrl = `http://${ip}:3001/api/grpc/connect`;
const response = await request(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
data: JSON.stringify(requestData)
});
let responseData;
try {
responseData = JSON.parse((response as any).data);
} catch (parseError) {
console.error('解析 gRPC 连接响应失败:', parseError);
return { success: false, message: '服务器响应格式错误' };
}
console.log('gRPC 连接响应:', responseData);
if (responseData.code === '200' || responseData.code === 200) {
console.log('gRPC 连接成功');
return {
success: true,
message: 'gRPC 连接成功',
data: responseData
};
} else {
const errorMessage = responseData.message || responseData.msg || responseData.error || 'gRPC 连接失败';
console.log('gRPC 连接失败:', errorMessage);
return { success: false, message: errorMessage };
}
} catch (error) {
console.error('gRPC 连接请求失败:', error);
return {
success: false,
message: error instanceof Error ? error.message : '未知错误'
};
}
});
// 镜像列表请求
ipcMain.handle('get-images-list', async (event, deviceInfo: { deviceId: string; macAddr: string }) => {
// return { success: true, message: '获取镜像列表成功', data: []}
try {
if (!(global as any).currentServerIp) {
return {
success: false,
error: '服务器未连接'
};
}
const apiUrl = `http://${(global as any).currentServerIp}:8113/api/nex/v1/client/getImageList`;
const response = await request(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
data: JSON.stringify({
device_id: deviceInfo.deviceId,
macAddr: deviceInfo.macAddr
})
});
let responseData;
try {
responseData = JSON.parse((response as any).data);
} catch (parseError) {
console.error('解析镜像列表响应失败:', parseError);
return { success: false, message: '服务器响应格式错误' };
}
return responseData;
} catch (error) {
console.error('获取镜像列表失败:', error);
return {
success: false,
message: error instanceof Error ? error.message : '未知错误'
};
}
});

View File

@ -0,0 +1,2 @@
// 设置全局变量
(global as any).currentServerIp;

View File

@ -5,16 +5,29 @@ contextBridge.exposeInMainWorld('electronAPI', {
getPlatform: async () => {
return await ipcRenderer.invoke('getPlatform');
},
// 【窗口】相关API
closeApp: () => ipcRenderer.send('close-app'),
minimizeApp: () => ipcRenderer.send('minimize-app'),
restoreWindow: () => ipcRenderer.send('restore-window'),
maximizeWindow: () => ipcRenderer.send('maximize-window'),
getWindowMaximized: () => ipcRenderer.invoke('get-window-maximized'),
adjustWindowForNormal:() => ipcRenderer.invoke('adjust-window-for-normal'),
// 【bt下载】相关 API
startDownload: (config: any) => ipcRenderer.invoke('start-download', config),
stopDownload: (downloadId: string) => ipcRenderer.invoke('stop-download', downloadId),
// 【配置步骤】相关API
applyWiredConfig: (config: any) => ipcRenderer.invoke('apply-wired-config', config),
connectServer: (config: any) => ipcRenderer.invoke('connect-server', config),
// 版本更新相关API
downloadAndUpdate: (url: string) => ipcRenderer.invoke('download-and-update', url),
// 服务器IP获取
// 【镜像列表】获取
getImagesList: (config: any) => ipcRenderer.invoke('get-images-list', config),
// 【服务器IP】获取
getCurrentServerIp: () => ipcRenderer.invoke('get-current-server-ip'),
// 【设备信息】获取
getDeviceInfo: () => ipcRenderer.invoke('get-device-info'),
// 【通知后端grpc连接】
connectToGrpc: (config:any) => ipcRenderer.invoke('connect-to-grpc', config),
// 事件监听
onMainProcessMessage: (callback: (data: string) => void) => {
ipcRenderer.on('main-process-message', (_, data) => callback(data));

View File

@ -6,11 +6,21 @@ const os = require('os');
const { promisify } = require('util');
const execAsync = promisify(exec);
/** ID
* TODO: mac
const fs = require('fs');
const path = require('path');
const configPath = path.join(os.homedir(), '.xspace_configData.json'); // 用户数据文件路径
/** IDMAC
* MAC
*/
export async function getDeviceId() {
export async function getDeviceIdAndMac() {
// return {
// deviceId:'125dfsdfsd',
// macAddr: "00:00:00:00:00"
// };
try {
let macAddr = '';
// 尝试多种方法获取唯一的设备标识
const methods = [
// 方法1: CPU序列号
@ -28,10 +38,27 @@ export async function getDeviceId() {
const { stdout } = await execAsync(command);
const deviceId = stdout.trim();
// 同时获取MAC地址供后续使用
if (!macAddr) {
const networkInterfaces = os.networkInterfaces();
outerLoop: for (const interfaceName of Object.keys(networkInterfaces)) {
const interfaces = networkInterfaces[interfaceName];
for (const iface of interfaces) {
if (iface.mac && iface.mac !== '00:00:00:00:00:00') {
macAddr = iface.mac.toUpperCase();
break outerLoop;
}
}
}
}
if (deviceId && deviceId !== '' && deviceId !== 'unknown' && deviceId !== '0000000000000000') {
console.log(`Device ID obtained using command: ${command}`);
console.log(`Device ID: ${deviceId}`);
return deviceId;
return {
deviceId,
macAddr: macAddr || 'NOT_FOUND'
};
}
} catch (error) {
console.log(`Method failed: ${command}, error: ${(error as Error).message}`);
@ -46,8 +73,12 @@ export async function getDeviceId() {
for (const iface of interfaces) {
if (iface.mac && iface.mac !== '00:00:00:00:00:00') {
const fallbackId = iface.mac.replace(/:/g, '').toUpperCase();
macAddr = iface.mac.toUpperCase();
console.log(`Using MAC address as fallback device ID: ${fallbackId}`);
return fallbackId;
return {
deviceId: fallbackId,
macAddr
};
}
}
}
@ -55,12 +86,18 @@ export async function getDeviceId() {
// 最后的fallback - 使用hostname
const hostname = os.hostname();
console.log(`Using hostname as final fallback device ID: ${hostname}`);
return hostname;
return {
deviceId: hostname,
macAddr: macAddr || 'NOT_FOUND'
};
} catch (error) {
console.error('Error getting device ID:', error);
console.error('Error getting device Info:', error);
// 返回一个默认的设备ID
return 'UNKNOWN_DEVICE';
return {
deviceId: 'UNKNOWN_DEVICE',
macAddr: 'ERROR'
};
}
}
@ -103,6 +140,39 @@ export function netmaskToCidr(netmask:string) {
return netmaskMap[netmask] || '24';
}
// 读取保存的用户数据
export function getConfig() {
try {
if (fs.existsSync(configPath)) {
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
}
} catch (error) {
console.error('读取配置失败:', error);
}
}
export function setConfig(config: any) {
try {
const oldConfig = getConfig();
const newConfig = { ...oldConfig, ...config };
fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2));
return true;
} catch (error) {
console.error('保存配置失败:', error);
return false;
}
}
// export function getServerIp() {
// return getConfig().currentServerIp;
// }
// export function setServerIp(ip: string) {
// const config = getConfig();
// config.currentServerIp = ip;
// return setConfig(config);
// }
/**模拟更新客户端 */
export async function simulateUpdate(event: any, url: any) {
const focusedWindow = BrowserWindow.getFocusedWindow();

View File

@ -1,10 +1,8 @@
import React, { useState, useEffect } from 'react';
import { Layout, message } from 'antd';
import { message } from 'antd';
import { history, useLocation, Outlet } from 'umi';
import './index.less';
const { Header, Sider, Content } = Layout;
const MainLayout: React.FC = () => {
const [username, setUsername] = useState('');
const location = useLocation();
@ -27,19 +25,26 @@ const MainLayout: React.FC = () => {
// TODO: 第一次来判断是否配置ip/DHCP、服务ip绑定终端 绑定:直接到版本更新页面 未绑定到配置ip/DHCP页面
setTimeout(() => {
// history.push('/login');
history.push('/configSteps?tab=terminalGetImage');
history.push('/configSteps?tab=serverConfig');
},1000)
// const fetchDeviceId = async () => {
// try {
// const res = await window.electronAPI.invoke('get-device-id');
// console.log('获取设备ID:', res);
// } catch (error) {
// console.error('获取设备ID失败:', error);
// }
// }
// fetchDeviceId()
}, []);
useEffect(() => {
const fetchDeviceId = async () => {
try {
const result = await window.electronAPI.getDeviceInfo();
if (result.success) {
console.log('获取设备信息:', JSON.stringify(result.deviceInfo));
} else {
console.error('获取设备信息失败:', result.error);
}
} catch (error) {
console.error('获取设备信息异常:', error);
}
};
fetchDeviceId();
}, []);
const handleMenuClick = (key: string) => {
// 使用路由导航

View File

@ -1,9 +1,10 @@
// src/pages/components/TitleBar/index.less
.title-bar {
margin: 20px 0;
padding: 0 20px;
// margin: 20px 0; // 使用margin拖动不会起作用需要使用padding
// height: 34px;
padding: 20px;
width: 100%;
height: 34px;
height: 74px;
display: flex;
justify-content: space-between;
align-items: center;

View File

@ -140,7 +140,7 @@ const NetworkConfig: React.FC = () => {
if (activeTab === 'dhcp') {
// 如果是 DHCP 模式直接IPC 处理应用有线网络配置
try {
const res = await window.electronAPI.invoke('apply-wired-config',{ method: 'dhcp' });
const res = await window.electronAPI.applyWiredConfig({ method: 'dhcp' });
console.log('网络配置返回信息成功:', res);
if(res.success){
message.success('网络配置成功');
@ -171,7 +171,7 @@ const NetworkConfig: React.FC = () => {
values.ipv6PrefixLength = parseInt(values.ipv6PrefixLength, 10);
}
const res = await window.electronAPI.invoke('apply-wired-config',{ method: 'static', ...values });
const res = await window.electronAPI.applyWiredConfig({ method: 'static', ...values });
console.log('网络配置返回信息成功:', res);
if(res.success){
message.success('网络配置成功');

View File

@ -54,7 +54,7 @@ const Index = () => {
const values = await form.validateFields();
console.log('表单提交数据:', values);
const { serverIp } = values;
const result = await window.electronAPI.invoke('connect-server', { serverIp });
const result = await window.electronAPI.connectServer({ serverIp });
console.log('连接结果:', result);
if (result.success) {
// 连接成功保存服务器IP到本地存储
@ -62,7 +62,14 @@ const Index = () => {
console.log('Connected server IP saved to localStorage:', serverIp);
// TODO:跳转到版本更新
// setActiveTab("watchManagement")
setActiveTab("terminalGetImage")
// 通知后端连接 gRPC 服务
const grpcResult = await window.electronAPI.connectToGrpc({ serverIp });
if (grpcResult.success) {
// console.log('gRPC 连接成功');
setActiveTab("terminalGetImage")
} else {
message.error(grpcResult.message || 'gRPC 连接失败');
}
// await ipcRenderer.invoke('show-login-window');
} else {
message.error(result.message || '连接服务器失败');

View File

@ -1,43 +1,79 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import ButtonCom from '../../../components/ButtonCom';
import { useConfigStep } from '@/contexts/ConfigStepContext';
import styles from './index.less';
import { message } from 'antd';
import { history } from 'umi';
import TrueIcon from '@assets/true.png'
import WebSocketService from '../../../services/webSocketService';
import { getDeviceInfo, getImagesList } from '../../../services/imageService';
const Index = () => {
const { goToNextTab,setActiveTab } = useConfigStep();
const [imageStatus, setImageStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [isWsConnected, setIsWsConnected] = useState(false);
const checkImageList = useCallback(async () => {
try {
// 获取设备信息
const deviceInfo = await getDeviceInfo();
// 获取镜像列表
const response = await getImagesList({
deviceId: deviceInfo.deviceId,
macAddr: deviceInfo.macAddr
});
// 模拟获取镜像数据的API调用
useEffect(() => {
const fetchImageData = async () => {
try {
// 模拟API请求
await new Promise(resolve => setTimeout(resolve, 3000));
// 模拟随机成功或失败
const isSuccess = Math.random() > 0.3;
setImageStatus(isSuccess ? 'success' : 'loading');
// 如果失败,继续轮询检查状态
if (!isSuccess) {
const checkStatus = async () => {
// 模拟WebSocket或轮询检查
await new Promise(resolve => setTimeout(resolve, 5000));
setImageStatus('success'); // 模拟收到通知
};
checkStatus();
}
} catch (error) {
message.error('获取镜像数据失败');
if (response.code === 200 && response.data?.data?.length > 0) {
// 有镜像数据,直接进入下一步
setImageStatus('success');
} else {
// 没有镜像数据需要监听WebSocket
await connectWebSocket();
}
};
fetchImageData();
} catch (error) {
console.error('检查镜像数据失败:', error);
message.error('获取镜像数据失败');
setImageStatus('error');
}
}, []);
const connectWebSocket = useCallback(async () => {
try {
// 连接WebSocket
const connected = await WebSocketService.connect();
setIsWsConnected(connected);
if (connected) {
// 监听镜像数据推送
const removeListener = WebSocketService.addListener('2', (data:any) => {
if (data.type === '2') {
// 有镜像数据了,更新状态
setImageStatus('success');
// 移除监听器
removeListener();
}
});
} else {
message.error('WebSocket连接失败');
}
} catch (error) {
console.error('WebSocket连接失败:', error);
message.error('WebSocket连接失败');
}
}, []);
useEffect(() => {
// 组件挂载时检查镜像数据
checkImageList();
// 组件卸载时清理
return () => {
// 可以选择是否断开WebSocket连接
};
}, [checkImageList]);
const ImageLoadingComponent = () => (
<div className={styles["image-loading-container"]}>
<div className={styles["loading-wrapper"]}>

View File

@ -1,93 +0,0 @@
// 页面头部样式
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #333;
}
}
// 镜像列表样式
.image-list {
.image-detail {
.detail-item {
margin-bottom: 16px;
label {
font-weight: 600;
color: #333;
display: inline-block;
width: 100px;
}
span {
color: #666;
}
p {
margin: 8px 0 0 100px;
color: #666;
line-height: 1.6;
}
}
}
}
// 个人资料样式
.profile-page {
.profile-content {
.profile-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
.profile-info {
h3 {
margin: 0 0 4px 0;
font-size: 20px;
font-weight: 600;
color: #333;
}
p {
margin: 0;
color: #666;
font-size: 14px;
}
}
}
.quick-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.profile-content {
.profile-header {
flex-direction: column;
text-align: center;
}
.quick-actions {
justify-content: center;
}
}
}

View File

@ -1,230 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Card, Table, Tag, Button, Space, Modal, message } from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
EyeOutlined,
DownloadOutlined
} from '@ant-design/icons';
import './index.less';
interface ImageItem {
id: string;
name: string;
version: string;
size: string;
status: 'active' | 'inactive' | 'building';
createTime: string;
description: string;
}
const ImageList: React.FC = () => {
const [images, setImages] = useState<ImageItem[]>([]);
const [loading, setLoading] = useState(false);
const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null);
const [detailVisible, setDetailVisible] = useState(false);
useEffect(() => {
loadImages();
}, []);
const loadImages = () => {
setLoading(true);
// 模拟数据加载
setTimeout(() => {
const mockData: ImageItem[] = [
{
id: '1',
name: 'Windows 10 专业版',
version: 'v1.0.0',
size: '15.2 GB',
status: 'active',
createTime: '2024-01-15 10:30:00',
description: 'Windows 10 专业版镜像,包含常用办公软件'
},
{
id: '2',
name: 'Ubuntu 22.04 LTS',
version: 'v2.1.0',
size: '8.5 GB',
status: 'active',
createTime: '2024-01-10 14:20:00',
description: 'Ubuntu 22.04 LTS 服务器版本,适用于开发环境'
},
{
id: '3',
name: 'CentOS 8',
version: 'v1.5.0',
size: '12.1 GB',
status: 'building',
createTime: '2024-01-20 09:15:00',
description: 'CentOS 8 企业级服务器操作系统'
},
{
id: '4',
name: 'macOS Monterey',
version: 'v1.2.0',
size: '18.7 GB',
status: 'inactive',
createTime: '2024-01-05 16:45:00',
description: 'macOS Monterey 开发环境镜像'
}
];
setImages(mockData);
setLoading(false);
}, 1000);
};
const getStatusTag = (status: string) => {
const statusMap = {
active: { color: 'green', text: '可用' },
inactive: { color: 'red', text: '不可用' },
building: { color: 'orange', text: '构建中' }
};
const config = statusMap[status as keyof typeof statusMap];
return <Tag color={config.color}>{config.text}</Tag>;
};
const handleViewDetail = (record: ImageItem) => {
setSelectedImage(record);
setDetailVisible(true);
};
const handleDownload = (record: ImageItem) => {
message.success(`开始下载镜像:${record.name}`);
};
const handleEdit = (record: ImageItem) => {
message.info(`编辑镜像:${record.name}`);
};
const handleDelete = (record: ImageItem) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除镜像 "${record.name}" 吗?`,
onOk: () => {
setImages(images.filter(img => img.id !== record.id));
message.success('删除成功');
}
});
};
const columns = [
{
title: '镜像名称',
dataIndex: 'name',
key: 'name',
width: 200,
},
{
title: '版本',
dataIndex: 'version',
key: 'version',
width: 100,
},
{
title: '大小',
dataIndex: 'size',
key: 'size',
width: 100,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: string) => getStatusTag(status),
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
width: 180,
},
{
title: '操作',
key: 'action',
width: 200,
render: (_: any, record: ImageItem) => (
<Space size="small">
<Button
type="text"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record)}
title="查看详情"
/>
<Button
type="text"
icon={<DownloadOutlined />}
onClick={() => handleDownload(record)}
title="下载"
/>
</Space>
),
},
];
return (
<div className="image-list">
<Card>
<Table
columns={columns}
dataSource={images}
rowKey="id"
loading={loading}
pagination={{
total: images.length,
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`,
}}
/>
</Card>
<Modal
title="镜像详情"
open={detailVisible}
onCancel={() => setDetailVisible(false)}
footer={[
<Button key="close" onClick={() => setDetailVisible(false)}>
</Button>
]}
width={600}
>
{selectedImage && (
<div className="image-detail">
<div className="detail-item">
<label></label>
<span>{selectedImage.name}</span>
</div>
<div className="detail-item">
<label></label>
<span>{selectedImage.version}</span>
</div>
<div className="detail-item">
<label></label>
<span>{selectedImage.size}</span>
</div>
<div className="detail-item">
<label></label>
<span>{getStatusTag(selectedImage.status)}</span>
</div>
<div className="detail-item">
<label></label>
<span>{selectedImage.createTime}</span>
</div>
<div className="detail-item">
<label></label>
<p>{selectedImage.description}</p>
</div>
</div>
)}
</Modal>
</div>
);
};
export default ImageList;

View File

@ -0,0 +1,169 @@
/* src/pages/imagesList/index.less */
.images-list-container {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
.shadow-divider {
height: 20px;
border-radius: 4px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.05) 0%, rgba(0, 0, 0, 0) 100%);
flex-shrink: 0;
}
.content-container {
flex: 1;
padding: 29px 52px;
display: flex;
flex-direction: column;
overflow: hidden;
.header {
margin-bottom: 50px;
h1 {
font-family: "PingFang SC";
font-weight: 600;
font-style: normal;
font-size: 18px;
line-height: 29px;
color: rgba(51, 51, 51, 1);
margin: 0;
}
}
.images-grid {
// 移除 flex: 1
display: grid;
grid-template-columns: repeat(auto-fill, 150px);
gap: 50px 38px; // row-gap: 50px, column-gap: 38px
padding-bottom: 70px;
overflow-y: auto; // 保持滚动功能
// 添加最小高度以确保滚动正常工作
min-height: 0; // 关键:允许网格容器收缩
.image-item {
position: relative;
width: 150px;
height: 129px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
&:hover {
transform: translateY(-4px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
// 添加状态图标样式
.status-icon {
position: absolute;
top: 8px;
right: 8px;
width: 15px;
height: 15px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
.status-icon-download {
font-size: 15px;
color: #1890ff; // 蓝色下载图标
}
.status-icon-completed {
font-size: 15px;
color: #52c41a; // 绿色完成图标
}
}
.image-icon {
display: flex;
justify-content: center;
align-items: center;
height: 80px;
padding-top: 10px;
}
.image-name {
width: 100%;
height: 29px;
padding: 0 10px;
font-size: 14px;
text-align: center;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.download-overlay,
.completed-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.download-overlay {
.download-info {
width: 100%;
padding: 0 10px;
text-align: center;
.download-text {
color: white;
font-size: 14px;
margin-bottom: 8px;
}
.progress-bar {
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.3);
border-radius: 3px;
overflow: hidden;
margin-bottom: 8px;
.progress-fill {
height: 100%;
background: #4a90e2;
border-radius: 3px;
transition: width 0.3s;
}
}
.speed-info,
.eta-info {
color: white;
font-size: 12px;
margin-bottom: 4px;
}
}
}
.completed-overlay {
.completed-text {
color: white;
font-size: 16px;
font-weight: bold;
}
}
}
}
}
}

View File

@ -1,11 +1,504 @@
import React from 'react';
// src/pages/imagesList/index.tsx
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { message } from 'antd';
import { DownloadOutlined, CheckCircleOutlined } from '@ant-design/icons'; // 引入 Ant Design 图标
import TitleBar from '../components/TitleBar';
import ImageFileIcon from '@assets/imageFileIcon.png'
import { formatDecimalSimple } from '@/utils/utils';
import WebSocketService from '../services/webSocketService';
import './index.less';
const Index = () => {
return (
<div>
</div>
);
// 镜像项类型定义
interface ImageItem {
id: string;
name: string;
torrentUrl: string;
icon: string;
}
export default Index;
// 下载状态类型定义
interface DownloadProgress {
downloadId: string;
progress: number;
downloadSpeedText: string;
etaText: string;
state: 'started' | 'progress' | 'completed' | 'error' | 'stopped';
}
const ImagesList: React.FC = () => {
// 镜像列表数据(示例数据)
const [images, setImages] = useState<ImageItem[]>([
{
id: 'ubuntu-22-04',
name: 'Ubuntu 22.04 LTS',
torrentUrl: 'https://releases.ubuntu.com/22.04/ubuntu-22.04.3-desktop-amd64.iso.torrent',
icon: ''
},
{
id: 'windows-11',
name: 'Windows 11 Pro',
torrentUrl: 'https://download.microsoft.com/windows-11.iso.torrent',
icon: ''
},
{
id: 'centos-8',
name: 'CentOS 8 Stream',
torrentUrl: 'https://centos.org/centos-8-stream.iso.torrent',
icon: ''
},
{
id: 'debian-12',
name: 'Debian 12 Bookworm',
torrentUrl: 'https://debian.org/debian-12.iso.torrent',
icon: ''
},
{
id: 'fedora-38',
name: 'Fedora 38 Workstation',
torrentUrl: 'https://fedoraproject.org/fedora-38.iso.torrent',
icon: ''
},
{
id: 'opensuse-leap-1',
name: 'openSUSE Leap 15.5',
torrentUrl: 'https://opensuse.org/opensuse-leap-15.5.iso.torrent',
icon: ''
},
{
id: 'opensuse-leap-2',
name: 'openSUSE Leap 15.5',
torrentUrl: 'https://opensuse.org/opensuse-leap-15.5.iso.torrent',
icon: ''
},
{
id: 'opensuse-leap-3',
name: 'openSUSE Leap 15.5openSUSE Leap 15.5',
torrentUrl: 'https://opensuse.org/opensuse-leap-15.5.iso.torrent',
icon: ''
}
]);
// 下载状态映射
const [downloadStatus, setDownloadStatus] = useState<Map<string, DownloadProgress>>(new Map());
// WebSocket连接状态
const [wsConnected, setWsConnected] = useState(false);
// 防抖计时器映射
const debounceTimers = useMemo(() => new Map<string, NodeJS.Timeout>(), []);
// 初始化WebSocket连接
useEffect(() => {
let removeProgressListener: (() => void) | null = null;
let removeStartListener: (() => void) | null = null;
let removeCompleteListener: (() => void) | null = null;
let removeErrorListener: (() => void) | null = null;
let removeStopListener: (() => void) | null = null;
const initWebSocket = async () => {
try {
const connected = await WebSocketService.connect();
setWsConnected(connected);
if (connected) {
// 监听下载进度更新
removeProgressListener = WebSocketService.addListener('download-progress', (data: any) => {
if (data.type === 'download-progress') {
setDownloadStatus(prev => {
const newMap = new Map(prev);
newMap.set(data.downloadId, {
downloadId: data.downloadId,
progress: data.progress,
downloadSpeedText: data.downloadSpeedText,
etaText: data.etaText,
state: 'progress'
});
return newMap;
});
}
});
// 监听下载开始
removeStartListener = WebSocketService.addListener('download-started', (data: any) => {
if (data.type === 'download-started') {
setDownloadStatus(prev => {
const newMap = new Map(prev);
newMap.set(data.downloadId, {
downloadId: data.downloadId,
progress: 0,
downloadSpeedText: '0 MB/s',
etaText: '计算中...',
state: 'started'
});
return newMap;
});
}
});
// 监听下载完成
removeCompleteListener = WebSocketService.addListener('download-completed', (data: any) => {
if (data.type === 'download-completed') {
setDownloadStatus(prev => {
const newMap = new Map(prev);
const status = newMap.get(data.downloadId);
if (status) {
newMap.set(data.downloadId, {
...status,
progress: 100,
state: 'completed'
});
}
return newMap;
});
message.success(`${ data.itemName } 下载完成`)
}
});
// 监听下载错误
removeErrorListener = WebSocketService.addListener('download-error', (data: any) => {
if (data.type === 'download-error') {
setDownloadStatus(prev => {
const newMap = new Map(prev);
const status = newMap.get(data.downloadId);
if (status) {
newMap.set(data.downloadId, {
...status,
state: 'error'
});
}
return newMap;
});
message.error(`下载错误: ${data.error}`);
}
});
// 监听下载停止
removeStopListener = WebSocketService.addListener('download-stopped', (data: any) => {
if (data.type === 'download-stopped') {
setDownloadStatus(prev => {
const newMap = new Map(prev);
const status = newMap.get(data.downloadId);
if (status) {
newMap.set(data.downloadId, {
...status,
state: 'stopped'
});
}
return newMap;
});
}
});
} else {
message.error('WebSocket连接失败');
}
} catch (error) {
console.error('WebSocket初始化失败:', error);
message.error('WebSocket初始化失败');
}
};
initWebSocket();
// 清理函数
return () => {
// 注意我们不主动断开WebSocket连接让它保持连接状态
// 只是移除当前页面的监听器
if (removeProgressListener) removeProgressListener();
if (removeStartListener) removeStartListener();
if (removeCompleteListener) removeCompleteListener();
if (removeErrorListener) removeErrorListener();
if (removeStopListener) removeStopListener();
};
}, []);
// 初始化WebSocket连接
// useEffect(() => {
// let progressWS: WebSocket;
// const initWebSocket = async () => {
// try {
// // 获取保存的服务器 IP
// const serverIp = await window.electronAPI.getCurrentServerIp();
// // if (!serverIp) {
// // message.error('服务器未连接,请先配置服务器');
// // return;
// // }
// // 如果没有服务器IP则使用本地测试
// const wsUrl = serverIp
// ? `ws://${serverIp}:3002`
// : 'ws://localhost:3002'; // 本地测试用的WebSocket地址
// console.log('尝试连接WebSocket:', wsUrl);
// progressWS = new WebSocket(wsUrl);
// // 使用服务器 IP 建立 WebSocket 连接
// // progressWS = new WebSocket(`ws://${serverIp}:3002`);
// progressWS.onopen = () => {
// console.log('WebSocket连接成功');
// setWsConnected(true);
// // 发送认证
// progressWS.send(JSON.stringify({
// type: 'auth',
// clientId: 'electron-frontend'
// }));
// };
// progressWS.onmessage = (event) => {
// const data = JSON.parse(event.data);
// handleProgressMessage(data);
// };
// progressWS.onerror = (error) => {
// console.error('WebSocket错误:', error);
// setWsConnected(false);
// };
// progressWS.onclose = () => {
// console.log('WebSocket连接关闭');
// setWsConnected(false);
// // 尝试重连
// setTimeout(initWebSocket, 5000);
// };
// } catch (error) {
// console.error('初始化WebSocket失败:', error);
// message.error('连接服务器失败');
// }
// };
// initWebSocket();
// return () => {
// if (progressWS) {
// progressWS.close();
// }
// };
// }, []);
// 处理WebSocket消息
const handleProgressMessage = useCallback((data: any) => {
switch (data.type) {
case 'download-started':
setDownloadStatus(prev => {
const newMap = new Map(prev);
newMap.set(data.downloadId, {
downloadId: data.downloadId,
progress: 0,
downloadSpeedText: '0 MB/s',
etaText: '计算中...',
state: 'started'
});
return newMap;
});
break;
case 'download-progress':
setDownloadStatus(prev => {
const newMap = new Map(prev);
newMap.set(data.downloadId, {
downloadId: data.downloadId,
progress: data.progress,
downloadSpeedText: data.downloadSpeedText,
etaText: data.etaText,
state: 'progress'
});
return newMap;
});
break;
case 'download-completed':
setDownloadStatus(prev => {
const newMap = new Map(prev);
const status = newMap.get(data.downloadId);
if (status) {
newMap.set(data.downloadId, {
...status,
progress: 100,
state: 'completed'
});
}
return newMap;
});
message.success(`下载完成: ${data.itemName}`);
break;
case 'download-error':
setDownloadStatus(prev => {
const newMap = new Map(prev);
const status = newMap.get(data.downloadId);
if (status) {
newMap.set(data.downloadId, {
...status,
state: 'error'
});
}
return newMap;
});
message.error(`下载错误: ${data.error}`);
break;
case 'download-stopped':
setDownloadStatus(prev => {
const newMap = new Map(prev);
const status = newMap.get(data.downloadId);
if (status) {
newMap.set(data.downloadId, {
...status,
state: 'stopped'
});
}
return newMap;
});
message.info('下载已停止');
break;
}
}, []);
// 开始下载
const startDownload = useCallback((image: ImageItem) => {
// 防抖处理
if (debounceTimers.has(image.id)) {
clearTimeout(debounceTimers.get(image.id)!);
}
const timer = setTimeout(async () => {
try {
// 检查是否已经在下载
if (downloadStatus.has(image.id)) {
const status = downloadStatus.get(image.id)!;
if (status.state === 'progress' || status.state === 'started') {
message.warning('已在下载中,请勿重复点击');
return;
}
}
// 通过 Electron IPC 发送 HTTP 请求
const result = await window.electronAPI.startDownload({
torrentUrl: image.torrentUrl,
itemName: image.name,
itemId: image.id,
savePath: '/mnt/disk/tmp',
targetPath: '/mnt/disk/voi'
});
if (!result.success) {
message.error(`下载启动失败: ${result.error}`);
} else {
message.success('开始下载');
}
} catch (error) {
console.error('下载请求失败:', error);
message.error('下载请求失败');
}
}, 300); // 300ms 防抖延迟
debounceTimers.set(image.id, timer);
}, [downloadStatus, debounceTimers]);
// 停止下载
const stopDownload = useCallback(async (downloadId: string) => {
try {
const result = await window.electronAPI.stopDownload(downloadId);
if (!result.success) {
message.error(`停止下载失败: ${result.error}`);
} else {
message.success('下载已停止');
// 停止成功后清除该下载的状态
setDownloadStatus(prev => {
const newMap = new Map(prev);
newMap.delete(downloadId);
return newMap;
});
}
} catch (error) {
console.error('停止下载失败:', error);
message.error('停止下载失败');
}
}, []);
// 渲染镜像项
const renderImageItem = (image: ImageItem) => {
const status = downloadStatus.get(image.id);
const isDownloading = status && (status.state === 'started' || status.state === 'progress');
const isCompleted = status && status.state === 'completed';
return (
<div
className={`image-item ${isCompleted ? 'completed' : ''}`}
key={image.id}
onClick={() => {
if (isDownloading) {
stopDownload(image.id);
} else if (!isCompleted) {
startDownload(image);
}
}}
>
{/* 添加状态图标 */}
<div className="status-icon">
{!isDownloading && (
<div className="status-icon">
{isCompleted ? (
<CheckCircleOutlined className="status-icon-completed" />
) : (
<DownloadOutlined className="status-icon-download" />
)}
</div>
)}
</div>
<div className="image-icon">
<img src={ImageFileIcon} />
</div>
<div className="image-name" title={image.name}>
{image.name}
</div>
{isDownloading && (
<div className="download-overlay">
<div className="download-info">
<div className="download-text">...{status?.progress.toFixed(1)}%</div>
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${formatDecimalSimple(status?.progress)}%` }}
/>
</div>
<div className="speed-info">{status?.downloadSpeedText}</div>
<div className="eta-info">{status?.etaText}</div>
</div>
</div>
)}
{/* {isCompleted && (
<div className="completed-overlay">
<div className="completed-text"></div>
</div>
)} */}
</div>
);
};
return (
<div className="images-list-container">
<TitleBar />
<div className="shadow-divider" />
<div className="content-container">
<div className="header">
<h1></h1>
</div>
<div className="images-grid-con">
<div className="images-grid">
{images.map(renderImageItem)}
</div>
</div>
</div>
</div>
);
};
export default ImagesList;

View File

@ -23,8 +23,27 @@ const LoginPage: React.FC = () => {
// 存储登录状态
localStorage.setItem('isLoggedIn', 'true');
localStorage.setItem('username', values.username);
// 跳转到镜像列表页面
history.push('/imagesList');
// 连接 gRPC 服务
// TODO:如果设置了ip后面会跳过serverConfig页面,这localStorage还能获取ip吗?
// const serverIp = localStorage.getItem('connected-server-ip');
const serverIp = await window.electronAPI.getCurrentServerIp();
console.log('登录后连接grpc 获取到的服务器IP:', serverIp);
if (serverIp) {
const grpcResult = await window.electronAPI.connectToGrpc({
serverIp,
clientUser: values.username
});
if (grpcResult.success) {
// console.log('gRPC 连接成功');
// 跳转到镜像列表页面
history.push('/imagesList');
} else {
message.error(grpcResult.message || 'gRPC 连接失败');
}
}
} else {
message.error('用户名或密码错误!');
}

View File

@ -0,0 +1,44 @@
// 获取设备信息的接口
interface DeviceInfo {
deviceId: string;
macAddr: string;
}
// 获取镜像列表的响应结构
interface ImageListResponse {
code: number;
data: {
data: any[];
};
message: string;
}
// 获取设备信息
export const getDeviceInfo = async (): Promise<DeviceInfo> => {
try {
const result = await window.electronAPI.getDeviceInfo();
if (result.success && result.deviceInfo) {
return {
deviceId: result.deviceInfo.deviceId || '',
macAddr: result.deviceInfo.macAddr || ''
};
}
throw new Error('获取设备信息失败');
} catch (error) {
console.error('获取设备信息异常:', error);
throw error;
}
};
// 获取镜像列表
export const getImagesList = async (deviceInfo: DeviceInfo): Promise<ImageListResponse> => {
try {
// 这里使用electron的request工具发送请求
const response = await window.electronAPI.getImagesList(deviceInfo);
return response;
} catch (error) {
console.error('获取镜像列表失败:', error);
throw error;
}
};

View File

@ -0,0 +1,197 @@
// src/services/webSocketService.ts
class WebSocketService {
private static instance: WebSocketService;
private ws: WebSocket | null = null;
private listeners: Map<string, Set<(data: any) => void>> = new Map();
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectDelay = 3000;
private isConnectedValue = false;
private connectionPromise: Promise<boolean> | null = null;
private constructor() {}
public static getInstance(): WebSocketService {
if (!WebSocketService.instance) {
WebSocketService.instance = new WebSocketService();
}
return WebSocketService.instance;
}
public connect(): Promise<boolean> {
// 如果已经有一个连接尝试正在进行返回该Promise
if (this.connectionPromise) {
return this.connectionPromise;
}
// 如果已经连接直接返回true
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
return Promise.resolve(true);
}
// 创建新的连接Promise
this.connectionPromise = new Promise<boolean>(async (resolve) => {
// 如果WebSocket已经存在且正在连接中等待连接结果
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
const onOpen = () => {
this.ws?.removeEventListener('open', onOpen);
this.ws?.removeEventListener('error', onError);
resolve(true);
};
const onError = () => {
this.ws?.removeEventListener('open', onOpen);
this.ws?.removeEventListener('error', onError);
resolve(false);
};
this.ws?.addEventListener('open', onOpen);
this.ws?.addEventListener('error', onError);
return;
}
try {
// 获取服务器IP地址
const serverIp = await window.electronAPI.getCurrentServerIp();
const wsUrl = serverIp
? `ws://${serverIp}:3002`
: 'ws://localhost:3002';
this.ws = new WebSocket(wsUrl);
// this.ws = new WebSocket('ws://localhost:3002'); // 本地测试用的WebSocket地址
this.ws.onopen = () => {
console.log('WebSocket连接成功');
this.isConnectedValue = true;
this.reconnectAttempts = 0;
this.sendAuth();
resolve(true);
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.handleMessage(data);
} catch (error) {
console.error('解析WebSocket消息失败:', error);
}
};
this.ws.onclose = () => {
console.log('WebSocket连接关闭');
this.isConnectedValue = false;
this.handleReconnect();
resolve(false);
};
this.ws.onerror = (error) => {
console.error('WebSocket错误:', error);
this.isConnectedValue = false;
resolve(false);
};
} catch (error) {
console.error('WebSocket连接失败:', error);
this.isConnectedValue = false;
resolve(false);
}
}).finally(() => {
// 清除连接Promise允许下一次连接尝试
this.connectionPromise = null;
});
return this.connectionPromise;
}
private sendAuth() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
type: 'auth',
clientId: 'electron-frontend'
}));
}
}
private handleMessage(data: any) {
// 通知所有监听器
const typeListeners = this.listeners.get(data.type);
if (typeListeners) {
typeListeners.forEach(callback => {
try {
callback(data);
} catch (error) {
console.error('处理WebSocket消息时出错:', error);
}
});
}
// 通知通用监听器
const allListeners = this.listeners.get('all');
if (allListeners) {
allListeners.forEach(callback => {
try {
callback(data);
} catch (error) {
console.error('处理WebSocket消息时出错:', error);
}
});
}
}
public addListener(type: string, callback: (data: any) => void): () => void {
if (!this.listeners.has(type)) {
this.listeners.set(type, new Set());
}
this.listeners.get(type)!.add(callback);
// 返回移除监听器的函数
return () => {
const typeListeners = this.listeners.get(type);
if (typeListeners) {
typeListeners.delete(callback);
}
};
}
public removeListener(type: string, callback: (data: any) => void) {
const typeListeners = this.listeners.get(type);
if (typeListeners) {
typeListeners.delete(callback);
}
}
public sendMessage(message: any) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
private handleReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
console.log(`尝试重新连接 (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
setTimeout(() => {
this.connect();
}, this.reconnectDelay);
}
}
public disconnect() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.listeners.clear();
this.reconnectAttempts = 0;
this.isConnectedValue = false;
this.connectionPromise = null;
}
public isConnected(): boolean {
return this.isConnectedValue && this.ws !== null && this.ws.readyState === WebSocket.OPEN;
}
}
export default WebSocketService.getInstance();

View File

@ -0,0 +1,10 @@
export function formatDecimalSimple(num: number | string) {
const number = Number(num);
if (isNaN(number)) return '0';
// 先四舍五入到两位小数
const rounded = Math.round(number * 100) / 100;
// 使用toFixed(2)然后移除末尾的0
return parseFloat(rounded.toFixed(2)).toString();
}

View File

@ -19,13 +19,17 @@ export default defineConfig({
path: '/login',
component: '@/pages/login',
},
{
path: '/vncClient',
component: '@/pages/vncClient',
},
{
path: '/',
component: '@/pages/components/Layout/index',
routes: [
{
path: '/images',
component: '@/pages/images',
component: '@/pages/imagePage',
},
{
path: '/profile',
@ -39,17 +43,26 @@ export default defineConfig({
path: '/terminal',
component: '@/pages/terminal',
},
{
path: '/network',
component: '@/pages/network',
},
{
path: '/storage',
component: '@/pages/storage',
},
],
},
],
npmClient: 'pnpm',
proxy: {
'/api/nex/v1/': {
target: 'http://10.100.51.85:8113',
target: 'http://192.168.2.224:8113',
// changeOrigin: true,
},
'/api/files': {
target: 'http://10.100.51.85:8113',
target: 'http://10.100.51.86:8113',
},
},
});

View File

@ -1,4 +1,108 @@
export default {
'POST /api/nex/v1/desktopImages/query': (req: any, res: any) => {
const { page_size, page_num } = req.body;
const data = [];
for (let i = 1; i <= page_size; i++) {
const id = (page_num - 1) * page_size + i;
data.push({
id: id,
image_name: `桌面镜像${id}`,
parent_image: Math.floor(Math.random() * 5) + 1,
cpu: '4核',
memory: '8GB',
image_version: `v1.0.${Math.floor(Math.random() * 10)}`,
os_version: id % 3 === 0 ? 'Windows 11' : id % 3 === 1 ? 'Windows 10' : 'Ubuntu 22.04',
image_status: Math.random() > 0.2 ? 1 : 2,
create_time: new Date(Date.now() - Math.floor(Math.random() * 30 * 24 * 60 * 60 * 1000)).toISOString(),
description: `这是桌面镜像${id}的描述信息`,
desktopType: id % 2 === 0 ? 'standard' : 'custom',
version: `1.0.${Math.floor(Math.random() * 10)}`,
size: `${Math.floor(Math.random() * 50) + 20}GB`,
status: Math.random() > 0.1 ? 'active' : Math.random() > 0.5 ? 'inactive' : 'building',
file_name: `desktop_image_${id}.qcow2`,
file_type: id % 2 === 0 ? '中型虚拟机' : '小型虚拟机',
file_size: `${Math.floor(Math.random() * 10) + 1}TB`,
desc: `这是测试的桌面镜像${id}`,
});
}
const result = {
code: '200',
message: '操作成功',
data: {
total: 50,
page_num: page_num,
page_size: page_size,
data: data,
},
};
setTimeout(() => {
res.send(result);
}, 300);
},
'POST /api/nex/v1/virtualImages/query': (req: any, res: any) => {
const { page_size, page_num } = req.body;
const data = [];
for (let i = 1; i <= page_size; i++) {
const id = (page_num - 1) * page_size + i;
data.push({
id: id,
image_name: `工具${id}`,
description: `这是工具${id}的描述信息`,
image_system_id: '1',
image_system_name: '系统镜像1',
os_version: 'Windows 10',
storage_path: '/path/to/image',
network_module: 'Ethernet',
image_status: 'active',
});
}
const result = {
code: '200',
message: '操作成功',
data: {
total: 100,
page_num: page_num,
page_size: page_size,
data: data,
},
};
setTimeout(() => {
res.send(result);
}, 300);
},
'POST /api/nex/v1/tool/select/page': (req: any, res: any) => {
const { page_size, page_num } = req.body;
const data = [];
for (let i = 1; i <= page_size; i++) {
const id = (page_num - 1) * page_size + i;
data.push({
id: id,
tool_name: `工具${id}`,
tool_type: Math.random() > 0.5 ? 'system' : Math.random() > 0.5 ? 'virtual' : 'desktop',
file_size: `${Math.floor(Math.random() * 1000) + 100}MB`,
version: `1.0.${Math.floor(Math.random() * 10)}`,
create_time: new Date().toISOString(),
description: `这是工具${id}的描述信息`,
});
}
const result = {
code: '200',
message: '操作成功',
data: {
total: 100,
page_num: page_num,
page_size: page_size,
data: data,
},
};
setTimeout(() => {
res.send(result);
}, 300);
},
'POST /api/nex/v1/queryimagesList': (req: any, res: any) => {
const { page_size, page_num } = req.body;
const data = [];
@ -90,4 +194,45 @@ export default {
});
}
},
// 'POST /api/nex/v1/network/select/page': (req: any, res: any) => {
// const { page_size, page_num } = req.body;
// const data = [];
// const networkTypes = ['NAT', 'Isolated', 'Bridge'];
// for (let i = 1; i <= page_size; i++) {
// const id = (page_num - 1) * page_size + i;
// const networkType = networkTypes[Math.floor(Math.random() * networkTypes.length)];
// data.push({
// id: id,
// network_name: `网络${id}`,
// bridge_name: `br${id}`,
// network_type: networkType,
// ip_range: networkType !== 'Bridge' ? `192.168.${id}.0/24` : null,
// gateway: networkType !== 'Bridge' ? `192.168.${id}.1` : null,
// subnet_mask: networkType !== 'Bridge' ? '255.255.255.0' : null,
// dhcp_start: networkType !== 'Bridge' ? `192.168.${id}.100` : null,
// dhcp_end: networkType !== 'Bridge' ? `192.168.${id}.200` : null,
// dhcp_enabled: networkType !== 'Bridge' ? Math.random() > 0.3 : null,
// auto_start: Math.random() > 0.5,
// create_time: new Date(Date.now() - Math.floor(Math.random() * 30 * 24 * 60 * 60 * 1000)).toISOString(),
// description: `这是网络${id}的描述信息`,
// });
// }
// const result = {
// code: '200',
// message: '操作成功',
// data: {
// total: 50,
// page_num: page_num,
// page_size: page_size,
// data: data,
// },
// };
// setTimeout(() => {
// res.send(result);
// }, 300);
// },
};

View File

@ -1,5 +1,5 @@
export default {
'POST /api/v1/terminal/query/devicelist': (req: any, res: any) => {
'POST /api/nex/v1/device/select/page': (req: any, res: any) => {
const { page_size, page_num } = req.body;
const data = [];
@ -18,16 +18,11 @@ export default {
});
}
const result = {
error_code: '0000000000',
code: '200',
message: '操作成功',
data: {
paging: {
total: 520,
total_num: 520,
page_num: page_num,
page_size: page_size,
},
data: data,
total: data.length,
},
};
setTimeout(() => {
@ -70,7 +65,10 @@ export default {
error_code: '0000000000',
message: '操作成功',
data: {
data: data,
data: {
data: data,
total: data.length,
},
},
};
setTimeout(() => {

18585
web-fe/package-lock.json generated 100644

File diff suppressed because it is too large Load Diff

View File

@ -12,10 +12,12 @@
"dependencies": {
"@ant-design/icons": "^5.0.1",
"@ant-design/pro-components": "^2.4.4",
"@novnc/novnc": "^1.6.0",
"@umijs/max": "^4.4.11",
"antd": "^5.4.0",
"dayjs": "^1.11.13",
"moment": "^2.30.1",
"novnc": "^1.2.0",
"spark-md5": "^3.0.2",
"uuid": "^11.1.0"
},

View File

@ -54,5 +54,37 @@ export const PRIOPRITY_OPTIONS = [
{ value: 3, label: '三级' },
];
export const DHCP_STATUS = {
1: '启用',
0: '禁用',
} as const;
export const NETWORK_STATUS = {
1: '活跃',
0: '非活跃',
} as const;
export const NETWORK_TYPE = {
nat: 'NAT(网络地址转换)',
bridge: 'Bridge(桥接)',
isolated: 'Isolated(隔离)',
} as const;
export const NETWORK_TYPE_LIST = [
{ value: "nat", label: 'NAT(网络地址转换)' },
{ value: 'bridge', label: 'Bridge(桥接)' },
{ value: 'isolated', label: 'Isolated(隔离)' },
];
export const NETWORK_STATUS_LIST = [
{ value: 1, label: '活跃' },
{ value: 0, label: '非活跃' },
];
export const DEFAULT_PASSWORD="a123456"
export const DEFAULT_BLICK_TAB="黑名单"
export const CPU_TOTAL=16
export const CPU_CORE_TOTAL=8

View File

@ -45,6 +45,8 @@ const MainLayout: React.FC = () => {
if (path.startsWith('/terminal')) return 'terminal';
if (path.startsWith('/images')) return 'images';
if (path.startsWith('/profile')) return 'profile';
if (path.startsWith('/network')) return 'network';
if (path.startsWith('/storage')) return 'storage';
return 'images'; // 默认选中镜像列表
};
@ -102,6 +104,12 @@ const MainLayout: React.FC = () => {
</Menu.Item>
<Menu.Item key="images" icon={<AppstoreOutlined />}>
</Menu.Item>
<Menu.Item key="network" icon={<AppstoreOutlined />}>
</Menu.Item>
<Menu.Item key="storage" icon={<AppstoreOutlined />}>
</Menu.Item>
<Menu.Item key="profile" icon={<UserOutlined />}>

View File

@ -0,0 +1,118 @@
import { Col, Form, Input, InputNumber, Modal, Row, Select } from 'antd';
// import { InputNumber } from 'antd/lib';
import React from 'react';
const { Option } = Select;
interface EditModalProps {
visible: boolean;
detialData: any;
onCancel: () => void;
onOk: (values: any) => void;
}
const EditModal: React.FC<EditModalProps> = ({
detialData,
visible,
onCancel,
onOk,
}) => {
const [form] = Form.useForm();
const handleOk = () => {
form
.validateFields()
.then((values) => {
onOk(values);
})
.catch((error) => {
console.error('表单验证失败:', error);
});
};
return (
<Modal
title="编辑信息"
open={visible}
onCancel={onCancel}
onOk={handleOk}
okText="确定"
cancelText="取消"
>
<Form form={form} labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
{/* 名称字段 */}
<Form.Item
name="name"
label="名称"
rules={[{ required: true, message: '请输入名称' }]}
>
<Input />
</Form.Item>
{/* CPU 字段 */}
<Form.Item label="CPU">
<Row gutter={16}>
<Col span={12}>
<Form.Item
name={['cpu', 'size']}
rules={[{ required: true, message: '请输入CPU大小' }]}
noStyle
>
<InputNumber
precision={0}
addonAfter={<span></span>}
style={{ width: '190px' }}
placeholder="CPU大小"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name={['cpu', 'cores']}
rules={[{ required: true, message: '请输入CPU内核数' }]}
noStyle
>
<InputNumber
precision={0}
addonAfter={<span></span>}
style={{ width: '190px' }}
placeholder="CPU内核数"
/>
</Form.Item>
</Col>
</Row>
</Form.Item>
{/* 内存字段 */}
<Form.Item
name="memory_total"
label="内存"
rules={[{ required: true, message: '请输入内存' }]}
>
<InputNumber
precision={0}
addonAfter={<span>CB</span>}
style={{ width: '393.33px' }}
placeholder="内存"
/>
</Form.Item>
{/* 存储卷字段 */}
<Form.Item
name="storageVolume"
label="存储卷"
rules={[{ required: true, message: '请输入存储卷信息' }]}
>
<Input disabled />
</Form.Item>
{/* 描述字段 */}
<Form.Item name="description" label="描述">
<Input.TextArea />
</Form.Item>
</Form>
</Modal>
);
};
export default EditModal;

View File

@ -0,0 +1,138 @@
import type { TableProps } from 'antd';
import { useCallback, useState } from 'react';
const useTableParams = (
initialParams: DESK.DeskTableParams = {
pagination: { current: 1, pageSize: 10 },
filters: {},// 表格的搜索对象
sort: {},
search: {}, // 添加搜索参数对象
},
) => {
const [tableParams, setTableParams] =
useState<DESK.DeskTableParams>(initialParams);
const getApiParams = useCallback(() => {
const { pagination, filters, sort, search, ...rest } = tableParams;
const apiParams: Record<string, any> = {
page_size: pagination?.pageSize,
page_num: pagination?.current,
...rest,
};
if (sort?.field) {
apiParams.orderby = sort.field;
apiParams.order = sort.order === 'ascend' ? 'asc' : 'desc';
}
// 处理表格搜索参数
Object.entries(filters || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
apiParams[key] = value;
}
});
// 处理搜索参数
Object.entries(search || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
apiParams[key] = value;
}
});
console.log('getApiParams apiParams', apiParams);
return apiParams;
}, [tableParams]);
// 统一的更新方法,可以处理所有参数类型
const updateParams = useCallback(
(
newParams: Partial<DESK.DeskTableParams>,
options?: { resetPage?: boolean },
) => {
// console.log('updateParams', newParams);
setTableParams((prev) => {
// 如果是搜索或过滤相关的更新,重置到第一页
const shouldResetPage =
options?.resetPage ??
((newParams.search && Object.keys(newParams.search).length > 0) || // 有搜索值
(newParams.filters && Object.keys(newParams.filters).length > 0)); // 有过滤值
return {
...prev,
...newParams,
pagination: {
...prev.pagination,
...newParams.pagination,
...(shouldResetPage ? { current: 1 } : {}), // 根据条件决定是否重置页码
},
};
});
},
[],
);
/**
*
* @param pagination
* @param filters filters
* @param sorter
* @param extra
* @returns void
* */
const handleTableChange = useCallback<
NonNullable<TableProps<DESK.DeskItem>['onChange']>
>(
(pagination, filters, sorter) => {
// console.log('handleTableChange',pagination,filters,sorter,extra);
// 过滤掉空值的filters
const filteredFilters: Record<string, any> = {};
Object.entries(filters || {}).forEach(([key, value]) => {
if (key === 'image_type') {
if (Array.isArray(value) && value.length > 0 && value[0] !== '全部') {
filteredFilters[key] = Number(value[0]);
}
} else {
if (Array.isArray(value) && value.length > 0) {
filteredFilters[key] = value[0];
} else if (value !== undefined && value !== null) {
if (!Array.isArray(value) && value !== '') {
filteredFilters[key] = value;
}
}
}
});
const newParams: Partial<DESK.DeskTableParams> = {
pagination: {
current: pagination.current || 1,
pageSize: pagination.pageSize || 10,
},
filters: filteredFilters,
};
if (!Array.isArray(sorter)) {
newParams.sort = {
field: sorter.field as string,
order:
sorter.order === 'ascend' || sorter.order === 'descend'
? sorter.order
: undefined,
};
}
// console.log('handleTableChange', newParams);
updateParams(newParams);
},
[updateParams],
);
return {
tableParams,
getApiParams,
updateParams, // 统一的更新方法
handleTableChange,
};
};
export default useTableParams;

View File

@ -0,0 +1,172 @@
// 页面头部样式
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #333;
}
}
// 镜像列表样式
.image-list {
width:100%;
height: 100%;
display: flex;
flex-direction: column;
// padding: 16px;
box-sizing: border-box;
.search-box {
margin-bottom: 16px;
display: flex;
justify-content: space-between;
.search-input {
display: flex;
gap: 8px;
align-items: center;
}
}
.images-list-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.images-list-table {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
// 表格适应样式
.ant-table-wrapper {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.ant-spin-nested-loading {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.ant-spin-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.ant-table {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.ant-table-container {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.ant-table-header {
flex-shrink: 0;
}
.ant-table-body {
flex: 1;
overflow: auto !important;
}
}
// 确保分页器在底部正确显示
.ant-table-pagination {
flex-shrink: 0;
// 确保分页器始终可见
position: relative;
z-index: 1;
}
}
}
}
}
}
}
.image-detail {
.detail-item {
margin-bottom: 16px;
label {
font-weight: 600;
color: #333;
display: inline-block;
width: 100px;
}
span {
color: #666;
}
p {
margin: 8px 0 0 100px;
color: #666;
line-height: 1.6;
}
}
}
}
// 个人资料样式
.profile-page {
.profile-content {
.profile-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
.profile-info {
h3 {
margin: 0 0 4px 0;
font-size: 20px;
font-weight: 600;
color: #333;
}
p {
margin: 0;
color: #666;
font-size: 14px;
}
}
}
.quick-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.profile-content {
.profile-header {
flex-direction: column;
text-align: center;
}
.quick-actions {
justify-content: center;
}
}
}

View File

@ -0,0 +1,482 @@
/* eslint-disable @typescript-eslint/no-use-before-define */
import { CODE } from '@/constants/images.constants';
import { SettingOutlined } from '@ant-design/icons';
import {
Button,
Checkbox,
Input,
message,
Modal,
Popconfirm,
Popover,
Space,
Table,
Tooltip,
} from 'antd';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import EditModal from './editModal';
import useTableParams from './hook/hook';
import './index.less';
import { getDesktopImagesList,deleteDesktopImages } from '@/services/imagePage';
import { ReactComponent as RefreshIcon } from '@/assets/icons/refresh.svg';
// 列配置定义
type ColumnConfig = {
key: string;
title: string;
dataIndex?: string;
width: number;
render?: (text: any, record: any, index: number) => React.ReactNode;
fixed?: 'left' | 'right';
align?: 'left' | 'center' | 'right';
defaultVisible: boolean; // 默认是否显示
alwaysVisible?: boolean; // 始终显示的列
ellipsis?: boolean; // 是否启用省略号
filters?: { text: string; value: string }[];
filterMultiple?: boolean; // 是否多选过滤
filterDropdown?: (props: any) => React.ReactNode;
defaultFilteredValue?: string[]; // 默认过滤值
onFilter?: (value: string, record: any) => boolean;
};
type TableColumn = {
title: string;
dataIndex?: string;
key: string;
width: number;
render?: any;
fixed?: 'left' | 'right';
hidden?: boolean;
};
// 在组件顶部添加防抖函数(支持取消)
// 增强版防抖函数,使用泛型明确函数类型
const debounce = <T extends (...args: any[]) => any>(
func: T,
delay: number,
immediate = false,
) => {
let timer: NodeJS.Timeout | null = null;
const debounced = (...args: Parameters<T>) => {
if (timer) clearTimeout(timer);
if (immediate && !timer) {
func(...args);
}
timer = setTimeout(() => {
if (!immediate) {
func(...args);
}
timer = null;
}, delay);
};
debounced.cancel = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
};
return debounced;
};
const Index: React.FC<DESK.ImagesProps> = (props) => {
const { activeTabKey } = props;
const [dataSource, setDataSource] = useState<DESK.DeskItem[]>([]);
const [loading, setLoading] = useState(false);
const [selectedImage, setSelectedImage] = useState<any>(null);
const [visible, setDetailVisible] = useState(false);
const [searchText, setSearchText] = useState<string>(''); // 添加本地搜索状态
const { tableParams, getApiParams, updateParams, handleTableChange } =
useTableParams({
pagination: {
current: 1,
pageSize: 10,
},
search: {}, // 初始化搜索参数
});
// 在组件顶部添加一个 ref 来保存最新的 tableParams
const tableParamsRef = useRef(tableParams);
tableParamsRef.current = tableParams; // 每次渲染时更新 ref 的值
const [columnSettingsVisible, setColumnSettingsVisible] = useState(false);
// 表格参数变化 获取镜像列表
useEffect(() => {
if (activeTabKey === '3') {
loadImages();
}
}, [
tableParams.pagination?.current,
tableParams.pagination?.pageSize,
tableParams?.sortOrder,
tableParams?.sortField,
JSON.stringify(tableParams.filters), // 表格搜索参数
JSON.stringify(tableParams.search), // 搜索参数依赖
activeTabKey,
]);
// 定义所有列的配置
const columnConfigs: ColumnConfig[] = [
{
key: 'index',
title: '序号',
width: 60,
render: (text: any, row: any, index: number) =>
(tableParams.pagination?.current - 1) *
tableParams.pagination?.pageSize +
index +
1,
defaultVisible: true,
alwaysVisible: true,
},
{
key: 'file_name',
title: '系统镜像',
dataIndex: 'file_name',
width: 150,
defaultVisible: true,
alwaysVisible: true,
ellipsis: true,
render: (text: string) =>
text ? (
<Tooltip title={text} placement="topLeft">
{text}
</Tooltip>
) : (
'--'
),
},
{
key: 'file_type',
title: '虚拟机规格',
dataIndex: 'file_type',
width: 120,
defaultVisible: true,
ellipsis: true,
render: (text: string) =>
text ? <Tooltip title={text}>{text}</Tooltip> : '--',
},
{
key: 'os_version',
title: '操作系统',
dataIndex: 'os_version',
width: 150,
defaultVisible: true,
ellipsis: true,
render: (text: string) =>
text ? <Tooltip title={text}>{text}</Tooltip> : '--',
},
{
key: 'desc',
// title: '模板存放路径',
title: '描述',
dataIndex: 'desc',
width: 140,
defaultVisible: true,
ellipsis: true,
render: (text: string) =>
text ? (
<Tooltip title={text} placement="topLeft">
{text}
</Tooltip>
) : (
'--'
),
},
{
key: 'action',
title: '操作',
width: 90,
align: 'center',
fixed: 'right' as 'right',
render: (_: any, record: IMAGES.ImageItem) => (
<Space size="small">
<Button
size="small"
type="link"
title="编辑"
onClick={() => handleViewDetail(record)}
>
</Button>
<Popconfirm
title="确定要删除这个镜像吗?"
description="删除后无法恢复,请谨慎操作。"
onConfirm={() => handleDelete(record)}
okText="确定"
cancelText="取消"
>
<Button size="small" type="link" title="删除">
</Button>
</Popconfirm>
</Space>
),
defaultVisible: true,
},
];
// 初始化 visibleColumns 状态
const initialVisibleColumns = columnConfigs.reduce<Record<string, boolean>>(
(acc, column) => {
if (!column.alwaysVisible) {
acc[column.key] = column.defaultVisible;
}
return acc;
},
{},
);
const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>(
initialVisibleColumns,
);
// 重置列设置
const resetColumns = () => {
setVisibleColumns(initialVisibleColumns);
};
const loadImages = async () => {
setLoading(true);
try {
// 将搜索文本合并到API参数中
const apiParams = {
...getApiParams(),
};
const imagesRes = await getDesktopImagesList(apiParams);
if (imagesRes.code === CODE) {
setDataSource(imagesRes.data?.data || []);
setLoading(false);
// 正确处理后端返回的分页信息
updateParams({
pagination: {
...tableParams.pagination,
current: imagesRes.data?.page_num || 1,
total: imagesRes.data?.total || 0,
pageSize: tableParams.pagination?.pageSize || 10,
},
});
} else {
message.error(imagesRes.message || '获取镜像列表失败');
setLoading(false);
}
} catch (err) {
message.error('获取镜像列表失败');
setLoading(false);
}
};
const handleViewDetail = (record: DESK.DeskItem) => {
setSelectedImage(record);
setDetailVisible(true);
};
const handleDelete = (record: DESK.DeskItem) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除镜像 "${record.image_name}" 吗?`,
onOk: () => {
deleteDesktopImages({ id: record.id }).then((res) => {
if (res.code === CODE) {
message.success('删除成功');
loadImages();
} else {
message.error(res.message || '删除失败');
}
});
},
});
};
// 列设置相关函数
const handleColumnChange = (columnKey: string, checked: boolean) => {
setVisibleColumns((prev) => ({
...prev,
[columnKey]: checked,
}));
};
// 列设置内容
const columnSettingsContent = (
<div style={{ padding: '8px 0' }}>
{columnConfigs
.filter((config) => !config.alwaysVisible) // 只显示可控制的列
.map((config) => (
<div key={config.key} style={{ padding: '4px 12px' }}>
<Checkbox
checked={visibleColumns[config.key]}
onChange={(e) => handleColumnChange(config.key, e.target.checked)}
>
{config.title}
</Checkbox>
</div>
))}
<div
style={{
padding: '8px 12px',
borderTop: '1px solid #f0f0f0',
marginTop: 8,
}}
>
<Button type="link" onClick={resetColumns} style={{ padding: 0 }}>
</Button>
</div>
</div>
);
// 根据visibleColumns过滤显示的列
const filteredColumns = columnConfigs
.map((config) => {
// 对于始终显示的列
if (config.alwaysVisible) {
return {
...config,
hidden: undefined,
};
}
// 对于可控制显示/隐藏的列
return {
...config,
...(visibleColumns[config.key] ? {} : { hidden: true }),
};
})
.filter((column) => !column.hidden) as TableColumn[];
const handleRefresh = () => {
loadImages();
};
// 自定义分页配置
const paginationConfig = {
...tableParams.pagination,
showTotal: (total: number) => `${total} 条记录`,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
};
const handleSearch = useCallback(
(searchValue: string) => {
const currentTableParams = tableParamsRef.current;
updateParams({
search: {
image_name: searchValue,
},
pagination: {
current: 1,
pageSize: currentTableParams.pagination?.pageSize || 10,
},
});
},
[updateParams],
);
// 防抖版本500ms延迟不立即执行
const debouncedSearch = useRef(debounce(handleSearch, 500)).current;
// 立即执行版本(用于清空时立即搜索)
const immediateSearch = useRef(debounce(handleSearch, 0, true)).current;
const handleSearchChange = (value: string) => {
setSearchText(value);
// 取消所有未执行的防抖请求
debouncedSearch.cancel();
immediateSearch.cancel();
// 清空时立即触发搜索
if (value === '') {
immediateSearch('');
return;
}
// 正常输入时使用防抖
debouncedSearch(value);
};
// 修改回车搜索处理
const handleEnterSearch = (value: string) => {
// 回车搜索时取消未执行的防抖
debouncedSearch.cancel();
immediateSearch.cancel();
// 直接执行搜索
handleSearch(value);
};
return (
<div className="image-list">
<div className="search-box">
<div>{/* */}</div>
<div className="search-input">
<Input.Search
placeholder="请输入名称"
value={searchText}
onChange={(e) => handleSearchChange(e.target.value)}
style={{ width: 300 }}
onSearch={handleEnterSearch}
/>
<Button
onClick={handleRefresh}
loading={loading}
icon={<RefreshIcon style={{ width: 13, height: 13 }} />}
></Button>
<Popover
content={columnSettingsContent}
title="列设置"
trigger="click"
open={columnSettingsVisible}
onOpenChange={setColumnSettingsVisible}
placement="bottomRight"
>
<Button icon={<SettingOutlined />}></Button>
</Popover>
</div>
</div>
<div className="images-list-container">
<div className="images-list-table">
<Table
columns={filteredColumns}
dataSource={dataSource}
rowKey="id"
loading={loading}
pagination={paginationConfig}
onChange={handleTableChange}
scroll={{
y: 'max-content', // 关键:允许内容决定高度
}}
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
}}
/>
</div>
</div>
{visible && (
<EditModal
visible={visible}
detialData={selectedImage}
onCancel={() => {
setDetailVisible(false);
}}
onOk={() => {
setDetailVisible(false);
}}
/>
)}
</div>
);
};
export default Index;

View File

@ -0,0 +1,44 @@
.imagePage {
height: 100%;
display: flex;
flex-direction: column;
background-color: #fafafa;
}
.content {
flex: 1;
padding: 10px;
// background-color: #fafafa;
background-color: #fff;
overflow: auto;
}
.tabContent {
// padding: 24px;
background-color: #fff;
border-radius: 6px;
min-height: 400px;
}
.emptyTip {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
font-size: 14px;
}
/* 覆盖Ant Design Tabs组件的默认样式 */
.ant-tabs {
background-color: #fff;
border-radius: 6px;
}
.ant-tabs-nav {
padding: 0 24px;
}
.ant-tabs-content {
padding-top: 0;
}

View File

@ -0,0 +1,85 @@
import ToolTable from '@/pages/imagePage/tool';
import Images from '@/pages/images';
import type { TabsProps } from 'antd';
import { Tabs } from 'antd';
import React, { useState } from 'react';
import DeskImage from './deskImage';
import styles from './index.less';
import VirtualImages from './virtualImages';
const ImagePage = () => {
// 设置默认选中的Tab为系统镜像索引为0
const [activeTabKey, setActiveTabKey] = useState<string>('1');
// 处理Tab切换事件
const handleTabChange = (key: string) => {
setActiveTabKey(key);
};
// Tabs的items配置
const items: TabsProps['items'] = [
{
key: '1',
label: '系统镜像',
children: (
<div className={styles.tabContent}>
{/* 系统镜像内容区域 */}
<div className={styles.emptyTip}>
<Images activeTabKey={activeTabKey} />
</div>
</div>
),
},
{
key: '2',
label: '虚拟机镜像',
children: (
<div className={styles.tabContent}>
{/* 虚拟机镜像内容区域 */}
<div className={styles.emptyTip}>
<VirtualImages activeTabKey={activeTabKey} />
</div>
</div>
),
},
{
key: '3',
label: '桌面镜像',
children: (
<div className={styles.tabContent}>
{/* 桌面镜像内容区域 */}
<div className={styles.emptyTip}>
<DeskImage activeTabKey={activeTabKey} />
</div>
</div>
),
},
{
key: '4',
label: '工具',
children: (
<div className={styles.tabContent}>
{/* 桌面镜像内容区域 */}
<div className={styles.emptyTip}>
<ToolTable activeTabKey={activeTabKey} />
</div>
</div>
),
},
];
return (
<div className={styles.imagePage}>
<div className={styles.content}>
<Tabs
activeKey={activeTabKey}
onChange={handleTabChange}
items={items}
size="large"
/>
</div>
</div>
);
};
export default ImagePage;

View File

@ -0,0 +1,138 @@
import type { TableProps } from 'antd';
import { useCallback, useState } from 'react';
const useTableParams = (
initialParams: TOOL.TableParams = {
pagination: { current: 1, pageSize: 10 },
filters: {},// 表格的搜索对象
sort: {},
search: {}, // 添加搜索参数对象
},
) => {
const [tableParams, setTableParams] =
useState<TOOL.TableParams>(initialParams);
const getApiParams = useCallback(() => {
const { pagination, filters, sort, search, ...rest } = tableParams;
const apiParams: Record<string, any> = {
page_size: pagination?.pageSize,
page_num: pagination?.current,
...rest,
};
if (sort?.field) {
apiParams.orderby = sort.field;
apiParams.order = sort.order === 'ascend' ? 'asc' : 'desc';
}
// 处理表格搜索参数
Object.entries(filters || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
apiParams[key] = value;
}
});
// 处理搜索参数
Object.entries(search || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
apiParams[key] = value;
}
});
console.log('getApiParams apiParams', apiParams);
return apiParams;
}, [tableParams]);
// 统一的更新方法,可以处理所有参数类型
const updateParams = useCallback(
(
newParams: Partial<TOOL.TableParams>,
options?: { resetPage?: boolean },
) => {
// console.log('updateParams', newParams);
setTableParams((prev) => {
// 如果是搜索或过滤相关的更新,重置到第一页
const shouldResetPage =
options?.resetPage ??
((newParams.search && Object.keys(newParams.search).length > 0) || // 有搜索值
(newParams.filters && Object.keys(newParams.filters).length > 0)); // 有过滤值
return {
...prev,
...newParams,
pagination: {
...prev.pagination,
...newParams.pagination,
...(shouldResetPage ? { current: 1 } : {}), // 根据条件决定是否重置页码
},
};
});
},
[],
);
/**
*
* @param pagination
* @param filters filters
* @param sorter
* @param extra
* @returns void
* */
const handleTableChange = useCallback<
NonNullable<TableProps<TOOL.ToolItem>['onChange']>
>(
(pagination, filters, sorter) => {
// console.log('handleTableChange',pagination,filters,sorter,extra);
// 过滤掉空值的filters
const filteredFilters: Record<string, any> = {};
Object.entries(filters || {}).forEach(([key, value]) => {
if (key === 'image_type') {
if (Array.isArray(value) && value.length > 0 && value[0] !== '全部') {
filteredFilters[key] = Number(value[0]);
}
} else {
if (Array.isArray(value) && value.length > 0) {
filteredFilters[key] = value[0];
} else if (value !== undefined && value !== null) {
if (!Array.isArray(value) && value !== '') {
filteredFilters[key] = value;
}
}
}
});
const newParams: Partial<TOOL.TableParams> = {
pagination: {
current: pagination.current || 1,
pageSize: pagination.pageSize || 10,
},
filters: filteredFilters,
};
if (!Array.isArray(sorter)) {
newParams.sort = {
field: sorter.field as string,
order:
sorter.order === 'ascend' || sorter.order === 'descend'
? sorter.order
: undefined,
};
}
// console.log('handleTableChange', newParams);
updateParams(newParams);
},
[updateParams],
);
return {
tableParams,
getApiParams,
updateParams, // 统一的更新方法
handleTableChange,
};
};
export default useTableParams;

View File

@ -0,0 +1,172 @@
// 页面头部样式
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #333;
}
}
// 镜像列表样式
.image-list {
width:100%;
height: 100%;
display: flex;
flex-direction: column;
// padding: 16px;
box-sizing: border-box;
.search-box {
margin-bottom: 16px;
display: flex;
justify-content: space-between;
.search-input {
display: flex;
gap: 8px;
align-items: center;
}
}
.images-list-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.images-list-table {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
// 表格适应样式
.ant-table-wrapper {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.ant-spin-nested-loading {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.ant-spin-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.ant-table {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.ant-table-container {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.ant-table-header {
flex-shrink: 0;
}
.ant-table-body {
flex: 1;
overflow: auto !important;
}
}
// 确保分页器在底部正确显示
.ant-table-pagination {
flex-shrink: 0;
// 确保分页器始终可见
position: relative;
z-index: 1;
}
}
}
}
}
}
}
.image-detail {
.detail-item {
margin-bottom: 16px;
label {
font-weight: 600;
color: #333;
display: inline-block;
width: 100px;
}
span {
color: #666;
}
p {
margin: 8px 0 0 100px;
color: #666;
line-height: 1.6;
}
}
}
}
// 个人资料样式
.profile-page {
.profile-content {
.profile-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
.profile-info {
h3 {
margin: 0 0 4px 0;
font-size: 20px;
font-weight: 600;
color: #333;
}
p {
margin: 0;
color: #666;
font-size: 14px;
}
}
}
.quick-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.profile-content {
.profile-header {
flex-direction: column;
text-align: center;
}
.quick-actions {
justify-content: center;
}
}
}

View File

@ -0,0 +1,523 @@
/* eslint-disable @typescript-eslint/no-use-before-define */
import { CODE } from '@/constants/images.constants';
import { deleteTool, getToollList } from '@/services/imagePage';
import { SettingOutlined,PlusOutlined } from '@ant-design/icons';
import {
Button,
Checkbox,
Input,
message,
Modal,
Popconfirm,
Popover,
Space,
Table,
Tooltip,
} from 'antd';
import dayjs from 'dayjs';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import useTableParams from './hook/hook';
import './index.less';
import UploadFileModal from './uploadFileModal';
import { ReactComponent as RefreshIcon } from '@/assets/icons/refresh.svg';
// 列配置定义
type ColumnConfig = {
key: string;
title: string;
dataIndex?: string;
width: number;
render?: (text: any, record: any, index: number) => React.ReactNode;
fixed?: 'left' | 'right';
align?: 'left' | 'center' | 'right';
defaultVisible: boolean; // 默认是否显示
alwaysVisible?: boolean; // 始终显示的列
ellipsis?: boolean; // 是否启用省略号
filters?: { text: string; value: string }[];
filterMultiple?: boolean; // 是否多选过滤
filterDropdown?: (props: any) => React.ReactNode;
defaultFilteredValue?: string[]; // 默认过滤值
onFilter?: (value: string, record: any) => boolean;
};
type TableColumn = {
title: string;
dataIndex?: string;
key: string;
width: number;
render?: any;
fixed?: 'left' | 'right';
hidden?: boolean;
};
// 在组件顶部添加防抖函数(支持取消)
// 增强版防抖函数,使用泛型明确函数类型
const debounce = <T extends (...args: any[]) => any>(
func: T,
delay: number,
immediate = false,
) => {
let timer: NodeJS.Timeout | null = null;
const debounced = (...args: Parameters<T>) => {
if (timer) clearTimeout(timer);
if (immediate && !timer) {
func(...args);
}
timer = setTimeout(() => {
if (!immediate) {
func(...args);
}
timer = null;
}, delay);
};
debounced.cancel = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
};
return debounced;
};
const Index: React.FC<DESK.ImagesProps> = (props) => {
const { activeTabKey } = props;
const [dataSource, setDataSource] = useState<TOOL.ToolItem[]>([]);
const [loading, setLoading] = useState(false);
const [selectedImage, setSelectedImage] = useState<any>(null);
const [importModalVisible, setImportModalVisible] = useState(false);
const [searchText, setSearchText] = useState<string>(''); // 添加本地搜索状态
const { tableParams, getApiParams, updateParams, handleTableChange } =
useTableParams({
pagination: {
current: 1,
pageSize: 10,
},
search: {}, // 初始化搜索参数
});
// 在组件顶部添加一个 ref 来保存最新的 tableParams
const tableParamsRef = useRef(tableParams);
tableParamsRef.current = tableParams; // 每次渲染时更新 ref 的值
const [columnSettingsVisible, setColumnSettingsVisible] = useState(false);
// 表格参数变化 获取镜像列表
useEffect(() => {
if (activeTabKey === '4') {
loadDataSource();
}
}, [
activeTabKey,
tableParams.pagination?.current,
tableParams.pagination?.pageSize,
tableParams?.sortOrder,
tableParams?.sortField,
JSON.stringify(tableParams.filters), // 表格搜索参数
JSON.stringify(tableParams.search), // 搜索参数依赖
]);
// 定义所有列的配置
const columnConfigs: ColumnConfig[] = [
{
key: 'index',
title: '序号',
width: 60,
render: (text: any, row: any, index: number) =>
(tableParams.pagination?.current - 1) *
tableParams.pagination?.pageSize +
index +
1,
defaultVisible: true,
alwaysVisible: true,
},
{
key: 'tool_name',
// title: '镜像名称',
title: '文件名',
dataIndex: 'tool_name',
width: 150,
defaultVisible: true,
alwaysVisible: true,
ellipsis: true,
render: (text: string) =>
text ? (
<Tooltip title={text} placement="topLeft">
{text}
</Tooltip>
) : (
'--'
),
},
{
key: 'tool_type',
title: '文件类型',
dataIndex: 'tool_type',
width: 100,
defaultVisible: true,
ellipsis: true,
render: (text: string) =>
text ? <Tooltip title={text}>{text}</Tooltip> : '--',
},
{
key: 'file_size',
// title: '镜像版本',
title: '文件大小',
dataIndex: 'file_size',
width: 100,
defaultVisible: true,
ellipsis: true,
render: (text: string) =>
text ? <Tooltip title={text}>{text}</Tooltip> : '--',
},
{
key: 'version',
// title: '模板存放路径',
title: '版本',
dataIndex: 'version',
width: 140,
defaultVisible: true,
ellipsis: true,
render: (text: string) =>
text ? (
<Tooltip title={text} placement="topLeft">
{text}
</Tooltip>
) : (
'--'
),
},
{
key: 'create_time',
title: '上传时间',
dataIndex: 'create_time',
width: 160,
render: (text: string) =>
text ? (
<Tooltip title={dayjs(text).format('YYYY-MM-DD HH:mm:ss')}>
{text ? dayjs(text).format('YYYY-MM-DD HH:mm:ss') : '--'}
</Tooltip>
) : (
'--'
),
defaultVisible: true,
ellipsis: true,
},
{
key: 'description',
title: '描述',
dataIndex: 'description',
width: 140,
defaultVisible: true,
ellipsis: true,
render: (text: string) =>
text ? (
<Tooltip title={text} placement="topLeft">
{text}
</Tooltip>
) : (
'--'
),
},
{
key: 'action',
title: '操作',
width: 90,
align: 'center',
fixed: 'right' as 'right',
render: (_: any, record: TOOL.ToolItem) => (
<Space size="small">
<Button
size="small"
type="link"
title="编辑"
onClick={() => handleViewDetail(record)}
>
</Button>
<Popconfirm
title="确定要删除这个镜像吗?"
description="删除后无法恢复,请谨慎操作。"
onConfirm={() => handleDelete(record)}
okText="确定"
cancelText="取消"
>
<Button size="small" type="link" title="删除">
</Button>
</Popconfirm>
</Space>
),
defaultVisible: true,
},
];
// 初始化 visibleColumns 状态
const initialVisibleColumns = columnConfigs.reduce<Record<string, boolean>>(
(acc, column) => {
if (!column.alwaysVisible) {
acc[column.key] = column.defaultVisible;
}
return acc;
},
{},
);
const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>(
initialVisibleColumns,
);
// 重置列设置
const resetColumns = () => {
setVisibleColumns(initialVisibleColumns);
};
const loadDataSource = async () => {
setLoading(true);
try {
// 将搜索文本合并到API参数中
const apiParams = {
...getApiParams(),
};
const imagesRes = await getToollList(apiParams);
if (imagesRes.code === CODE) {
setDataSource(imagesRes.data?.data || []);
setLoading(false);
// 正确处理后端返回的分页信息
updateParams({
pagination: {
...tableParams.pagination,
current: imagesRes.data?.page_num || 1,
total: imagesRes.data?.total || 0,
pageSize: tableParams.pagination?.pageSize || 10,
},
});
} else {
message.error(imagesRes.message || '获取镜像列表失败');
setLoading(false);
}
} catch (err) {
message.error('获取镜像列表失败');
setLoading(false);
}
};
const handleViewDetail = (record: TOOL.ToolItem) => {
setSelectedImage(record);
setImportModalVisible(true);
};
const handleDelete = (record: TOOL.ToolItem) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除镜像 "${record.image_name}" 吗?`,
onOk: () => {
deleteTool({ id: record.id }).then((res) => {
if (res.code === CODE) {
message.success('删除成功');
loadDataSource();
} else {
message.error(res.message || '删除失败');
}
});
},
});
};
// 列设置相关函数
const handleColumnChange = (columnKey: string, checked: boolean) => {
setVisibleColumns((prev) => ({
...prev,
[columnKey]: checked,
}));
};
// 列设置内容
const columnSettingsContent = (
<div style={{ padding: '8px 0' }}>
{columnConfigs
.filter((config) => !config.alwaysVisible) // 只显示可控制的列
.map((config) => (
<div key={config.key} style={{ padding: '4px 12px' }}>
<Checkbox
checked={visibleColumns[config.key]}
onChange={(e) => handleColumnChange(config.key, e.target.checked)}
>
{config.title}
</Checkbox>
</div>
))}
<div
style={{
padding: '8px 12px',
borderTop: '1px solid #f0f0f0',
marginTop: 8,
}}
>
<Button type="link" onClick={resetColumns} style={{ padding: 0 }}>
</Button>
</div>
</div>
);
// 根据visibleColumns过滤显示的列
const filteredColumns = columnConfigs
.map((config) => {
// 对于始终显示的列
if (config.alwaysVisible) {
return {
...config,
hidden: undefined,
};
}
// 对于可控制显示/隐藏的列
return {
...config,
...(visibleColumns[config.key] ? {} : { hidden: true }),
};
})
.filter((column) => !column.hidden) as TableColumn[];
const handleRefresh = () => {
loadDataSource();
};
// 自定义分页配置
const paginationConfig = {
...tableParams.pagination,
showTotal: (total: number) => `${total} 条记录`,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
};
const handleSearch = useCallback(
(searchValue: string) => {
const currentTableParams = tableParamsRef.current;
updateParams({
search: {
image_name: searchValue,
},
pagination: {
current: 1,
pageSize: currentTableParams.pagination?.pageSize || 10,
},
});
},
[updateParams],
);
// 防抖版本500ms延迟不立即执行
const debouncedSearch = useRef(debounce(handleSearch, 500)).current;
// 立即执行版本(用于清空时立即搜索)
const immediateSearch = useRef(debounce(handleSearch, 0, true)).current;
const handleSearchChange = (value: string) => {
setSearchText(value);
// 取消所有未执行的防抖请求
debouncedSearch.cancel();
immediateSearch.cancel();
// 清空时立即触发搜索
if (value === '') {
immediateSearch('');
return;
}
// 正常输入时使用防抖
debouncedSearch(value);
};
// 修改回车搜索处理
const handleEnterSearch = (value: string) => {
// 回车搜索时取消未执行的防抖
debouncedSearch.cancel();
immediateSearch.cancel();
// 直接执行搜索
handleSearch(value);
};
const onSubmitBack = () => {
setImportModalVisible(false);
setSelectedImage(null);
handleRefresh();
};
return (
<div className="image-list">
<div className="search-box">
<Button type="primary" icon={<PlusOutlined />} onClick={() => setImportModalVisible(true)}></Button>
<div className="search-input">
<Input.Search
placeholder="请输入名称"
value={searchText}
onChange={(e) => handleSearchChange(e.target.value)}
style={{ width: 300 }}
onSearch={handleEnterSearch}
/>
<Button
onClick={handleRefresh}
loading={loading}
icon={<RefreshIcon style={{ width: 13, height: 13 }} />}
></Button>
<Popover
content={columnSettingsContent}
title="列设置"
trigger="click"
open={columnSettingsVisible}
onOpenChange={setColumnSettingsVisible}
placement="bottomRight"
>
<Button icon={<SettingOutlined />}></Button>
</Popover>
</div>
</div>
<div className="images-list-container">
<div className="images-list-table">
<Table
columns={filteredColumns}
dataSource={dataSource}
rowKey="id"
loading={loading}
pagination={paginationConfig}
onChange={handleTableChange}
scroll={{
y: 'max-content', // 关键:允许内容决定高度
}}
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
}}
/>
</div>
</div>
{importModalVisible && (
<UploadFileModal
visible={importModalVisible}
onCancel={() => {
setImportModalVisible(false);
setSelectedImage(null);
}}
onSubmit={onSubmitBack}
isEditing={selectedImage ? true : false}
initialValues={selectedImage}
/>
)}
</div>
);
};
export default Index;

View File

@ -0,0 +1,139 @@
/* 上传区域样式 */
.ant-upload-drag-icon {
color: #1890ff;
font-size: 48px;
margin-bottom: 16px;
}
/* 上传文本样式 */
.ant-upload-text {
font-size: 16px;
margin-bottom: 8px;
}
/* 提示文本样式 */
.ant-upload-hint {
color: #8c8c8c;
}
/* 表单标签样式 */
.ant-form-item-label > label {
font-weight: 500;
font-size: 14px;
}
/* 操作按钮区域样式 */
.ant-form-item-control-wrapper {
text-align: right;
}
/* 按钮间距样式 */
.ant-space-item {
margin-right: 8px;
}
/* 上传区域悬停效果 */
.ant-upload-wrapper.ant-upload-drag {
border: 2px dashed #d9d9d9;
border-radius: 4px;
padding: 40px 0;
transition: border-color 0.3s;
}
.ant-upload-wrapper.ant-upload-drag:hover {
border-color: #40a9ff;
}
/* 上传状态样式 */
.ant-upload-list-item {
margin-top: 16px;
}
/* 错误提示样式 */
.ant-form-item-explain-error {
color: #ff4d4f;
font-size: 12px;
}
/* 描述输入框样式 */
.ant-input-textarea {
min-height: 100px;
resize: vertical;
}
/* 上传进度容器样式 */
.upload-progress-container {
margin-top: 16px;
background-color: #fafafa;
padding: 16px;
border-radius: 6px;
}
.upload-progress-item {
margin-bottom: 16px;
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.file-name {
font-size: 14px;
color: rgba(0, 0, 0, 0.85);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
margin-right: 16px;
}
.cancel-button {
color: #ff4d4f;
}
.upload-actions {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.upload-actions .ant-btn {
margin-left: 8px;
}
/* 进度条样式 */
.ant-progress {
margin-bottom: 0;
}
/* 禁用状态下的上传区域 */
.ant-upload-disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
/* 多文件上传列表样式 */
.ant-upload-list {
margin-top: 12px;
}
/* 上传文件项样式 */
.ant-upload-list-item {
margin-bottom: 8px;
}
/* 响应式调整 */
@media (max-width: 768px) {
.ant-form-item-label,
.ant-form-item-control-wrapper {
width: 100%;
text-align: left;
}
.upload-progress-container {
padding: 8px;
}
}

View File

@ -0,0 +1,435 @@
import { ERROR_CODE } from '@/constants/constants';
import { addTool, updateTool } from '@/services/imagePage';
import {
CloseOutlined,
DeleteOutlined,
ReloadOutlined,
UploadOutlined,
} from '@ant-design/icons';
import {
Button,
Form,
Input,
message,
Modal,
Progress,
Select,
Space,
Upload,
} from 'antd';
import React, { useEffect, useState } from 'react';
import './index.less';
const { Dragger } = Upload;
type UploadFile = any; // 简化类型定义,实际项目中应使用正确的类型
export interface UploadFileModalProps {
visible: boolean;
onCancel: () => void;
onSubmit: (values: any) => void;
initialValues?: any; // 编辑时的初始值
isEditing?: boolean; // 是否处于编辑状态
}
// 文件类型选项
const FILE_TYPE_OPTIONS = [
{ value: 'system', label: '系统文件' },
{ value: 'virtual', label: '虚拟机文件' },
{ value: 'desktop', label: '桌面文件' },
];
// 分片大小 (5MB)
const CHUNK_SIZE = 5 * 1024 * 1024;
const UploadFileModal: React.FC<UploadFileModalProps> = ({
visible,
onCancel,
onSubmit,
initialValues = {},
isEditing = false,
}) => {
const [form] = Form.useForm();
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<Record<string, number>>(
{},
);
const [uploadCancelToken, setUploadCancelToken] =
useState<AbortController | null>(null);
// 当visible变化时重置表单
useEffect(() => {
if (visible) {
// 设置初始值
const { description } = initialValues || {};
if (initialValues) {
form.setFieldsValue({ description: description });
}
} else {
// 关闭时重置状态
form.resetFields();
setFileList([]);
setIsUploading(false);
setUploadProgress({});
}
}, [visible, initialValues, isEditing, form]);
// 生成文件ID
const generateFileId = () => {
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
};
// 计算文件MD5 (简化版)
const calculateFileMD5 = (file: File): Promise<string> => {
return new Promise((resolve) => {
// 实际项目中应使用 crypto 库计算真实的MD5
// 这里使用简化版,仅用于演示
const fileId = generateFileId();
resolve(fileId);
});
};
// 分片上传函数
const uploadChunk = async (
file: File,
fileId: string,
chunkIndex: number,
totalChunks: number,
chunkSize: number,
): Promise<boolean> => {
try {
const start = chunkIndex * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const chunkMD5 = await calculateFileMD5(chunk);
console.log('chunkMD5====', chunkMD5);
const formData = new FormData();
formData.append('file_id', fileId);
formData.append('file_name', file.name);
formData.append('file_size', file.size.toString());
formData.append('shard_index', chunkIndex.toString());
formData.append('shard_total', totalChunks.toString());
formData.append('chunk_size', chunkSize.toString());
formData.append('chunk_md5', chunkMD5);
formData.append('chunk', chunk);
// 创建新的取消控制器
const controller = new AbortController();
setUploadCancelToken(controller);
const response = await fetch('/api/v1/images/file/chunk/upload', {
method: 'POST',
body: formData,
signal: controller.signal,
});
const result = await response.json();
// 更新进度
const progress = Math.round(((chunkIndex + 1) / totalChunks) * 100);
setUploadProgress((prev) => ({
...prev,
[fileId]: progress,
}));
return result.success;
} catch (error) {
console.error('分片上传失败:', error);
message.error(`文件 ${file.name} 分片上传失败`);
return false;
}
};
// 处理单个文件的分片上传
const handleFileChunkUpload = async (file: File): Promise<string | null> => {
try {
setIsUploading(true);
const fileId = generateFileId();
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
setUploadProgress((prev) => ({
...prev,
[fileId]: 0,
}));
// 按顺序上传分片
for (let i = 0; i < totalChunks; i++) {
// 检查是否已取消上传
if (!uploadCancelToken) {
message.info(`文件 ${file.name} 上传已取消`);
setIsUploading(false);
return null;
}
const success = await uploadChunk(
file,
fileId,
i,
totalChunks,
CHUNK_SIZE,
);
if (!success) {
return null;
}
}
// 所有分片上传完成后模拟文件URL
const fileUrl = `/uploads/${fileId}/${file.name}`;
return fileUrl;
} catch (error) {
console.error('文件上传失败:', error);
message.error(`文件 ${file.name} 上传失败`);
return null;
} finally {
setIsUploading(false);
}
};
// 处理文件上传变化
const handleUploadChange = ({
fileList: newFileList,
file,
}: {
fileList: UploadFile[];
file?: UploadFile;
}) => {
setFileList(newFileList);
// 上传前处理
if (file && file.status === 'uploading') {
setIsUploading(true);
setUploadProgress({});
}
// 上传完成处理
if (file && file.status === 'done') {
handleFileChunkUpload(file.originFileObj);
}
// 上传错误处理
if (file && file.status === 'error') {
setIsUploading(false);
message.error('文件上传失败');
}
};
// 取消上传
const handleCancelUpload = () => {
if (uploadCancelToken) {
uploadCancelToken.abort();
setUploadCancelToken(null);
}
setIsUploading(false);
setUploadProgress({});
message.info('上传已取消');
};
// 重新上传失败的文件
const handleReUpload = (file: UploadFile) => {
if (file.originFileObj) {
handleFileChunkUpload(file.originFileObj);
}
};
// 删除已上传的文件
const handleDeleteFile = () => {
setFileList([]);
setUploadProgress({});
message.success('文件已删除');
};
// 新建
const handleCreate = (payload: any) => {
addTool(payload).then((res: any) => {
const { code } = res || {};
if (code === ERROR_CODE) {
message.success('保存成功');
onSubmit(payload);
}
});
};
const handleUpdata = (payload: any) => {
updateTool(payload).then((res: any) => {
const { code } = res || {};
if (code === ERROR_CODE) {
message.success('保存成功');
onSubmit(payload);
}
});
};
// 处理提交
const handleSubmit = async () => {
try {
// 验证表单
const values = await form.validateFields();
console.log('form表单字段=======', values);
if (isEditing) {
handleUpdata({ ...values, id: initialValues?.id });
} else {
// 上传文件
// 处理文件
if (!isEditing) {
if (fileList.length === 0 || fileList[0].status !== 'done') {
message.error('请先上传文件');
return;
}
values.fileUrl = `/uploads/${generateFileId()}/${fileList[0].name}`;
values.fileName = fileList[0].name;
handleCreate(values);
}
}
} catch (error) {
console.error('提交失败:', error);
}
};
// 上传前处理
const beforeUpload = (file: File) => {
// 允许多文件上传
return true;
};
return (
<Modal
title={isEditing ? '编辑' : '上传工具'}
open={visible}
onCancel={onCancel}
footer={null}
width={800}
>
<Form
form={form}
labelCol={{ span: 4 }}
wrapperCol={{ span: 20 }}
initialValues={initialValues}
>
{/* 文件类型 - 编辑时隐藏 */}
{!isEditing && (
<Form.Item
name="fileType"
label="文件类型"
rules={[{ required: true, message: '请选择文件类型' }]}
>
<Select options={FILE_TYPE_OPTIONS} placeholder="请选择文件类型" />
</Form.Item>
)}
{/* 文件上传 - 编辑时隐藏 */}
{!isEditing && (
<Form.Item
name="file"
label="文件"
valuePropName="fileList"
getValueFromEvent={({ fileList }) => fileList}
rules={[{ required: true, message: '请上传文件' }]}
>
<>
<Dragger
accept=".zip,.rar,.7z,.tar,.gz"
fileList={fileList}
beforeUpload={beforeUpload}
onChange={handleUploadChange}
multiple={false}
disabled={isUploading}
customRequest={({ file, onSuccess }) => {
// 自定义上传处理
setTimeout(() => {
if (onSuccess) {
onSuccess(undefined, file);
}
}, 0);
}}
>
<p className="ant-upload-drag-icon">
<UploadOutlined />
</p>
<p className="ant-upload-text">
</p>
<p className="ant-upload-hint">
.zip, .rar, .7z, .tar, .gz
</p>
</Dragger>
{/* 显示上传进度和操作按钮 */}{' '}
{Object.keys(uploadProgress).length > 0 &&
fileList.length > 0 && (
<div className="upload-progress-container">
{Object.entries(uploadProgress).map(
([fileId, progress]) => (
<div key={fileId} className="upload-progress-item">
<div className="progress-info">
<span className="file-name">
{fileList[0].name}
</span>
{isUploading && (
<Button
type="text"
icon={<CloseOutlined />}
onClick={handleCancelUpload}
className="cancel-button"
>
</Button>
)}
</div>
<Progress percent={progress} status="active" />
</div>
),
)}
</div>
)}
{/* 上传完成后的删除按钮和上传失败后的重新上传按钮 */}
{fileList.length > 0 && (
<div className="upload-actions">
{fileList[0].status === 'done' && (
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={handleDeleteFile}
>
</Button>
)}
{fileList[0].status === 'error' && (
<Button
type="text"
icon={<ReloadOutlined />}
onClick={() => handleReUpload(fileList[0])}
>
</Button>
)}
</div>
)}
</>
</Form.Item>
)}
{/* 描述 */}
<Form.Item
name="description"
label="描述"
rules={[{ required: false }]}
>
<Input.TextArea rows={4} placeholder="请输入描述信息" />
</Form.Item>
{/* 操作按钮 */}
<Form.Item wrapperCol={{ offset: 19, span: 5 }}>
<Space size="middle">
<Button onClick={onCancel}></Button>
<Button type="primary" onClick={handleSubmit} loading={isUploading}>
{isEditing ? '保存' : '提交'}
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
);
};
export default UploadFileModal;

View File

@ -0,0 +1,249 @@
/* eslint-disable @typescript-eslint/no-use-before-define */
import { ERROR_CODE } from '@/constants/constants';
import {
addVirtualImage,
getVirtualImageDetail,
updateVirtualImage,
} from '@/services/imagePage/index';
import {
Col,
Form,
Input,
InputNumber,
Modal,
Row,
Select,
message,
} from 'antd';
import React, { useEffect } from 'react';
const { Option } = Select;
interface EditModalProps {
visible: boolean;
detialData: any;
storagePathList: any[];
networkList: any[];
systemList: any[];
onCancel: () => void;
onOk: (values: any) => void;
}
const EditModal: React.FC<EditModalProps> = ({
visible,
detialData,
storagePathList,
networkList,
systemList,
onCancel,
onOk,
}) => {
const [form] = Form.useForm();
useEffect(() => {
if (detialData?.id) {
const params = { id: detialData.id };
getVirtualImageDetail(params).then((res: any) => {
console.log('res=======', res);
const { code, data } = res || {};
if (code === ERROR_CODE) {
const { cpu_total, cpu_core_total } = data;
const initialValues = {
...data,
cpu: { size: cpu_total, core: cpu_core_total },
};
form.setFieldsValue(initialValues);
}
});
}
}, [visible, form, detialData]);
const onAddVirtualImage = (payload: any) => {
addVirtualImage(payload).then((res: any) => {
const { code } = res || {};
if (code === ERROR_CODE) {
message.success('保存成功');
onOk(payload);
}
});
};
const onEditVirtualImage = (payload: any) => {
updateVirtualImage(payload).then((res: any) => {
const { code } = res || {};
if (code === ERROR_CODE) {
message.success('保存成功');
onOk(payload);
}
});
};
const handleOk = () => {
form
.validateFields()
.then((values) => {
console.log('表单字段值values', values);
const { cpu } = values || {};
const { size, core } = cpu || {};
const obj: any = { ...values };
delete obj.cpu;
const payload = {
cpu_total: size,
cpu_core_total: core,
...obj,
};
if (detialData.id) {
onEditVirtualImage({ id: detialData.id, ...payload });
} else {
onAddVirtualImage(payload);
}
})
.catch((error) => {
console.error('表单验证失败:', error);
});
};
return (
<Modal
title="编辑信息"
open={visible}
onCancel={onCancel}
onOk={handleOk}
okText="确定"
cancelText="取消"
>
<Form form={form} labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
{/* 名称字段 */}
<Form.Item
name="image_name"
label="名称"
rules={[{ required: true, message: '请输入名称' }]}
>
<Input />
</Form.Item>
<Form.Item
name="image_system_id"
label="系统镜像"
rules={[{ required: true, message: '请选择系统镜像' }]}
>
<Select placeholder="请选择系统镜像">
{systemList.map((item: any) => {
const { name, value } = item || {};
return (
<Option key={value} value={value}>
{name}
</Option>
);
})}
</Select>
</Form.Item>
{/* CPU 字段 */}
<Form.Item
label="CPU"
rules={[{ required: true, message: '请输入CPU数量' }]}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name={['cpu', 'size']}
rules={[{ required: true, message: '请输入CPU数量' }]}
noStyle
>
<InputNumber
precision={0}
style={{ width: '190px' }}
placeholder="CPU数量"
addonAfter={<span></span>}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name={['cpu', 'cores']}
rules={[{ required: true, message: '请输入CPU内核数' }]}
noStyle
>
<InputNumber
precision={0}
style={{ width: '190px' }}
placeholder="CPU内核数"
addonAfter={<span></span>}
/>
</Form.Item>
</Col>
</Row>
</Form.Item>
{/* 内存字段 */}
<Form.Item
name="memory_total"
label="内存"
rules={[{ required: true, message: '请输入内存' }]}
>
<InputNumber
precision={0}
addonAfter={<span>CB</span>}
style={{ width: '393.33px' }}
placeholder="内存"
/>
</Form.Item>
<Form.Item
name="system_total"
label="系统盘"
rules={[{ required: true, message: '请输入系统盘内存' }]}
>
<InputNumber
precision={0}
addonAfter={<span>CB</span>}
style={{ width: '393.33px' }}
placeholder="系统盘内存"
/>
</Form.Item>
{/* 存储卷字段 */}
<Form.Item
name="storage_path"
label="存储卷"
rules={[{ required: true, message: '请选择存储卷' }]}
>
<Select placeholder="请选择存储卷">
{storagePathList.map((item: any) => {
const { path } = item || {};
return (
<Option key={path} value={path}>
{path}
</Option>
);
})}
</Select>
</Form.Item>
<Form.Item
name="network_module"
label="网络模板"
rules={[{ required: true, message: '请选择网络模板' }]}
>
<Select placeholder="请选择网络模板">
{networkList.map((item: any) => {
const { name, value } = item || {};
return (
<Option key={value} value={value}>
{name}
</Option>
);
})}
</Select>
</Form.Item>
{/* 描述字段 */}
<Form.Item name="description" label="描述">
<Input.TextArea />
</Form.Item>
</Form>
</Modal>
);
};
export default EditModal;

View File

@ -0,0 +1,138 @@
import type { TableProps } from 'antd';
import { useCallback, useState } from 'react';
const useTableParams = (
initialParams: DESK.VirtualTableParams = {
pagination: { current: 1, pageSize: 10 },
filters: {},// 表格的搜索对象
sort: {},
search: {}, // 添加搜索参数对象
},
) => {
const [tableParams, setTableParams] =
useState<DESK.VirtualTableParams>(initialParams);
const getApiParams = useCallback(() => {
const { pagination, filters, sort, search, ...rest } = tableParams;
const apiParams: Record<string, any> = {
page_size: pagination?.pageSize,
page_num: pagination?.current,
...rest,
};
if (sort?.field) {
apiParams.orderby = sort.field;
apiParams.order = sort.order === 'ascend' ? 'asc' : 'desc';
}
// 处理表格搜索参数
Object.entries(filters || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
apiParams[key] = value;
}
});
// 处理搜索参数
Object.entries(search || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
apiParams[key] = value;
}
});
console.log('getApiParams apiParams', apiParams);
return apiParams;
}, [tableParams]);
// 统一的更新方法,可以处理所有参数类型
const updateParams = useCallback(
(
newParams: Partial<DESK.VirtualTableParams>,
options?: { resetPage?: boolean },
) => {
// console.log('updateParams', newParams);
setTableParams((prev) => {
// 如果是搜索或过滤相关的更新,重置到第一页
const shouldResetPage =
options?.resetPage ??
((newParams.search && Object.keys(newParams.search).length > 0) || // 有搜索值
(newParams.filters && Object.keys(newParams.filters).length > 0)); // 有过滤值
return {
...prev,
...newParams,
pagination: {
...prev.pagination,
...newParams.pagination,
...(shouldResetPage ? { current: 1 } : {}), // 根据条件决定是否重置页码
},
};
});
},
[],
);
/**
*
* @param pagination
* @param filters filters
* @param sorter
* @param extra
* @returns void
* */
const handleTableChange = useCallback<
NonNullable<TableProps<DESK.VirtualItem>['onChange']>
>(
(pagination, filters, sorter) => {
// console.log('handleTableChange',pagination,filters,sorter,extra);
// 过滤掉空值的filters
const filteredFilters: Record<string, any> = {};
Object.entries(filters || {}).forEach(([key, value]) => {
// if (key === 'image_type') {
// if (Array.isArray(value) && value.length > 0 && value[0] !== '全部') {
// filteredFilters[key] = Number(value[0]);
// }
// } else {
if (Array.isArray(value) && value.length > 0) {
filteredFilters[key] = value[0];
} else if (value !== undefined && value !== null) {
if (!Array.isArray(value) && value !== '') {
filteredFilters[key] = value;
}
}
// }
});
const newParams: Partial<DESK.VirtualTableParams> = {
pagination: {
current: pagination.current || 1,
pageSize: pagination.pageSize || 10,
},
filters: filteredFilters,
};
if (!Array.isArray(sorter)) {
newParams.sort = {
field: sorter.field as string,
order:
sorter.order === 'ascend' || sorter.order === 'descend'
? sorter.order
: undefined,
};
}
// console.log('handleTableChange', newParams);
updateParams(newParams);
},
[updateParams],
);
return {
tableParams,
getApiParams,
updateParams, // 统一的更新方法
handleTableChange,
};
};
export default useTableParams;

View File

@ -0,0 +1,172 @@
// 页面头部样式
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #333;
}
}
// 镜像列表样式
.image-list {
width:100%;
height: 100%;
display: flex;
flex-direction: column;
// padding: 16px;
box-sizing: border-box;
.search-box {
margin-bottom: 16px;
display: flex;
justify-content: space-between;
.search-input {
display: flex;
gap: 8px;
align-items: center;
}
}
.images-list-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.images-list-table {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
// 表格适应样式
.ant-table-wrapper {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.ant-spin-nested-loading {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.ant-spin-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.ant-table {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.ant-table-container {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.ant-table-header {
flex-shrink: 0;
}
.ant-table-body {
flex: 1;
overflow: auto !important;
}
}
// 确保分页器在底部正确显示
.ant-table-pagination {
flex-shrink: 0;
// 确保分页器始终可见
position: relative;
z-index: 1;
}
}
}
}
}
}
}
.image-detail {
.detail-item {
margin-bottom: 16px;
label {
font-weight: 600;
color: #333;
display: inline-block;
width: 100px;
}
span {
color: #666;
}
p {
margin: 8px 0 0 100px;
color: #666;
line-height: 1.6;
}
}
}
}
// 个人资料样式
.profile-page {
.profile-content {
.profile-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
.profile-info {
h3 {
margin: 0 0 4px 0;
font-size: 20px;
font-weight: 600;
color: #333;
}
p {
margin: 0;
color: #666;
font-size: 14px;
}
}
}
.quick-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.profile-content {
.profile-header {
flex-direction: column;
text-align: center;
}
.quick-actions {
justify-content: center;
}
}
}

View File

@ -0,0 +1,691 @@
/* eslint-disable @typescript-eslint/no-use-before-define */
/* eslint-disable eqeqeq */
import { ReactComponent as RefreshIcon } from '@/assets/icons/refresh.svg';
import { ERROR_CODE } from '@/constants/constants';
import { CODE } from '@/constants/images.constants';
import {
cloneVirtualImage,
deleteVirtualImage,
getNetworkList,
getStorageList,
getVirtualImagesList,
operateVirtualImage,
} from '@/services/imagePage/index';
import { getImagesList } from '@/services/images';
import { DownOutlined, SettingOutlined,PlusOutlined } from '@ant-design/icons';
import {
Button,
Checkbox,
Input,
message,
Modal,
Popconfirm,
Popover,
Space,
Table,
Tooltip,
} from 'antd';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import CreatModal from './creatModal';
import useTableParams from './hook/hook';
import './index.less';
// 列配置定义
type ColumnConfig = {
key: string;
title: string;
dataIndex?: string;
width: number;
render?: (text: any, record: any, index: number) => React.ReactNode;
fixed?: 'left' | 'right';
align?: 'left' | 'right' | 'center';
defaultVisible: boolean; // 默认是否显示
alwaysVisible?: boolean; // 始终显示的列
ellipsis?: boolean; // 是否启用省略号
filters?: { text: string; value: string }[];
filterMultiple?: boolean; // 是否多选过滤
filterDropdown?: (props: any) => React.ReactNode;
defaultFilteredValue?: string[]; // 默认过滤值
onFilter?: (value: string, record: any) => boolean;
};
type TableColumn = {
title: string;
dataIndex?: string;
key: string;
width: number;
render?: any;
fixed?: 'left' | 'right';
hidden?: boolean;
};
// 在组件顶部添加防抖函数(支持取消)
// 增强版防抖函数
// 增强版防抖函数,使用泛型明确函数类型
const debounce = <T extends (...args: any[]) => any>(
func: T,
delay: number,
immediate = false,
) => {
let timer: NodeJS.Timeout | null = null;
const debounced = (...args: Parameters<T>) => {
if (timer) clearTimeout(timer);
if (immediate && !timer) {
func(...args);
}
timer = setTimeout(() => {
if (!immediate) {
func(...args);
}
timer = null;
}, delay);
};
debounced.cancel = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
};
return debounced;
};
const Index: React.FC<DESK.ImagesProps> = (props) => {
const { activeTabKey } = props;
const [dataSource, setDataSource] = useState<DESK.VirtualItem[]>([]);
const [loading, setLoading] = useState(false);
const [searchText, setSearchText] = useState<string>(''); // 添加本地搜索状态
const [visible, setVisible] = useState(false); // 新增状态管理
const [detialData, setDetialData] = useState<DESK.VirtualItem | null>(null); // 新增状态管理
const [storagePathList, setStoragePathList] = useState<any>([
{ path: 'cvar/lib/libvirt/images' },
]);
const [networkList, setNetworkList] = useState<any>([
{ name: 'default', value: 'default' },
{ name: '桥接', value: 'qiaojie' },
]);
const [systemList, setSystemList] = useState<any>([
{ name: 'CentOS 7', value: 'CentOS 7' },
{ name: 'CentOS 8', value: 'CentOS 8' },
]);
// 存储清理函数的引用,用于在组件卸载时执行清理
const cleanupFunctions = useRef<Array<() => void>>([]);
const { tableParams, getApiParams, updateParams, handleTableChange } =
useTableParams({
pagination: {
current: 1,
pageSize: 10,
},
search: {}, // 初始化搜索参数
});
// 在组件顶部添加一个 ref 来保存最新的 tableParams
const tableParamsRef = useRef(tableParams);
tableParamsRef.current = tableParams; // 每次渲染时更新 ref 的值
const [columnSettingsVisible, setColumnSettingsVisible] = useState(false);
// 组件卸载时执行所有清理函数
useEffect(() => {
return () => {
cleanupFunctions.current.forEach((cleanup) => cleanup());
cleanupFunctions.current = [];
};
}, []);
useEffect(() => {
onGetNetworkList();
onGetStorageList();
onGetImagesList();
}, []);
// 表格参数变化 获取镜像列表
useEffect(() => {
if (activeTabKey === '2') {
loadDataSource();
}
}, [
activeTabKey,
tableParams.pagination?.current,
tableParams.pagination?.pageSize,
tableParams?.sortOrder,
tableParams?.sortField,
JSON.stringify(tableParams.filters), // 表格搜索参数
JSON.stringify(tableParams.search), // 搜索参数依赖
]);
// 定义所有列的配置
const columnConfigs: ColumnConfig[] = [
{
key: 'index',
title: '序号',
width: 60,
render: (text: any, row: any, index: number) =>
(tableParams.pagination?.current - 1) *
tableParams.pagination?.pageSize +
index +
1,
defaultVisible: true,
alwaysVisible: true,
},
{
key: 'image_name',
title: '名称',
dataIndex: 'image_name',
width: 150,
defaultVisible: true,
alwaysVisible: true,
ellipsis: true,
render: (text: string) =>
text ? (
<Tooltip title={text} placement="topLeft">
{text}
</Tooltip>
) : (
'--'
),
},
{
key: 'os_version',
title: '操作系统',
dataIndex: 'os_version',
width: 150,
defaultVisible: true,
ellipsis: true,
render: (text: string) =>
text ? <Tooltip title={text}>{text}</Tooltip> : '--',
},
{
key: 'image_status',
title: '状态',
dataIndex: 'image_status',
width: 120,
defaultVisible: true,
ellipsis: true,
render: (text: string) =>
text ? <Tooltip title={text}>{text}</Tooltip> : '--',
},
{
key: 'image_system_name',
title: '系统镜像',
dataIndex: 'image_system_name',
width: 120,
defaultVisible: true,
ellipsis: true,
render: (text: string) =>
text ? <Tooltip title={text}>{text}</Tooltip> : '--',
},
{
key: 'description',
title: '描述',
dataIndex: 'description',
width: 140,
defaultVisible: true,
ellipsis: true,
render: (text: string) =>
text ? (
<Tooltip title={text} placement="topLeft">
{text}
</Tooltip>
) : (
'--'
),
},
{
key: 'action',
title: '操作',
width: 150,
align: 'center',
fixed: 'right' as 'right',
render: (_: any, record: DESK.VirtualItem) => (
<Space size="small">
<Button
size="small"
type="link"
title="控制台"
onClick={() => handleOpenVnc(record)}
>
</Button>
<Button
onClick={() => {
handleClone(record);
}}
size="small"
type="link"
title="克隆为模板"
>
</Button>
<Popover
placement="bottomRight"
content={
<div>
<div>
<Button
type="link"
onClick={() => {
handleOperate(record, 'start');
}}
>
</Button>
</div>
<div>
<Button
type="link"
onClick={() => {
handleOperate(record, 'close');
}}
>
</Button>
</div>
<div>
<Button
type="link"
onClick={() => {
handleOperate(record, 'restart');
}}
>
</Button>
</div>
<div>
<Button
type="link"
onClick={() => {
handelOpenModal(record);
}}
>
</Button>
</div>
<Popconfirm
title="确定要删除这个镜像吗?"
description="删除后无法恢复,请谨慎操作。"
onConfirm={() => handleDelete(record)}
okText="确定"
cancelText="取消"
>
<Button type="link" title="删除">
</Button>
</Popconfirm>
</div>
}
>
<a onClick={(e) => e.preventDefault()}>
<DownOutlined style={{ fontSize: '0.7rem' }} />
</a>
</Popover>
</Space>
),
defaultVisible: true,
},
];
// 初始化 visibleColumns 状态
const initialVisibleColumns = columnConfigs.reduce<Record<string, boolean>>(
(acc, column) => {
if (!column.alwaysVisible) {
acc[column.key] = column.defaultVisible;
}
return acc;
},
{},
);
const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>(
initialVisibleColumns,
);
// 重置列设置
const resetColumns = () => {
setVisibleColumns(initialVisibleColumns);
};
const loadDataSource = async () => {
setLoading(true);
try {
// 将搜索文本合并到API参数中
const apiParams = {
...getApiParams(),
};
const result = await getVirtualImagesList(apiParams);
if (result.code == CODE) {
setDataSource(result.data?.data || []);
setLoading(false);
// 正确处理后端返回的分页信息
updateParams({
pagination: {
...tableParams.pagination,
current: result.data?.page_num || 1,
total: result.data?.total || 0,
pageSize: tableParams.pagination?.pageSize || 10,
},
});
} else {
message.error(result.message || '获取镜像列表失败');
setLoading(false);
}
} catch (err) {
message.error('获取镜像列表失败');
setLoading(false);
}
};
//虚拟镜像克隆为桌面镜像
const handleClone = (record: DESK.VirtualItem) => {
cloneVirtualImage({ id: record.id }).then((res) => {
if (res.code == CODE) {
message.success('克隆成功');
loadDataSource();
} else {
message.error(res.message || '克隆失败');
}
});
};
const onGetImagesList = () => {
const payload = {
page_num: 1,
page_size: 5000,
};
getImagesList(payload).then((res) => {
const { code, data } = res || {};
const { data: list } = data || {};
if (code === ERROR_CODE) {
setSystemList(list);
}
});
};
const onGetStorageList = () => {
const params = { id: detialData?.id };
getStorageList(params).then((res) => {
const { code, data } = res || {};
const { data: list } = data || {};
if (code === ERROR_CODE) {
setStoragePathList(list);
}
});
};
const onGetNetworkList = () => {
const params = { id: detialData?.id };
getNetworkList(params).then((res) => {
const { code, data } = res || {};
const { data: list } = data || {};
if (code === ERROR_CODE) {
setNetworkList(list);
}
});
};
const handleDelete = (record: DESK.VirtualItem) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除镜像 "${record.image_name}" 吗?`,
onOk: () => {
deleteVirtualImage({ id: record.id }).then((res) => {
if (res.code == CODE) {
message.success('删除成功');
loadDataSource();
} else {
message.error(res.message || '删除失败');
}
});
},
});
};
// 处理打开VNC客户端 - 在新窗口中打开
const handleOpenVnc = (record: DESK.VirtualItem) => {
try {
// 构建远程桌面页面的URL
const vncUrl = 'ws://10.100.51.118:8000/api/v1/ws/vnc/win10u';
const remotePageUrl = `/vncClient?imageId=${
record.id
}&imageName=${encodeURIComponent(
'NEXSPACE远程桌面',
)}&vncUrl=${encodeURIComponent(vncUrl)}`;
// 在新窗口中打开远程桌面
const vncWindow = window.open(
remotePageUrl,
'_blank',
'width=1200,height=800,menubar=no,toolbar=no,location=no,status=no',
);
// 检查窗口是否成功打开
if (!vncWindow) {
message.error('无法打开新窗口,可能被浏览器阻止');
return;
}
} catch (error) {
message.error('远程桌面连接失败');
}
};
// 列设置相关函数
const handleColumnChange = (columnKey: string, checked: boolean) => {
setVisibleColumns((prev) => ({
...prev,
[columnKey]: checked,
}));
};
// 列设置内容
const columnSettingsContent = (
<div style={{ padding: '8px 0' }}>
{columnConfigs
.filter((config) => !config.alwaysVisible) // 只显示可控制的列
.map((config) => (
<div key={config.key} style={{ padding: '4px 12px' }}>
<Checkbox
checked={visibleColumns[config.key]}
onChange={(e) => handleColumnChange(config.key, e.target.checked)}
>
{config.title}
</Checkbox>
</div>
))}
<div
style={{
padding: '8px 12px',
borderTop: '1px solid #f0f0f0',
marginTop: 8,
}}
>
<Button type="link" onClick={resetColumns} style={{ padding: 0 }}>
</Button>
</div>
</div>
);
// 根据visibleColumns过滤显示的列
const filteredColumns = columnConfigs
.map((config) => {
// 对于始终显示的列
if (config.alwaysVisible) {
return {
...config,
hidden: undefined,
};
}
// 对于可控制显示/隐藏的列
return {
...config,
...(visibleColumns[config.key] ? {} : { hidden: true }),
};
})
.filter((column) => !column.hidden) as TableColumn[];
const handleRefresh = () => {
loadDataSource();
};
// 自定义分页配置
const paginationConfig = {
...tableParams.pagination,
showTotal: (total: number) => `${total} 条记录`,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
};
const handleSearch = useCallback(
(searchValue: string) => {
const currentTableParams = tableParamsRef.current;
updateParams({
search: {
image_name: searchValue,
},
pagination: {
current: 1,
pageSize: currentTableParams.pagination?.pageSize || 10,
},
});
},
[updateParams],
);
// 防抖版本500ms延迟不立即执行
const debouncedSearch = useRef(debounce(handleSearch, 500)).current;
// 立即执行版本(用于清空时立即搜索)
const immediateSearch = useRef(debounce(handleSearch, 0, true)).current;
const handleSearchChange = (value: string) => {
setSearchText(value);
// 取消所有未执行的防抖请求
debouncedSearch.cancel();
immediateSearch.cancel();
// 清空时立即触发搜索
if (value === '') {
immediateSearch('');
return;
}
// 正常输入时使用防抖
debouncedSearch(value);
};
// 修改回车搜索处理
const handleEnterSearch = (value: string) => {
// 回车搜索时取消未执行的防抖
debouncedSearch.cancel();
immediateSearch.cancel();
// 直接执行搜索
handleSearch(value);
};
// 新增弹窗
const handelOpenModal = (record?: DESK.VirtualItem) => {
setDetialData(record || null);
setVisible(true);
};
// 编辑、新建保存成功回调
const onOk = () => {
setDetialData(null);
setVisible(false);
handleRefresh();
};
// 虚拟镜像关闭、开启、重启
const handleOperate = (record: DESK.VirtualItem, type: string) => {
operateVirtualImage({
id: record.id,
operate_type: type,
}).then((res: any) => {
const { code } = res || {};
if (code === ERROR_CODE) {
message.success('操作成功');
handleRefresh();
}
});
};
return (
<div className="image-list">
<div className="search-box">
<div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => handelOpenModal()}></Button>
</div>
<div className="search-input">
<Input.Search
placeholder="请输入名称"
value={searchText}
onChange={(e) => handleSearchChange(e.target.value)}
style={{ width: 300 }}
onSearch={handleEnterSearch}
/>
<Button
onClick={handleRefresh}
loading={loading}
icon={<RefreshIcon style={{ width: 13, height: 13 }} />}
></Button>
<Popover
content={columnSettingsContent}
title="列设置"
trigger="click"
open={columnSettingsVisible}
onOpenChange={setColumnSettingsVisible}
placement="bottomRight"
>
<Button icon={<SettingOutlined />}></Button>
</Popover>
</div>
</div>
<div className="images-list-container">
<div className="images-list-table">
<Table
columns={filteredColumns}
dataSource={dataSource}
rowKey="id"
loading={loading}
pagination={paginationConfig}
onChange={handleTableChange}
scroll={{
y: 'max-content', // 关键:允许内容决定高度
}}
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
}}
/>
</div>
</div>
{visible && (
<CreatModal
onCancel={() => {
setVisible(false);
}}
onOk={onOk}
detialData={detialData}
visible={visible}
storagePathList={storagePathList}
networkList={networkList}
systemList={systemList}
/>
)}
</div>
);
};
export default Index;

View File

@ -15,10 +15,11 @@
// 镜像列表样式
.image-list {
width:100%;
height: 100%;
display: flex;
flex-direction: column;
padding: 16px;
// padding: 16px;
box-sizing: border-box;
.search-box {
margin-bottom: 16px;

View File

@ -1,19 +1,16 @@
import {
CODE,
IMAGES_TYPE_MAP,
STATUS_MAP,
} from '@/constants/images.constants';
/* eslint-disable @typescript-eslint/no-use-before-define */
import { CODE, STATUS_MAP } from '@/constants/images.constants';
import { delImagesAPI, getImagesList } from '@/services/images';
import {
DeleteOutlined,
EyeOutlined,
SettingOutlined,
PlusOutlined
} from '@ant-design/icons';
import {
Button,
Checkbox,
Input,
Menu,
message,
Modal,
Popconfirm,
@ -32,6 +29,10 @@ import './index.less';
import { ReactComponent as RefreshIcon } from '@/assets/icons/refresh.svg';
// interface ImagesProps {
// activeTabKey?: string;
// }
// 列配置定义
type ColumnConfig = {
key: string;
@ -40,6 +41,7 @@ type ColumnConfig = {
width: number;
render?: (text: any, record: any, index: number) => React.ReactNode;
fixed?: 'left' | 'right';
align?: 'left' | 'center' | 'right';
defaultVisible: boolean; // 默认是否显示
alwaysVisible?: boolean; // 始终显示的列
ellipsis?: boolean; // 是否启用省略号
@ -66,11 +68,11 @@ const debounce = (func: Function, delay: number, immediate = false) => {
let timer: NodeJS.Timeout | null = null;
const debounced = (...args: any[]) => {
if (timer) clearTimeout(timer);
if (immediate && !timer) {
func(...args);
}
timer = setTimeout(() => {
if (!immediate) {
func(...args);
@ -78,18 +80,19 @@ const debounce = (func: Function, delay: number, immediate = false) => {
timer = null;
}, delay);
};
debounced.cancel = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
};
return debounced;
};
const ImageList: React.FC = () => {
const ImageList: React.FC<DESK.ImagesProps> = (props) => {
const { activeTabKey } = props;
const [images, setImages] = useState<IMAGES.ImageItem[]>([]);
const [loading, setLoading] = useState(false);
const [selectedImage, setSelectedImage] = useState<IMAGES.ImageItem | null>(
@ -115,219 +118,6 @@ const ImageList: React.FC = () => {
const [columnSettingsVisible, setColumnSettingsVisible] = useState(false);
// 表格参数变化 获取镜像列表
useEffect(() => {
loadImages();
}, [
tableParams.pagination?.current,
tableParams.pagination?.pageSize,
tableParams?.sortOrder,
tableParams?.sortField,
JSON.stringify(tableParams.filters), // 表格搜索参数
JSON.stringify(tableParams.search), // 搜索参数依赖
]);
// 定义所有列的配置
const columnConfigs: ColumnConfig[] = [
{
key: 'index',
title: '序号',
width: 60,
render: (text: any, row: any, index: number) =>
(tableParams.pagination?.current - 1) *
tableParams.pagination?.pageSize +
index +
1,
defaultVisible: true,
alwaysVisible: true,
},
{
key: 'image_name',
title: '镜像名称',
dataIndex: 'image_name',
width: 150,
defaultVisible: true,
alwaysVisible: true,
ellipsis: true,
render: (text: string) =>
text ? (
<Tooltip title={text} placement="topLeft">
{text}
</Tooltip>
) : (
'--'
),
},
{
key: 'image_file_name',
title: '镜像文件',
dataIndex: 'image_file_name',
width: 150,
defaultVisible: true,
alwaysVisible: true,
ellipsis: true,
render: (text: string) =>
text ? (
<Tooltip title={text} placement="topLeft">
{text}
</Tooltip>
) : (
'--'
),
},
// {
// key: 'image_type',
// title: '桌面类型',
// dataIndex: 'image_type',
// width: 120,
// render: (text: number) => {
// const key = text as keyof typeof IMAGES_TYPE_MAP;
// return text ? IMAGES_TYPE_MAP[key] : '--';
// },
// defaultVisible: true,
// filterDropdown: ({ setSelectedKeys, selectedKeys, confirm }) => (
// <Menu
// selectedKeys={selectedKeys.length > 0 ? selectedKeys : ['全部']}
// onClick={({ key }) => {
// setSelectedKeys(key === '全部' ? [] : [key]);
// confirm({ closeDropdown: true }); // 立即触发筛选并关闭下拉菜单
// }}
// items={[
// { key: '全部', label: '全部' },
// ...Object.entries(IMAGES_TYPE_MAP).map(([key, value]) => ({
// key,
// label: value,
// })),
// ]}
// />
// ),
// filterMultiple: false,
// defaultFilteredValue: ['全部'],
// },
{
key: 'storage_path',
title: '模板存放路径',
dataIndex: 'storage_path',
width: 140,
defaultVisible: true,
ellipsis: true,
render: (text: string) =>
text ? (
<Tooltip title={text} placement="topLeft">
{text}
</Tooltip>
) : (
'--'
),
},
{
key: 'bt_path',
title: 'BT路径',
dataIndex: 'bt_path',
width: 140,
defaultVisible: true,
ellipsis: true,
render: (text: string) =>
text ? (
<Tooltip title={text} placement="topLeft">
{text}
</Tooltip>
) : (
'--'
),
},
{
key: 'image_version',
title: '镜像版本',
dataIndex: 'image_version',
width: 100,
defaultVisible: true,
ellipsis: true,
render: (text: string) =>
text ? <Tooltip title={text}>{text}</Tooltip> : '--',
},
{
key: 'os_version',
title: '操作系统',
dataIndex: 'os_version',
width: 100,
defaultVisible: true,
ellipsis: true,
render: (text: string) =>
text ? <Tooltip title={text}>{text}</Tooltip> : '--',
},
{
key: 'image_status',
title: '镜像状态',
dataIndex: 'image_status',
width: 90,
render: (text: number) => (text ? getStatusTag(text) : '--'),
defaultVisible: true,
},
{
key: 'create_time',
title: '上传时间',
dataIndex: 'create_time',
width: 160,
render: (text: string) =>
text ? (
<Tooltip title={dayjs(text).format('YYYY-MM-DD HH:mm:ss')}>
{text ? dayjs(text).format('YYYY-MM-DD HH:mm:ss') : '--'}
</Tooltip>
) : (
'--'
),
defaultVisible: true,
ellipsis: true,
},
{
key: 'action',
title: '操作',
width: 90,
fixed: 'right' as 'right',
render: (_: any, record: IMAGES.ImageItem) => (
<Space size="small">
<Button
type="text"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record)}
title="查看详情"
/>
<Popconfirm
title="确定要删除这个镜像吗?"
description="删除后无法恢复,请谨慎操作。"
onConfirm={() => handleDelete(record)}
okText="确定"
cancelText="取消"
>
<Button type="text" icon={<DeleteOutlined />} title="删除" danger />
</Popconfirm>
</Space>
),
defaultVisible: true,
},
];
// 初始化 visibleColumns 状态
const initialVisibleColumns = columnConfigs.reduce<Record<string, boolean>>(
(acc, column) => {
if (!column.alwaysVisible) {
acc[column.key] = column.defaultVisible;
}
return acc;
},
{},
);
const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>(
initialVisibleColumns,
);
// 重置列设置
const resetColumns = () => {
setVisibleColumns(initialVisibleColumns);
};
const loadImages = async () => {
setLoading(true);
try {
@ -359,6 +149,243 @@ const ImageList: React.FC = () => {
setLoading(false);
}
};
// 表格参数变化 获取镜像列表
useEffect(() => {
if (activeTabKey === '1') {
loadImages();
}
}, [
tableParams.pagination?.current,
tableParams.pagination?.pageSize,
tableParams?.sortOrder,
tableParams?.sortField,
JSON.stringify(tableParams.filters), // 表格搜索参数
JSON.stringify(tableParams.search), // 搜索参数依赖
activeTabKey,
]);
// 定义所有列的配置
const columnConfigs: ColumnConfig[] = [
{
key: 'index',
title: '序号',
width: 60,
render: (text: any, row: any, index: number) =>
(tableParams.pagination?.current - 1) *
tableParams.pagination?.pageSize +
index +
1,
defaultVisible: true,
alwaysVisible: true,
},
{
key: 'image_name',
// title: '镜像名称',
title: '名称',
dataIndex: 'image_name',
width: 150,
defaultVisible: true,
alwaysVisible: true,
ellipsis: true,
render: (text: string) =>
text ? (
<Tooltip title={text} placement="topLeft">
{text}
</Tooltip>
) : (
'--'
),
},
{
key: 'os_version',
title: '操作系统',
dataIndex: 'os_version',
width: 100,
defaultVisible: true,
ellipsis: true,
render: (text: string) =>
text ? <Tooltip title={text}>{text}</Tooltip> : '--',
},
{
key: 'image_version',
// title: '镜像版本',
title: '版本',
dataIndex: 'image_version',
width: 100,
defaultVisible: true,
ellipsis: true,
render: (text: string) =>
text ? <Tooltip title={text}>{text}</Tooltip> : '--',
},
{
key: 'storage_path',
// title: '模板存放路径',
title: '存储位置',
dataIndex: 'storage_path',
width: 140,
defaultVisible: true,
ellipsis: true,
render: (text: string) =>
text ? (
<Tooltip title={text} placement="topLeft">
{text}
</Tooltip>
) : (
'--'
),
},
// {
// key: 'image_file_name',
// title: '镜像文件',
// dataIndex: 'image_file_name',
// width: 150,
// defaultVisible: true,
// alwaysVisible: true,
// ellipsis: true,
// render: (text: string) =>
// text ? (
// <Tooltip title={text} placement="topLeft">
// {text}
// </Tooltip>
// ) : (
// '--'
// ),
// },
// {
// key: 'image_type',
// title: '桌面类型',
// dataIndex: 'image_type',
// width: 120,
// render: (text: number) => {
// const key = text as keyof typeof IMAGES_TYPE_MAP;
// return text ? IMAGES_TYPE_MAP[key] : '--';
// },
// defaultVisible: true,
// filterDropdown: ({ setSelectedKeys, selectedKeys, confirm }) => (
// <Menu
// selectedKeys={selectedKeys.length > 0 ? selectedKeys : ['全部']}
// onClick={({ key }) => {
// setSelectedKeys(key === '全部' ? [] : [key]);
// confirm({ closeDropdown: true }); // 立即触发筛选并关闭下拉菜单
// }}
// items={[
// { key: '全部', label: '全部' },
// ...Object.entries(IMAGES_TYPE_MAP).map(([key, value]) => ({
// key,
// label: value,
// })),
// ]}
// />
// ),
// filterMultiple: false,
// defaultFilteredValue: ['全部'],
// },
// {
// key: 'bt_path',
// title: 'BT路径',
// dataIndex: 'bt_path',
// width: 140,
// defaultVisible: true,
// ellipsis: true,
// render: (text: string) =>
// text ? (
// <Tooltip title={text} placement="topLeft">
// {text}
// </Tooltip>
// ) : (
// '--'
// ),
// },
// {
// key: 'image_status',
// title: '镜像状态',
// dataIndex: 'image_status',
// width: 90,
// render: (text: number) => (text ? getStatusTag(text) : '--'),
// defaultVisible: true,
// },
{
key: 'create_time',
title: '上传时间',
dataIndex: 'create_time',
width: 160,
render: (text: string) =>
text ? (
<Tooltip title={dayjs(text).format('YYYY-MM-DD HH:mm:ss')}>
{text ? dayjs(text).format('YYYY-MM-DD HH:mm:ss') : '--'}
</Tooltip>
) : (
'--'
),
defaultVisible: true,
ellipsis: true,
},
{
key: 'action',
title: '操作',
width: 90,
align: 'center',
fixed: 'right' as 'right',
render: (_: any, record: IMAGES.ImageItem) => (
<Space size="small">
{/* <Button
type="text"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record)}
title="查看详情"
/> */}
<Button
size="small"
type="link"
title="编辑"
onClick={() => handleViewDetail(record)}
>
</Button>
<Popconfirm
title="确定要删除这个镜像吗?"
description="删除后无法恢复,请谨慎操作。"
onConfirm={() => handleDelete(record)}
okText="确定"
cancelText="取消"
>
{/* <Button type="text" icon={<DeleteOutlined />} title="删除" danger /> */}
<Button
size="small"
type="link"
title="删除"
>
</Button>
</Popconfirm>
</Space>
),
defaultVisible: true,
},
];
// 初始化 visibleColumns 状态
const initialVisibleColumns = columnConfigs.reduce<Record<string, boolean>>(
(acc, column) => {
if (!column.alwaysVisible) {
acc[column.key] = column.defaultVisible;
}
return acc;
},
{},
);
const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>(
initialVisibleColumns,
);
// 重置列设置
const resetColumns = () => {
setVisibleColumns(initialVisibleColumns);
};
const getStatusTag = (status: number) => {
const config = STATUS_MAP[status as keyof typeof STATUS_MAP];
return <Tag color={config?.color}>{config.text}</Tag>;
@ -374,7 +401,7 @@ const ImageList: React.FC = () => {
title: '确认删除',
content: `确定要删除镜像 "${record.image_name}" 吗?`,
onOk: () => {
delImagesAPI({id:record.id}).then((res) => {
delImagesAPI({ id: record.id }).then((res) => {
if (res.code == CODE) {
message.success('删除成功');
loadImages();
@ -513,7 +540,7 @@ const ImageList: React.FC = () => {
return (
<div className="image-list">
<div className="search-box">
<Button onClick={() => setImportModalVisible(true)}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setImportModalVisible(true)}></Button>
<div className="search-input">
<Input.Search
placeholder="镜像名称"

View File

@ -0,0 +1,258 @@
import { ERROR_CODE, NETWORK_TYPE_LIST } from '@/constants/constants';
import { createNetwork, updateNetwork } from '@/services/network';
import { Form, Input, Modal, Select, Switch, message } from 'antd';
import React, { useEffect } from 'react';
const { Option } = Select;
interface NetworkFormData {
id?: number;
network_name: string;
bridge__name: string;
type: 'nat' | 'isolated' | 'bridge';
autostart: boolean;
ip_range?: string;
gateway?: string;
netmask?: string;
dhcp_start?: string;
dhcp_end?: string;
dhcp_enabled: boolean;
}
interface NetworkEditModalProps {
visible: boolean;
onCancel: () => void;
onOk: (values: NetworkFormData) => void;
initialValues?: NetworkFormData;
title?: string;
}
const NetworkEditModal: React.FC<NetworkEditModalProps> = ({
visible,
onCancel,
onOk,
initialValues,
title = '新增网络',
}) => {
const [form] = Form.useForm<NetworkFormData>();
useEffect(() => {
if (visible && initialValues) {
form.setFieldsValue(initialValues);
} else if (visible) {
form.resetFields();
}
}, [visible, initialValues, form]);
//新增网络
const onCreateNetwork = (payload: any) => {
createNetwork(payload).then((res: any) => {
console.log('res=======onCreateNetwork', res);
const { code } = res || {};
if (code === ERROR_CODE) {
message.success('网络新建成功');
onOk(payload);
form.resetFields();
} else {
message.error(res?.data || res?.message || '网络新建失败');
}
});
};
//编辑网络
const onUpdateNetwork = (payload: any) => {
updateNetwork(payload).then((res: any) => {
console.log('res=======onCreateNetwork', res);
const { code } = res || {};
if (code === ERROR_CODE) {
message.success('网络修改成功');
onOk(payload);
form.resetFields();
} else {
message.error(res?.data || res?.message || '网修改建失败');
}
});
};
const handleOk = async () => {
try {
const values = await form.validateFields();
const { autostart, dhcp_enabled } = values || {};
const params: any = { ...values };
params.autostart = autostart ? 1 : 0;
params.dhcp_enabled = dhcp_enabled ? 1 : 0;
if (initialValues?.id) {
params.id = initialValues?.id;
onUpdateNetwork(params);
} else {
onCreateNetwork(params);
}
} catch (error) {
console.error('表单验证失败:', error);
}
};
const handleCancel = () => {
form.resetFields();
onCancel();
};
const networkType = Form.useWatch('type', form);
return (
<Modal
title={title}
open={visible}
onOk={handleOk}
onCancel={handleCancel}
width={600}
// unmountOnClose
>
<Form
form={form}
labelCol={{ span: 6 }}
wrapperCol={{ span: 18 }}
initialValues={{
autostart: true,
enable_dhcp: true,
type: 'nat',
}}
>
<Form.Item
label="网络名称"
name="network_name"
rules={[
{ required: true, message: '请输入网络名称' },
{ max: 50, message: '网络名称最多50个字符' },
]}
>
<Input
disabled={initialValues?.id ? true : false}
placeholder="请输入网络名称"
/>
</Form.Item>
<Form.Item
label="桥接名称"
name="bridge_name"
rules={[
{ required: false, message: '请输入桥接名称' },
{ max: 30, message: '桥接名称最多30个字符' },
]}
>
<Input placeholder="请输入桥接名称" />
</Form.Item>
<Form.Item
label="网络类型"
name="type"
rules={[{ required: true, message: '请选择网络类型' }]}
extra={
networkType === 'nat'
? 'NAT模式:虚拟机通过宿主机访问外网,适合大多数场景'
: networkType === 'isolated'
? '隔离模式:虚拟机之间可通信,但无法访问外网'
: '桥接模式:虚拟机直接连接到物理网络,获得独立IP'
}
>
<Select placeholder="请选择网络类型">
{NETWORK_TYPE_LIST.map((item) => {
const { value, label } = item || {};
return (
<Option key={value} value={value}>
{label}
</Option>
);
})}
</Select>
</Form.Item>
{(networkType === 'nat' || networkType === 'isolated') && (
<>
<Form.Item
label="IP范围"
name="ip_range"
rules={[
{ required: true, message: '请输入IP范围' },
{
pattern: /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}$/,
message: '请输入有效的IP范围格式192.168.1.0/24',
},
]}
>
<Input placeholder="如192.168.1.0/24" />
</Form.Item>
<Form.Item
label="网关地址"
name="gateway"
rules={[
{ required: true, message: '请输入网关地址' },
{
pattern: /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
message: '请输入有效的IP地址格式',
},
]}
>
<Input placeholder="如192.168.1.1" />
</Form.Item>
<Form.Item
label="子网掩码"
name="netmask"
rules={[
{ required: true, message: '请输入子网掩码' },
{
pattern: /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
message: '请输入有效的子网掩码格式',
},
]}
>
<Input placeholder="如255.255.255.0" />
</Form.Item>
<Form.Item
label="DHCP起始地址"
name="dhcp_start"
rules={[
{ required: false, message: '请输入DHCP起始地址' },
{
pattern: /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
message: '请输入有效的IP地址格式',
},
]}
>
<Input placeholder="如192.168.1.100" />
</Form.Item>
<Form.Item
label="DHCP结束地址"
name="dhcp_end"
rules={[
{ required: false, message: '请输入DHCP结束地址' },
{
pattern: /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
message: '请输入有效的IP地址格式',
},
]}
>
<Input placeholder="如192.168.1.200" />
</Form.Item>
<Form.Item
label="启用DHCP"
name="enable_dhcp"
valuePropName="checked"
>
<Switch />
</Form.Item>
</>
)}
<Form.Item label="开机自启动" name="autostart" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
);
};
export default NetworkEditModal;

View File

@ -0,0 +1,138 @@
import type { TableProps } from 'antd';
import { useCallback, useState } from 'react';
const useTableParams = (
initialParams: NETWORK.TableParams = {
pagination: { current: 1, pageSize: 10 },
filters: {},// 表格的搜索对象
sort: {},
search: {}, // 添加搜索参数对象
},
) => {
const [tableParams, setTableParams] =
useState<NETWORK.TableParams>(initialParams);
const getApiParams = useCallback(() => {
const { pagination, filters, sort, search, ...rest } = tableParams;
const apiParams: Record<string, any> = {
page_size: pagination?.pageSize,
page_num: pagination?.current,
...rest,
};
if (sort?.field) {
apiParams.orderby = sort.field;
apiParams.order = sort.order === 'ascend' ? 'asc' : 'desc';
}
// 处理表格搜索参数
Object.entries(filters || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
apiParams[key] = value;
}
});
// 处理搜索参数
Object.entries(search || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
apiParams[key] = value;
}
});
console.log('getApiParams apiParams', apiParams);
return apiParams;
}, [tableParams]);
// 统一的更新方法,可以处理所有参数类型
const updateParams = useCallback(
(
newParams: Partial<NETWORK.TableParams>,
options?: { resetPage?: boolean },
) => {
// console.log('updateParams', newParams);
setTableParams((prev) => {
// 如果是搜索或过滤相关的更新,重置到第一页
const shouldResetPage =
options?.resetPage ??
((newParams.search && Object.keys(newParams.search).length > 0) || // 有搜索值
(newParams.filters && Object.keys(newParams.filters).length > 0)); // 有过滤值
return {
...prev,
...newParams,
pagination: {
...prev.pagination,
...newParams.pagination,
...(shouldResetPage ? { current: 1 } : {}), // 根据条件决定是否重置页码
},
};
});
},
[],
);
/**
*
* @param pagination
* @param filters filters
* @param sorter
* @param extra
* @returns void
* */
const handleTableChange = useCallback<
NonNullable<TableProps<NETWORK.NetworkItem>['onChange']>
>(
(pagination, filters, sorter) => {
// console.log('handleTableChange',pagination,filters,sorter,extra);
// 过滤掉空值的filters
const filteredFilters: Record<string, any> = {};
Object.entries(filters || {}).forEach(([key, value]) => {
if (key === 'image_type') {
if (Array.isArray(value) && value.length > 0 && value[0] !== '全部') {
filteredFilters[key] = Number(value[0]);
}
} else {
if (Array.isArray(value) && value.length > 0) {
filteredFilters[key] = value[0];
} else if (value !== undefined && value !== null) {
if (!Array.isArray(value) && value !== '') {
filteredFilters[key] = value;
}
}
}
});
const newParams: Partial<NETWORK.TableParams> = {
pagination: {
current: pagination.current || 1,
pageSize: pagination.pageSize || 10,
},
filters: filteredFilters,
};
if (!Array.isArray(sorter)) {
newParams.sort = {
field: sorter.field as string,
order:
sorter.order === 'ascend' || sorter.order === 'descend'
? sorter.order
: undefined,
};
}
// console.log('handleTableChange', newParams);
updateParams(newParams);
},
[updateParams],
);
return {
tableParams,
getApiParams,
updateParams, // 统一的更新方法
handleTableChange,
};
};
export default useTableParams;

View File

@ -0,0 +1,172 @@
// 页面头部样式
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #333;
}
}
// 镜像列表样式
.image-list {
width:100%;
height: 100%;
display: flex;
flex-direction: column;
padding: 10px;
box-sizing: border-box;
.search-box {
margin-bottom: 16px;
display: flex;
justify-content: space-between;
.search-input {
display: flex;
gap: 8px;
align-items: center;
}
}
.images-list-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.images-list-table {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
// 表格适应样式
.ant-table-wrapper {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.ant-spin-nested-loading {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.ant-spin-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.ant-table {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.ant-table-container {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.ant-table-header {
flex-shrink: 0;
}
.ant-table-body {
flex: 1;
overflow: auto !important;
}
}
// 确保分页器在底部正确显示
.ant-table-pagination {
flex-shrink: 0;
// 确保分页器始终可见
position: relative;
z-index: 1;
}
}
}
}
}
}
}
.image-detail {
.detail-item {
margin-bottom: 16px;
label {
font-weight: 600;
color: #333;
display: inline-block;
width: 100px;
}
span {
color: #666;
}
p {
margin: 8px 0 0 100px;
color: #666;
line-height: 1.6;
}
}
}
}
// 个人资料样式
.profile-page {
.profile-content {
.profile-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
.profile-info {
h3 {
margin: 0 0 4px 0;
font-size: 20px;
font-weight: 600;
color: #333;
}
p {
margin: 0;
color: #666;
font-size: 14px;
}
}
}
.quick-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.profile-content {
.profile-header {
flex-direction: column;
text-align: center;
}
.quick-actions {
justify-content: center;
}
}
}

View File

@ -0,0 +1,613 @@
/* eslint-disable @typescript-eslint/no-use-before-define */
import { ReactComponent as RefreshIcon } from '@/assets/icons/refresh.svg';
import {
DHCP_STATUS,
NETWORK_STATUS,
NETWORK_TYPE,
NETWORK_TYPE_LIST,
NETWORK_STATUS_LIST
} from '@/constants/constants';
import { CODE } from '@/constants/images.constants';
import { getNetworkList,deleteNetwork } from '@/services/network';
import { PlusOutlined, SettingOutlined } from '@ant-design/icons';
import {
Button,
Checkbox,
Input,
message,
Modal,
Popconfirm,
Popover,
Select,
Space,
Table,
Tag,
Tooltip,
} from 'antd';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import NetworkEditModal from './components/NetworkEditModal';
import useTableParams from './hook/hook';
import './index.less';
const { Option } = Select;
// 列配置定义
type ColumnConfig = {
key: string;
title: string;
dataIndex?: string;
width: number;
render?: (text: any, record: any, index: number) => React.ReactNode;
fixed?: 'left' | 'right';
align?: 'left' | 'center' | 'right';
defaultVisible: boolean; // 默认是否显示
alwaysVisible?: boolean; // 始终显示的列
ellipsis?: boolean; // 是否启用省略号
filters?: { text: string; value: string }[];
filterMultiple?: boolean; // 是否多选过滤
filterDropdown?: (props: any) => React.ReactNode;
defaultFilteredValue?: string[]; // 默认过滤值
onFilter?: (value: string, record: any) => boolean;
};
type TableColumn = {
title: string;
dataIndex?: string;
key: string;
width: number;
render?: any;
fixed?: 'left' | 'right';
hidden?: boolean;
};
// 在组件顶部添加防抖函数(支持取消)
// 增强版防抖函数,使用泛型明确函数类型
const debounce = <T extends (...args: any[]) => any>(
func: T,
delay: number,
immediate = false,
) => {
let timer: NodeJS.Timeout | null = null;
const debounced = (...args: Parameters<T>) => {
if (timer) clearTimeout(timer);
if (immediate && !timer) {
func(...args);
}
timer = setTimeout(() => {
if (!immediate) {
func(...args);
}
timer = null;
}, delay);
};
debounced.cancel = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
};
return debounced;
};
const Index = () => {
const [dataSource, setDataSource] = useState<NETWORK.NetworkItem[]>([]);
const [loading, setLoading] = useState(false);
const [selectedNetwork, setSelectedNetwork] = useState<any>(null);
const [editModalVisible, setEditModalVisible] = useState(false);
const [searchText, setSearchText] = useState<string>(''); // 添加本地搜索状态
const [networkType, setNetworkType] = useState<string>('不限'); // 网络类型
const [status, setStatus] = useState<string>('不限'); // 网络类型
const { tableParams, getApiParams, updateParams, handleTableChange } =
useTableParams({
pagination: {
current: 1,
pageSize: 10,
},
search: {}, // 初始化搜索参数
});
// 在组件顶部添加一个 ref 来保存最新的 tableParams
const tableParamsRef = useRef(tableParams);
tableParamsRef.current = tableParams; // 每次渲染时更新 ref 的值
const [columnSettingsVisible, setColumnSettingsVisible] = useState(false);
// 表格参数变化 获取镜像列表
useEffect(() => {
loadDataSource();
}, [
status,
networkType,
tableParams.pagination?.current,
tableParams.pagination?.pageSize,
tableParams?.sortOrder,
tableParams?.sortField,
JSON.stringify(tableParams.filters), // 表格搜索参数
JSON.stringify(tableParams.search), // 搜索参数依赖
]);
// 定义所有列的配置
const columnConfigs: ColumnConfig[] = [
{
key: 'index',
title: '序号',
width: 60,
render: (text: any, row: any, index: number) =>
(tableParams.pagination?.current - 1) *
tableParams.pagination?.pageSize +
index +
1,
defaultVisible: true,
alwaysVisible: true,
},
{
key: 'network_name',
title: '网络名称',
dataIndex: 'network_name',
width: 150,
defaultVisible: true,
alwaysVisible: true,
ellipsis: true,
render: (text: string) =>
text ? (
<Tooltip title={text} placement="topLeft">
{text}
</Tooltip>
) : (
'--'
),
},
{
key: 'type',
title: '网络类型',
dataIndex: 'type',
width: 150,
defaultVisible: true,
ellipsis: true,
render: (text: string) => {
const key = text ? (text as keyof typeof NETWORK_TYPE) : '';
return (
<Tooltip title={key ? NETWORK_TYPE[key] : '--'}>
{key ? NETWORK_TYPE[key] : '--'}
</Tooltip>
);
},
},
{
key: 'bridge_name',
title: '桥接',
dataIndex: 'bridge_name',
width: 150,
defaultVisible: true,
ellipsis: true,
render: (text: string) =>
text ? <Tooltip title={text}>{text}</Tooltip> : '--',
},
{
key: 'ip_range',
// title: '模板存放路径',
title: 'IP范围',
dataIndex: 'ip_range',
width: 140,
defaultVisible: true,
ellipsis: true,
render: (text: string) =>
text ? (
<Tooltip title={text} placement="topLeft">
{text}
</Tooltip>
) : (
'--'
),
},
{
key: 'dhcp_enable',
title: 'DHCP',
dataIndex: 'dhcp_enable',
width: 120,
defaultVisible: true,
ellipsis: true,
render: (text: number) => {
const key = text ? (text as keyof typeof DHCP_STATUS) : '';
return text ? (
<Tag color={text === 1 ? 'green' : 'red'}>
{' '}
{key ? DHCP_STATUS[key] : '--'}
</Tag>
) : null;
},
},
{
key: 'status',
title: '状态',
dataIndex: 'status',
width: 100,
defaultVisible: true,
ellipsis: true,
//NETWORK_STATUS
render: (text: number) => {
const key = text ? (text as keyof typeof NETWORK_STATUS) : '';
return text ? (
<Tag color={text === 1 ? 'green' : 'red'}>
{' '}
{key ? NETWORK_STATUS[key] : '--'}
</Tag>
) : null;
},
},
{
key: 'action',
title: '操作',
width: 120,
align: 'center',
fixed: 'right' as 'right',
render: (_: any, record: NETWORK.NetworkItem) => (
<Space size="small">
<Button
size="small"
type="link"
title="编辑"
onClick={() => handleEdit(record)}
>
</Button>
<Popconfirm
title="确定要删除这个网络吗?"
description="删除后无法恢复,请谨慎操作。"
onConfirm={() => handleDelete(record)}
okText="确定"
cancelText="取消"
>
<Button size="small" type="link" title="删除">
</Button>
</Popconfirm>
</Space>
),
defaultVisible: true,
},
];
// 初始化 visibleColumns 状态
const initialVisibleColumns = columnConfigs.reduce<Record<string, boolean>>(
(acc, column) => {
if (!column.alwaysVisible) {
acc[column.key] = column.defaultVisible;
}
return acc;
},
{},
);
const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>(
initialVisibleColumns,
);
// 重置列设置
const resetColumns = () => {
setVisibleColumns(initialVisibleColumns);
};
const loadDataSource = async () => {
setLoading(true);
try {
// 将搜索文本合并到API参数中
const apiParams = {
...getApiParams(),
};
if(networkType!=="不限"){
apiParams.type=networkType;
}
if(status!=="不限"){
apiParams.status=status;
}
const imagesRes = await getNetworkList(apiParams);
if (imagesRes.code === CODE) {
setDataSource(imagesRes.data?.data || []);
setLoading(false);
// 正确处理后端返回的分页信息
updateParams({
pagination: {
...tableParams.pagination,
current: imagesRes.data?.page_num || 1,
total: imagesRes.data?.total || 0,
pageSize: tableParams.pagination?.pageSize || 10,
},
});
} else {
message.error(imagesRes.message || '获取网络列表失败');
setLoading(false);
}
} catch (err) {
message.error('获取网络列表失败');
setLoading(false);
}
};
const handleAdd = () => {
setSelectedNetwork(null);
setEditModalVisible(true);
};
const handleEdit = (record: NETWORK.NetworkItem) => {
setSelectedNetwork(record);
setEditModalVisible(true);
};
const handleDelete = (record: NETWORK.NetworkItem) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除网络 "${record.network_name}" 吗?`,
onOk: () => {
deleteNetwork({ id: record.id }).then((res) => {
if (res.code === CODE) {
message.success('删除成功');
loadDataSource();
} else {
message.error(res.message || '删除失败');
}
});
},
});
};
const handleEditModalOk = (values: any) => {
// 这里可以调用新增或编辑网络的API
if (selectedNetwork) {
// 编辑模式
message.success('编辑网络成功');
} else {
// 新增模式
message.success('新增网络成功');
}
setEditModalVisible(false);
loadDataSource();
};
const handleEditModalCancel = () => {
setEditModalVisible(false);
setSelectedNetwork(null);
};
// 列设置相关函数
const handleColumnChange = (columnKey: string, checked: boolean) => {
setVisibleColumns((prev) => ({
...prev,
[columnKey]: checked,
}));
};
// 列设置内容
const columnSettingsContent = (
<div style={{ padding: '8px 0' }}>
{columnConfigs
.filter((config) => !config.alwaysVisible) // 只显示可控制的列
.map((config) => (
<div key={config.key} style={{ padding: '4px 12px' }}>
<Checkbox
checked={visibleColumns[config.key]}
onChange={(e) => handleColumnChange(config.key, e.target.checked)}
>
{config.title}
</Checkbox>
</div>
))}
<div
style={{
padding: '8px 12px',
borderTop: '1px solid #f0f0f0',
marginTop: 8,
}}
>
<Button type="link" onClick={resetColumns} style={{ padding: 0 }}>
</Button>
</div>
</div>
);
// 根据visibleColumns过滤显示的列
const filteredColumns = columnConfigs
.map((config) => {
// 对于始终显示的列
if (config.alwaysVisible) {
return {
...config,
hidden: undefined,
};
}
// 对于可控制显示/隐藏的列
return {
...config,
...(visibleColumns[config.key] ? {} : { hidden: true }),
};
})
.filter((column) => !column.hidden) as TableColumn[];
const handleRefresh = () => {
loadDataSource();
};
// 自定义分页配置
const paginationConfig = {
...tableParams.pagination,
showTotal: (total: number) => `${total} 条记录`,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
};
const handleSearch = useCallback(
(searchValue: string) => {
const currentTableParams = tableParamsRef.current;
updateParams({
search: {
network_name: searchValue,
},
pagination: {
current: 1,
pageSize: currentTableParams.pagination?.pageSize || 10,
},
});
},
[updateParams],
);
// 防抖版本500ms延迟不立即执行
const debouncedSearch = useRef(debounce(handleSearch, 500)).current;
// 立即执行版本(用于清空时立即搜索)
const immediateSearch = useRef(debounce(handleSearch, 0, true)).current;
const handleSearchChange = (value: string) => {
setSearchText(value);
// 取消所有未执行的防抖请求
debouncedSearch.cancel();
immediateSearch.cancel();
// 清空时立即触发搜索
if (value === '') {
immediateSearch('');
return;
}
// 正常输入时使用防抖
debouncedSearch(value);
};
// 修改回车搜索处理
const handleEnterSearch = (value: string) => {
// 回车搜索时取消未执行的防抖
debouncedSearch.cancel();
immediateSearch.cancel();
// 直接执行搜索
handleSearch(value);
};
//类型查询
const handleTypeChange = (value: string) => {
debouncedSearch.cancel();
immediateSearch.cancel();
// 直接执行搜索
setNetworkType(value);
};
// 状态查询
const handleStatusChange = (value: string) => {
debouncedSearch.cancel();
immediateSearch.cancel();
// 直接执行搜索
setStatus(value);
};
return (
<div className="image-list">
<div className="search-box">
<div>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
</Button>
</div>
<div className="search-input">
{/* status */}
<span></span>
<Select
// defaultValue={'不限'}
value={networkType}
onChange={handleTypeChange}
style={{ width: '180px' }}
>
{[{ value: '不限', label: '不限' }, ...NETWORK_TYPE_LIST].map(
(item) => {
const { value, label } = item || {};
return (
<Option key={value} value={value}>
{label}
</Option>
);
},
)}
</Select>
<span></span>
<Select
// defaultValue={'不限'}
value={status}
onChange={handleStatusChange}
style={{ width: '180px' }}
>
{[{ value: '不限', label: '不限' }, ...NETWORK_STATUS_LIST].map(
(item) => {
const { value, label } = item || {};
return (
<Option key={value} value={value}>
{label}
</Option>
);
},
)}
</Select>
<Input.Search
placeholder="网络名称"
value={searchText}
onChange={(e) => handleSearchChange(e.target.value)}
style={{ width: 300 }}
onSearch={handleEnterSearch}
/>
<Button
onClick={handleRefresh}
loading={loading}
icon={<RefreshIcon style={{ width: 13, height: 13 }} />}
></Button>
<Popover
content={columnSettingsContent}
title="列设置"
trigger="click"
open={columnSettingsVisible}
onOpenChange={setColumnSettingsVisible}
placement="bottomRight"
>
<Button icon={<SettingOutlined />}></Button>
</Popover>
</div>
</div>
<div className="images-list-container">
<div className="images-list-table">
<Table
columns={filteredColumns}
dataSource={dataSource}
rowKey="id"
loading={loading}
pagination={paginationConfig}
onChange={handleTableChange}
scroll={{
y: 'max-content', // 关键:允许内容决定高度
}}
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
}}
/>
</div>
</div>
<NetworkEditModal
visible={editModalVisible}
onCancel={handleEditModalCancel}
onOk={handleEditModalOk}
initialValues={selectedNetwork}
title={selectedNetwork ? '编辑网络' : '新增网络'}
/>
</div>
);
};
export default Index;

View File

@ -0,0 +1,216 @@
import { NETWORK_TYPE_LIST } from '@/constants/constants';
import { Form, Input, Modal, Select, Switch } from 'antd';
import React, { useEffect } from 'react';
const { Option } = Select;
interface NetworkFormData {
network_name: string;
bridge_name: string;
network_type: 'NAT' | 'Isolated' | 'Bridge' | 'Open';
auto_start: boolean;
ip_range?: string;
gateway_address?: string;
subnet_mask?: string;
dhcp_start_address?: string;
dhcp_end_address?: string;
enable_dhcp: boolean;
}
interface NetworkEditModalProps {
visible: boolean;
onCancel: () => void;
onOk: (values: NetworkFormData) => void;
initialValues?: NetworkFormData;
title?: string;
}
const NetworkEditModal: React.FC<NetworkEditModalProps> = ({
visible,
onCancel,
onOk,
initialValues,
title = '新增网络',
}) => {
const [form] = Form.useForm<NetworkFormData>();
useEffect(() => {
if (visible && initialValues) {
form.setFieldsValue(initialValues);
} else if (visible) {
form.resetFields();
}
}, [visible, initialValues, form]);
const handleOk = async () => {
try {
const values = await form.validateFields();
onOk(values);
form.resetFields();
} catch (error) {
console.error('表单验证失败:', error);
}
};
const handleCancel = () => {
form.resetFields();
onCancel();
};
const networkType = Form.useWatch('network_type', form);
return (
<Modal
title={title}
open={visible}
onOk={handleOk}
onCancel={handleCancel}
width={600}
// unmountOnClose
>
<Form
form={form}
labelCol={{ span: 6 }}
wrapperCol={{ span: 18 }}
initialValues={{
auto_start: true,
enable_dhcp: true,
network_type: 'NAT',
}}
>
<Form.Item
label="网络名称"
name="network_name"
rules={[
{ required: true, message: '请输入网络名称' },
{ max: 50, message: '网络名称最多50个字符' },
]}
>
<Input placeholder="请输入网络名称" />
</Form.Item>
<Form.Item
label="桥接名称"
name="bridge_name"
rules={[
{ required: false, message: '请输入桥接名称' },
{ max: 30, message: '桥接名称最多30个字符' },
]}
>
<Input placeholder="请输入桥接名称" />
</Form.Item>
<Form.Item
label="网络类型"
name="network_type"
rules={[{ required: true, message: '请选择网络类型' }]}
extra={
networkType === 'NAT'
? 'NAT模式:虚拟机通过宿主机访问外网,适合大多数场景'
: networkType === 'Isolated'
? '隔离模式:虚拟机之间可通信,但无法访问外网'
: '桥接模式:虚拟机直接连接到物理网络,获得独立IP'
}
>
<Select placeholder="请选择网络类型">
{NETWORK_TYPE_LIST.map((item) => {
const { value, label } = item || {};
return (
<Option key={value} value={value}>
{label}
</Option>
);
})}
</Select>
</Form.Item>
{(networkType === 'NAT' || networkType === 'Isolated') && (
<>
<Form.Item
label="IP范围"
name="ip_range"
rules={[
{ required: true, message: '请输入IP范围' },
{
pattern: /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}$/,
message: '请输入有效的IP范围格式192.168.1.0/24',
},
]}
>
<Input placeholder="如192.168.1.0/24" />
</Form.Item>
<Form.Item
label="网关地址"
name="gateway_address"
rules={[
{ required: true, message: '请输入网关地址' },
{
pattern: /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
message: '请输入有效的IP地址格式',
},
]}
>
<Input placeholder="如192.168.1.1" />
</Form.Item>
<Form.Item
label="子网掩码"
name="subnet_mask"
rules={[
{ required: true, message: '请输入子网掩码' },
{
pattern: /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
message: '请输入有效的子网掩码格式',
},
]}
>
<Input placeholder="如255.255.255.0" />
</Form.Item>
<Form.Item
label="DHCP起始地址"
name="dhcp_start_address"
rules={[
{ required: false, message: '请输入DHCP起始地址' },
{
pattern: /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
message: '请输入有效的IP地址格式',
},
]}
>
<Input placeholder="如192.168.1.100" />
</Form.Item>
<Form.Item
label="DHCP结束地址"
name="dhcp_end_address"
rules={[
{ required: false, message: '请输入DHCP结束地址' },
{
pattern: /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
message: '请输入有效的IP地址格式',
},
]}
>
<Input placeholder="如192.168.1.200" />
</Form.Item>
<Form.Item
label="启用DHCP"
name="enable_dhcp"
valuePropName="checked"
>
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
</>
)}
<Form.Item label="开机自启动" name="auto_start" valuePropName="checked">
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
</Form>
</Modal>
);
};
export default NetworkEditModal;

View File

@ -0,0 +1,138 @@
import type { TableProps } from 'antd';
import { useCallback, useState } from 'react';
const useTableParams = (
initialParams: STORAGE.TableParams = {
pagination: { current: 1, pageSize: 10 },
filters: {},// 表格的搜索对象
sort: {},
search: {}, // 添加搜索参数对象
},
) => {
const [tableParams, setTableParams] =
useState<STORAGE.TableParams>(initialParams);
const getApiParams = useCallback(() => {
const { pagination, filters, sort, search, ...rest } = tableParams;
const apiParams: Record<string, any> = {
page_size: pagination?.pageSize,
page_num: pagination?.current,
...rest,
};
if (sort?.field) {
apiParams.orderby = sort.field;
apiParams.order = sort.order === 'ascend' ? 'asc' : 'desc';
}
// 处理表格搜索参数
Object.entries(filters || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
apiParams[key] = value;
}
});
// 处理搜索参数
Object.entries(search || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
apiParams[key] = value;
}
});
console.log('getApiParams apiParams', apiParams);
return apiParams;
}, [tableParams]);
// 统一的更新方法,可以处理所有参数类型
const updateParams = useCallback(
(
newParams: Partial<STORAGE.TableParams>,
options?: { resetPage?: boolean },
) => {
// console.log('updateParams', newParams);
setTableParams((prev) => {
// 如果是搜索或过滤相关的更新,重置到第一页
const shouldResetPage =
options?.resetPage ??
((newParams.search && Object.keys(newParams.search).length > 0) || // 有搜索值
(newParams.filters && Object.keys(newParams.filters).length > 0)); // 有过滤值
return {
...prev,
...newParams,
pagination: {
...prev.pagination,
...newParams.pagination,
...(shouldResetPage ? { current: 1 } : {}), // 根据条件决定是否重置页码
},
};
});
},
[],
);
/**
*
* @param pagination
* @param filters filters
* @param sorter
* @param extra
* @returns void
* */
const handleTableChange = useCallback<
NonNullable<TableProps<STORAGE.StorageItem>['onChange']>
>(
(pagination, filters, sorter) => {
// console.log('handleTableChange',pagination,filters,sorter,extra);
// 过滤掉空值的filters
const filteredFilters: Record<string, any> = {};
Object.entries(filters || {}).forEach(([key, value]) => {
if (key === 'image_type') {
if (Array.isArray(value) && value.length > 0 && value[0] !== '全部') {
filteredFilters[key] = Number(value[0]);
}
} else {
if (Array.isArray(value) && value.length > 0) {
filteredFilters[key] = value[0];
} else if (value !== undefined && value !== null) {
if (!Array.isArray(value) && value !== '') {
filteredFilters[key] = value;
}
}
}
});
const newParams: Partial<STORAGE.TableParams> = {
pagination: {
current: pagination.current || 1,
pageSize: pagination.pageSize || 10,
},
filters: filteredFilters,
};
if (!Array.isArray(sorter)) {
newParams.sort = {
field: sorter.field as string,
order:
sorter.order === 'ascend' || sorter.order === 'descend'
? sorter.order
: undefined,
};
}
// console.log('handleTableChange', newParams);
updateParams(newParams);
},
[updateParams],
);
return {
tableParams,
getApiParams,
updateParams, // 统一的更新方法
handleTableChange,
};
};
export default useTableParams;

View File

@ -0,0 +1,172 @@
// 页面头部样式
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #333;
}
}
// 镜像列表样式
.image-list {
width:100%;
height: 100%;
display: flex;
flex-direction: column;
padding: 10px;
box-sizing: border-box;
.search-box {
margin-bottom: 16px;
display: flex;
justify-content: space-between;
.search-input {
display: flex;
gap: 8px;
align-items: center;
}
}
.images-list-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.images-list-table {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
// 表格适应样式
.ant-table-wrapper {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.ant-spin-nested-loading {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.ant-spin-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.ant-table {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.ant-table-container {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.ant-table-header {
flex-shrink: 0;
}
.ant-table-body {
flex: 1;
overflow: auto !important;
}
}
// 确保分页器在底部正确显示
.ant-table-pagination {
flex-shrink: 0;
// 确保分页器始终可见
position: relative;
z-index: 1;
}
}
}
}
}
}
}
.image-detail {
.detail-item {
margin-bottom: 16px;
label {
font-weight: 600;
color: #333;
display: inline-block;
width: 100px;
}
span {
color: #666;
}
p {
margin: 8px 0 0 100px;
color: #666;
line-height: 1.6;
}
}
}
}
// 个人资料样式
.profile-page {
.profile-content {
.profile-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
.profile-info {
h3 {
margin: 0 0 4px 0;
font-size: 20px;
font-weight: 600;
color: #333;
}
p {
margin: 0;
color: #666;
font-size: 14px;
}
}
}
.quick-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.profile-content {
.profile-header {
flex-direction: column;
text-align: center;
}
.quick-actions {
justify-content: center;
}
}
}

View File

@ -0,0 +1,524 @@
/* eslint-disable @typescript-eslint/no-use-before-define */
import { NETWORK_TYPE } from '@/constants/constants';
import { CODE } from '@/constants/images.constants';
import { deleteTool } from '@/services/imagePage';
import { getNetworkList } from '@/services/network';
import { SettingOutlined, PlusOutlined } from '@ant-design/icons';
import {
Button,
Checkbox,
Input,
message,
Modal,
Popconfirm,
Popover,
Space,
Table,
Tooltip,
} from 'antd';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import useTableParams from './hook/hook';
import NetworkEditModal from './components/NetworkEditModal';
import './index.less';
import { ReactComponent as RefreshIcon } from '@/assets/icons/refresh.svg';
// 列配置定义
type ColumnConfig = {
key: string;
title: string;
dataIndex?: string;
width: number;
render?: (text: any, record: any, index: number) => React.ReactNode;
fixed?: 'left' | 'right';
align?: 'left' | 'center' | 'right';
defaultVisible: boolean; // 默认是否显示
alwaysVisible?: boolean; // 始终显示的列
ellipsis?: boolean; // 是否启用省略号
filters?: { text: string; value: string }[];
filterMultiple?: boolean; // 是否多选过滤
filterDropdown?: (props: any) => React.ReactNode;
defaultFilteredValue?: string[]; // 默认过滤值
onFilter?: (value: string, record: any) => boolean;
};
type TableColumn = {
title: string;
dataIndex?: string;
key: string;
width: number;
render?: any;
fixed?: 'left' | 'right';
hidden?: boolean;
};
// 在组件顶部添加防抖函数(支持取消)
// 增强版防抖函数,使用泛型明确函数类型
const debounce = <T extends (...args: any[]) => any>(
func: T,
delay: number,
immediate = false,
) => {
let timer: NodeJS.Timeout | null = null;
const debounced = (...args: Parameters<T>) => {
if (timer) clearTimeout(timer);
if (immediate && !timer) {
func(...args);
}
timer = setTimeout(() => {
if (!immediate) {
func(...args);
}
timer = null;
}, delay);
};
debounced.cancel = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
};
return debounced;
};
const Index = () => {
const [dataSource, setDataSource] = useState<STORAGE.StorageItem[]>([]);
const [loading, setLoading] = useState(false);
const [selectedNetwork, setSelectedNetwork] = useState<any>(null);
const [editModalVisible, setEditModalVisible] = useState(false);
const [searchText, setSearchText] = useState<string>(''); // 添加本地搜索状态
const { tableParams, getApiParams, updateParams, handleTableChange } =
useTableParams({
pagination: {
current: 1,
pageSize: 10,
},
search: {}, // 初始化搜索参数
});
// 在组件顶部添加一个 ref 来保存最新的 tableParams
const tableParamsRef = useRef(tableParams);
tableParamsRef.current = tableParams; // 每次渲染时更新 ref 的值
const [columnSettingsVisible, setColumnSettingsVisible] = useState(false);
// 表格参数变化 获取镜像列表
useEffect(() => {
loadDataSource();
}, [
tableParams.pagination?.current,
tableParams.pagination?.pageSize,
tableParams?.sortOrder,
tableParams?.sortField,
JSON.stringify(tableParams.filters), // 表格搜索参数
JSON.stringify(tableParams.search), // 搜索参数依赖
]);
// 定义所有列的配置
const columnConfigs: ColumnConfig[] = [
{
key: 'index',
title: '序号',
width: 60,
render: (text: any, row: any, index: number) =>
(tableParams.pagination?.current - 1) *
tableParams.pagination?.pageSize +
index +
1,
defaultVisible: true,
alwaysVisible: true,
},
{
key: 'pool_name',
title: '名称',
dataIndex: 'pool_name',
width: 100,
defaultVisible: true,
ellipsis: true,
render: (text: string) => {
const key = text ? (text as keyof typeof NETWORK_TYPE) : '';
return (
<Tooltip title={key ? NETWORK_TYPE[key] : '--'}>
{key ? NETWORK_TYPE[key] : '--'}
</Tooltip>
);
},
},
{
key: 'type',
title: '类型',
dataIndex: 'type',
width: 100,
defaultVisible: true,
ellipsis: true,
render: (text: string) =>
text ? <Tooltip title={text}>{text}</Tooltip> : '--',
},
{
key: 'path',
title: '路径',
dataIndex: 'ip_range',
width: 140,
defaultVisible: true,
ellipsis: true,
render: (text: string) =>
text ? (
<Tooltip title={text} placement="topLeft">
{text}
</Tooltip>
) : (
'--'
),
},
{
key: 'dhcp',
title: '总容量',
dataIndex: 'dhcp',
width: 160,
render: (text: string) =>
text ? <Tooltip title={text || ''}>{text || '--'}</Tooltip> : '--',
defaultVisible: true,
ellipsis: true,
},
{
key: 'dhcp',
title: '剩余容量',
dataIndex: 'dhcp',
width: 160,
render: (text: string) =>
text ? <Tooltip title={text || ''}>{text || '--'}</Tooltip> : '--',
defaultVisible: true,
ellipsis: true,
},
{
key: 'network_status',
title: '已分配容量',
dataIndex: 'network_status',
width: 160,
defaultVisible: true,
ellipsis: true,
render: (text: string) =>
text ? <Tooltip title={text || ''}>{text || '--'}</Tooltip> : '--',
},
{
key: 'action',
title: '操作',
width: 120,
align: 'center',
fixed: 'right' as 'right',
render: (_: any, record: STORAGE.StorageItem) => (
<Space size="small">
<Button
size="small"
type="link"
title="编辑"
onClick={() => handleEdit(record)}
>
</Button>
<Popconfirm
title="确定要删除这个网络吗?"
description="删除后无法恢复,请谨慎操作。"
onConfirm={() => handleDelete(record)}
okText="确定"
cancelText="取消"
>
<Button size="small" type="link" title="删除">
</Button>
</Popconfirm>
</Space>
),
defaultVisible: true,
},
];
// 初始化 visibleColumns 状态
const initialVisibleColumns = columnConfigs.reduce<Record<string, boolean>>(
(acc, column) => {
if (!column.alwaysVisible) {
acc[column.key] = column.defaultVisible;
}
return acc;
},
{},
);
const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>(
initialVisibleColumns,
);
// 重置列设置
const resetColumns = () => {
setVisibleColumns(initialVisibleColumns);
};
const loadDataSource = async () => {
setLoading(true);
try {
// 将搜索文本合并到API参数中
const apiParams = {
...getApiParams(),
};
const imagesRes = await getNetworkList(apiParams);
if (imagesRes.code === CODE) {
setDataSource(imagesRes.data?.data || []);
setLoading(false);
// 正确处理后端返回的分页信息
updateParams({
pagination: {
...tableParams.pagination,
current: imagesRes.data?.page_num || 1,
total: imagesRes.data?.total || 0,
pageSize: tableParams.pagination?.pageSize || 10,
},
});
} else {
message.error(imagesRes.message || '获取网络列表失败');
setLoading(false);
}
} catch (err) {
message.error('获取网络列表失败');
setLoading(false);
}
};
const handleAdd = () => {
setSelectedNetwork(null);
setEditModalVisible(true);
};
const handleEdit = (record: STORAGE.StorageItem) => {
setSelectedNetwork(record);
setEditModalVisible(true);
};
const handleDelete = (record: STORAGE.StorageItem) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除网络 "${record.id}" 吗?`,
onOk: () => {
deleteTool({ id: record.id }).then((res) => {
if (res.code === CODE) {
message.success('删除成功');
loadDataSource();
} else {
message.error(res.message || '删除失败');
}
});
},
});
};
const handleEditModalOk = (values: any) => {
// 这里可以调用新增或编辑网络的API
if (selectedNetwork) {
// 编辑模式
message.success('编辑网络成功');
} else {
// 新增模式
message.success('新增网络成功');
}
setEditModalVisible(false);
loadDataSource();
};
const handleEditModalCancel = () => {
setEditModalVisible(false);
setSelectedNetwork(null);
};
// 列设置相关函数
const handleColumnChange = (columnKey: string, checked: boolean) => {
setVisibleColumns((prev) => ({
...prev,
[columnKey]: checked,
}));
};
// 列设置内容
const columnSettingsContent = (
<div style={{ padding: '8px 0' }}>
{columnConfigs
.filter((config) => !config.alwaysVisible) // 只显示可控制的列
.map((config) => (
<div key={config.key} style={{ padding: '4px 12px' }}>
<Checkbox
checked={visibleColumns[config.key]}
onChange={(e) => handleColumnChange(config.key, e.target.checked)}
>
{config.title}
</Checkbox>
</div>
))}
<div
style={{
padding: '8px 12px',
borderTop: '1px solid #f0f0f0',
marginTop: 8,
}}
>
<Button type="link" onClick={resetColumns} style={{ padding: 0 }}>
</Button>
</div>
</div>
);
// 根据visibleColumns过滤显示的列
const filteredColumns = columnConfigs
.map((config) => {
// 对于始终显示的列
if (config.alwaysVisible) {
return {
...config,
hidden: undefined,
};
}
// 对于可控制显示/隐藏的列
return {
...config,
...(visibleColumns[config.key] ? {} : { hidden: true }),
};
})
.filter((column) => !column.hidden) as TableColumn[];
const handleRefresh = () => {
loadDataSource();
};
// 自定义分页配置
const paginationConfig = {
...tableParams.pagination,
showTotal: (total: number) => `${total} 条记录`,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
};
const handleSearch = useCallback(
(searchValue: string) => {
const currentTableParams = tableParamsRef.current;
updateParams({
search: {
pool_name: searchValue,
},
pagination: {
current: 1,
pageSize: currentTableParams.pagination?.pageSize || 10,
},
});
},
[updateParams],
);
// 防抖版本500ms延迟不立即执行
const debouncedSearch = useRef(debounce(handleSearch, 500)).current;
// 立即执行版本(用于清空时立即搜索)
const immediateSearch = useRef(debounce(handleSearch, 0, true)).current;
const handleSearchChange = (value: string) => {
setSearchText(value);
// 取消所有未执行的防抖请求
debouncedSearch.cancel();
immediateSearch.cancel();
// 清空时立即触发搜索
if (value === '') {
immediateSearch('');
return;
}
// 正常输入时使用防抖
debouncedSearch(value);
};
// 修改回车搜索处理
const handleEnterSearch = (value: string) => {
// 回车搜索时取消未执行的防抖
debouncedSearch.cancel();
immediateSearch.cancel();
// 直接执行搜索
handleSearch(value);
};
return (
<div className="image-list">
<div className="search-box">
<div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAdd}
>
</Button>
</div>
<div className="search-input">
<Input.Search
placeholder="名称"
value={searchText}
onChange={(e) => handleSearchChange(e.target.value)}
style={{ width: 300 }}
onSearch={handleEnterSearch}
/>
<Button
onClick={handleRefresh}
loading={loading}
icon={<RefreshIcon style={{ width: 13, height: 13 }} />}
></Button>
<Popover
content={columnSettingsContent}
title="列设置"
trigger="click"
open={columnSettingsVisible}
onOpenChange={setColumnSettingsVisible}
placement="bottomRight"
>
<Button icon={<SettingOutlined />}></Button>
</Popover>
</div>
</div>
<div className="images-list-container">
<div className="images-list-table">
<Table
columns={filteredColumns}
dataSource={dataSource}
rowKey="id"
loading={loading}
pagination={paginationConfig}
onChange={handleTableChange}
scroll={{
y: 'max-content', // 关键:允许内容决定高度
}}
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
}}
/>
</div>
</div>
<NetworkEditModal
visible={editModalVisible}
onCancel={handleEditModalCancel}
onOk={handleEditModalOk}
initialValues={selectedNetwork}
title={selectedNetwork ? '编辑网络' : '新增网络'}
/>
</div>
);
};
export default Index;

View File

@ -1,12 +1,16 @@
/* eslint-disable @typescript-eslint/no-use-before-define */
import {
DEFAULT_BLICK_TAB,
DEVICE_TYPE_MAP,
// DEVICE_TYPE_MAP,
ERROR_CODE,
} from '@/constants/constants';
import CustomTree from '@/pages/components/customTree';
import CreatGroup from '@/pages/userList/mod/group';
import { deleteDevice, getTerminalList } from '@/services/terminal';
import {
deleteDevice,
getTerminalList,
getTerminalPower,
} from '@/services/terminal';
import { deleteUserGroup, getGroupTree } from '@/services/userList';
import {
DeleteOutlined,
@ -207,7 +211,8 @@ const UserListPage: React.FC = () => {
},
},
{
title: '序列号',
// title: '序列号',
title: '终端标识',
dataIndex: 'device_id',
key: 'device_id',
width: 250,
@ -232,26 +237,27 @@ const UserListPage: React.FC = () => {
);
},
},
// {
// title: '类型',
// dataIndex: 'device_type',
// key: 'device_type',
// width: 150,
// align: 'center',
// ellipsis: true,
// render: (text: number) => {
// const key = text ? (text as keyof typeof DEVICE_TYPE_MAP) : '';
// return (
// <Tooltip title={key ? DEVICE_TYPE_MAP[key] : '--'}>
// {key ? DEVICE_TYPE_MAP[key] : '--'}
// </Tooltip>
// );
// },
// },
{
title: '类型',
dataIndex: 'device_type',
key: 'device_type',
width: 150,
align: 'center',
ellipsis: true,
render: (text: number) => {
const key = text as keyof typeof DEVICE_TYPE_MAP;
return (
<Tooltip title={DEVICE_TYPE_MAP[key] || '--'}>
{DEVICE_TYPE_MAP[key] || '--'}
</Tooltip>
);
},
},
{
title: '型号',
dataIndex: 'model',
key: 'model',
title: '状态',
dataIndex: 'status',
key: 'status',
width: 150,
align: 'center',
ellipsis: true,
@ -263,6 +269,36 @@ const UserListPage: React.FC = () => {
);
},
},
{
title: '桌面镜像',
dataIndex: 'images_name',
key: 'images_name',
width: 150,
align: 'center',
ellipsis: true,
render: (text) => {
return (
<div>
<Tooltip title={text || ''}>{text || '--'}</Tooltip>
</div>
);
},
},
// {
// title: '型号',
// dataIndex: 'model',
// key: 'model',
// width: 150,
// align: 'center',
// ellipsis: true,
// render: (text) => {
// return (
// <div>
// <Tooltip title={text || ''}>{text || '--'}</Tooltip>
// </div>
// );
// },
// },
{
title: 'IP地址',
dataIndex: 'ip_addr',
@ -293,54 +329,65 @@ const UserListPage: React.FC = () => {
);
},
},
{
title: '备注',
dataIndex: 'description',
key: 'description',
ellipsis: true,
align: 'center',
width: 200,
render: (text) => {
return (
<div>
<Tooltip title={text || ''}>{text || '--'}</Tooltip>
</div>
);
},
},
// {
// title: '备注',
// dataIndex: 'description',
// key: 'description',
// ellipsis: true,
// align: 'center',
// width: 200,
// render: (text) => {
// return (
// <div>
// <Tooltip title={text || ''}>{text || '--'}</Tooltip>
// </div>
// );
// },
// },
{
title: '操作',
key: 'actions',
align: 'center',
width: 230,
width: 170,
fixed: 'right',
render: (_, record) => (
<div style={{ display: 'flex', alignItems: 'center' }}>
<Button
type="link"
size="small"
onClick={() => {
setBindUserData({
recordData: record,
visible: true,
});
}}
>
<Button size="small" type="link" onClick={() => {onOrgChange('stop',record)}}>
</Button>
<Button
type="link"
size="small"
onClick={() => {
setBindImageDta({ recordData: record, visible: true });
}}
>
<Button size="small" type="link" onClick={() => {onOrgChange('start',record)}}>
</Button>
<Popover
placement="bottomRight"
content={
<div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Button
type="link"
// size="small"
onClick={() => {
setBindUserData({
recordData: record,
visible: true,
});
}}
>
</Button>
<Button
type="link"
// size="small"
onClick={() => {
setBindImageDta({ recordData: record, visible: true });
}}
>
</Button>
<Button type="link" onClick={() => handleEditInfo(record)}>
</Button>
<Popconfirm
title=""
description="删除操作不可逆,请确认是否删除?"
@ -351,11 +398,6 @@ const UserListPage: React.FC = () => {
>
<Button type="link"></Button>
</Popconfirm>
<div>
<Button type="link" onClick={() => handleEditInfo(record)}>
</Button>
</div>
</div>
}
>
@ -369,6 +411,27 @@ const UserListPage: React.FC = () => {
},
];
// 终端开机、关机
const onOrgChange = (value: string, record: any) => {
getTerminalPower({
id: record.id,
status: value,
}).then((res:any) => {
const { code } = res || {};
if (code === ERROR_CODE) {
const text = value === 'start'?"开机成功":"关机成功"
message.success(text);
getDataSource();
} else {
if (value === 'start') {
message.error('开机失败');
} else {
message.error('关机失败');
}
}
});
};
const onOrgSelect = (selectedKeys: React.Key[], e: any) => {
const { node } = e || {};
if (selectedKeys.length > 0) {
@ -388,9 +451,10 @@ const UserListPage: React.FC = () => {
setPageSize(size);
};
const onSelectChange = (newSelectedRowKeys: React.Key[]) => {
setSelectedRowKeys(newSelectedRowKeys as any);
};
// const onSelectChange = (newSelectedRowKeys: React.Key[]) => {
// setSelectedRowKeys(newSelectedRowKeys as any);
// };
const onDeleteGroup = async () => {
if (selectedOrg) {
const params: any = {

View File

@ -0,0 +1,96 @@
import React, { useEffect } from 'react';
import { useLocation } from 'umi';
import VncClient from './mod/index';
/**
* VNC
* VNC
*/
const VncRemotePage: React.FC = () => {
const location = useLocation();
// 从URL参数中获取镜像ID和其他信息
const searchParams = new URLSearchParams(location.search);
console.log("location=======",location);
console.log("location=======searchParams.get('vncUrl')",searchParams.get('vncUrl'))
// const imageId = searchParams.get('imageId') || '';
const imageName = searchParams.get('imageName') || 'NEXSPACE远程桌面';
const vncUrl = searchParams.get('vncUrl') || '';
const password = searchParams.get('password') || '';
// 设置页面标题
useEffect(() => {
document.title = `${imageName} - 远程控制台`;
// 添加关闭窗口时的清理逻辑
const handleBeforeUnload = () => {
// 确保在窗口关闭前通知VNC客户端进行清理
console.log('Performing cleanup before window unload');
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [imageName]);
// 验证并修复VNC URL格式
const validateAndFixVncUrl = (url: string) => {
if (!url) return '';
// 检查URL格式修复常见问题
if (url.startsWith('ws:http')) {
// 修复错误格式 ws:http// -> ws://
return url.replace('ws:http//', 'ws://');
} else if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
// 如果没有协议前缀添加ws://
return `ws://${url}`;
}
return url;
};
// 生成默认的VNC URL如果没有提供
// const defaultVncUrl = imageId ? `ws://0.0.0.0:5091/vnc/0.0.0.0:5901` : '';
const defaultVncUrl = `ws://0.0.0.0:5091/vnc/0.0.0.0:5901`;
const finalVncUrl = validateAndFixVncUrl(vncUrl) || defaultVncUrl;
console.log('最终使用的VNC URL:', finalVncUrl);
// 当收到父窗口的消息时处理
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data === 'close-vnc-window') {
window.close();
}
};
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, []);
return (
<div
style={{
width: '100vw',
height: '100vh',
margin: 0,
padding: 0,
overflow: 'hidden',
backgroundColor: '#000',
}}
>
<VncClient
onClose={() => {
// 确保在关闭前有适当的清理时间
setTimeout(() => window.close(), 100);
}}
vncUrl={finalVncUrl}
password={password}
viewOnly={false}
/>
</div>
);
};
export default VncRemotePage;

View File

@ -0,0 +1,325 @@
// VNC 远程桌面组件样式
.vnc-remote-desktop {
position: relative;
width: 100%;
height: 100%;
background-color: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
display: flex;
flex-direction: column;
overflow: hidden;
// 全屏模式
&.fullscreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
margin: 0;
border: none;
border-radius: 0;
}
// 连接状态样式
&.connecting {
border-color: #ff9800;
}
&.connected {
border-color: #4caf50;
}
&.disconnected {
border-color: #f44336;
}
}
// 控制栏样式
.vnc-controls {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background-color: #2a2a2a;
border-bottom: 1px solid #333;
color: #fff;
font-size: 14px;
z-index: 100;
}
// 状态指示器
.vnc-status-indicator {
padding: 4px 8px;
border-radius: 4px;
font-weight: 500;
&.connecting {
background-color: rgba(255, 152, 0, 0.2);
color: #ff9800;
}
&.connected {
background-color: rgba(76, 175, 80, 0.2);
color: #4caf50;
}
&.disconnected {
background-color: rgba(244, 67, 54, 0.2);
color: #f44336;
}
}
// 操作按钮组
.vnc-actions {
display: flex;
align-items: center;
gap: 8px;
:where(
.css-dev-only-do-not-override-1vjf2v5
).ant-btn-variant-outlined:disabled,
:where(.css-dev-only-do-not-override-1vjf2v5).ant-btn-variant-dashed:disabled,
:where(
.css-dev-only-do-not-override-1vjf2v5
).ant-btn-variant-outlined.ant-btn-disabled,
:where(
.css-dev-only-do-not-override-1vjf2v5
).ant-btn-variant-dashed.ant-btn-disabled {
background-color: #ffffff;
}
}
.popover-content {
display: flex;
flex-direction: column;
// gap: 8px;
}
// 仅查看模式指示器
.view-only-indicator {
color: #ffeb3b;
font-size: 12px;
background-color: rgba(255, 235, 59, 0.1);
padding: 2px 6px;
border-radius: 3px;
}
// 按钮样式
.vnc-button {
width: 32px;
height: 32px;
border: none;
border-radius: 4px;
background-color: #444;
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background-color: #555;
transform: translateY(-1px);
}
&:active:not(:disabled) {
transform: translateY(0);
}
&:disabled {
background-color: #333;
color: #666;
cursor: not-allowed;
}
&.disconnect {
background-color: #f44336;
&:hover:not(:disabled) {
background-color: #d32f2f;
}
}
}
// 内容区域
.vnc-content {
width: 100%;
height: calc(100% - 40px);
flex: 1;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background-color: #000;
overflow: hidden;
}
// 加载覆盖层
.vnc-loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.8);
color: #fff;
z-index: 10;
}
// 加载动画
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top: 4px solid #fff;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
// 错误覆盖层
.vnc-error-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: rgba(244, 67, 54, 0.1);
color: #fff;
z-index: 10;
padding: 20px;
text-align: center;
}
.error-icon {
font-size: 48px;
margin-bottom: 16px;
}
.error-message {
font-size: 16px;
margin-bottom: 20px;
color: #ffcdcd;
max-width: 80%;
word-wrap: break-word;
}
.reconnect-button {
padding: 10px 20px;
background-color: #4caf50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background-color 0.2s ease;
&:hover {
background-color: #45a049;
}
}
// VNC 画布
.vnc-canvas {
max-width: 100%;
max-height: 100%;
cursor: default;
&:focus {
outline: 2px solid #2196f3;
outline-offset: 2px;
}
}
// 未连接时的占位符
.vnc-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #666;
.placeholder-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.3;
}
}
// 响应式设计
@media (max-width: 768px) {
.vnc-controls {
padding: 6px 12px;
font-size: 12px;
z-index: 100;
}
.vnc-button {
width: 28px;
height: 28px;
font-size: 14px;
}
.loading-spinner {
width: 30px;
height: 30px;
}
.error-icon {
font-size: 36px;
}
.error-message {
font-size: 14px;
}
.placeholder-icon {
font-size: 48px;
}
}
// 深色主题增强
@media (prefers-color-scheme: dark) {
.vnc-remote-desktop {
background-color: #0d1117;
border-color: #30363d;
}
.vnc-controls {
background-color: #161b22;
border-bottom-color: #30363d;
z-index: 100;
}
.vnc-button {
background-color: #21262d;
}
.vnc-button:hover:not(:disabled) {
background-color: #30363d;
}
}

View File

@ -0,0 +1,400 @@
/* eslint-disable @typescript-eslint/no-use-before-define */
import type { MenuProps } from 'antd';
import { Button, Dropdown } from 'antd';
import React, { useEffect, useRef, useState } from 'react';
// import { RFB } from '@novnc/novnc/core/rfb';
import RFB from '@/public/novnc/core/rfb';
import './index.less';
interface VncRemoteDesktopProps {
vncUrl: string;
password?: string;
onConnected?: () => void;
onDisconnected?: () => void;
onError?: (error: string) => void;
loadingText?: string;
className?: string;
isFullscreen?: boolean;
viewOnly?: boolean;
autoScale?: boolean;
maxRetries?: number;
retryInterval?: number;
}
/**
* noVNC
* 使@novnc/novnc
*/
const VncRemoteDesktop: React.FC<VncRemoteDesktopProps> = ({
vncUrl,
password,
onConnected,
onDisconnected,
onError,
loadingText = '正在连接远程桌面...',
className = '',
viewOnly = false,
autoScale = true,
maxRetries = 3,
retryInterval = 2000,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const rfbRef = useRef<RFB | null>(null);
const retryCountRef = useRef(0);
const retryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [connectionStatus, setConnectionStatus] = useState<
'disconnected' | 'connecting' | 'connected'
>('disconnected');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [visible, setVisible] = useState(false);
// 监听浏览器窗口关闭事件
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
disconnect();
return '';
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, []);
// 清除重试定时器
const clearRetryTimeout = () => {
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null;
}
};
// 连接到VNC服务器
const connect = (resetRetry = false) => {
if (!vncUrl || !canvasRef.current) {
return;
}
// 清除之前的重试定时器
clearRetryTimeout();
// 重置重试计数(如果需要)
if (resetRetry) {
retryCountRef.current = 0;
}
// 断开已有连接
if (rfbRef.current) {
disconnect();
}
setConnectionStatus('connecting');
setErrorMessage(null);
try {
// 验证URL格式
if (!vncUrl.startsWith('ws://') && !vncUrl.startsWith('wss://')) {
throw new Error('无效的VNC URL格式请使用ws://或wss://开头');
}
console.log('WebSocket URL=========', vncUrl);
console.log('尝试连接到VNC服务器:', vncUrl);
// 创建RFB实例
const rfb = new RFB(canvasRef.current, vncUrl, {
credentials: password ? { password } : undefined,
shared: true,
wsProtocols: ['binary'],
// focusOnClick: !viewOnly,
dragViewport: true,
scaleViewport: true,
resizeSession: true,
// viewOnly: viewOnly,
// background: '#000000',
});
// 保存RFB实例引用用于后续操作和事件处理
rfbRef.current = rfb;
console.log('rfbRef.current=====保存RFB实例引用', rfbRef.current);
// 监听连接事件
rfb.addEventListener('connect', () => {
console.log('VNC连接成功');
retryCountRef.current = 0; // 重置重试计数
setConnectionStatus('connected');
onConnected?.();
});
// 监听断开连接事件
rfb.addEventListener('disconnect', () => {
console.log('VNC连接断开');
setConnectionStatus('disconnected');
onDisconnected?.();
console.log('rfbRef.current=====监听断开连接事件', rfbRef.current);
});
// 监听安全失败事件
rfb.addEventListener('securityfailure', (e: any) => {
console.error('VNC安全连接失败:', e.detail);
setErrorMessage('安全连接失败: ' + e.detail);
setConnectionStatus('disconnected');
onError?.('安全连接失败: ' + e.detail);
console.log('rfbRef.current=====监听安全失败事件', rfbRef.current);
rfbRef.current = null;
});
// 监听凭证请求事件
rfb.addEventListener('credentialsrequired', () => {
console.log('需要凭证');
if (password && rfbRef.current) {
rfbRef.current.sendCredentials({ password });
}
});
// 监听错误事件
rfb.addEventListener('error', (e: any) => {
const errorDetail = e.detail || {};
const errorMsg = errorDetail.message || errorDetail || '未知错误';
console.error('VNC错误:', errorMsg);
// 提供更具体的错误信息
let userFriendlyError = '';
if (errorMsg.toString().includes('WebSocket')) {
userFriendlyError = `WebSocket连接失败: ${errorMsg}\n可能的原因: 服务器不可达、网络问题或防火墙阻止`;
} else if (errorMsg.toString().includes('401')) {
userFriendlyError = '认证失败: 用户名或密码错误';
} else if (errorMsg.toString().includes('403')) {
userFriendlyError = '权限不足: 您没有访问该远程桌面的权限';
} else if (errorMsg.toString().includes('timeout')) {
userFriendlyError = '连接超时: 服务器响应超时,请检查网络连接';
} else {
userFriendlyError = `连接错误: ${errorMsg}`;
}
setErrorMessage(userFriendlyError);
setConnectionStatus('disconnected');
onError?.(userFriendlyError);
// 自动重试连接(如果未达到最大重试次数)
if (retryCountRef.current < maxRetries) {
retryCountRef.current++;
console.log(`尝试重新连接 (${retryCountRef.current}/${maxRetries})`);
retryTimeoutRef.current = setTimeout(() => {
connect();
}, retryInterval);
}
});
console.log('RFB连接配置完成');
} catch (error) {
console.error('创建VNC连接失败:', error);
const errorMsg = error instanceof Error ? error.message : '创建连接失败';
setErrorMessage(errorMsg);
setConnectionStatus('disconnected');
onError?.(errorMsg);
}
};
// 断开连接
const disconnect = () => {
// 清除重试定时器
clearRetryTimeout();
console.log('rfbRef.current=====断开连接', rfbRef);
console.log('rfbRef.current=====断开连接', rfbRef.current);
if (rfbRef.current) {
try {
// 确保完全断开连接并释放所有资源
rfbRef.current.disconnect();
} catch (error) {
console.error('Error during disconnect/cleanup:', error);
}
}
// 重置重试计数
retryCountRef.current = 0;
};
// 连接
const reconnect = () => {
setVisible(true);
connect(true); // 重置重试计数后连接
};
// 当vncUrl或password变化时重新连接
useEffect(() => {
if (vncUrl && visible) {
connect(true); // 重置重试计数后连接
}
// 组件卸载时断开连接并清理资源
return () => {
disconnect();
clearRetryTimeout();
};
}, [visible, vncUrl, password, maxRetries, retryInterval]);
// 当autoScale或viewOnly属性变化时更新RFB实例
useEffect(() => {
if (rfbRef.current) {
rfbRef.current.viewOnly = viewOnly;
rfbRef.current.focusOnClick = !viewOnly;
rfbRef.current.dragViewport = !viewOnly;
rfbRef.current.scaleViewport = autoScale;
rfbRef.current.resizeSession = autoScale;
}
}, [viewOnly, autoScale]);
const menuItems: MenuProps = {
items: [
{
key: '1',
label: <div></div>,
// label: (
// <Button
// onClick={() => {}}
// disabled={connectionStatus === 'connecting'}
// title="挂载优化工具"
// >
// 挂载优化工具
// </Button>
// ),
},
{
key: '2', // 注意 key 应该唯一
label: <div></div>,
// label: (
// <Button
// onClick={() => {}}
// disabled={connectionStatus === 'connecting'}
// title="挂载应用软件盘"
// >
// 挂载应用软件盘
// </Button>
// ),
},
{
key: '3',
label: <div></div>,
// label: (
// <Button
// onClick={() => {}}
// disabled={connectionStatus === 'connecting'}
// title="挂载应用软件盘"
// >
// 挂载应用软件盘
// </Button>
// ),
},
],
};
return (
<div className={`vnc-remote-desktop ${className} ${connectionStatus}`}>
{/* 控制栏 */}
<div className="vnc-controls">
<span className={`vnc-status-indicator ${connectionStatus}`}>
{connectionStatus === 'connecting' && '连接中...'}
{connectionStatus === 'connected' && '已连接'}
{connectionStatus === 'disconnected' && '已断开'}
</span>
<div className="vnc-actions">
{viewOnly && <span className="view-only-indicator"></span>}
{!(connectionStatus === 'connected') ? (
<Button
onClick={reconnect}
disabled={connectionStatus === 'connecting'}
title="重新连接"
>
</Button>
) : (
<>
<Button
onClick={() => {}}
disabled={!(connectionStatus === 'connected')}
title="关机"
>
</Button>
<Button
onClick={() => {}}
disabled={!(connectionStatus === 'connected')}
title="重启"
>
</Button>
<Dropdown
menu={menuItems}
disabled={!(connectionStatus === 'connected')}
>
<Button
onClick={() => {}}
disabled={!(connectionStatus === 'connected')}
title="安装模板工具"
>
</Button>
</Dropdown>
<Button
onClick={disconnect}
disabled={!(connectionStatus === 'connected')}
title="断开连接"
type="default"
>
</Button>
</>
)}
</div>
</div>
{/* 远程桌面内容 */}
<div className="vnc-content">
{/* 加载状态 */}
{connectionStatus === 'connecting' && (
<div className="vnc-loading-overlay">
<div className="loading-spinner"></div>
<div>{loadingText}</div>
</div>
)}
{/* 错误状态 */}
{errorMessage && connectionStatus === 'disconnected' && (
<div className="vnc-error-overlay">
<div className="error-icon"></div>
<div className="error-message">{errorMessage}</div>
<Button className="reconnect-button" onClick={reconnect}>
</Button>
</div>
)}
{/* VNC画布,使用canvas远程桌面控制页面无法展示不要使用 */}
<div
ref={canvasRef}
className="vnc-canvas"
tabIndex={viewOnly ? -1 : 0}
style={{
display: connectionStatus === 'connected' ? 'block' : 'none',
}}
/>
{/* 未连接时的占位符 */}
{connectionStatus === 'disconnected' && !errorMessage && (
<div className="vnc-placeholder">
<div className="placeholder-icon">🖥</div>
<div>...</div>
</div>
)}
</div>
</div>
);
};
export default VncRemoteDesktop;

View File

@ -0,0 +1,6 @@
# README
`noVnc` 从1.5版本及以上移除了core文件夹且EMS支持有问题
解决方案:
使用1.4版本或下载1.6版本源码使用

View File

@ -0,0 +1,79 @@
/*
* noVNC: HTML5 VNC client
* Copyright (C) 2019 The noVNC authors
* Licensed under MPL 2.0 (see LICENSE.txt)
*
* See README.md for usage and integration instructions.
*/
// Fallback for all uncought errors
function handleError(event, err) {
try {
const msg = document.getElementById('noVNC_fallback_errormsg');
// Work around Firefox bug:
// https://bugzilla.mozilla.org/show_bug.cgi?id=1685038
if (event.message === "ResizeObserver loop completed with undelivered notifications.") {
return false;
}
// Only show the initial error
if (msg.hasChildNodes()) {
return false;
}
let div = document.createElement("div");
div.classList.add('noVNC_message');
div.appendChild(document.createTextNode(event.message));
msg.appendChild(div);
if (event.filename) {
div = document.createElement("div");
div.className = 'noVNC_location';
let text = event.filename;
if (event.lineno !== undefined) {
text += ":" + event.lineno;
if (event.colno !== undefined) {
text += ":" + event.colno;
}
}
div.appendChild(document.createTextNode(text));
msg.appendChild(div);
}
if (err && err.stack) {
div = document.createElement("div");
div.className = 'noVNC_stack';
div.appendChild(document.createTextNode(err.stack));
msg.appendChild(div);
}
document.getElementById('noVNC_fallback_error')
.classList.add("noVNC_open");
} catch (exc) {
document.write("noVNC encountered an error.");
}
// Try to disable keyboard interaction, best effort
try {
// Remove focus from the currently focused element in order to
// prevent keyboard interaction from continuing
if (document.activeElement) { document.activeElement.blur(); }
// Don't let any element be focusable when showing the error
let keyboardFocusable = 'a[href], button, input, textarea, select, details, [tabindex]';
document.querySelectorAll(keyboardFocusable).forEach((elem) => {
elem.setAttribute("tabindex", "-1");
});
} catch (exc) {
// Do nothing
}
// Don't return true since this would prevent the error
// from being printed to the browser console.
return false;
}
window.addEventListener('error', evt => handleError(evt, evt.error));
window.addEventListener('unhandledrejection', evt => handleError(evt.reason, evt.reason));

View File

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="alt.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="16"
inkscape:cx="18.205425"
inkscape:cy="17.531398"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<g
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:48px;line-height:125%;font-family:'DejaVu Sans';-inkscape-font-specification:'Sans Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="text5290">
<path
d="m 9.9560547,1042.3329 -2.9394531,0 -0.4638672,1.3281 -1.8896485,0 2.7001953,-7.29 2.241211,0 2.7001958,7.29 -1.889649,0 -0.4589843,-1.3281 z m -2.4707031,-1.3526 1.9970703,0 -0.9960938,-2.9003 -1.0009765,2.9003 z"
style="font-size:10px;fill:#ffffff;fill-opacity:1"
id="path5340" />
<path
d="m 13.188477,1036.0634 1.748046,0 0,7.5976 -1.748046,0 0,-7.5976 z"
style="font-size:10px;fill:#ffffff;fill-opacity:1"
id="path5342" />
<path
d="m 18.535156,1036.6395 0,1.5528 1.801758,0 0,1.25 -1.801758,0 0,2.3193 q 0,0.3809 0.151367,0.5176 0.151368,0.1318 0.600586,0.1318 l 0.898438,0 0,1.25 -1.499024,0 q -1.035156,0 -1.469726,-0.4297 -0.429688,-0.4345 -0.429688,-1.4697 l 0,-2.3193 -0.86914,0 0,-1.25 0.86914,0 0,-1.5528 1.748047,0 z"
style="font-size:10px;fill:#ffffff;fill-opacity:1"
id="path5344" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -0,0 +1,106 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="clipboard.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="15.366606"
inkscape:cy="16.42981"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<path
style="opacity:1;fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="M 9,6 6,6 C 5.4459889,6 5,6.4459889 5,7 l 0,13 c 0,0.554011 0.4459889,1 1,1 l 13,0 c 0.554011,0 1,-0.445989 1,-1 L 20,7 C 20,6.4459889 19.554011,6 19,6 l -3,0"
transform="translate(0,1027.3622)"
id="rect6083"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cssssssssc" />
<rect
style="opacity:1;fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect6085"
width="7"
height="4"
x="9"
y="1031.3622"
ry="1.00002" />
<path
style="fill:none;fill-rule:evenodd;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.50196081"
d="m 8.5071212,1038.8622 7.9999998,0"
id="path6087"
inkscape:connector-curvature="0" />
<path
style="fill:none;fill-rule:evenodd;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.50196081"
d="m 8.5071212,1041.8622 3.9999998,0"
id="path6089"
inkscape:connector-curvature="0" />
<path
style="fill:none;fill-rule:evenodd;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.50196081"
d="m 8.5071212,1044.8622 5.9999998,0"
id="path6091"
inkscape:connector-curvature="0" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="connect.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="37.14834"
inkscape:cy="1.9525926"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<g
id="g5103"
transform="matrix(0.70710678,-0.70710678,0.70710678,0.70710678,-729.15757,315.8823)">
<path
sodipodi:nodetypes="cssssc"
inkscape:connector-curvature="0"
id="rect5096"
d="m 11,1040.3622 -5,0 c -1.108,0 -2,-0.892 -2,-2 l 0,-4 c 0,-1.108 0.892,-2 2,-2 l 5,0"
style="opacity:1;fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
<path
style="opacity:1;fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="m 14,1032.3622 5,0 c 1.108,0 2,0.892 2,2 l 0,4 c 0,1.108 -0.892,2 -2,2 l -5,0"
id="path5099"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cssssc" />
<path
inkscape:connector-curvature="0"
id="path5101"
d="m 9,1036.3622 7,0"
style="fill:none;fill-rule:evenodd;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="ctrl.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="16"
inkscape:cx="18.205425"
inkscape:cy="17.531398"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<g
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:48px;line-height:125%;font-family:'DejaVu Sans';-inkscape-font-specification:'Sans Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="text5290">
<path
d="m 9.1210938,1043.1898 q -0.5175782,0.2686 -1.0791016,0.4053 -0.5615235,0.1367 -1.171875,0.1367 -1.8212891,0 -2.8857422,-1.0156 -1.0644531,-1.0205 -1.0644531,-2.7637 0,-1.748 1.0644531,-2.7637 1.0644531,-1.0205 2.8857422,-1.0205 0.6103515,0 1.171875,0.1368 0.5615234,0.1367 1.0791016,0.4052 l 0,1.5088 q -0.522461,-0.3564 -1.0302735,-0.5224 -0.5078125,-0.1661 -1.0693359,-0.1661 -1.0058594,0 -1.5820313,0.6446 -0.5761719,0.6445 -0.5761719,1.7773 0,1.1279 0.5761719,1.7725 0.5761719,0.6445 1.5820313,0.6445 0.5615234,0 1.0693359,-0.166 0.5078125,-0.166 1.0302735,-0.5225 l 0,1.5088 z"
style="font-size:10px;fill:#ffffff;fill-opacity:1"
id="path5370" />
<path
d="m 12.514648,1036.5687 0,1.5528 1.801758,0 0,1.25 -1.801758,0 0,2.3193 q 0,0.3809 0.151368,0.5176 0.151367,0.1318 0.600586,0.1318 l 0.898437,0 0,1.25 -1.499023,0 q -1.035157,0 -1.469727,-0.4297 -0.429687,-0.4345 -0.429687,-1.4697 l 0,-2.3193 -0.8691411,0 0,-1.25 0.8691411,0 0,-1.5528 1.748046,0 z"
style="font-size:10px;fill:#ffffff;fill-opacity:1"
id="path5372" />
<path
d="m 19.453125,1039.6107 q -0.229492,-0.1074 -0.458984,-0.1562 -0.22461,-0.054 -0.454102,-0.054 -0.673828,0 -1.040039,0.4345 -0.361328,0.4297 -0.361328,1.2354 l 0,2.5195 -1.748047,0 0,-5.4687 1.748047,0 0,0.8984 q 0.336914,-0.5371 0.771484,-0.7813 0.439453,-0.249 1.049805,-0.249 0.08789,0 0.19043,0.01 0.102539,0 0.297851,0.029 l 0.0049,1.582 z"
style="font-size:10px;fill:#ffffff;fill-opacity:1"
id="path5374" />
<path
d="m 20.332031,1035.9926 1.748047,0 0,7.5976 -1.748047,0 0,-7.5976 z"
style="font-size:10px;fill:#ffffff;fill-opacity:1"
id="path5376" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="ctrlaltdel.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="8"
inkscape:cx="11.135667"
inkscape:cy="16.407428"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<rect
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect5253"
width="5"
height="5.0000172"
x="16"
y="1031.3622"
ry="1.0000174" />
<rect
y="1043.3622"
x="4"
height="5.0000172"
width="5"
id="rect5255"
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
ry="1.0000174" />
<rect
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect5257"
width="5"
height="5.0000172"
x="13"
y="1043.3622"
ry="1.0000174" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,94 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="disconnect.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="16"
inkscape:cx="25.05707"
inkscape:cy="11.594858"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="false">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<g
id="g5171"
transform="translate(-24.062499,-6.15775e-4)">
<path
id="path5110"
transform="translate(0,1027.3622)"
d="m 39.744141,3.4960938 c -0.769923,0 -1.539607,0.2915468 -2.121094,0.8730468 l -2.566406,2.5664063 1.414062,1.4140625 2.566406,-2.5664063 c 0.403974,-0.404 1.010089,-0.404 1.414063,0 l 2.828125,2.828125 c 0.40398,0.4039 0.403907,1.0101621 0,1.4140629 l -2.566406,2.566406 1.414062,1.414062 2.566406,-2.566406 c 1.163041,-1.1629 1.162968,-3.0791874 0,-4.2421874 L 41.865234,4.3691406 C 41.283747,3.7876406 40.514063,3.4960937 39.744141,3.4960938 Z M 39.017578,9.015625 a 1.0001,1.0001 0 0 0 -0.6875,0.3027344 l -0.445312,0.4453125 1.414062,1.4140621 0.445313,-0.445312 A 1.0001,1.0001 0 0 0 39.017578,9.015625 Z m -6.363281,0.7070312 a 1.0001,1.0001 0 0 0 -0.6875,0.3027348 L 28.431641,13.5625 c -1.163042,1.163 -1.16297,3.079187 0,4.242188 l 2.828125,2.828124 c 1.162974,1.163101 3.079213,1.163101 4.242187,0 l 3.535156,-3.535156 a 1.0001,1.0001 0 1 0 -1.414062,-1.414062 l -3.535156,3.535156 c -0.403974,0.404 -1.010089,0.404 -1.414063,0 l -2.828125,-2.828125 c -0.403981,-0.404 -0.403908,-1.010162 0,-1.414063 l 3.535156,-3.537109 A 1.0001,1.0001 0 0 0 32.654297,9.7226562 Z m 3.109375,2.1621098 -2.382813,2.384765 a 1.0001,1.0001 0 1 0 1.414063,1.414063 l 2.382812,-2.384766 -1.414062,-1.414062 z"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
inkscape:connector-curvature="0" />
<rect
transform="matrix(0.70710678,-0.70710678,0.70710678,0.70710678,0,0)"
y="752.29541"
x="-712.31262"
height="18.000017"
width="3"
id="rect5116"
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="drag.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="22.627417"
inkscape:cx="9.8789407"
inkscape:cy="9.5008608"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="true"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="false"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<path
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="m 7.039733,1049.3037 c -0.4309106,-0.1233 -0.7932634,-0.4631 -0.9705434,-0.9103 -0.04922,-0.1241 -0.057118,-0.2988 -0.071321,-1.5771 l -0.015972,-1.4375 -0.328125,-0.082 c -0.7668138,-0.1927 -1.1897046,-0.4275 -1.7031253,-0.9457 -0.4586773,-0.4629 -0.6804297,-0.8433 -0.867034,-1.4875 -0.067215,-0.232 -0.068001,-0.2642 -0.078682,-3.2188 -0.012078,-3.341 -0.020337,-3.2012 0.2099452,-3.5555 0.2246623,-0.3458 0.5798271,-0.5892 0.9667343,-0.6626 0.092506,-0.017 0.531898,-0.032 0.9764271,-0.032 l 0.8082347,0 1.157e-4,1.336 c 1.125e-4,1.2779 0.00281,1.3403 0.062214,1.4378 0.091785,0.1505 0.2357707,0.226 0.4314082,0.2261 0.285389,2e-4 0.454884,-0.1352 0.5058962,-0.4042 0.019355,-0.102 0.031616,-0.982 0.031616,-2.269 0,-1.9756 0.00357,-2.1138 0.059205,-2.2926 0.1645475,-0.5287 0.6307616,-0.9246 1.19078,-1.0113 0.8000572,-0.1238 1.5711277,0.4446 1.6860387,1.2429 0.01732,0.1203 0.03177,0.8248 0.03211,1.5657 6.19e-4,1.3449 7.22e-4,1.347 0.07093,1.4499 0.108355,0.1587 0.255268,0.2248 0.46917,0.2108 0.204069,-0.013 0.316116,-0.08 0.413642,-0.2453 0.06028,-0.1024 0.06307,-0.1778 0.07862,-2.1218 0.01462,-1.8283 0.02124,-2.0285 0.07121,-2.1549 0.260673,-0.659 0.934894,-1.0527 1.621129,-0.9465 0.640523,0.099 1.152269,0.6104 1.243187,1.2421 0.01827,0.1269 0.03175,0.9943 0.03211,2.0657 l 6.19e-4,1.8469 0.07031,0.103 c 0.108355,0.1587 0.255267,0.2248 0.46917,0.2108 0.204069,-0.013 0.316115,-0.08 0.413642,-0.2453 0.05951,-0.1011 0.06329,-0.1786 0.07907,-1.6218 0.01469,-1.3438 0.02277,-1.5314 0.07121,-1.6549 0.257975,-0.6576 0.934425,-1.0527 1.620676,-0.9465 0.640522,0.099 1.152269,0.6104 1.243186,1.2421 0.0186,0.1292 0.03179,1.0759 0.03222,2.3125 7.15e-4,2.0335 0.0025,2.0966 0.06283,2.1956 0.09178,0.1505 0.235771,0.226 0.431409,0.2261 0.285388,2e-4 0.454884,-0.1352 0.505897,-0.4042 0.01874,-0.099 0.03161,-0.8192 0.03161,-1.769 0,-1.4848 0.0043,-1.6163 0.0592,-1.7926 0.164548,-0.5287 0.630762,-0.9246 1.19078,-1.0113 0.800057,-0.1238 1.571128,0.4446 1.686039,1.2429 0.04318,0.2999 0.04372,9.1764 5.78e-4,9.4531 -0.04431,0.2841 -0.217814,0.6241 -0.420069,0.8232 -0.320102,0.315 -0.63307,0.4268 -1.194973,0.4268 l -0.35281,0 -2.51e-4,1.2734 c -1.25e-4,0.7046 -0.01439,1.3642 -0.03191,1.4766 -0.06665,0.4274 -0.372966,0.8704 -0.740031,1.0702 -0.349999,0.1905 0.01748,0.18 -6.242199,0.1776 -5.3622439,0 -5.7320152,-0.01 -5.9121592,-0.057 l 1.4e-5,0 z"
id="path4379"
inkscape:connector-curvature="0" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="error.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="14.00357"
inkscape:cy="12.443398"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<path
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="M 7 3 C 4.7839905 3 3 4.7839905 3 7 L 3 18 C 3 20.21601 4.7839905 22 7 22 L 18 22 C 20.21601 22 22 20.21601 22 18 L 22 7 C 22 4.7839905 20.21601 3 18 3 L 7 3 z M 7.6992188 6 A 1.6916875 1.6924297 0 0 1 8.9121094 6.5117188 L 12.5 10.101562 L 16.087891 6.5117188 A 1.6916875 1.6924297 0 0 1 17.251953 6 A 1.6916875 1.6924297 0 0 1 18.480469 8.90625 L 14.892578 12.496094 L 18.480469 16.085938 A 1.6916875 1.6924297 0 1 1 16.087891 18.478516 L 12.5 14.888672 L 8.9121094 18.478516 A 1.6916875 1.6924297 0 1 1 6.5214844 16.085938 L 10.109375 12.496094 L 6.5214844 8.90625 A 1.6916875 1.6924297 0 0 1 7.6992188 6 z "
transform="translate(0,1027.3622)"
id="rect4135" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="esc.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="16"
inkscape:cx="18.205425"
inkscape:cy="17.531398"
inkscape:document-units="px"
inkscape:current-layer="text5290"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<g
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:48px;line-height:125%;font-family:'DejaVu Sans';-inkscape-font-specification:'Sans Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="text5290">
<path
d="m 3.9331055,1036.1464 5.0732422,0 0,1.4209 -3.1933594,0 0,1.3574 3.0029297,0 0,1.4209 -3.0029297,0 0,1.6699 3.3007812,0 0,1.4209 -5.180664,0 0,-7.29 z"
style="font-size:10px;fill:#ffffff;fill-opacity:1"
id="path5314" />
<path
d="m 14.963379,1038.1385 0,1.3282 q -0.561524,-0.2344 -1.083984,-0.3516 -0.522461,-0.1172 -0.986329,-0.1172 -0.498046,0 -0.742187,0.127 -0.239258,0.122 -0.239258,0.3808 0,0.21 0.180664,0.3223 0.185547,0.1123 0.65918,0.166 l 0.307617,0.044 q 1.342773,0.1709 1.806641,0.5615 0.463867,0.3906 0.463867,1.2256 0,0.874 -0.644531,1.3134 -0.644532,0.4395 -1.923829,0.4395 -0.541992,0 -1.123046,-0.088 -0.576172,-0.083 -1.186524,-0.2539 l 0,-1.3281 q 0.522461,0.2539 1.069336,0.3808 0.551758,0.127 1.118164,0.127 0.512695,0 0.771485,-0.1416 0.258789,-0.1416 0.258789,-0.4199 0,-0.2344 -0.180664,-0.3467 -0.175782,-0.1172 -0.708008,-0.1807 l -0.307617,-0.039 q -1.166993,-0.1465 -1.635743,-0.542 -0.46875,-0.3955 -0.46875,-1.2012 0,-0.8691 0.595703,-1.2891 0.595704,-0.4199 1.826172,-0.4199 0.483399,0 1.015625,0.073 0.532227,0.073 1.157227,0.2294 z"
style="font-size:10px;fill:#ffffff;fill-opacity:1"
id="path5316" />
<path
d="m 21.066895,1038.1385 0,1.4258 q -0.356446,-0.2441 -0.717774,-0.3613 -0.356445,-0.1172 -0.742187,-0.1172 -0.732422,0 -1.142579,0.4297 -0.405273,0.4248 -0.405273,1.1914 0,0.7666 0.405273,1.1963 0.410157,0.4248 1.142579,0.4248 0.410156,0 0.776367,-0.1221 0.371094,-0.122 0.683594,-0.3613 l 0,1.4307 q -0.410157,0.1513 -0.834961,0.2246 -0.419922,0.078 -0.844727,0.078 -1.479492,0 -2.314453,-0.7568 -0.834961,-0.7618 -0.834961,-2.1143 0,-1.3525 0.834961,-2.1094 0.834961,-0.7617 2.314453,-0.7617 0.429688,0 0.844727,0.078 0.419921,0.073 0.834961,0.2246 z"
style="font-size:10px;fill:#ffffff;fill-opacity:1"
id="path5318" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="9"
height="10"
viewBox="0 0 9 10"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="expander.svg">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="45.254834"
inkscape:cx="9.8737281"
inkscape:cy="6.4583132"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="true"
units="px"
inkscape:snap-object-midpoints="false"
inkscape:object-nodes="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1042.3622)">
<path
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:4;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="M 2.0800781,1042.3633 A 2.0002,2.0002 0 0 0 0,1044.3613 l 0,6 a 2.0002,2.0002 0 0 0 3.0292969,1.7168 l 5,-3 a 2.0002,2.0002 0 0 0 0,-3.4316 l -5,-3 a 2.0002,2.0002 0 0 0 -0.9492188,-0.2832 z"
id="path4138"
inkscape:connector-curvature="0" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="fullscreen.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="16.400723"
inkscape:cy="15.083758"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="false"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="false">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<rect
style="opacity:1;fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect5006"
width="17"
height="17.000017"
x="4"
y="1031.3622"
ry="3.0000174" />
<path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#ffffff;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="m 7.5,1044.8622 4,0 -1.5,-1.5 1.5,-1.5 -1,-1 -1.5,1.5 -1.5,-1.5 0,4 z"
id="path5017"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path5025"
d="m 17.5,1034.8622 -4,0 1.5,1.5 -1.5,1.5 1,1 1.5,-1.5 1.5,1.5 0,-4 z"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#ffffff;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="5"
height="6"
viewBox="0 0 5 6"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="handle.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="32"
inkscape:cx="1.3551778"
inkscape:cy="8.7800329"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="true"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="false"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1046.3622)">
<path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 4.0000803,1049.3622 -3,-2 0,4 z"
id="path4247"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,172 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="15"
height="50"
viewBox="0 0 15 50"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="handle_bg.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="16"
inkscape:cx="-10.001409"
inkscape:cy="24.512566"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="true"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="false"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1002.3622)">
<rect
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4249"
width="1"
height="1.0000174"
x="9.5"
y="1008.8622"
ry="1.7382812e-05" />
<rect
ry="1.7382812e-05"
y="1013.8622"
x="9.5"
height="1.0000174"
width="1"
id="rect4255"
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
<rect
ry="1.7382812e-05"
y="1008.8622"
x="4.5"
height="1.0000174"
width="1"
id="rect4261"
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
<rect
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4263"
width="1"
height="1.0000174"
x="4.5"
y="1013.8622"
ry="1.7382812e-05" />
<rect
ry="1.7382812e-05"
y="1039.8622"
x="9.5"
height="1.0000174"
width="1"
id="rect4265"
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
<rect
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4267"
width="1"
height="1.0000174"
x="9.5"
y="1044.8622"
ry="1.7382812e-05" />
<rect
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4269"
width="1"
height="1.0000174"
x="4.5"
y="1039.8622"
ry="1.7382812e-05" />
<rect
ry="1.7382812e-05"
y="1044.8622"
x="4.5"
height="1.0000174"
width="1"
id="rect4271"
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
<rect
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4273"
width="1"
height="1.0000174"
x="9.5"
y="1018.8622"
ry="1.7382812e-05" />
<rect
ry="1.7382812e-05"
y="1018.8622"
x="4.5"
height="1.0000174"
width="1"
id="rect4275"
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
<rect
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4277"
width="1"
height="1.0000174"
x="9.5"
y="1034.8622"
ry="1.7382812e-05" />
<rect
ry="1.7382812e-05"
y="1034.8622"
x="4.5"
height="1.0000174"
width="1"
id="rect4279"
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@ -0,0 +1,42 @@
BROWSER_SIZES := 16 24 32 48 64
#ANDROID_SIZES := 72 96 144 192
# FIXME: The ICO is limited to 8 icons due to a Chrome bug:
# https://bugs.chromium.org/p/chromium/issues/detail?id=1381393
ANDROID_SIZES := 96 144 192
WEB_ICON_SIZES := $(BROWSER_SIZES) $(ANDROID_SIZES)
#IOS_1X_SIZES := 20 29 40 76 # No such devices exist anymore
IOS_2X_SIZES := 40 58 80 120 152 167
IOS_3X_SIZES := 60 87 120 180
ALL_IOS_SIZES := $(IOS_1X_SIZES) $(IOS_2X_SIZES) $(IOS_3X_SIZES)
ALL_ICONS := \
$(ALL_IOS_SIZES:%=novnc-ios-%.png) \
novnc.ico
all: $(ALL_ICONS)
# Our testing shows that the ICO file need to be sorted in largest to
# smallest to get the apporpriate behviour
WEB_ICON_SIZES_REVERSE := $(shell echo $(WEB_ICON_SIZES) | tr ' ' '\n' | sort -nr | tr '\n' ' ')
WEB_BASE_ICONS := $(WEB_ICON_SIZES_REVERSE:%=novnc-%.png)
.INTERMEDIATE: $(WEB_BASE_ICONS)
novnc.ico: $(WEB_BASE_ICONS)
convert $(WEB_BASE_ICONS) "$@"
# General conversion
novnc-%.png: novnc-icon.svg
convert -depth 8 -background transparent \
-size $*x$* "$(lastword $^)" "$@"
# iOS icons use their own SVG
novnc-ios-%.png: novnc-ios-icon.svg
convert -depth 8 -background transparent \
-size $*x$* "$(lastword $^)" "$@"
# The smallest sizes are generated using a different SVG
novnc-16.png novnc-24.png novnc-32.png: novnc-icon-sm.svg
clean:
rm -f *.png

View File

@ -0,0 +1,163 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="16"
height="16"
viewBox="0 0 16 16"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="novnc-icon-sm.svg">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="45.254834"
inkscape:cx="9.722703"
inkscape:cy="5.5311896"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:object-nodes="true"
inkscape:snap-smooth-nodes="true"
inkscape:snap-midpoints="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1">
<inkscape:grid
type="xygrid"
id="grid4169" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1036.3621)">
<rect
style="opacity:1;fill:#494949;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4167"
width="16"
height="15.999992"
x="0"
y="1036.3622"
ry="2.6666584" />
<path
style="opacity:1;fill:#313131;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="M 2.6666667,1036.3621 C 1.1893373,1036.3621 0,1037.5515 0,1039.0288 l 0,10.6666 c 0,1.4774 1.1893373,2.6667 2.6666667,2.6667 l 4,0 C 11.837333,1052.3621 16,1046.7128 16,1039.6955 l 0,-0.6667 c 0,-1.4773 -1.189337,-2.6667 -2.666667,-2.6667 l -10.6666663,0 z"
id="rect4173"
inkscape:connector-curvature="0" />
<g
id="g4381">
<g
transform="translate(0.25,0.25)"
style="fill:#000000;fill-opacity:1"
id="g4365">
<g
style="fill:#000000;fill-opacity:1"
id="g4367">
<path
inkscape:connector-curvature="0"
id="path4369"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 4.3289754,1039.3621 c 0.1846149,0 0.3419956,0.071 0.4716623,0.2121 C 4.933546,1039.7121 5,1039.8793 5,1040.0759 l 0,3.2862 -1,0 0,-2.964 c 0,-0.024 -0.011592,-0.036 -0.034038,-0.036 l -1.931924,0 C 2.011349,1040.3621 2,1040.3741 2,1040.3981 l 0,2.964 -1,0 0,-4 z"
sodipodi:nodetypes="scsccsssscccs" />
<path
inkscape:connector-curvature="0"
id="path4371"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 6.6710244,1039.3621 2.6579513,0 c 0.184775,0 0.3419957,0.071 0.471662,0.2121 C 9.933546,1039.7121 10,1039.8793 10,1040.0759 l 0,2.5724 c 0,0.1966 -0.066454,0.3655 -0.1993623,0.5069 -0.1296663,0.1379 -0.286887,0.2069 -0.471662,0.2069 l -2.6579513,0 c -0.184775,0 -0.3436164,-0.069 -0.4765247,-0.2069 C 6.0648334,1043.0138 6,1042.8449 6,1042.6483 l 0,-2.5724 c 0,-0.1966 0.064833,-0.3638 0.1944997,-0.5017 0.1329083,-0.1414 0.2917497,-0.2121 0.4765247,-0.2121 z m 2.2949386,1 -1.931926,0 C 7.011344,1040.3621 7,1040.3741 7,1040.3981 l 0,1.928 c 0,0.024 0.011347,0.036 0.034037,0.036 l 1.931926,0 c 0.02269,0 0.034037,-0.012 0.034037,-0.036 l 0,-1.928 c 0,-0.024 -0.011347,-0.036 -0.034037,-0.036 z"
sodipodi:nodetypes="sscsscsscsscssssssssss" />
</g>
<g
style="fill:#000000;fill-opacity:1"
id="g4373">
<path
inkscape:connector-curvature="0"
id="path4375"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 3,1047.1121 1,-2.75 1,0 -1.5,4 -1,0 -1.5,-4 1,0 z"
sodipodi:nodetypes="cccccccc" />
<path
inkscape:connector-curvature="0"
id="path4377"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 9,1046.8621 0,-2.5 1,0 0,4 -1,0 -2,-2.5 0,2.5 -1,0 0,-4 1,0 z"
sodipodi:nodetypes="ccccccccccc" />
<path
inkscape:connector-curvature="0"
id="path4379"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 15,1045.3621 -2.96596,0 c -0.02269,0 -0.03404,0.012 -0.03404,0.036 l 0,1.928 c 0,0.024 0.01135,0.036 0.03404,0.036 l 2.96596,0 0,1 -3.324113,0 c -0.188017,0 -0.348479,-0.068 -0.481388,-0.2037 C 11.064833,1048.0192 11,1047.8511 11,1047.6542 l 0,-2.5842 c 0,-0.1969 0.06483,-0.3633 0.194499,-0.4991 0.132909,-0.1392 0.293371,-0.2088 0.481388,-0.2088 l 3.324113,0 z"
sodipodi:nodetypes="cssssccscsscscc" />
</g>
</g>
<g
id="g4356">
<g
id="g4347">
<path
sodipodi:nodetypes="scsccsssscccs"
d="m 4.3289754,1039.3621 c 0.1846149,0 0.3419956,0.071 0.4716623,0.2121 C 4.933546,1039.7121 5,1039.8793 5,1040.0759 l 0,3.2862 -1,0 0,-2.964 c 0,-0.024 -0.011592,-0.036 -0.034038,-0.036 l -1.931924,0 c -0.022689,0 -0.034038,0.012 -0.034038,0.036 l 0,2.964 -1,0 0,-4 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#008000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4143"
inkscape:connector-curvature="0" />
<path
sodipodi:nodetypes="sscsscsscsscssssssssss"
d="m 6.6710244,1039.3621 2.6579513,0 c 0.184775,0 0.3419957,0.071 0.471662,0.2121 C 9.933546,1039.7121 10,1039.8793 10,1040.0759 l 0,2.5724 c 0,0.1966 -0.066454,0.3655 -0.1993623,0.5069 -0.1296663,0.1379 -0.286887,0.2069 -0.471662,0.2069 l -2.6579513,0 c -0.184775,0 -0.3436164,-0.069 -0.4765247,-0.2069 C 6.0648334,1043.0138 6,1042.8449 6,1042.6483 l 0,-2.5724 c 0,-0.1966 0.064833,-0.3638 0.1944997,-0.5017 0.1329083,-0.1414 0.2917497,-0.2121 0.4765247,-0.2121 z m 2.2949386,1 -1.931926,0 C 7.011344,1040.3621 7,1040.3741 7,1040.3981 l 0,1.928 c 0,0.024 0.011347,0.036 0.034037,0.036 l 1.931926,0 c 0.02269,0 0.034037,-0.012 0.034037,-0.036 l 0,-1.928 c 0,-0.024 -0.011347,-0.036 -0.034037,-0.036 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#008000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4145"
inkscape:connector-curvature="0" />
</g>
<g
id="g4351">
<path
sodipodi:nodetypes="cccccccc"
d="m 3,1047.1121 1,-2.75 1,0 -1.5,4 -1,0 -1.5,-4 1,0 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4147"
inkscape:connector-curvature="0" />
<path
sodipodi:nodetypes="ccccccccccc"
d="m 9,1046.8621 0,-2.5 1,0 0,4 -1,0 -2,-2.5 0,2.5 -1,0 0,-4 1,0 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4149"
inkscape:connector-curvature="0" />
<path
sodipodi:nodetypes="cssssccscsscscc"
d="m 15,1045.3621 -2.96596,0 c -0.02269,0 -0.03404,0.012 -0.03404,0.036 l 0,1.928 c 0,0.024 0.01135,0.036 0.03404,0.036 l 2.96596,0 0,1 -3.324113,0 c -0.188017,0 -0.348479,-0.068 -0.481388,-0.2037 C 11.064833,1048.0192 11,1047.8511 11,1047.6542 l 0,-2.5842 c 0,-0.1969 0.06483,-0.3633 0.194499,-0.4991 0.132909,-0.1392 0.293371,-0.2088 0.481388,-0.2088 l 3.324113,0 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4151"
inkscape:connector-curvature="0" />
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -0,0 +1,163 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="48"
height="48"
viewBox="0 0 48 48.000001"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="novnc-icon.svg">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="11.313708"
inkscape:cx="27.187245"
inkscape:cy="17.700974"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:object-nodes="true"
inkscape:snap-smooth-nodes="true"
inkscape:snap-midpoints="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1">
<inkscape:grid
type="xygrid"
id="grid4169" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1004.3621)">
<rect
style="opacity:1;fill:#494949;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4167"
width="48"
height="48"
x="0"
y="1004.3621"
ry="7.9999785" />
<path
style="opacity:1;fill:#313131;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="m 8,1004.3621 c -4.4319881,0 -8,3.568 -8,8 l 0,32 c 0,4.432 3.5680119,8 8,8 l 12,0 c 15.512,0 28,-16.948 28,-38 l 0,-2 c 0,-4.432 -3.568012,-8 -8,-8 l -32,0 z"
id="rect4173"
inkscape:connector-curvature="0" />
<g
id="g4300"
style="fill:#000000;fill-opacity:1;stroke:none"
transform="translate(0.5,0.5)">
<g
id="g4302"
style="fill:#000000;fill-opacity:1;stroke:none">
<path
sodipodi:nodetypes="scsccsssscccs"
d="m 11.986926,1016.3621 c 0.554325,0 1.025987,0.2121 1.414987,0.6362 0.398725,0.4138 0.600909,0.9155 0.598087,1.5052 l 0,6.8586 -2,0 0,-6.8914 c 0,-0.072 -0.03404,-0.1086 -0.102113,-0.1086 l -4.7957745,0 C 7.0340375,1018.3621 7,1018.3983 7,1018.4707 l 0,6.8914 -2,0 0,-9 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4304"
inkscape:connector-curvature="0" />
<path
sodipodi:nodetypes="sscsscsscsscssssssssss"
d="m 17.013073,1016.3621 4.973854,0 c 0.554325,0 1.025987,0.2121 1.414986,0.6362 0.398725,0.4138 0.598087,0.9155 0.598087,1.5052 l 0,4.7172 c 0,0.5897 -0.199362,1.0966 -0.598087,1.5207 -0.388999,0.4138 -0.860661,0.6207 -1.414986,0.6207 l -4.973854,0 c -0.554325,0 -1.030849,-0.2069 -1.429574,-0.6207 C 15.1945,1024.3173 15,1023.8104 15,1023.2207 l 0,-4.7172 c 0,-0.5897 0.1945,-1.0914 0.583499,-1.5052 0.398725,-0.4241 0.875249,-0.6362 1.429574,-0.6362 z m 4.884815,2 -4.795776,0 c -0.06808,0 -0.102112,0.036 -0.102112,0.1086 l 0,4.7828 c 0,0.072 0.03404,0.1086 0.102112,0.1086 l 4.795776,0 c 0.06807,0 0.102112,-0.036 0.102112,-0.1086 l 0,-4.7828 c 0,-0.072 -0.03404,-0.1086 -0.102112,-0.1086 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4306"
inkscape:connector-curvature="0" />
</g>
<g
id="g4308"
style="fill:#000000;fill-opacity:1;stroke:none">
<path
sodipodi:nodetypes="cccccccc"
d="m 12,1036.9177 4.768114,-8.5556 2.231886,0 -6,11 -2,0 -6,-11 2.2318854,0 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4310"
inkscape:connector-curvature="0" />
<path
sodipodi:nodetypes="ccccccccccc"
d="m 29,1036.3621 0,-8 2,0 0,11 -2,0 -7,-8 0,8 -2,0 0,-11 2,0 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4312"
inkscape:connector-curvature="0" />
<path
sodipodi:nodetypes="cssssccscsscscc"
d="m 43,1030.3621 -8.897887,0 c -0.06808,0 -0.102113,0.036 -0.102113,0.1069 l 0,6.7862 c 0,0.071 0.03404,0.1069 0.102113,0.1069 l 8.897887,0 0,2 -8.972339,0 c -0.56405,0 -1.045437,-0.2037 -1.444162,-0.6111 C 32.1945,1038.3334 32,1037.8292 32,1037.2385 l 0,-6.7528 c 0,-0.5907 0.1945,-1.0898 0.583499,-1.4972 0.398725,-0.4176 0.880112,-0.6264 1.444162,-0.6264 l 8.972339,0 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4314"
inkscape:connector-curvature="0" />
</g>
</g>
<g
id="g4291"
style="stroke:none">
<g
id="g4282"
style="stroke:none">
<path
inkscape:connector-curvature="0"
id="path4143"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#008000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 11.986926,1016.3621 c 0.554325,0 1.025987,0.2121 1.414987,0.6362 0.398725,0.4138 0.600909,0.9155 0.598087,1.5052 l 0,6.8586 -2,0 0,-6.8914 c 0,-0.072 -0.03404,-0.1086 -0.102113,-0.1086 l -4.7957745,0 C 7.0340375,1018.3621 7,1018.3983 7,1018.4707 l 0,6.8914 -2,0 0,-9 z"
sodipodi:nodetypes="scsccsssscccs" />
<path
inkscape:connector-curvature="0"
id="path4145"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#008000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 17.013073,1016.3621 4.973854,0 c 0.554325,0 1.025987,0.2121 1.414986,0.6362 0.398725,0.4138 0.598087,0.9155 0.598087,1.5052 l 0,4.7172 c 0,0.5897 -0.199362,1.0966 -0.598087,1.5207 -0.388999,0.4138 -0.860661,0.6207 -1.414986,0.6207 l -4.973854,0 c -0.554325,0 -1.030849,-0.2069 -1.429574,-0.6207 C 15.1945,1024.3173 15,1023.8104 15,1023.2207 l 0,-4.7172 c 0,-0.5897 0.1945,-1.0914 0.583499,-1.5052 0.398725,-0.4241 0.875249,-0.6362 1.429574,-0.6362 z m 4.884815,2 -4.795776,0 c -0.06808,0 -0.102112,0.036 -0.102112,0.1086 l 0,4.7828 c 0,0.072 0.03404,0.1086 0.102112,0.1086 l 4.795776,0 c 0.06807,0 0.102112,-0.036 0.102112,-0.1086 l 0,-4.7828 c 0,-0.072 -0.03404,-0.1086 -0.102112,-0.1086 z"
sodipodi:nodetypes="sscsscsscsscssssssssss" />
</g>
<g
id="g4286"
style="stroke:none">
<path
inkscape:connector-curvature="0"
id="path4147"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 12,1036.9177 4.768114,-8.5556 2.231886,0 -6,11 -2,0 -6,-11 2.2318854,0 z"
sodipodi:nodetypes="cccccccc" />
<path
inkscape:connector-curvature="0"
id="path4149"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 29,1036.3621 0,-8 2,0 0,11 -2,0 -7,-8 0,8 -2,0 0,-11 2,0 z"
sodipodi:nodetypes="ccccccccccc" />
<path
inkscape:connector-curvature="0"
id="path4151"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 43,1030.3621 -8.897887,0 c -0.06808,0 -0.102113,0.036 -0.102113,0.1069 l 0,6.7862 c 0,0.071 0.03404,0.1069 0.102113,0.1069 l 8.897887,0 0,2 -8.972339,0 c -0.56405,0 -1.045437,-0.2037 -1.444162,-0.6111 C 32.1945,1038.3334 32,1037.8292 32,1037.2385 l 0,-6.7528 c 0,-0.5907 0.1945,-1.0898 0.583499,-1.4972 0.398725,-0.4176 0.880112,-0.6264 1.444162,-0.6264 l 8.972339,0 z"
sodipodi:nodetypes="cssssccscsscscc" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,183 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="48"
height="48"
viewBox="0 0 48 48.000001"
id="svg2"
version="1.1"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="novnc-ios-icon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="11.313708"
inkscape:cx="27.356195"
inkscape:cy="17.810253"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:object-nodes="true"
inkscape:snap-smooth-nodes="true"
inkscape:snap-midpoints="true"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1">
<inkscape:grid
type="xygrid"
id="grid4169" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1004.3621)">
<rect
style="opacity:1;fill:#494949;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4167"
width="48"
height="48"
x="0"
y="1004.3621"
inkscape:label="background" />
<path
style="opacity:1;fill:#313131;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="m 0,1004.3621 v 48 h 20 c 15.512,0 28,-16.948 28,-38 v -10 z"
id="rect4173"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccc"
inkscape:label="darker_grey_plate" />
<g
id="g4300"
style="display:inline;fill:#000000;fill-opacity:1;stroke:none"
transform="translate(0.5,0.5)"
inkscape:label="shadows">
<g
id="g4302"
style="fill:#000000;fill-opacity:1;stroke:none"
inkscape:label="no">
<path
sodipodi:nodetypes="scsccsssscccs"
d="m 11.986926,1016.3621 c 0.554325,0 1.025987,0.2121 1.414987,0.6362 0.398725,0.4138 0.600909,0.9155 0.598087,1.5052 v 6.8586 h -2 v -6.8914 c 0,-0.072 -0.03404,-0.1086 -0.102113,-0.1086 H 7.1021125 C 7.0340375,1018.3621 7,1018.3983 7,1018.4707 v 6.8914 H 5 v -9 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4304"
inkscape:connector-curvature="0"
inkscape:label="n" />
<path
sodipodi:nodetypes="sscsscsscsscssssssssss"
d="m 17.013073,1016.3621 h 4.973854 c 0.554325,0 1.025987,0.2121 1.414986,0.6362 0.398725,0.4138 0.598087,0.9155 0.598087,1.5052 v 4.7172 c 0,0.5897 -0.199362,1.0966 -0.598087,1.5207 -0.388999,0.4138 -0.860661,0.6207 -1.414986,0.6207 h -4.973854 c -0.554325,0 -1.030849,-0.2069 -1.429574,-0.6207 C 15.1945,1024.3173 15,1023.8104 15,1023.2207 v -4.7172 c 0,-0.5897 0.1945,-1.0914 0.583499,-1.5052 0.398725,-0.4241 0.875249,-0.6362 1.429574,-0.6362 z m 4.884815,2 h -4.795776 c -0.06808,0 -0.102112,0.036 -0.102112,0.1086 v 4.7828 c 0,0.072 0.03404,0.1086 0.102112,0.1086 h 4.795776 c 0.06807,0 0.102112,-0.036 0.102112,-0.1086 v -4.7828 c 0,-0.072 -0.03404,-0.1086 -0.102112,-0.1086 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4306"
inkscape:connector-curvature="0"
inkscape:label="o" />
</g>
<g
id="g4308"
style="fill:#000000;fill-opacity:1;stroke:none"
inkscape:label="VNC">
<path
sodipodi:nodetypes="cccccccc"
d="m 12,1036.9177 4.768114,-8.5556 H 19 l -6,11 h -2 l -6,-11 h 2.2318854 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4310"
inkscape:connector-curvature="0"
inkscape:label="V" />
<path
sodipodi:nodetypes="ccccccccccc"
d="m 29,1036.3621 v -8 h 2 v 11 h -2 l -7,-8 v 8 h -2 v -11 h 2 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4312"
inkscape:connector-curvature="0"
inkscape:label="N" />
<path
sodipodi:nodetypes="cssssccscsscscc"
d="m 43,1030.3621 h -8.897887 c -0.06808,0 -0.102113,0.036 -0.102113,0.1069 v 6.7862 c 0,0.071 0.03404,0.1069 0.102113,0.1069 H 43 v 2 h -8.972339 c -0.56405,0 -1.045437,-0.2037 -1.444162,-0.6111 C 32.1945,1038.3334 32,1037.8292 32,1037.2385 v -6.7528 c 0,-0.5907 0.1945,-1.0898 0.583499,-1.4972 0.398725,-0.4176 0.880112,-0.6264 1.444162,-0.6264 H 43 Z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4314"
inkscape:connector-curvature="0"
inkscape:label="C" />
</g>
</g>
<g
id="g4291"
style="stroke:none"
inkscape:label="noVNC">
<g
id="g4282"
style="stroke:none"
inkscape:label="no">
<path
inkscape:connector-curvature="0"
id="path4143"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#008000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 11.986926,1016.3621 c 0.554325,0 1.025987,0.2121 1.414987,0.6362 0.398725,0.4138 0.600909,0.9155 0.598087,1.5052 l 0,6.8586 -2,0 0,-6.8914 c 0,-0.072 -0.03404,-0.1086 -0.102113,-0.1086 l -4.7957745,0 C 7.0340375,1018.3621 7,1018.3983 7,1018.4707 l 0,6.8914 -2,0 0,-9 z"
sodipodi:nodetypes="scsccsssscccs"
inkscape:label="n" />
<path
inkscape:connector-curvature="0"
id="path4145"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#008000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 17.013073,1016.3621 4.973854,0 c 0.554325,0 1.025987,0.2121 1.414986,0.6362 0.398725,0.4138 0.598087,0.9155 0.598087,1.5052 l 0,4.7172 c 0,0.5897 -0.199362,1.0966 -0.598087,1.5207 -0.388999,0.4138 -0.860661,0.6207 -1.414986,0.6207 l -4.973854,0 c -0.554325,0 -1.030849,-0.2069 -1.429574,-0.6207 C 15.1945,1024.3173 15,1023.8104 15,1023.2207 l 0,-4.7172 c 0,-0.5897 0.1945,-1.0914 0.583499,-1.5052 0.398725,-0.4241 0.875249,-0.6362 1.429574,-0.6362 z m 4.884815,2 -4.795776,0 c -0.06808,0 -0.102112,0.036 -0.102112,0.1086 l 0,4.7828 c 0,0.072 0.03404,0.1086 0.102112,0.1086 l 4.795776,0 c 0.06807,0 0.102112,-0.036 0.102112,-0.1086 l 0,-4.7828 c 0,-0.072 -0.03404,-0.1086 -0.102112,-0.1086 z"
sodipodi:nodetypes="sscsscsscsscssssssssss"
inkscape:label="o" />
</g>
<g
id="g4286"
style="stroke:none"
inkscape:label="VNC">
<path
inkscape:connector-curvature="0"
id="path4147"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 12,1036.9177 4.768114,-8.5556 2.231886,0 -6,11 -2,0 -6,-11 2.2318854,0 z"
sodipodi:nodetypes="cccccccc"
inkscape:label="V" />
<path
inkscape:connector-curvature="0"
id="path4149"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 29,1036.3621 0,-8 2,0 0,11 -2,0 -7,-8 0,8 -2,0 0,-11 2,0 z"
sodipodi:nodetypes="ccccccccccc"
inkscape:label="N" />
<path
inkscape:connector-curvature="0"
id="path4151"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 43,1030.3621 -8.897887,0 c -0.06808,0 -0.102113,0.036 -0.102113,0.1069 l 0,6.7862 c 0,0.071 0.03404,0.1069 0.102113,0.1069 l 8.897887,0 0,2 -8.972339,0 c -0.56405,0 -1.045437,-0.2037 -1.444162,-0.6111 C 32.1945,1038.3334 32,1037.8292 32,1037.2385 l 0,-6.7528 c 0,-0.5907 0.1945,-1.0898 0.583499,-1.4972 0.398725,-0.4176 0.880112,-0.6264 1.444162,-0.6264 l 8.972339,0 z"
sodipodi:nodetypes="cssssccscsscscc"
inkscape:label="C" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

View File

@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="info.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="15.720838"
inkscape:cy="8.9111233"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="false"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<path
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="M 12.5 3 A 9.5 9.4999914 0 0 0 3 12.5 A 9.5 9.4999914 0 0 0 12.5 22 A 9.5 9.4999914 0 0 0 22 12.5 A 9.5 9.4999914 0 0 0 12.5 3 z M 12.5 5 A 1.5 1.5000087 0 0 1 14 6.5 A 1.5 1.5000087 0 0 1 12.5 8 A 1.5 1.5000087 0 0 1 11 6.5 A 1.5 1.5000087 0 0 1 12.5 5 z M 10.521484 8.9785156 L 12.521484 8.9785156 A 1.50015 1.50015 0 0 1 14.021484 10.478516 L 14.021484 15.972656 A 1.50015 1.50015 0 0 1 14.498047 18.894531 C 14.498047 18.894531 13.74301 19.228309 12.789062 18.912109 C 12.312092 18.754109 11.776235 18.366625 11.458984 17.828125 C 11.141734 17.289525 11.021484 16.668469 11.021484 15.980469 L 11.021484 11.980469 L 10.521484 11.980469 A 1.50015 1.50015 0 1 1 10.521484 8.9804688 L 10.521484 8.9785156 z "
transform="translate(0,1027.3622)"
id="path4136" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="keyboard.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/keyboard.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#717171"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="31.285341"
inkscape:cy="8.8028469"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:snap-bbox-midpoints="false"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:object-paths="true"
inkscape:snap-intersection-paths="true"
inkscape:object-nodes="true"
inkscape:snap-midpoints="true"
inkscape:snap-smooth-nodes="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<path
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="M 7,3 C 4.8012876,3 3,4.8013 3,7 3,11.166667 3,15.333333 3,19.5 3,20.8764 4.1236413,22 5.5,22 l 14,0 C 20.876358,22 22,20.8764 22,19.5 22,15.333333 22,11.166667 22,7 22,4.8013 20.198712,3 18,3 Z m 0,2 11,0 c 1.125307,0 2,0.8747 2,2 L 20,12 5,12 5,7 C 5,5.8747 5.8746931,5 7,5 Z M 6.5,14 C 6.777,14 7,14.223 7,14.5 7,14.777 6.777,15 6.5,15 6.223,15 6,14.777 6,14.5 6,14.223 6.223,14 6.5,14 Z m 2,0 C 8.777,14 9,14.223 9,14.5 9,14.777 8.777,15 8.5,15 8.223,15 8,14.777 8,14.5 8,14.223 8.223,14 8.5,14 Z m 2,0 C 10.777,14 11,14.223 11,14.5 11,14.777 10.777,15 10.5,15 10.223,15 10,14.777 10,14.5 10,14.223 10.223,14 10.5,14 Z m 2,0 C 12.777,14 13,14.223 13,14.5 13,14.777 12.777,15 12.5,15 12.223,15 12,14.777 12,14.5 12,14.223 12.223,14 12.5,14 Z m 2,0 C 14.777,14 15,14.223 15,14.5 15,14.777 14.777,15 14.5,15 14.223,15 14,14.777 14,14.5 14,14.223 14.223,14 14.5,14 Z m 2,0 C 16.777,14 17,14.223 17,14.5 17,14.777 16.777,15 16.5,15 16.223,15 16,14.777 16,14.5 16,14.223 16.223,14 16.5,14 Z m 2,0 C 18.777,14 19,14.223 19,14.5 19,14.777 18.777,15 18.5,15 18.223,15 18,14.777 18,14.5 18,14.223 18.223,14 18.5,14 Z m -13,2 C 5.777,16 6,16.223 6,16.5 6,16.777 5.777,17 5.5,17 5.223,17 5,16.777 5,16.5 5,16.223 5.223,16 5.5,16 Z m 2,0 C 7.777,16 8,16.223 8,16.5 8,16.777 7.777,17 7.5,17 7.223,17 7,16.777 7,16.5 7,16.223 7.223,16 7.5,16 Z m 2,0 C 9.777,16 10,16.223 10,16.5 10,16.777 9.777,17 9.5,17 9.223,17 9,16.777 9,16.5 9,16.223 9.223,16 9.5,16 Z m 2,0 C 11.777,16 12,16.223 12,16.5 12,16.777 11.777,17 11.5,17 11.223,17 11,16.777 11,16.5 11,16.223 11.223,16 11.5,16 Z m 2,0 C 13.777,16 14,16.223 14,16.5 14,16.777 13.777,17 13.5,17 13.223,17 13,16.777 13,16.5 13,16.223 13.223,16 13.5,16 Z m 2,0 C 15.777,16 16,16.223 16,16.5 16,16.777 15.777,17 15.5,17 15.223,17 15,16.777 15,16.5 15,16.223 15.223,16 15.5,16 Z m 2,0 C 17.777,16 18,16.223 18,16.5 18,16.777 17.777,17 17.5,17 17.223,17 17,16.777 17,16.5 17,16.223 17.223,16 17.5,16 Z m 2,0 C 19.777,16 20,16.223 20,16.5 20,16.777 19.777,17 19.5,17 19.223,17 19,16.777 19,16.5 19,16.223 19.223,16 19.5,16 Z M 6,18 c 0.554,0 1,0.446 1,1 0,0.554 -0.446,1 -1,1 -0.554,0 -1,-0.446 -1,-1 0,-0.554 0.446,-1 1,-1 z m 2.8261719,0 7.3476561,0 C 16.631643,18 17,18.368372 17,18.826172 l 0,0.347656 C 17,19.631628 16.631643,20 16.173828,20 L 8.8261719,20 C 8.3683573,20 8,19.631628 8,19.173828 L 8,18.826172 C 8,18.368372 8.3683573,18 8.8261719,18 Z m 10.1113281,0 0.125,0 C 19.581551,18 20,18.4184 20,18.9375 l 0,0.125 C 20,19.5816 19.581551,20 19.0625,20 l -0.125,0 C 18.418449,20 18,19.5816 18,19.0625 l 0,-0.125 C 18,18.4184 18.418449,18 18.9375,18 Z"
transform="translate(0,1027.3622)"
id="rect4160"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sccssccsssssccssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss" />
<path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#ffffff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:round;stroke-opacity:1"
d="m 12.499929,1033.8622 -2,2 1.500071,0 0,2 1,0 0,-2 1.499929,0 z"
id="path4150"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="power.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="9.3159849"
inkscape:cy="13.436208"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<path
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="M 9 6.8183594 C 6.3418164 8.1213032 4.5 10.849161 4.5 14 C 4.5 18.4065 8.0935666 22 12.5 22 C 16.906433 22 20.5 18.4065 20.5 14 C 20.5 10.849161 18.658184 8.1213032 16 6.8183594 L 16 9.125 C 17.514327 10.211757 18.5 11.984508 18.5 14 C 18.5 17.3256 15.825553 20 12.5 20 C 9.1744469 20 6.5 17.3256 6.5 14 C 6.5 11.984508 7.4856727 10.211757 9 9.125 L 9 6.8183594 z "
transform="translate(0,1027.3622)"
id="path6140" />
<path
style="fill:none;fill-rule:evenodd;stroke:#ffffff;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 12.5,1031.8836 0,6.4786"
id="path6142"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="settings.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="22.627417"
inkscape:cx="14.69683"
inkscape:cy="8.8039511"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="true"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="false"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<path
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="M 11 3 L 11 5.1601562 A 7.5 7.5 0 0 0 8.3671875 6.2460938 L 6.84375 4.7226562 L 4.7226562 6.84375 L 6.2480469 8.3691406 A 7.5 7.5 0 0 0 5.1523438 11 L 3 11 L 3 14 L 5.1601562 14 A 7.5 7.5 0 0 0 6.2460938 16.632812 L 4.7226562 18.15625 L 6.84375 20.277344 L 8.3691406 18.751953 A 7.5 7.5 0 0 0 11 19.847656 L 11 22 L 14 22 L 14 19.839844 A 7.5 7.5 0 0 0 16.632812 18.753906 L 18.15625 20.277344 L 20.277344 18.15625 L 18.751953 16.630859 A 7.5 7.5 0 0 0 19.847656 14 L 22 14 L 22 11 L 19.839844 11 A 7.5 7.5 0 0 0 18.753906 8.3671875 L 20.277344 6.84375 L 18.15625 4.7226562 L 16.630859 6.2480469 A 7.5 7.5 0 0 0 14 5.1523438 L 14 3 L 11 3 z M 12.5 10 A 2.5 2.5 0 0 1 15 12.5 A 2.5 2.5 0 0 1 12.5 15 A 2.5 2.5 0 0 1 10 12.5 A 2.5 2.5 0 0 1 12.5 10 z "
transform="translate(0,1027.3622)"
id="rect4967" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="tab.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="16"
inkscape:cx="11.67335"
inkscape:cy="17.881696"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<path
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="m 3,1031.3622 0,8 2,0 0,-4 0,-4 -2,0 z m 2,4 4,4 0,-3 13,0 0,-2 -13,0 0,-3 -4,4 z"
id="rect5194"
inkscape:connector-curvature="0" />
<path
id="path5211"
d="m 22,1048.3622 0,-8 -2,0 0,4 0,4 2,0 z m -2,-4 -4,-4 0,3 -13,0 0,2 13,0 0,3 4,-4 z"
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
inkscape:connector-curvature="0" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="extrakeys.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="15.234555"
inkscape:cy="9.9710826"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="false"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="false">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<path
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 8,1031.3622 c -2.1987124,0 -4,1.8013 -4,4 l 0,8.9996 c 0,2.1987 1.8012876,4 4,4 l 9,0 c 2.198712,0 4,-1.8013 4,-4 l 0,-8.9996 c 0,-2.1987 -1.801288,-4 -4,-4 z m 0,2 9,0 c 1.125307,0 2,0.8747 2,2 l 0,7.0005 c 0,1.1253 -0.874693,2 -2,2 l -9,0 c -1.1253069,0 -2,-0.8747 -2,-2 l 0,-7.0005 c 0,-1.1253 0.8746931,-2 2,-2 z"
id="rect5006"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ssssssssssssssssss" />
<g
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:10px;line-height:125%;font-family:'DejaVu Sans';-inkscape-font-specification:'Sans Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="text4167"
transform="matrix(0.96021948,0,0,0.96021948,0.18921715,41.80659)">
<path
d="m 14.292969,1040.6791 -2.939453,0 -0.463868,1.3281 -1.889648,0 2.700195,-7.29 2.241211,0 2.700196,7.29 -1.889649,0 -0.458984,-1.3281 z m -2.470703,-1.3526 1.99707,0 -0.996094,-2.9004 -1.000976,2.9004 z"
id="path4172"
inkscape:connector-curvature="0" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="warning.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="16.457343"
inkscape:cy="12.179552"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="false"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<path
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:4;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="M 12.513672 3.0019531 C 11.751609 2.9919531 11.052563 3.4242687 10.710938 4.1054688 L 3.2109375 19.105469 C 2.5461937 20.435369 3.5132277 21.9999 5 22 L 20 22 C 21.486772 21.9999 22.453806 20.435369 21.789062 19.105469 L 14.289062 4.1054688 C 13.951849 3.4330688 13.265888 3.0066531 12.513672 3.0019531 z M 12.478516 6.9804688 A 1.50015 1.50015 0 0 1 14 8.5 L 14 14.5 A 1.50015 1.50015 0 1 1 11 14.5 L 11 8.5 A 1.50015 1.50015 0 0 1 12.478516 6.9804688 z M 12.5 17 A 1.5 1.5 0 0 1 14 18.5 A 1.5 1.5 0 0 1 12.5 20 A 1.5 1.5 0 0 1 11 18.5 A 1.5 1.5 0 0 1 12.5 17 z "
transform="translate(0,1027.3622)"
id="path4208" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="svg2"
inkscape:export-ydpi="90"
inkscape:export-xdpi="90"
sodipodi:docname="windows.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:version="0.92.4 (unknown)"
x="0px"
y="0px"
viewBox="-293 384 25 25"
xml:space="preserve"
width="25"
height="25"><metadata
id="metadata21"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
id="defs19" /><sodipodi:namedview
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1136"
id="namedview17"
showgrid="true"
inkscape:pagecheckerboard="false"
inkscape:zoom="32"
inkscape:cx="3.926913"
inkscape:cy="13.255959"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="svg2"><inkscape:grid
type="xygrid"
id="grid818" /></sodipodi:namedview>
<style
type="text/css"
id="style2">
.st0{fill:#FFFFFF;}
</style>
<path
style="fill:#ffffff;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;fill-opacity:1"
d="M 21 4 L 11 5.1757812 L 11 12 L 21 12 L 21 4 z M 10 5.2949219 L 4 6 L 4 12 L 10 12 L 10 5.2949219 z "
transform="translate(-293,384)"
id="path853" /><path
id="path858"
d="m -272,405 -10,-1.17578 V 397 h 10 z M -283,403.70508 -289,403 v -6 h 6 z"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
inkscape:connector-curvature="0" /></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

Some files were not shown because too many files have changed in this diff Show More