update
This commit is contained in:
@@ -52,7 +52,8 @@ import terminalThemeRoutes from './terminal-themes/terminal-theme.routes';
|
||||
import appearanceRoutes from './appearance/appearance.routes';
|
||||
import sshKeysRouter from './ssh_keys/ssh_keys.routes';
|
||||
import quickCommandTagRoutes from './quick-command-tags/quick-command-tag.routes';
|
||||
import sshSuspendRouter from './ssh-suspend/ssh-suspend.routes';
|
||||
import sshSuspendRouter from './ssh-suspend/ssh-suspend.routes';
|
||||
import { transfersRoutes } from './transfers/transfers.routes'; // 新增:导入传输路由
|
||||
import { initializeWebSocket } from './websocket';
|
||||
import { ipWhitelistMiddleware } from './auth/ipWhitelist.middleware';
|
||||
|
||||
@@ -273,6 +274,7 @@ const startServer = () => {
|
||||
app.use('/api/v1/ssh-keys', sshKeysRouter); // +++ Register SSH Key routes +++
|
||||
app.use('/api/v1/quick-command-tags', quickCommandTagRoutes); // +++ Register Quick Command Tag routes +++
|
||||
app.use('/api/v1/ssh-suspend', sshSuspendRouter); // +++ Register SSH Suspend routes +++
|
||||
app.use('/api/v1/transfers', transfersRoutes()); // 新增:注册传输路由
|
||||
|
||||
// 状态检查接口
|
||||
app.get('/api/v1/status', (req: Request, res: Response) => {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { TransfersService } from './transfers.service';
|
||||
import { InitiateTransferPayload } from './transfers.types';
|
||||
|
||||
export class TransfersController {
|
||||
private transfersService: TransfersService;
|
||||
|
||||
constructor() {
|
||||
this.transfersService = new TransfersService();
|
||||
// 绑定 'this' 上下文
|
||||
this.initiateTransfer = this.initiateTransfer.bind(this);
|
||||
this.getAllStatuses = this.getAllStatuses.bind(this);
|
||||
this.getTaskStatus = this.getTaskStatus.bind(this);
|
||||
}
|
||||
|
||||
public async initiateTransfer(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
// @ts-ignore // session可能没有强类型定义,或者userId是可选的
|
||||
const userId = req.session?.userId;
|
||||
if (!userId) {
|
||||
// 此检查是为了双重保险,理论上isAuthenticated中间件会阻止未认证的请求
|
||||
res.status(401).json({ message: '用户未认证或会话无效。' });
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = req.body as InitiateTransferPayload;
|
||||
// TODO: 添加payload验证逻辑
|
||||
if (!payload || !payload.connectionIds || !payload.sourceItems || !payload.remoteTargetPath || !payload.transferMethod) {
|
||||
res.status(400).json({ message: 'Invalid payload. Required fields: connectionIds, sourceItems, remoteTargetPath, transferMethod.' });
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(payload.connectionIds) || payload.connectionIds.length === 0) {
|
||||
res.status(400).json({ message: 'connectionIds must be a non-empty array.' });
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(payload.sourceItems) || payload.sourceItems.length === 0) {
|
||||
res.status(400).json({ message: 'sourceItems must be a non-empty array.' });
|
||||
return;
|
||||
}
|
||||
// 更多详细验证可以后续添加
|
||||
|
||||
const task = await this.transfersService.initiateNewTransfer(payload, userId);
|
||||
res.status(202).json(task); // 202 Accepted 表示请求已接受处理,但尚未完成
|
||||
} catch (error) {
|
||||
console.error('[TransfersController] Error initiating transfer:', error);
|
||||
// next(error) 可以将错误传递给全局错误处理中间件
|
||||
res.status(500).json({ message: 'Failed to initiate transfer.', error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
public async getAllStatuses(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const userId = req.session?.userId;
|
||||
if (!userId) {
|
||||
res.status(401).json({ message: '用户未认证或会话无效。' });
|
||||
return;
|
||||
}
|
||||
const tasks = await this.transfersService.getAllTransferTasks(userId);
|
||||
res.status(200).json(tasks);
|
||||
} catch (error) {
|
||||
console.error('[TransfersController] Error getting all transfer statuses:', error);
|
||||
res.status(500).json({ message: 'Failed to retrieve transfer statuses.', error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
public async getTaskStatus(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const userId = req.session?.userId;
|
||||
if (!userId) {
|
||||
res.status(401).json({ message: '用户未认证或会话无效。' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { taskId } = req.params;
|
||||
if (!taskId) {
|
||||
res.status(400).json({ message: 'Task ID is required.' });
|
||||
return;
|
||||
}
|
||||
const task = await this.transfersService.getTransferTaskDetails(taskId, userId);
|
||||
if (task) {
|
||||
res.status(200).json(task);
|
||||
} else {
|
||||
// 服务层现在会根据userId过滤,所以404可能是任务不存在,或用户无权访问
|
||||
res.status(404).json({ message: `Transfer task with ID ${taskId} not found or not accessible by this user.` });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[TransfersController] Error getting status for task ${req.params.taskId}:`, error);
|
||||
res.status(500).json({ message: 'Failed to retrieve task status.', error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Router } from 'express';
|
||||
import { TransfersController } from './transfers.controller';
|
||||
import { isAuthenticated } from '../auth/auth.middleware';
|
||||
|
||||
export const transfersRoutes = (): Router => {
|
||||
const router = Router();
|
||||
const controller = new TransfersController();
|
||||
|
||||
// 应用认证中间件到所有 /transfers 路由
|
||||
router.use(isAuthenticated);
|
||||
|
||||
// POST /api/transfers/send - 发起新的文件传输任务
|
||||
router.post('/send', controller.initiateTransfer);
|
||||
|
||||
// GET /api/transfers/status - 获取所有活动或近期传输任务的状态
|
||||
router.get('/status', controller.getAllStatuses);
|
||||
|
||||
// GET /api/transfers/status/:taskId - 获取特定传输任务的详细状态
|
||||
router.get('/status/:taskId', controller.getTaskStatus);
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,534 @@
|
||||
import { spawn } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import { v4 as uuidv4 } from 'uuid'; // 用于生成唯一ID
|
||||
import { InitiateTransferPayload, TransferTask, TransferSubTask } from './transfers.types';
|
||||
import { getConnectionWithDecryptedCredentials } from '../services/connection.service';
|
||||
import type { ConnectionWithTags } from '../types/connection.types';
|
||||
// import { logger } from '../utils/logger'; // 假设的日志工具路径
|
||||
|
||||
export class TransfersService {
|
||||
private transferTasks: Map<string, TransferTask> = new Map();
|
||||
|
||||
constructor() {
|
||||
console.info('[TransfersService] Initialized.');
|
||||
}
|
||||
|
||||
public async initiateNewTransfer(payload: InitiateTransferPayload, userId: string | number): Promise<TransferTask> {
|
||||
const taskId = uuidv4();
|
||||
const now = new Date();
|
||||
const subTasks: TransferSubTask[] = [];
|
||||
|
||||
for (const connectionId of payload.connectionIds) {
|
||||
for (const item of payload.sourceItems) {
|
||||
const subTaskId = uuidv4();
|
||||
subTasks.push({
|
||||
subTaskId,
|
||||
connectionId,
|
||||
sourceItemName: item.name,
|
||||
status: 'queued',
|
||||
startTime: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newTask: TransferTask = {
|
||||
taskId,
|
||||
status: 'queued',
|
||||
userId, // 添加 userId
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
subTasks,
|
||||
payload,
|
||||
};
|
||||
|
||||
this.transferTasks.set(taskId, newTask);
|
||||
console.info(`[TransfersService] New transfer task created: ${taskId} with ${subTasks.length} sub-tasks.`);
|
||||
|
||||
// 异步启动传输,不阻塞当前请求
|
||||
this.processTransferTask(taskId).catch(error => {
|
||||
console.error(`[TransfersService] Error processing task ${taskId} in background:`, error);
|
||||
// 可能需要更新父任务状态为 failed
|
||||
this.updateOverallTaskStatus(taskId, 'failed', `Background processing error: ${error.message}`);
|
||||
});
|
||||
|
||||
return { ...newTask }; // 返回任务的副本
|
||||
}
|
||||
|
||||
private async processTransferTask(taskId: string): Promise<void> {
|
||||
const task = this.transferTasks.get(taskId);
|
||||
if (!task) {
|
||||
console.error(`[TransfersService] Task ${taskId} not found for processing.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateOverallTaskStatus(taskId, 'in-progress');
|
||||
|
||||
for (const subTask of task.subTasks) {
|
||||
let tempKeyPath: string | undefined;
|
||||
try {
|
||||
this.updateSubTaskStatus(taskId, subTask.subTaskId, 'connecting');
|
||||
const connectionResult = await getConnectionWithDecryptedCredentials(subTask.connectionId);
|
||||
|
||||
if (!connectionResult || !connectionResult.connection) {
|
||||
this.updateSubTaskStatus(taskId, subTask.subTaskId, 'failed', undefined, `Connection with ID ${subTask.connectionId} not found or inaccessible.`);
|
||||
continue;
|
||||
}
|
||||
const { connection, decryptedPassword, decryptedPrivateKey, decryptedPassphrase } = connectionResult;
|
||||
|
||||
if (connection.auth_method === 'key' && decryptedPrivateKey) {
|
||||
try {
|
||||
const tempDir = os.tmpdir();
|
||||
const randomFileName = `nexus_tmp_key_${crypto.randomBytes(6).toString('hex')}`;
|
||||
tempKeyPath = path.join(tempDir, randomFileName);
|
||||
await fs.promises.writeFile(tempKeyPath, decryptedPrivateKey, { mode: 0o600 });
|
||||
console.info(`[TransfersService] Temporary private key created at ${tempKeyPath} for sub-task ${subTask.subTaskId}`);
|
||||
} catch (keyError: any) {
|
||||
console.error(`[TransfersService] Failed to prepare private key for sub-task ${subTask.subTaskId}:`, keyError);
|
||||
this.updateSubTaskStatus(taskId, subTask.subTaskId, 'failed', undefined, `Failed to prepare private key: ${keyError.message}`);
|
||||
if (tempKeyPath) {
|
||||
try { await fs.promises.unlink(tempKeyPath); } catch (e) { console.error(`[TransfersService] Error cleaning up partially created temp key ${tempKeyPath}:`, e); }
|
||||
}
|
||||
tempKeyPath = undefined;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const sourceItem = task.payload.sourceItems.find(s => s.name === subTask.sourceItemName);
|
||||
if (!sourceItem) {
|
||||
this.updateSubTaskStatus(taskId, subTask.subTaskId, 'failed', undefined, `Source item ${subTask.sourceItemName} not found in payload.`);
|
||||
// No 'continue' here, let finally block handle tempKeyPath cleanup if it was created
|
||||
// However, if sourceItem is not found, we should not proceed with transfer commands.
|
||||
// The 'continue' implies we might have created a temp key that needs cleanup *before* continuing.
|
||||
// So, cleanup and continue pattern is better.
|
||||
if (tempKeyPath) {
|
||||
try { await fs.promises.unlink(tempKeyPath); console.info(`[TransfersService] Temporary private key ${tempKeyPath} deleted after source item not found.`);}
|
||||
catch (e) { console.error(`[TransfersService] Error cleaning temp key ${tempKeyPath} after source item not found:`, e); }
|
||||
tempKeyPath = undefined; // Ensure it is not used/cleaned again in finally
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const determinedMethod = await this.determineTransferCommand(
|
||||
connection,
|
||||
task.payload.transferMethod,
|
||||
connection.host,
|
||||
tempKeyPath, // Pass tempKeyPath (which is undefined if not key auth or error)
|
||||
decryptedPassphrase
|
||||
);
|
||||
this.updateSubTaskStatus(taskId, subTask.subTaskId, 'transferring', 0, `Using ${determinedMethod}.`);
|
||||
subTask.transferMethodUsed = determinedMethod;
|
||||
|
||||
if (determinedMethod === 'rsync') {
|
||||
await this.executeRsync(taskId, subTask.subTaskId, connection, sourceItem.path, task.payload.remoteTargetPath, sourceItem.type === 'directory', decryptedPassword, tempKeyPath, decryptedPassphrase);
|
||||
} else { // scp
|
||||
await this.executeScp(taskId, subTask.subTaskId, connection, sourceItem.path, task.payload.remoteTargetPath, sourceItem.type === 'directory', decryptedPassword, tempKeyPath, decryptedPassphrase);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[TransfersService] Error processing sub-task ${subTask.subTaskId} for task ${taskId}:`, error);
|
||||
// Avoid double-updating status if it was already set to failed due to key prep error
|
||||
const currentSubTask = task.subTasks.find(st => st.subTaskId === subTask.subTaskId);
|
||||
if (currentSubTask && currentSubTask.status !== 'failed') {
|
||||
this.updateSubTaskStatus(taskId, subTask.subTaskId, 'failed', undefined, error.message || 'Unknown error during sub-task processing.');
|
||||
}
|
||||
} finally {
|
||||
if (tempKeyPath) {
|
||||
try {
|
||||
await fs.promises.unlink(tempKeyPath);
|
||||
console.info(`[TransfersService] Temporary private key ${tempKeyPath} deleted for sub-task ${subTask.subTaskId}`);
|
||||
} catch (cleanupError) {
|
||||
console.error(`[TransfersService] Error cleaning up temporary private key ${tempKeyPath} for sub-task ${subTask.subTaskId}:`, cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.finalizeOverallTaskStatus(taskId);
|
||||
}
|
||||
|
||||
|
||||
public async getTransferTaskDetails(taskId: string, userId: string | number): Promise<TransferTask | null> {
|
||||
const task = this.transferTasks.get(taskId);
|
||||
console.debug(`[TransfersService] Retrieving details for task: ${taskId} for user: ${userId}`);
|
||||
if (task && task.userId === userId) {
|
||||
return { ...task };
|
||||
}
|
||||
if (task && task.userId !== userId) {
|
||||
console.warn(`[TransfersService] User ${userId} attempted to access task ${taskId} owned by ${task.userId}.`);
|
||||
return null; // Or throw ForbiddenException
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async getAllTransferTasks(userId: string | number): Promise<TransferTask[]> {
|
||||
console.debug(`[TransfersService] Retrieving all transfer tasks for user: ${userId}.`);
|
||||
return Array.from(this.transferTasks.values())
|
||||
.filter(task => task.userId === userId)
|
||||
.map(task => ({ ...task }));
|
||||
}
|
||||
|
||||
public updateSubTaskStatus(
|
||||
taskId: string,
|
||||
subTaskId: string,
|
||||
newStatus: TransferSubTask['status'],
|
||||
progress?: number,
|
||||
message?: string
|
||||
): void {
|
||||
const task = this.transferTasks.get(taskId);
|
||||
if (task) {
|
||||
const subTask = task.subTasks.find(st => st.subTaskId === subTaskId);
|
||||
if (subTask) {
|
||||
subTask.status = newStatus;
|
||||
if (progress !== undefined) subTask.progress = progress;
|
||||
if (message !== undefined) subTask.message = message;
|
||||
if (newStatus === 'completed' || newStatus === 'failed') {
|
||||
subTask.endTime = new Date();
|
||||
}
|
||||
task.updatedAt = new Date();
|
||||
// 可能需要根据子任务状态更新父任务状态和进度
|
||||
this.updateOverallTaskStatusBasedOnSubTasks(taskId);
|
||||
console.info(`[TransfersService] Sub-task ${subTaskId} for task ${taskId} updated: ${newStatus}, progress: ${progress}%, message: ${message}`);
|
||||
} else {
|
||||
console.warn(`[TransfersService] Sub-task ${subTaskId} not found for task ${taskId} during status update.`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[TransfersService] Task ${taskId} not found during sub-task status update.`);
|
||||
}
|
||||
}
|
||||
|
||||
private updateOverallTaskStatus(taskId: string, newStatus: TransferTask['status'], message?: string): void {
|
||||
const task = this.transferTasks.get(taskId);
|
||||
if (task) {
|
||||
task.status = newStatus;
|
||||
task.updatedAt = new Date();
|
||||
if (message && (newStatus === 'failed' || newStatus === 'partially-completed')) {
|
||||
// Append to existing messages or set if none
|
||||
task.payload.sourceItems.forEach(item => { // Simplified: maybe a task-level message array
|
||||
// task.message = (task.message ? task.message + "; " : "") + message;
|
||||
});
|
||||
}
|
||||
console.info(`[TransfersService] Overall status for task ${taskId} updated to: ${newStatus}`);
|
||||
}
|
||||
}
|
||||
|
||||
private updateOverallTaskStatusBasedOnSubTasks(taskId: string): void {
|
||||
const task = this.transferTasks.get(taskId);
|
||||
if (!task) return;
|
||||
|
||||
let completedCount = 0;
|
||||
let failedCount = 0;
|
||||
let totalProgress = 0;
|
||||
const activeSubTasks = task.subTasks.filter(st => st.status !== 'queued');
|
||||
|
||||
|
||||
if (activeSubTasks.length === 0 && task.subTasks.length > 0) {
|
||||
// If no subtasks have started processing, keep task as queued or in-progress if already set
|
||||
if (task.status === 'queued') return;
|
||||
}
|
||||
|
||||
|
||||
task.subTasks.forEach(st => {
|
||||
if (st.status === 'completed') {
|
||||
completedCount++;
|
||||
totalProgress += 100;
|
||||
} else if (st.status === 'failed') {
|
||||
failedCount++;
|
||||
// Failed tasks contribute 0 to progress for simplicity, or 100 if considering them "done"
|
||||
} else if (st.status === 'transferring' && st.progress !== undefined) {
|
||||
totalProgress += st.progress;
|
||||
}
|
||||
// 'queued' and 'connecting' don't add to progress here
|
||||
});
|
||||
|
||||
if (task.subTasks.length > 0) {
|
||||
task.overallProgress = Math.round(totalProgress / task.subTasks.length);
|
||||
} else {
|
||||
task.overallProgress = 0;
|
||||
}
|
||||
|
||||
if (failedCount === task.subTasks.length && task.subTasks.length > 0) {
|
||||
task.status = 'failed';
|
||||
} else if (completedCount === task.subTasks.length && task.subTasks.length > 0) {
|
||||
task.status = 'completed';
|
||||
} else if (failedCount > 0 && (failedCount + completedCount) === task.subTasks.length) {
|
||||
task.status = 'partially-completed';
|
||||
} else if (activeSubTasks.some(st => st.status === 'transferring' || st.status === 'connecting')) {
|
||||
task.status = 'in-progress';
|
||||
} else if (task.subTasks.every(st => st.status === 'queued')) {
|
||||
task.status = 'queued';
|
||||
}
|
||||
// else, if some are queued and others completed/failed, it might remain 'in-progress' or 'partially-completed'
|
||||
// This logic might need refinement based on exact desired behavior for mixed states.
|
||||
|
||||
task.updatedAt = new Date();
|
||||
console.debug(`[TransfersService] Task ${taskId} overall progress: ${task.overallProgress}%, status: ${task.status}`);
|
||||
}
|
||||
|
||||
private finalizeOverallTaskStatus(taskId: string): void {
|
||||
const task = this.transferTasks.get(taskId);
|
||||
if (!task) return;
|
||||
this.updateOverallTaskStatusBasedOnSubTasks(taskId); // Recalculate based on final sub-task states
|
||||
console.info(`[TransfersService] Finalized overall status for task ${taskId}: ${task.status}`);
|
||||
}
|
||||
|
||||
|
||||
private async executeRsync(
|
||||
taskId: string,
|
||||
subTaskId: string,
|
||||
connection: ConnectionWithTags,
|
||||
sourcePath: string,
|
||||
remoteBaseDestPath: string,
|
||||
isDir: boolean,
|
||||
decryptedPassword?: string,
|
||||
privateKeyPath?: string, // Changed from decryptedPrivateKey
|
||||
decryptedPassphrase?: string
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { host, username, port, auth_method } = connection;
|
||||
const remoteDest = `${username}@${host}:${remoteBaseDestPath.endsWith('/') ? remoteBaseDestPath : remoteBaseDestPath + '/'}`;
|
||||
|
||||
let sshCommand = `ssh -p ${port || 22}`;
|
||||
if (auth_method === 'key' && privateKeyPath) {
|
||||
sshCommand += ` -i "${privateKeyPath}"`; // Use the provided temporary key path
|
||||
}
|
||||
|
||||
const rsyncArgs = [
|
||||
'-avz',
|
||||
'--progress',
|
||||
'-e',
|
||||
sshCommand,
|
||||
];
|
||||
|
||||
if (isDir && !sourcePath.endsWith('/')) {
|
||||
sourcePath += '/';
|
||||
}
|
||||
rsyncArgs.push(sourcePath);
|
||||
rsyncArgs.push(remoteDest);
|
||||
|
||||
console.info(`[TransfersService] Executing rsync for sub-task ${subTaskId}: rsync ${rsyncArgs.join(' ')}`);
|
||||
|
||||
let command = 'rsync';
|
||||
let finalArgs = rsyncArgs.filter(arg => arg); // Ensure no empty strings if sshCommand parts were conditional
|
||||
|
||||
// Logic for sshpass with password auth remains as a comment/TODO, as per original
|
||||
if (auth_method === 'password' && decryptedPassword) {
|
||||
console.warn(`[TransfersService] Rsync with password authentication. Consider using sshpass if direct password input is needed and rsync/ssh doesn't prompt. Sub-task ${subTaskId} might fail if not configured for passwordless sudo or if sshpass is not used correctly.`);
|
||||
// Example for sshpass (requires sshpass to be installed):
|
||||
// command = 'sshpass';
|
||||
// finalArgs = ['-p', decryptedPassword, 'rsync', ...rsyncArgs.filter(arg => arg)];
|
||||
} else if (auth_method === 'key' && privateKeyPath && decryptedPassphrase) {
|
||||
// If key (now a file path) has a passphrase, ssh itself will prompt or use ssh-agent.
|
||||
// sshpass could be used here if ssh-agent is not an option and no TTY for prompt.
|
||||
// console.warn(`[TransfersService] Rsync with passphrase-protected key. Ensure ssh-agent is configured or use sshpass if direct passphrase input is needed.`);
|
||||
}
|
||||
|
||||
const process = spawn(command, finalArgs);
|
||||
|
||||
let stdoutData = '';
|
||||
let stderrData = '';
|
||||
|
||||
process.stdout.on('data', (data) => {
|
||||
const output = data.toString();
|
||||
stdoutData += output;
|
||||
const progressMatch = output.match(/(\d+)%/);
|
||||
if (progressMatch && progressMatch[1]) {
|
||||
this.updateSubTaskStatus(taskId, subTaskId, 'transferring', parseInt(progressMatch[1], 10));
|
||||
}
|
||||
console.debug(`[TransfersService] Rsync STDOUT (sub-task ${subTaskId}): ${output.trim()}`);
|
||||
});
|
||||
|
||||
process.stderr.on('data', (data) => {
|
||||
stderrData += data.toString();
|
||||
console.warn(`[TransfersService] Rsync STDERR (sub-task ${subTaskId}): ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
process.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
this.updateSubTaskStatus(taskId, subTaskId, 'completed', 100, 'Rsync transfer successful.');
|
||||
console.info(`[TransfersService] Rsync completed successfully for sub-task ${subTaskId}.`);
|
||||
resolve();
|
||||
} else {
|
||||
const errorMessage = `Rsync failed with code ${code}. STDERR: ${stderrData.trim()} STDOUT: ${stdoutData.trim()}`;
|
||||
this.updateSubTaskStatus(taskId, subTaskId, 'failed', undefined, errorMessage);
|
||||
console.error(`[TransfersService] Rsync failed for sub-task ${subTaskId}. Code: ${code}. Error: ${errorMessage}`);
|
||||
reject(new Error(errorMessage));
|
||||
}
|
||||
});
|
||||
|
||||
process.on('error', (err) => {
|
||||
const errorMessage = `Rsync process error: ${err.message}`;
|
||||
this.updateSubTaskStatus(taskId, subTaskId, 'failed', undefined, errorMessage);
|
||||
console.error(`[TransfersService] Rsync process error for sub-task ${subTaskId}:`, err);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async executeScp(
|
||||
taskId: string,
|
||||
subTaskId: string,
|
||||
connection: ConnectionWithTags,
|
||||
sourcePath: string,
|
||||
remoteBaseDestPath: string,
|
||||
isDir: boolean,
|
||||
decryptedPassword?: string,
|
||||
privateKeyPath?: string, // Changed from decryptedPrivateKey
|
||||
decryptedPassphrase?: string
|
||||
): Promise<void> {
|
||||
const { host, username, port, auth_method } = connection;
|
||||
// Source is on the remote server identified by 'connection'
|
||||
const remoteSourceIdentifier = `${username}@${host}:${sourcePath}`;
|
||||
|
||||
// Destination is local to the backend server.
|
||||
// remoteBaseDestPath from payload is the local directory to save to.
|
||||
const sourceFileName = path.basename(sourcePath);
|
||||
// Ensure remoteBaseDestPath is treated as a directory for path.join
|
||||
const localTargetDirectory = remoteBaseDestPath.endsWith(path.sep) ? remoteBaseDestPath : path.join(remoteBaseDestPath, path.sep);
|
||||
const localTargetFullPath = path.join(localTargetDirectory, sourceFileName);
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(localTargetDirectory, { recursive: true });
|
||||
console.info(`[TransfersService] Ensured local destination directory exists: ${localTargetDirectory}`);
|
||||
} catch (mkdirError: any) {
|
||||
const errorMessage = `Failed to create local destination directory ${localTargetDirectory}: ${mkdirError.message}`;
|
||||
console.error(`[TransfersService] ${errorMessage}`);
|
||||
this.updateSubTaskStatus(taskId, subTaskId, 'failed', undefined, errorMessage);
|
||||
// Return a rejected promise directly, as the function is async
|
||||
return Promise.reject(new Error(errorMessage));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const scpArgs = [];
|
||||
if (port) scpArgs.push('-P', port.toString());
|
||||
if (auth_method === 'key' && privateKeyPath) {
|
||||
scpArgs.push('-i', privateKeyPath); // Use the provided temporary key path
|
||||
}
|
||||
if (isDir) { // If the remote source is a directory, use -r
|
||||
scpArgs.push('-r');
|
||||
}
|
||||
|
||||
scpArgs.push(remoteSourceIdentifier); // Remote source
|
||||
scpArgs.push(localTargetFullPath); // Local destination
|
||||
|
||||
console.info(`[TransfersService] Executing SCP for sub-task ${subTaskId}: scp ${scpArgs.join(' ')}`);
|
||||
|
||||
let command = 'scp';
|
||||
let finalArgs = [...scpArgs];
|
||||
|
||||
// Logic for sshpass with password auth remains as a comment/TODO, as per original
|
||||
if (auth_method === 'password' && decryptedPassword) {
|
||||
console.warn(`[TransfersService] SCP with password authentication. Consider using sshpass. Sub-task ${subTaskId} might fail if not configured for passwordless sudo or if sshpass is not used correctly.`);
|
||||
// Example with sshpass (requires sshpass to be installed):
|
||||
// command = 'sshpass';
|
||||
// finalArgs = ['-p', decryptedPassword, 'scp', ...scpArgs];
|
||||
} else if (auth_method === 'key' && privateKeyPath && decryptedPassphrase) {
|
||||
// If key (now a file path) has a passphrase, scp/ssh itself will prompt or use ssh-agent.
|
||||
// console.warn(`[TransfersService] SCP with passphrase-protected key. Ensure ssh-agent is configured or use sshpass if direct passphrase input is needed.`);
|
||||
}
|
||||
|
||||
const process = spawn(command, finalArgs);
|
||||
let stderrData = '';
|
||||
let stdoutData = '';
|
||||
|
||||
process.stdout.on('data', (data) => {
|
||||
stdoutData += data.toString();
|
||||
console.debug(`[TransfersService] SCP STDOUT (sub-task ${subTaskId}): ${data.toString().trim()}`);
|
||||
this.updateSubTaskStatus(taskId, subTaskId, 'transferring', 50, 'SCP transfer in progress.');
|
||||
});
|
||||
|
||||
process.stderr.on('data', (data) => {
|
||||
stderrData += data.toString();
|
||||
console.warn(`[TransfersService] SCP STDERR (sub-task ${subTaskId}): ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
process.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
this.updateSubTaskStatus(taskId, subTaskId, 'completed', 100, 'SCP transfer successful.');
|
||||
console.info(`[TransfersService] SCP completed successfully for sub-task ${subTaskId}.`);
|
||||
resolve();
|
||||
} else {
|
||||
const errorMessage = `SCP failed with code ${code}. STDERR: ${stderrData.trim()} STDOUT: ${stdoutData.trim()}`;
|
||||
this.updateSubTaskStatus(taskId, subTaskId, 'failed', undefined, errorMessage);
|
||||
console.error(`[TransfersService] SCP failed for sub-task ${subTaskId}. Code: ${code}. Error: ${errorMessage}`);
|
||||
reject(new Error(errorMessage));
|
||||
}
|
||||
});
|
||||
|
||||
process.on('error', (err) => {
|
||||
const errorMessage = `SCP process error: ${err.message}`;
|
||||
this.updateSubTaskStatus(taskId, subTaskId, 'failed', undefined, errorMessage);
|
||||
console.error(`[TransfersService] SCP process error for sub-task ${subTaskId}:`, err);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async determineTransferCommand(
|
||||
connection: ConnectionWithTags,
|
||||
method: 'auto' | 'rsync' | 'scp',
|
||||
remoteHost: string,
|
||||
privateKeyPath?: string, // Changed from decryptedPrivateKey
|
||||
decryptedPassphrase?: string
|
||||
): Promise<'rsync' | 'scp'> {
|
||||
if (method === 'rsync') return 'rsync';
|
||||
if (method === 'scp') return 'scp';
|
||||
|
||||
if (method === 'auto') {
|
||||
console.info(`[TransfersService] Auto-detecting rsync capability on ${remoteHost}`);
|
||||
return new Promise((resolve) => {
|
||||
const { username, port, auth_method } = connection;
|
||||
const sshArgs = [];
|
||||
if (port) sshArgs.push('-p', port.toString());
|
||||
|
||||
if (auth_method === 'key' && privateKeyPath) {
|
||||
sshArgs.push('-i', privateKeyPath); // Use the provided temporary key path
|
||||
// If privateKeyPath (a file) is passphrase protected, ssh will handle it (prompt or agent)
|
||||
// For detection, we hope it works without interactive passphrase entry if agent is not set up.
|
||||
}
|
||||
// Password auth detection remains best-effort as ssh won't take password directly for a command.
|
||||
|
||||
const filteredSshArgs = sshArgs.filter(arg => !['-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null'].includes(arg));
|
||||
|
||||
const commandToRun = 'command -v rsync';
|
||||
const fullSshCommand = [...filteredSshArgs, `${username}@${remoteHost}`, commandToRun];
|
||||
|
||||
console.debug(`[TransfersService] Executing SSH for rsync check: ssh ${fullSshCommand.join(' ')}`);
|
||||
|
||||
const process = spawn('ssh', fullSshCommand);
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
process.stdout.on('data', (data) => stdout += data.toString());
|
||||
process.stderr.on('data', (data) => stderr += data.toString());
|
||||
|
||||
const timeoutDuration = 5000; // 5 seconds
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!process.killed) {
|
||||
process.kill();
|
||||
console.warn(`[TransfersService] Rsync detection on ${remoteHost} timed out after ${timeoutDuration}ms. Falling back to SCP.`);
|
||||
resolve('scp');
|
||||
}
|
||||
}, timeoutDuration);
|
||||
|
||||
process.on('close', (code) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (code === 0 && stdout.trim() !== '') {
|
||||
console.info(`[TransfersService] Rsync detected on ${remoteHost}. Path: ${stdout.trim()}`);
|
||||
resolve('rsync');
|
||||
} else {
|
||||
console.warn(`[TransfersService] Rsync not detected on ${remoteHost} (exit code ${code}, stderr: ${stderr.trim()}). Falling back to SCP.`);
|
||||
resolve('scp');
|
||||
}
|
||||
});
|
||||
|
||||
process.on('error', (err) => {
|
||||
clearTimeout(timeoutId);
|
||||
console.error(`[TransfersService] Error trying to detect rsync on ${remoteHost}: ${err.message}. Falling back to SCP.`);
|
||||
resolve('scp');
|
||||
});
|
||||
});
|
||||
}
|
||||
return 'scp'; // Default fallback
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface InitiateTransferPayload {
|
||||
connectionIds: number[];
|
||||
sourceItems: Array<{ name: string; path: string; type: 'file' | 'directory' }>;
|
||||
remoteTargetPath: string;
|
||||
transferMethod: 'auto' | 'rsync' | 'scp';
|
||||
}
|
||||
|
||||
export interface TransferSubTask {
|
||||
subTaskId: string;
|
||||
connectionId: number;
|
||||
sourceItemName: string;
|
||||
status: 'queued' | 'connecting' | 'transferring' | 'completed' | 'failed';
|
||||
progress?: number; // 0-100
|
||||
message?: string; // 例如错误信息
|
||||
transferMethodUsed?: 'rsync' | 'scp';
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
}
|
||||
|
||||
export interface TransferTask {
|
||||
taskId: string;
|
||||
status: 'queued' | 'in-progress' | 'completed' | 'failed' | 'partially-completed';
|
||||
userId: string | number; // 添加用户ID字段
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
subTasks: TransferSubTask[];
|
||||
overallProgress?: number; // 0-100, 根据子任务计算
|
||||
payload: InitiateTransferPayload; // 存储原始请求负载,方便追溯
|
||||
}
|
||||
@@ -677,6 +677,7 @@ const {
|
||||
contextMenuPosition,
|
||||
contextMenuItems,
|
||||
contextMenuRef, // 获取 ref 以传递给子组件
|
||||
contextTargetItem, // Get the target item from the composable
|
||||
showContextMenu, // 使用 Composable 提供的函数
|
||||
hideContextMenu, // <-- 获取 hideContextMenu 函数
|
||||
} = useFileManagerContextMenu({
|
||||
@@ -1617,6 +1618,8 @@ const handleOpenEditorClick = () => {
|
||||
:is-visible="contextMenuVisible"
|
||||
:position="contextMenuPosition"
|
||||
:items="contextMenuItems"
|
||||
:active-context-item="contextTargetItem"
|
||||
:current-directory-path="currentSftpManager?.currentPath?.value ?? '/'"
|
||||
@close-request="hideContextMenu"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, type PropType } from 'vue';
|
||||
import type { ContextMenuItem } from '../composables/file-manager/useFileManagerContextMenu';
|
||||
import { onUnmounted } from 'vue';
|
||||
import { ref, watch, nextTick, type PropType, onUnmounted } from 'vue'; // Added watch, nextTick
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import SendFilesModal from './SendFilesModal.vue';
|
||||
import type { ContextMenuItem } from '../composables/file-manager/useFileManagerContextMenu';
|
||||
import type { FileListItem } from '../types/sftp.types'; // Import FileListItem
|
||||
import { useDeviceDetection } from '../composables/useDeviceDetection';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
isVisible: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
@@ -17,9 +19,82 @@ defineProps({
|
||||
type: Array as PropType<ContextMenuItem[]>,
|
||||
required: true,
|
||||
},
|
||||
activeContextItem: { // Item that was right-clicked
|
||||
type: Object as PropType<FileListItem | null>,
|
||||
default: null,
|
||||
},
|
||||
currentDirectoryPath: { // Current path of the file manager
|
||||
type: String,
|
||||
required: true,
|
||||
}
|
||||
});
|
||||
|
||||
const { isMobile } = useDeviceDetection();
|
||||
const { t } = useI18n();
|
||||
const showSendFilesModal = ref(false);
|
||||
// Update the type for itemsToSendData
|
||||
const itemsToSendData = ref<{ name: string; path: string; type: 'file' | 'directory' }[]>([]);
|
||||
|
||||
// +++ 新增:用于菜单位置调整的 ref +++
|
||||
const contextMenuRef = ref<HTMLDivElement | null>(null);
|
||||
const computedRenderPosition = ref({ x: props.position.x, y: props.position.y });
|
||||
|
||||
watch(
|
||||
[() => props.isVisible, () => props.position],
|
||||
([newIsVisible, newPosition], [oldIsVisible, oldPosition]) => {
|
||||
if (newIsVisible) {
|
||||
// 仅当菜单从不可见变为可见,或当菜单可见时其初始位置改变时,才进行位置计算
|
||||
// oldPosition 可能为 undefined,所以需要检查
|
||||
const positionChangedWhileVisible = oldIsVisible && oldPosition && (newPosition.x !== oldPosition.x || newPosition.y !== oldPosition.y);
|
||||
|
||||
if (!oldIsVisible || positionChangedWhileVisible) {
|
||||
computedRenderPosition.value = { ...newPosition }; // 设置初始位置为当前点击位置
|
||||
|
||||
nextTick(() => {
|
||||
if (contextMenuRef.value) {
|
||||
const menuElement = contextMenuRef.value;
|
||||
const menuRect = menuElement.getBoundingClientRect();
|
||||
|
||||
// 如果菜单没有实际尺寸 (例如,内容为空或未渲染),则不进行调整
|
||||
if (menuRect.width === 0 && menuRect.height === 0) {
|
||||
// console.debug("[FileManagerContextMenu] Menu dimensions are zero, sticking to initial position.");
|
||||
return;
|
||||
}
|
||||
|
||||
let finalX = newPosition.x;
|
||||
let finalY = newPosition.y;
|
||||
const menuWidth = menuRect.width;
|
||||
const menuHeight = menuRect.height;
|
||||
const margin = 10; // 距离窗口边缘的最小间距
|
||||
|
||||
// console.debug(`[FileManagerContextMenu] Initial pos: (${finalX}, ${finalY}), Menu size: (${menuWidth}x${menuHeight}), Window: (${window.innerWidth}x${window.innerHeight})`);
|
||||
|
||||
// 调整水平位置,防止溢出右侧
|
||||
if (finalX + menuWidth > window.innerWidth) {
|
||||
finalX = window.innerWidth - menuWidth - margin;
|
||||
}
|
||||
|
||||
// 调整垂直位置,防止溢出底部
|
||||
if (finalY + menuHeight > window.innerHeight) {
|
||||
finalY = window.innerHeight - menuHeight - margin;
|
||||
}
|
||||
|
||||
// 确保菜单不超出屏幕左上角
|
||||
finalX = Math.max(margin, finalX);
|
||||
finalY = Math.max(margin, finalY);
|
||||
|
||||
// console.debug(`[FileManagerContextMenu] Adjusted pos: (${finalX}, ${finalY})`);
|
||||
computedRenderPosition.value = { x: finalX, y: finalY };
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 如果菜单不可见,确保 computedRenderPosition 与 props.position 同步,为下次显示做准备
|
||||
computedRenderPosition.value = { ...newPosition };
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true } // immediate 确保初始状态(如果isVisible为true)也设置正确
|
||||
);
|
||||
|
||||
// 隐藏菜单的逻辑由 useFileManagerContextMenu 中的全局点击监听器处理
|
||||
// 但我们仍然需要触发菜单项的 action,并通知父组件关闭菜单
|
||||
@@ -32,6 +107,43 @@ const handleItemClick = (item: ContextMenuItem) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendToClick = () => {
|
||||
if (props.activeContextItem) {
|
||||
const item = props.activeContextItem;
|
||||
const type = item.attrs.isDirectory ? 'directory' : 'file';
|
||||
// Ensure path is constructed correctly, assuming currentDirectoryPath ends with / or is root '/'
|
||||
// And filename does not start with /
|
||||
let fullPath = props.currentDirectoryPath;
|
||||
if (!fullPath.endsWith('/')) {
|
||||
fullPath += '/';
|
||||
}
|
||||
fullPath += item.filename;
|
||||
|
||||
// Normalize path to remove any double slashes, except for protocol like sftp://
|
||||
fullPath = fullPath.replace(/(?<!:)\/\//g, '/');
|
||||
|
||||
|
||||
itemsToSendData.value = [{
|
||||
name: item.filename,
|
||||
path: fullPath,
|
||||
type: type,
|
||||
}];
|
||||
} else {
|
||||
// No specific item clicked, perhaps send selected items? Or disable "Send To"?
|
||||
// For now, sending an empty array if no activeContextItem.
|
||||
// This scenario should ideally be handled by disabling the "Send to..." option
|
||||
// if no item is targeted or no selection is made that can be sent.
|
||||
itemsToSendData.value = [];
|
||||
}
|
||||
showSendFilesModal.value = true;
|
||||
emit('close-request');
|
||||
};
|
||||
|
||||
const handleFilesSent = (payload: any) => {
|
||||
console.log('Files to send (from FileManagerContextMenu):', payload);
|
||||
// 实际发送逻辑可以后续添加或委派
|
||||
};
|
||||
|
||||
|
||||
|
||||
// 管理二级菜单的展开状态
|
||||
@@ -62,9 +174,10 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="contextMenuRef"
|
||||
v-if="isVisible"
|
||||
class="fixed bg-background border border-border shadow-lg rounded-md z-[1002] min-w-[150px]"
|
||||
:style="{ top: `${position.y}px`, left: `${position.x}px` }"
|
||||
:style="{ top: `${computedRenderPosition.y}px`, left: `${computedRenderPosition.x}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<ul class="list-none p-1 m-0">
|
||||
@@ -123,6 +236,20 @@ onUnmounted(() => {
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
<li
|
||||
@click.stop="handleSendToClick"
|
||||
:class="[
|
||||
'px-4 py-1.5 cursor-pointer text-foreground text-sm flex items-center transition-colors duration-150 rounded mx-1',
|
||||
'hover:bg-primary/10 hover:text-primary'
|
||||
]"
|
||||
>
|
||||
{{ t('fileManager.contextMenu.sendTo', 'Send to...') }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<SendFilesModal
|
||||
v-model:visible="showSendFilesModal"
|
||||
:items-to-send="itemsToSendData"
|
||||
@send="handleFilesSent"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 bg-overlay flex justify-center items-center z-50 p-4"
|
||||
@click.self="handleCancel"
|
||||
>
|
||||
<div class="bg-background text-foreground p-6 rounded-lg shadow-xl border border-border w-full max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center pb-4 mb-4 border-b border-border flex-shrink-0">
|
||||
<h3 class="text-xl font-semibold">
|
||||
{{ t('sendFilesModal.title') }}
|
||||
</h3>
|
||||
<button
|
||||
@click="handleCancel"
|
||||
class="text-text-secondary hover:text-foreground transition-colors"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex-grow overflow-y-auto pr-1 space-y-4">
|
||||
<!-- Top Section: Search, Target Path, Transfer Method -->
|
||||
<div class="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
:placeholder="t('sendFilesModal.searchConnectionsPlaceholder')"
|
||||
v-model="searchTerm"
|
||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-input text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary text-sm"
|
||||
/>
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<div class="form-group flex-1">
|
||||
<label for="targetPath" class="block text-sm font-medium text-text-secondary mb-1">{{ t('sendFilesModal.targetPathLabel') }}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="targetPath"
|
||||
v-model="targetPath"
|
||||
:placeholder="t('sendFilesModal.targetPathPlaceholder')"
|
||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-input text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group sm:w-48">
|
||||
<label for="transferMethod" class="block text-sm font-medium text-text-secondary mb-1">{{ t('sendFilesModal.transferMethodLabel') }}</label>
|
||||
<select
|
||||
id="transferMethod"
|
||||
v-model="transferMethod"
|
||||
class="form-select w-full px-3 py-2 border border-border rounded-md shadow-sm bg-input text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary text-sm"
|
||||
>
|
||||
<option value="auto">{{ t('sendFilesModal.transferMethodAuto') }}</option>
|
||||
<option value="rsync">rsync</option>
|
||||
<option value="scp">scp</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connections Section -->
|
||||
<div class="border border-border rounded-md p-4 space-y-3 max-h-72 overflow-y-auto bg-muted/30">
|
||||
<div v-if="isLoadingConnections || isLoadingTags" class="flex items-center justify-center h-24 text-text-secondary">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i> {{ t('sendFilesModal.loadingConnections') }}
|
||||
</div>
|
||||
<div v-else-if="filteredGroupedConnections.length === 0 && !searchTerm" class="flex flex-col items-center justify-center h-24 text-text-secondary">
|
||||
<i class="fas fa-folder-open text-2xl mb-2"></i>
|
||||
<p>{{ t('sendFilesModal.noConnections') }}</p>
|
||||
</div>
|
||||
<div v-else-if="filteredGroupedConnections.length === 0 && searchTerm" class="flex flex-col items-center justify-center h-24 text-text-secondary">
|
||||
<i class="fas fa-search text-2xl mb-2"></i>
|
||||
<p>{{ t('sendFilesModal.noConnectionsFound') }}</p>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="group in filteredGroupedConnections" :key="group.tag ? group.tag.id : 'untagged'" class="tag-group">
|
||||
<div class="flex items-center py-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
:id="'tag-' + (group.tag ? group.tag.id : 'untagged')"
|
||||
:checked="isTagGroupSelected(group)"
|
||||
:indeterminate="isTagGroupIndeterminate(group)"
|
||||
@change="toggleTagGroupSelection(group)"
|
||||
class="mr-2 h-4 w-4 rounded border-border text-primary focus:ring-primary focus:ring-offset-0 cursor-pointer"
|
||||
/>
|
||||
<label :for="'tag-' + (group.tag ? group.tag.id : 'untagged')" class="font-semibold text-foreground select-none cursor-pointer text-sm">
|
||||
{{ group.tag ? group.tag.name : t('sendFilesModal.untaggedConnections') }} ({{ group.connections.length }})
|
||||
</label>
|
||||
</div>
|
||||
<ul class="pl-7 space-y-0.5">
|
||||
<li v-for="connection in group.connections" :key="connection.id" class="flex items-center py-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
:id="'conn-' + connection.id"
|
||||
:value="connection.id"
|
||||
v-model="selectedConnectionIds"
|
||||
class="mr-2 h-4 w-4 rounded border-border text-primary focus:ring-primary focus:ring-offset-0 cursor-pointer"
|
||||
/>
|
||||
<label :for="'conn-' + connection.id" class="text-sm text-foreground select-none cursor-pointer truncate" :title="connection.name">{{ connection.name }}</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items to Send Summary -->
|
||||
<div class="p-3 border border-border rounded-md bg-muted/30 space-y-1">
|
||||
<h3 class="text-sm font-semibold text-foreground">{{ t('sendFilesModal.itemsToSendTitle') }}</h3>
|
||||
<ul v-if="itemsToSend && itemsToSend.length > 0" class="max-h-24 overflow-y-auto space-y-0.5">
|
||||
<li v-for="item in itemsToSend" :key="item.path" class="text-xs text-text-secondary truncate" :title="item.path">
|
||||
{{ item.name }}
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="text-xs text-text-secondary italic">{{ t('sendFilesModal.noItemsSelected') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end items-center pt-4 mt-auto border-t border-border flex-shrink-0 space-x-3">
|
||||
<button
|
||||
@click="handleCancel"
|
||||
class="px-4 py-2 bg-transparent text-text-secondary border border-border rounded-md shadow-sm hover:bg-border hover:text-foreground focus:outline-none focus:ring-2 focus:ring-offset-background focus:ring-primary disabled:opacity-50 transition-colors duration-150 ease-in-out"
|
||||
>
|
||||
{{ t('sendFilesModal.cancelButton') }}
|
||||
</button>
|
||||
<button
|
||||
@click="handleSend"
|
||||
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-background focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-150 ease-in-out"
|
||||
:disabled="selectedConnectionIds.length === 0 || !targetPath.trim()"
|
||||
>
|
||||
{{ t('sendFilesModal.sendButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useConnectionsStore, type ConnectionInfo } from '../stores/connections.store';
|
||||
import { useTagsStore, type TagInfo } from '../stores/tags.store';
|
||||
import apiClient from '../utils/apiClient';
|
||||
import { useUiNotificationsStore } from '../stores/uiNotifications.store';
|
||||
|
||||
interface ItemToSend {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory'; // Type is now mandatory
|
||||
}
|
||||
|
||||
interface SourceItem { // As per backend InitiateTransferPayload
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
}
|
||||
|
||||
interface GroupedConnection {
|
||||
tag: TagInfo | null;
|
||||
connections: ConnectionInfo[];
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
itemsToSend: ItemToSend[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
// 'send' emit might become obsolete or change if all logic moves to API call
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionsStore = useConnectionsStore();
|
||||
const tagsStore = useTagsStore();
|
||||
const uiNotificationsStore = useUiNotificationsStore();
|
||||
|
||||
const searchTerm = ref('');
|
||||
const targetPath = ref('');
|
||||
const transferMethod = ref<'auto' | 'rsync' | 'scp'>('auto');
|
||||
const selectedConnectionIds = ref<number[]>([]);
|
||||
|
||||
const isLoadingConnections = ref(false);
|
||||
const isLoadingTags = ref(false);
|
||||
|
||||
// Simulate data for itemsToSend for development if not provided
|
||||
const itemsToSendInternal = computed<ItemToSend[]>(() => {
|
||||
if (props.itemsToSend && props.itemsToSend.length > 0) {
|
||||
return props.itemsToSend;
|
||||
}
|
||||
return [
|
||||
{ name: 'file1.txt', path: '/local/file1.txt', type: 'file' },
|
||||
{ name: 'folderA', path: '/local/folderA', type: 'directory' },
|
||||
{ name: 'another-item.zip', path: '/local/another-item.zip', type: 'file' }
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
isLoadingConnections.value = true;
|
||||
isLoadingTags.value = true;
|
||||
try {
|
||||
if (connectionsStore.connections.length === 0) {
|
||||
await connectionsStore.fetchConnections();
|
||||
}
|
||||
if (tagsStore.tags.length === 0) {
|
||||
await tagsStore.fetchTags();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(t('sendFilesModal.errorFetchingData'), error);
|
||||
// Optionally, show a user-facing error message
|
||||
} finally {
|
||||
isLoadingConnections.value = false;
|
||||
isLoadingTags.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const allConnections = computed(() => connectionsStore.connections);
|
||||
const allTags = computed(() => tagsStore.tags);
|
||||
|
||||
const groupedConnections = computed<GroupedConnection[]>(() => {
|
||||
const groups: Record<string, GroupedConnection> = {};
|
||||
const untaggedConnections: ConnectionInfo[] = [];
|
||||
|
||||
allConnections.value.forEach(conn => {
|
||||
const connTagIds = conn.tag_ids || [];
|
||||
if (connTagIds.length === 0) {
|
||||
untaggedConnections.push(conn);
|
||||
} else {
|
||||
connTagIds.forEach((tagId: number) => {
|
||||
const tag = allTags.value.find(t => t.id === tagId);
|
||||
if (tag) {
|
||||
if (!groups[tag.id]) {
|
||||
groups[tag.id] = { tag, connections: [] };
|
||||
}
|
||||
// Avoid adding duplicate connections to the same group
|
||||
if (!groups[tag.id].connections.some(c => c.id === conn.id)) {
|
||||
groups[tag.id].connections.push(conn);
|
||||
}
|
||||
} else {
|
||||
// Connection has a tag ID that doesn't exist in tagsStore, treat as untagged for this modal
|
||||
// Or handle as an error, or create a "missing tag" group
|
||||
if (!untaggedConnections.some(c => c.id === conn.id)) {
|
||||
untaggedConnections.push(conn);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const sortedGroups = Object.values(groups).sort((a, b) =>
|
||||
a.tag!.name.localeCompare(b.tag!.name)
|
||||
);
|
||||
|
||||
if (untaggedConnections.length > 0) {
|
||||
return [...sortedGroups, { tag: null, connections: untaggedConnections }];
|
||||
}
|
||||
return sortedGroups;
|
||||
});
|
||||
|
||||
const filteredGroupedConnections = computed<GroupedConnection[]>(() => {
|
||||
if (!searchTerm.value.trim()) {
|
||||
return groupedConnections.value;
|
||||
}
|
||||
const lowerSearchTerm = searchTerm.value.toLowerCase();
|
||||
return groupedConnections.value
|
||||
.map(group => {
|
||||
const filteredConns = group.connections.filter(conn =>
|
||||
conn.name.toLowerCase().includes(lowerSearchTerm)
|
||||
);
|
||||
return { ...group, connections: filteredConns };
|
||||
})
|
||||
.filter(group => group.connections.length > 0);
|
||||
});
|
||||
|
||||
const isTagGroupSelected = (group: GroupedConnection): boolean => {
|
||||
if (group.connections.length === 0) return false;
|
||||
return group.connections.every(conn => selectedConnectionIds.value.includes(conn.id));
|
||||
};
|
||||
|
||||
const isTagGroupIndeterminate = (group: GroupedConnection): boolean => {
|
||||
if (group.connections.length === 0) return false;
|
||||
const selectedCount = group.connections.filter(conn => selectedConnectionIds.value.includes(conn.id)).length;
|
||||
return selectedCount > 0 && selectedCount < group.connections.length;
|
||||
};
|
||||
|
||||
const toggleTagGroupSelection = (group: GroupedConnection) => {
|
||||
const groupConnectionIds = group.connections.map(conn => conn.id);
|
||||
if (isTagGroupSelected(group)) {
|
||||
// Deselect all
|
||||
selectedConnectionIds.value = selectedConnectionIds.value.filter(id => !groupConnectionIds.includes(id));
|
||||
} else {
|
||||
// Select all (or add to selection if partially selected)
|
||||
groupConnectionIds.forEach(id => {
|
||||
if (!selectedConnectionIds.value.includes(id)) {
|
||||
selectedConnectionIds.value.push(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.visible, (newValue) => {
|
||||
if (newValue) {
|
||||
// Reset state when modal becomes visible, except perhaps targetPath if desired
|
||||
// searchTerm.value = '';
|
||||
// selectedConnectionIds.value = [];
|
||||
// transferMethod.value = 'auto';
|
||||
// If stores might be empty, fetch again or ensure they are fresh
|
||||
if (connectionsStore.connections.length === 0) {
|
||||
connectionsStore.fetchConnections().catch(error => console.error(t('sendFilesModal.errorFetchingConnections'), error));
|
||||
}
|
||||
if (tagsStore.tags.length === 0) {
|
||||
tagsStore.fetchTags().catch(error => console.error(t('sendFilesModal.errorFetchingTags'), error));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleSend = async () => {
|
||||
if (selectedConnectionIds.value.length === 0 || !targetPath.value.trim()) {
|
||||
uiNotificationsStore.showError(t('sendFilesModal.validationError')); // Assuming you add this key
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceItems: SourceItem[] = itemsToSendInternal.value;
|
||||
|
||||
const payload = {
|
||||
sourceItems,
|
||||
connectionIds: [...selectedConnectionIds.value],
|
||||
remoteTargetPath: targetPath.value.trim(),
|
||||
transferMethod: transferMethod.value,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await apiClient.post('/transfers/send', payload);
|
||||
// Assuming the backend returns something like { taskId: "some-id" } on success
|
||||
if (response.data && response.data.taskId) {
|
||||
uiNotificationsStore.showSuccess(t('sendFilesModal.transferInitiated', { taskId: response.data.taskId }));
|
||||
} else {
|
||||
uiNotificationsStore.showSuccess(t('sendFilesModal.transferInitiatedGeneric'));
|
||||
}
|
||||
emit('update:visible', false);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to initiate transfer:', error);
|
||||
const errorMessage = error.response?.data?.message || error.message || t('sendFilesModal.transferFailedError');
|
||||
uiNotificationsStore.showError(errorMessage);
|
||||
// Do not close modal on error
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
// Fallback i18n messages are now removed as they are expected to be in the locale JSON files.
|
||||
</script>
|
||||
@@ -6,6 +6,7 @@ import { useRoute } from 'vue-router';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import WorkspaceConnectionListComponent from './WorkspaceConnectionList.vue';
|
||||
import TabBarContextMenu from './TabBarContextMenu.vue';
|
||||
import TransferProgressModal from './TransferProgressModal.vue'; // 导入传输进度模态框
|
||||
import { useSessionStore } from '../stores/session.store';
|
||||
import { useConnectionsStore, type ConnectionInfo } from '../stores/connections.store';
|
||||
import { useLayoutStore, type PaneName } from '../stores/layout.store';
|
||||
@@ -60,6 +61,7 @@ const closeSession = (event: MouseEvent, sessionId: string) => {
|
||||
const sessionStore = useSessionStore(); // Session store 保持不变
|
||||
const showConnectionListPopup = ref(false); // 连接列表弹出状态
|
||||
const draggableSessions = ref<SessionTabInfoWithStatus[]>([]); // + Local state for draggable
|
||||
const showTransferProgressModal = ref(false); // 控制传输进度模态框的显示状态
|
||||
|
||||
// + Watch prop changes to update local state
|
||||
watch(() => props.sessions, (newSessions) => {
|
||||
@@ -457,6 +459,13 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<i :class="[eyeIconClass, 'text-sm']"></i>
|
||||
</button>
|
||||
<!-- 新增:查看传输进度按钮 -->
|
||||
<button v-if="!isMobile"
|
||||
class="flex items-center justify-center px-3 h-full border-l border-border text-text-secondary hover:bg-border hover:text-foreground transition-colors duration-150"
|
||||
@click="showTransferProgressModal = true"
|
||||
:title="t('terminalTabBar.showTransferProgressTooltip', '查看传输进度')">
|
||||
<i class="fas fa-tasks text-sm"></i>
|
||||
</button>
|
||||
<!-- +++ 使用 v-if 隐藏移动端的布局按钮 +++ -->
|
||||
<button v-if="!isMobile" class="flex items-center justify-center px-3 h-full border-l border-border text-text-secondary hover:bg-border hover:text-foreground transition-colors duration-150"
|
||||
@click="openLayoutConfigurator" :title="t('layout.configure', '配置布局')">
|
||||
@@ -492,5 +501,7 @@ onBeforeUnmount(() => {
|
||||
@menu-action="handleContextMenuAction"
|
||||
@close="closeContextMenu"
|
||||
/>
|
||||
<!-- 传输进度模态框 -->
|
||||
<TransferProgressModal v-model:visible="showTransferProgressModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import apiClient from '../utils/apiClient';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits(['update:visible']);
|
||||
const { t } = useI18n();
|
||||
|
||||
// --- 新增:文件传输相关 ---
|
||||
|
||||
// 数据结构参考
|
||||
interface TransferSubTask {
|
||||
subTaskId: string;
|
||||
connectionId: number;
|
||||
sourceItemName: string;
|
||||
status: 'queued' | 'connecting' | 'transferring' | 'completed' | 'failed';
|
||||
progress?: number; // 0-100
|
||||
message?: string;
|
||||
transferMethodUsed?: 'rsync' | 'scp';
|
||||
}
|
||||
|
||||
interface TransferTask {
|
||||
taskId: string;
|
||||
status: 'queued' | 'in-progress' | 'completed' | 'failed' | 'partially-completed';
|
||||
createdAt: string | Date;
|
||||
updatedAt: string | Date;
|
||||
subTasks: TransferSubTask[];
|
||||
overallProgress?: number;
|
||||
}
|
||||
|
||||
const transferTasks = ref<TransferTask[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const errorLoading = ref<string | null>(null);
|
||||
const pollingIntervalId = ref<number | null>(null);
|
||||
|
||||
const fetchTransferTasks = async () => {
|
||||
isLoading.value = true;
|
||||
errorLoading.value = null;
|
||||
try {
|
||||
// 假设后端API路径为 /api/v1/transfers/status,且返回数据结构为 { data: TransferTask[] }
|
||||
// 请根据实际API调整这里的类型和数据访问
|
||||
const response = await apiClient.get<{ data: TransferTask[] }>('/transfers/status');
|
||||
transferTasks.value = Array.isArray(response.data.data) ? response.data.data : (Array.isArray(response.data) ? response.data : []);
|
||||
} catch (error: any) {
|
||||
console.error("Failed to fetch transfer tasks:", error);
|
||||
errorLoading.value = error.response?.data?.message || error.message || t('transferProgressModal.error.unknown', '未知错误');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getDisplayStatus = (status: string): string => {
|
||||
const statusKeyMap: Record<string, string> = {
|
||||
'queued': 'transferProgressModal.status.queued',
|
||||
'in-progress': 'transferProgressModal.status.inProgress',
|
||||
'completed': 'transferProgressModal.status.completed',
|
||||
'failed': 'transferProgressModal.status.failed',
|
||||
'partially-completed': 'transferProgressModal.status.partiallyCompleted',
|
||||
'connecting': 'transferProgressModal.status.connecting',
|
||||
'transferring': 'transferProgressModal.status.transferring',
|
||||
};
|
||||
// 提供一个默认的回退文本,以防i18n key缺失
|
||||
const defaultText = status.charAt(0).toUpperCase() + status.slice(1).replace('-', ' ');
|
||||
return t(statusKeyMap[status] || `status.${status}`, defaultText);
|
||||
};
|
||||
|
||||
const formatDate = (dateInput: string | Date): string => {
|
||||
if (!dateInput) return '';
|
||||
try {
|
||||
return new Date(dateInput).toLocaleString(navigator.language, {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
});
|
||||
} catch (e) {
|
||||
return String(dateInput); // Fallback if date is invalid
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (props.visible) {
|
||||
fetchTransferTasks();
|
||||
if (pollingIntervalId.value === null) {
|
||||
pollingIntervalId.value = window.setInterval(fetchTransferTasks, 5000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollingIntervalId.value !== null) {
|
||||
clearInterval(pollingIntervalId.value);
|
||||
pollingIntervalId.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.visible, (newVisible) => {
|
||||
// internalVisible.value = newVisible; // 由下面的watch处理
|
||||
if (newVisible) {
|
||||
fetchTransferTasks(); // 模态框可见时立即获取一次数据
|
||||
if (pollingIntervalId.value === null) { // 只有在没有定时器时才启动
|
||||
pollingIntervalId.value = window.setInterval(fetchTransferTasks, 5000);
|
||||
}
|
||||
} else {
|
||||
if (pollingIntervalId.value !== null) {
|
||||
clearInterval(pollingIntervalId.value);
|
||||
pollingIntervalId.value = null;
|
||||
}
|
||||
}
|
||||
}, { immediate: false }); // immediate: false 避免在组件初始化时立即执行,onMounted已处理首次加载
|
||||
|
||||
// --- 原有:模态框可见性控制 ---
|
||||
const internalVisible = ref(props.visible);
|
||||
|
||||
// 监听 props.visible 的变化来更新 internalVisible
|
||||
watch(() => props.visible, (newVisibleValue) => {
|
||||
internalVisible.value = newVisibleValue;
|
||||
}, { immediate: true }); // 确保初始状态同步
|
||||
|
||||
// 监听 internalVisible 的变化来 emit update:visible
|
||||
watch(internalVisible, (newVal) => {
|
||||
if (newVal !== props.visible) {
|
||||
emit('update:visible', newVal);
|
||||
}
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
internalVisible.value = false;
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="internalVisible"
|
||||
class="fixed inset-0 bg-overlay flex justify-center items-center z-50 p-4"
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<div class="bg-background text-foreground p-6 rounded-lg shadow-xl border border-border w-full max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<!-- Header -->
|
||||
<h3 class="text-xl font-semibold text-center mb-6 flex-shrink-0">
|
||||
{{ t('transferProgressModal.title', '文件传输进度') }}
|
||||
</h3>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex-grow overflow-y-auto mb-6 pr-2 space-y-4 custom-scrollbar">
|
||||
<div v-if="isLoading && transferTasks.length === 0" class="text-center text-text-secondary py-10">
|
||||
<svg class="animate-spin h-8 w-8 text-primary mx-auto mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ t('transferProgressModal.loading', '正在加载传输任务...') }}
|
||||
</div>
|
||||
<div v-else-if="errorLoading" class="text-center text-red-500 bg-red-50 p-4 rounded-md">
|
||||
<p class="font-semibold">{{ t('transferProgressModal.errorLoadingTitle', '加载错误') }}</p>
|
||||
<p>{{ t('transferProgressModal.errorLoading', { error: errorLoading }) }}</p>
|
||||
</div>
|
||||
<div v-else-if="!isLoading && transferTasks.length === 0" class="text-center text-text-secondary py-10">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-gray-400 mx-auto mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{{ t('transferProgressModal.noTasks', '当前没有活动的传输任务。') }}
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="task in transferTasks" :key="task.taskId" class="bg-background-alt p-3 rounded-lg border border-border-alt shadow-sm hover:shadow-md transition-shadow">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<span class="font-semibold text-md block">{{ t('transferProgressModal.task.idLabel', '任务') }}: {{ task.taskId }}</span>
|
||||
<span class="text-xs text-text-muted">{{ t('transferProgressModal.task.createdAt', '创建于') }}: {{ formatDate(task.createdAt) }}</span>
|
||||
</div>
|
||||
<span :class="['px-2.5 py-1 text-xs font-semibold rounded-full',
|
||||
{ 'bg-green-100 text-green-700': task.status === 'completed' },
|
||||
{ 'bg-red-100 text-red-700': task.status === 'failed' },
|
||||
{ 'bg-yellow-100 text-yellow-700': task.status === 'partially-completed' || task.status === 'queued' },
|
||||
{ 'bg-blue-100 text-blue-700': task.status === 'in-progress' }
|
||||
]">
|
||||
{{ getDisplayStatus(task.status) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="task.overallProgress !== undefined" class="mb-2">
|
||||
<div class="flex justify-between text-xs text-text-secondary mb-0.5">
|
||||
<span>{{ t('transferProgressModal.task.overallProgress', '整体进度') }}</span>
|
||||
<span>{{ task.overallProgress }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-border rounded-full h-1.5">
|
||||
<div class="bg-primary h-1.5 rounded-full" :style="{ width: task.overallProgress + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details v-if="task.subTasks && task.subTasks.length > 0" class="mt-2 group">
|
||||
<summary class="text-xs font-medium text-primary hover:underline cursor-pointer list-none">
|
||||
{{ t('transferProgressModal.subTasks.titleToggle', { count: task.subTasks.length }) }}
|
||||
<span class="group-open:hidden">+</span><span class="hidden group-open:inline">-</span>
|
||||
</summary>
|
||||
<ul class="mt-2 space-y-1.5 pl-3 border-l border-border-alt ml-1">
|
||||
<li v-for="subTask in task.subTasks" :key="subTask.subTaskId" class="text-xs p-1.5 rounded bg-background border border-border-alt/50">
|
||||
<p><strong>{{ t('transferProgressModal.subTask.source', '源文件') }}:</strong> {{ subTask.sourceItemName }}</p>
|
||||
<p><strong>{{ t('transferProgressModal.subTask.connectionId', '目标连接') }}:</strong> {{ subTask.connectionId }}</p>
|
||||
<p><strong>{{ t('transferProgressModal.subTask.status', '状态') }}:</strong> {{ getDisplayStatus(subTask.status) }}
|
||||
<span v-if="subTask.progress !== undefined"> ({{ subTask.progress }}%)</span>
|
||||
</p>
|
||||
<p v-if="subTask.transferMethodUsed"><strong>{{ t('transferProgressModal.subTask.method', '方法') }}:</strong> {{ subTask.transferMethodUsed }}</p>
|
||||
<p v-if="subTask.status === 'failed' && subTask.message" class="text-red-600">
|
||||
<strong>{{ t('transferProgressModal.subTask.error', '错误') }}:</strong> {{ subTask.message }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
<div v-else-if="task.subTasks && task.subTasks.length === 0" class="mt-2 text-xs text-text-muted">
|
||||
{{ t('transferProgressModal.subTasks.noSubTasks', '没有子任务。') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end items-center pt-4 mt-auto flex-shrink-0 border-t border-border">
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary transition duration-150 ease-in-out"
|
||||
>
|
||||
{{ t('common.close', '关闭') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bg-overlay {
|
||||
background-color: rgba(0, 0, 0, 0.6); /* Slightly darker overlay */
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(128, 128, 128, 0.3);
|
||||
border-radius: 10px;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(128, 128, 128, 0.5);
|
||||
}
|
||||
|
||||
/* For Firefox */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(128, 128, 128, 0.3) transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -1424,5 +1424,28 @@
|
||||
"logExportSuccess": "Suspended session log {name} has started downloading.",
|
||||
"logExportError": "Failed to export suspended session log: {error}"
|
||||
}
|
||||
},
|
||||
"sendFilesModal": {
|
||||
"title": "Send Files",
|
||||
"searchConnectionsPlaceholder": "Search connections...",
|
||||
"targetPathLabel": "Target Path",
|
||||
"targetPathPlaceholder": "/remote/path/to/destination",
|
||||
"transferMethodLabel": "Transfer Method",
|
||||
"transferMethodAuto": "Auto",
|
||||
"loadingConnections": "Loading connections...",
|
||||
"noConnections": "No connections available. Please add connections first.",
|
||||
"noConnectionsFound": "No connections found matching your search.",
|
||||
"untaggedConnections": "Untagged",
|
||||
"itemsToSendTitle": "Items to send:",
|
||||
"noItemsSelected": "No items selected to send.",
|
||||
"sendButton": "Send",
|
||||
"cancelButton": "Cancel",
|
||||
"errorFetchingData": "Error fetching data for modal.",
|
||||
"errorFetchingConnections": "Error fetching connections.",
|
||||
"errorFetchingTags": "Error fetching tags.",
|
||||
"validationError": "Please select at least one connection and specify a target path.",
|
||||
"transferInitiated": "Transfer task created, Task ID: ",
|
||||
"transferInitiatedGeneric": "Transfer task created successfully.",
|
||||
"transferFailedError": "Failed to initiate transfer. Please try again."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1385,5 +1385,28 @@
|
||||
"resumeErrorBackend": "バックエンドがセッションの再開に失敗しました: {error}",
|
||||
"autoTerminated": "中断されたセッション「{name}」は、理由: {reason} によりバックエンドによって自動終了されました。"
|
||||
}
|
||||
},
|
||||
"sendFilesModal": {
|
||||
"title": "ファイル送信",
|
||||
"searchConnectionsPlaceholder": "接続を検索...",
|
||||
"targetPathLabel": "ターゲットパス",
|
||||
"targetPathPlaceholder": "/remote/path/to/destination",
|
||||
"transferMethodLabel": "転送方法",
|
||||
"transferMethodAuto": "自動",
|
||||
"loadingConnections": "接続を読み込み中...",
|
||||
"noConnections": "利用可能な接続がありません。まず接続を追加してください。",
|
||||
"noConnectionsFound": "検索に一致する接続が見つかりませんでした。",
|
||||
"untaggedConnections": "タグなし",
|
||||
"itemsToSendTitle": "送信するアイテム:",
|
||||
"noItemsSelected": "送信するアイテムが選択されていません。",
|
||||
"sendButton": "送信",
|
||||
"cancelButton": "キャンセル",
|
||||
"errorFetchingData": "モーダルデータの取得中にエラーが発生しました。",
|
||||
"errorFetchingConnections": "接続データの取得中にエラーが発生しました。",
|
||||
"errorFetchingTags": "タグデータの取得中にエラーが発生しました。",
|
||||
"validationError": "少なくとも1つの接続を選択し、ターゲットパスを指定してください。",
|
||||
"transferInitiated": "転送タスクが作成されました、タスクID: ",
|
||||
"transferInitiatedGeneric": "転送タスクが正常に作成されました。",
|
||||
"transferFailedError": "転送の開始に失敗しました。もう一度お試しください。"
|
||||
}
|
||||
}
|
||||
@@ -1396,6 +1396,65 @@
|
||||
"remove": "移除",
|
||||
"exportLog": "导出日志"
|
||||
}
|
||||
|
||||
},
|
||||
"transferProgressModal": {
|
||||
"title": "文件传输进度",
|
||||
"loading": "正在加载传输任务...",
|
||||
"errorLoadingTitle": "加载错误",
|
||||
"errorLoading": "加载传输任务时出错: {error}",
|
||||
"noTasks": "当前没有活动的传输任务。",
|
||||
"error": {
|
||||
"unknown": "发生未知错误"
|
||||
},
|
||||
"status": {
|
||||
"queued": "排队中",
|
||||
"inProgress": "进行中",
|
||||
"completed": "已完成",
|
||||
"failed": "已失败",
|
||||
"partiallyCompleted": "部分完成",
|
||||
"connecting": "连接中",
|
||||
"transferring": "传输中"
|
||||
},
|
||||
"task": {
|
||||
"idLabel": "任务",
|
||||
"createdAt": "创建于",
|
||||
"overallProgress": "整体进度"
|
||||
},
|
||||
"subTasks": {
|
||||
"titleToggle": "查看/隐藏 {count} 个子任务",
|
||||
"noSubTasks": "没有子任务。"
|
||||
},
|
||||
"subTask": {
|
||||
"source": "源文件",
|
||||
"connectionId": "目标连接",
|
||||
"status": "状态",
|
||||
"method": "方法",
|
||||
"error": "错误"
|
||||
}
|
||||
},
|
||||
"sendFilesModal": {
|
||||
"title": "发送文件",
|
||||
"searchConnectionsPlaceholder": "搜索连接...",
|
||||
"targetPathLabel": "目标路径",
|
||||
"targetPathPlaceholder": "/远程/路径/到/目的地",
|
||||
"transferMethodLabel": "传输方式",
|
||||
"transferMethodAuto": "自动",
|
||||
"loadingConnections": "正在加载连接...",
|
||||
"noConnections": "没有可用的连接。请先添加连接。",
|
||||
"noConnectionsFound": "未找到匹配搜索的连接。",
|
||||
"untaggedConnections": "未标记",
|
||||
"itemsToSendTitle": "待发送项目:",
|
||||
"noItemsSelected": "未选择待发送的项目。",
|
||||
"sendButton": "发送",
|
||||
"cancelButton": "取消",
|
||||
"errorFetchingData": "获取模态框数据时出错。",
|
||||
"errorFetchingConnections": "获取连接数据时出错。",
|
||||
"errorFetchingTags": "获取标签数据时出错。",
|
||||
"validationError": "请至少选择一个连接并指定目标路径。",
|
||||
"transferInitiated": "传输任务已创建,任务 ID: ",
|
||||
"transferInitiatedGeneric": "传输任务创建成功。",
|
||||
"transferFailedError": "发起传输失败。请重试。"
|
||||
},
|
||||
"time": {
|
||||
"unknown": "未知时间",
|
||||
|
||||
Reference in New Issue
Block a user