feat(i18n): add multi-language support (ja/ko/vi/ru/zh-TW) with system auto-detection
- Extend AppLanguage to 7 locales: zh-CN, zh-TW, en, ja, ko, vi, ru - Auto-detect system language via navigator.languages; fallback to English - Add BCP47 locale mapping for date formatting and sorting - Create 50 translation JSON files (5 languages × 10 namespaces, 1137 keys each) - Update language selector with native language names - Extend locale key parity test to cover all 7 languages (70 assertions) - Add VNC/RDP desktop, serial terminal, AI agent, port forwarding, and SFTP features - Add credential encryption, host metrics, session backup crypto, and zmodem bridge - Update Rust backend: desktop services, host metrics, SSH/SFTP enhancements
This commit is contained in:
1 parent
fea6d8db04
commit
b0d0e3a5f3
301 files changed
+28002
-2705
No files matched your search
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
FolderOpen, Upload, Download, Trash2,
|
||||
Edit3, Copy, FileText, ShieldCheck,
|
||||
@@ -35,6 +36,7 @@ interface SFTPViewProps {
|
||||
}
|
||||
|
||||
export default function SFTPView({ tabId, isActive: _isActive = true, sessionId }: SFTPViewProps) {
|
||||
const { t } = useTranslation('sftp')
|
||||
const {
|
||||
sessions,
|
||||
localPath,
|
||||
@@ -137,23 +139,29 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
})
|
||||
}, [setRemoteSelected])
|
||||
|
||||
const reportTransfer = useCallback((action: '上传' | '下载', result: { completed: number; failed: number; errors: string[] }) => {
|
||||
const reportTransfer = useCallback((action: 'upload' | 'download', result: { completed: number; failed: number; errors: string[] }) => {
|
||||
const actionLabel = action === 'upload' ? t('toast.uploadAction') : t('toast.downloadAction')
|
||||
if (result.failed > 0) {
|
||||
const detail = result.errors[0] || `${action}失败`
|
||||
toast.error(`${action}完成 ${result.completed} 个,失败 ${result.failed} 个:${detail}`)
|
||||
const detail = result.errors[0] || actionLabel
|
||||
toast.error(t('toast.uploadPartial', {
|
||||
action: actionLabel,
|
||||
completed: result.completed,
|
||||
failed: result.failed,
|
||||
detail,
|
||||
}))
|
||||
} else if (result.completed > 0) {
|
||||
toast.success(`${action}完成 ${result.completed} 个文件`)
|
||||
toast.success(t('toast.uploadSuccess', { action: actionLabel, completed: result.completed }))
|
||||
}
|
||||
}, [toast])
|
||||
}, [t, toast])
|
||||
|
||||
const performUpload = useCallback(async (sources: Array<{ path: string; name?: string; size?: number }>, targetPath?: string) => {
|
||||
const performUpload = useCallback(async (sources: Array<{ path: string; name?: string; size?: number; isDirectory?: boolean }>, targetPath?: string) => {
|
||||
const result = await uploadFiles(sources, targetPath)
|
||||
reportTransfer('上传', result)
|
||||
reportTransfer('upload', result)
|
||||
}, [reportTransfer, uploadFiles])
|
||||
|
||||
const performDownload = useCallback(async (files?: FileItem[], targetPath?: string) => {
|
||||
const result = await downloadFiles(files, targetPath)
|
||||
reportTransfer('下载', result)
|
||||
reportTransfer('download', result)
|
||||
}, [downloadFiles, reportTransfer])
|
||||
|
||||
// ===== Context menu handlers =====
|
||||
@@ -161,30 +169,30 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
// Rename handler
|
||||
const handleRename = useCallback(async (file: FileItem) => {
|
||||
const newName = await prompt({
|
||||
title: '重命名',
|
||||
message: '输入新名称:',
|
||||
placeholder: '新名称',
|
||||
title: t('dialog.renameTitle'),
|
||||
message: t('dialog.renameMessage'),
|
||||
placeholder: t('dialog.renamePlaceholder'),
|
||||
defaultValue: file.name,
|
||||
})
|
||||
if (newName && newName !== file.name) {
|
||||
const ok = await renameRemote(file, newName)
|
||||
if (ok) {
|
||||
toast.success(`已重命名为 ${newName}`)
|
||||
toast.success(t('toast.renamed', { name: newName }))
|
||||
} else {
|
||||
toast.error('重命名失败')
|
||||
toast.error(t('toast.renameFailed'))
|
||||
}
|
||||
}
|
||||
}, [prompt, renameRemote, toast])
|
||||
}, [prompt, renameRemote, t, toast])
|
||||
|
||||
// Copy path handler
|
||||
const handleCopyPath = useCallback(async (filePath: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(filePath)
|
||||
toast.success('路径已复制到剪贴板')
|
||||
toast.success(t('toast.pathCopied'))
|
||||
} catch {
|
||||
toast.error('复制路径失败')
|
||||
toast.error(t('toast.pathCopyFailed'))
|
||||
}
|
||||
}, [toast])
|
||||
}, [t, toast])
|
||||
|
||||
// Properties handler
|
||||
const handleShowProperties = useCallback((file: FileItem) => {
|
||||
@@ -203,10 +211,10 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
const handleChmodConfirm = useCallback(async (filePath: string, mode: number) => {
|
||||
const ok = await chmodRemote(filePath, mode)
|
||||
if (ok) {
|
||||
toast.success(`权限已更新为 ${mode.toString(8)}`)
|
||||
toast.success(t('toast.chmodUpdated', { mode: mode.toString(8) }))
|
||||
return true
|
||||
} else {
|
||||
toast.error('权限修改失败')
|
||||
toast.error(t('toast.chmodFailed'))
|
||||
return false
|
||||
}
|
||||
}, [chmodRemote, toast])
|
||||
@@ -217,20 +225,20 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
icon: <FolderOpen className="w-4 h-4" />,
|
||||
label: '打开',
|
||||
label: t('contextMenu.open'),
|
||||
action: () => file.type === 'directory' && navigateLocal(file.path),
|
||||
},
|
||||
...(file.type !== 'directory'
|
||||
? [{
|
||||
icon: <Upload className="w-4 h-4" />,
|
||||
label: '上传',
|
||||
label: t('contextMenu.upload'),
|
||||
action: () => void performUpload([{ path: file.path, name: file.name, size: file.size }]),
|
||||
} as MenuItem]
|
||||
: []),
|
||||
{ icon: <Copy className="w-4 h-4" />, label: '复制路径', separator: true, action: () => handleCopyPath(file.path) },
|
||||
{ icon: <Copy className="w-4 h-4" />, label: t('contextMenu.copyPath'), separator: true, action: () => handleCopyPath(file.path) },
|
||||
{
|
||||
icon: <FileText className="w-4 h-4" />,
|
||||
label: '属性',
|
||||
label: t('contextMenu.properties'),
|
||||
action: () => handleShowProperties(file),
|
||||
},
|
||||
]
|
||||
@@ -243,36 +251,36 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
icon: <FolderOpen className="w-4 h-4" />,
|
||||
label: '打开',
|
||||
label: t('contextMenu.open'),
|
||||
action: () => file.type === 'directory' && navigateRemote(file.path),
|
||||
},
|
||||
{
|
||||
icon: <Download className="w-4 h-4" />,
|
||||
label: '下载',
|
||||
label: t('contextMenu.download'),
|
||||
action: () => void performDownload([file]),
|
||||
},
|
||||
...(file.type === 'file'
|
||||
? [
|
||||
{
|
||||
icon: <FileCode className="w-4 h-4" />,
|
||||
label: '编辑',
|
||||
label: t('contextMenu.edit'),
|
||||
action: () => setEditFile(file),
|
||||
} as MenuItem,
|
||||
]
|
||||
: []),
|
||||
{ icon: <Edit3 className="w-4 h-4" />, label: '重命名', separator: true, action: () => handleRename(file) },
|
||||
{ icon: <Copy className="w-4 h-4" />, label: '复制路径', action: () => handleCopyPath(file.path) },
|
||||
{ icon: <Edit3 className="w-4 h-4" />, label: t('contextMenu.rename'), separator: true, action: () => handleRename(file) },
|
||||
{ icon: <Copy className="w-4 h-4" />, label: t('contextMenu.copyPath'), action: () => handleCopyPath(file.path) },
|
||||
{
|
||||
icon: <FileText className="w-4 h-4" />,
|
||||
label: '属性',
|
||||
label: t('contextMenu.properties'),
|
||||
action: () => handleShowProperties(file),
|
||||
},
|
||||
{
|
||||
icon: <ShieldCheck className="w-4 h-4" />,
|
||||
label: '权限',
|
||||
label: t('contextMenu.permissions'),
|
||||
action: () => handleChmod(file),
|
||||
},
|
||||
{ icon: <Trash2 className="w-4 h-4" />, label: '删除', separator: true, danger: true, action: () => deleteRemote([file]) },
|
||||
{ icon: <Trash2 className="w-4 h-4" />, label: t('contextMenu.delete'), separator: true, danger: true, action: () => deleteRemote([file]) },
|
||||
]
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, items })
|
||||
}, [navigateRemote, performDownload, deleteRemote, handleRename, handleCopyPath, handleShowProperties, handleChmod])
|
||||
@@ -302,34 +310,28 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
// Upload selected local rows first; otherwise use the native picker so Rust
|
||||
// receives real absolute paths instead of browser-only File objects.
|
||||
const handleUpload = useCallback(async () => {
|
||||
const selectedFiles = localFiles.filter(
|
||||
(file) => localSelected.has(file.name) && file.type !== 'directory'
|
||||
)
|
||||
const selectedFiles = localFiles.filter((file) => localSelected.has(file.name))
|
||||
if (selectedFiles.length > 0) {
|
||||
await performUpload(selectedFiles.map((file) => ({
|
||||
path: file.path,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
isDirectory: file.type === 'directory',
|
||||
})))
|
||||
return
|
||||
}
|
||||
|
||||
if (localSelected.size > 0) {
|
||||
toast.warning('暂不支持上传文件夹,请选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await window.opsxApi?.showOpenDialog({
|
||||
title: '选择要上传的文件',
|
||||
title: t('dialog.pickUploadTitle'),
|
||||
defaultPath: localPath,
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
properties: ['openFile', 'openDirectory', 'multiSelections'],
|
||||
})
|
||||
if (!result || result.canceled) return
|
||||
const paths = result.filePaths ?? (result.filePath ? [result.filePath] : [])
|
||||
if (paths.length > 0) {
|
||||
await performUpload(paths.map((path) => ({ path })))
|
||||
}
|
||||
}, [localFiles, localPath, localSelected, performUpload, toast])
|
||||
}, [localFiles, localPath, localSelected, performUpload, t])
|
||||
|
||||
// Handle drag-and-drop upload on remote panel
|
||||
const handleDropFiles = useCallback((files: FileList, targetPath: string) => {
|
||||
@@ -339,20 +341,19 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
return path ? [{ path, name: file.name, size: file.size }] : []
|
||||
})
|
||||
if (sources.length === 0) {
|
||||
toast.warning('当前系统未提供拖拽文件路径,请使用“上传”按钮选择文件')
|
||||
toast.warning(t('toast.noDragPath'))
|
||||
return
|
||||
}
|
||||
void performUpload(sources, targetPath)
|
||||
}, [isConnected, performUpload, toast])
|
||||
}, [isConnected, performUpload, toast, t])
|
||||
|
||||
const handleDropEntry = useCallback((entry: SftpDragEntry, targetPath: string) => {
|
||||
if (!isConnected) return
|
||||
if (entry.type !== 'file') {
|
||||
toast.warning('暂不支持文件夹递归传输,请拖拽单个文件')
|
||||
return
|
||||
}
|
||||
if (entry.source === 'local') {
|
||||
void performUpload([{ path: entry.path, name: entry.name, size: entry.size }], targetPath)
|
||||
void performUpload(
|
||||
[{ path: entry.path, name: entry.name, size: entry.size, isDirectory: entry.type === 'directory' }],
|
||||
targetPath
|
||||
)
|
||||
} else {
|
||||
void performDownload([{
|
||||
name: entry.name,
|
||||
@@ -363,21 +364,21 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
permissions: '',
|
||||
}], targetPath)
|
||||
}
|
||||
}, [isConnected, performDownload, performUpload, toast])
|
||||
}, [isConnected, performDownload, performUpload])
|
||||
|
||||
// Handle new folder
|
||||
const handleNewFolder = useCallback(async () => {
|
||||
const name = await prompt({
|
||||
title: '新建文件夹',
|
||||
message: '输入文件夹名称:',
|
||||
placeholder: '文件夹名称',
|
||||
title: t('dialog.newFolderTitle'),
|
||||
message: t('dialog.newFolderMessage'),
|
||||
placeholder: t('dialog.newFolderPlaceholder'),
|
||||
})
|
||||
if (name && window.opsxApi?.localMkdir) {
|
||||
window.opsxApi.localMkdir(`${localPath}/${name}`).then(() => {
|
||||
loadLocalDirectory(localPath)
|
||||
})
|
||||
}
|
||||
}, [localPath, loadLocalDirectory, prompt])
|
||||
}, [localPath, loadLocalDirectory, prompt, t])
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-dark-900">
|
||||
@@ -397,9 +398,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
<Toolbar
|
||||
isConnected={isConnected}
|
||||
onUpload={handleUpload}
|
||||
canDownload={remoteFiles.some(
|
||||
(file) => remoteSelected.has(file.name) && file.type !== 'directory'
|
||||
)}
|
||||
canDownload={remoteFiles.some((file) => remoteSelected.has(file.name))}
|
||||
onDownload={() => void performDownload()}
|
||||
onRefresh={() => {
|
||||
loadLocalDirectory(localPath)
|
||||
@@ -413,15 +412,15 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<EmptyState
|
||||
icon={Unplug}
|
||||
title="未连接到远程服务器"
|
||||
description="请先连接 SSH 会话以使用 SFTP"
|
||||
title={t('panel.notConnectedTitle')}
|
||||
description={t('panel.notConnectedDesc')}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex gap-4 p-4 min-h-0">
|
||||
{/* Local Panel */}
|
||||
<FileListPanel
|
||||
title="本地"
|
||||
title={t('panel.local')}
|
||||
isLocal={true}
|
||||
path={localPath}
|
||||
files={sortFiles(localFiles, sortField, sortOrder)}
|
||||
@@ -445,7 +444,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
|
||||
{/* Remote Panel */}
|
||||
<FileListPanel
|
||||
title="远程"
|
||||
title={t('panel.remote')}
|
||||
isLocal={false}
|
||||
path={remotePath}
|
||||
files={sortFiles(remoteFiles, sortField, sortOrder)}
|
||||
@@ -525,7 +524,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
remotePath={editFile.path}
|
||||
fileName={editFile.name}
|
||||
onClose={() => setEditFile(null)}
|
||||
onSaved={() => toast.success('文件已保存')}
|
||||
onSaved={() => toast.success(t('editor.saved'))}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in new issue
Block a user