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:
Logan.Li committed 2026-08-12 00:07:48 +08:00
1 parent fea6d8db04
commit b0d0e3a5f3
301 files changed
+28002 -2705

No files matched your search

+61 -62
View File
@@ -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'))}
/>
)}