Files
opsxshell/src/components/sessions/QuickConnectDialog.tsx
T
Logan.Li b0d0e3a5f3 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
2026-08-12 00:07:48 +08:00

186 lines
5.4 KiB
TypeScript

/**
* 快速连接 — 尽量像 ssh 命令一样简单
* 支持:host | user@host | user@host:port | host:port
*/
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LogIn, ArrowRight, AlertCircle } from 'lucide-react'
import { useTabStore } from '../../stores/tabStore'
import Modal from '../common/Modal'
import Button from '../common/Button'
import { translateErrorMessage } from '../../i18n/format'
interface QuickConnectDialogProps {
isOpen: boolean
onClose: () => void
initialTarget?: string
}
/** 解析 user@host:port / host:port / host */
// eslint-disable-next-line react-refresh/only-export-components
export function parseSshTarget(raw: string): {
host: string
port: number
username: string
} | null {
const s = raw.trim()
if (!s) return null
// strip optional ssh://
const cleaned = s.replace(/^ssh:\/\//i, '')
let username = 'root'
let hostPort = cleaned
const at = cleaned.lastIndexOf('@')
if (at > 0) {
username = cleaned.slice(0, at).trim() || 'root'
hostPort = cleaned.slice(at + 1).trim()
}
// IPv6 in brackets [2001:db8::1]:22
let host = hostPort
let port = 22
const bracket = /^\[([^\]]+)\](?::(\d+))?$/.exec(hostPort)
if (bracket) {
host = bracket[1]
if (bracket[2]) port = parseInt(bracket[2], 10) || 22
} else {
// last :port — avoid splitting bare IPv6 without brackets
const colon = hostPort.lastIndexOf(':')
if (colon > 0 && hostPort.indexOf(':') === colon) {
const maybePort = hostPort.slice(colon + 1)
if (/^\d+$/.test(maybePort)) {
host = hostPort.slice(0, colon)
port = parseInt(maybePort, 10) || 22
}
}
}
host = host.trim()
if (!host || !username) return null
return { host, port, username }
}
export default function QuickConnectDialog({ isOpen, onClose, initialTarget = '' }: QuickConnectDialogProps) {
const { t } = useTranslation(['sessions', 'common'])
const { addTab } = useTabStore()
const [target, setTarget] = useState('')
const [password, setPassword] = useState('')
const [isConnecting, setIsConnecting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!isOpen) return
setTarget(initialTarget)
setPassword('')
setError(null)
setIsConnecting(false)
}, [initialTarget, isOpen])
const handleSubmit = async () => {
const parsed = parseSshTarget(target)
if (!parsed) {
setError(t('sessions:quickConnect.errorInvalidTarget'))
return
}
setIsConnecting(true)
setError(null)
try {
addTab({
title: `${parsed.username}@${parsed.host}`,
type: 'terminal',
connectionConfig: {
host: parsed.host,
port: parsed.port,
username: parsed.username,
password: password || undefined,
},
})
onClose()
setTarget('')
setPassword('')
} catch (err: unknown) {
const raw = (err instanceof Error ? err.message : String(err)) || t('sessions:quickConnect.errorConnectFailed')
setError(translateErrorMessage(raw))
} finally {
setIsConnecting(false)
}
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={t('sessions:quickConnect.title')}
icon={<LogIn className="h-5 w-5" />}
size="md"
showFooter={false}
>
<form
onSubmit={(e) => {
e.preventDefault()
void handleSubmit()
}}
className="space-y-4"
>
<div>
<label className="ui-label">{t('sessions:quickConnect.target')}</label>
<input
type="text"
required
autoFocus
value={target}
onChange={(e) => setTarget(e.target.value)}
placeholder={t('sessions:quickConnect.targetPlaceholder')}
className="ui-input font-mono"
autoComplete="off"
spellCheck={false}
/>
<p className="mt-1.5 text-[11px] leading-relaxed text-txt-faint">{t('sessions:quickConnect.targetHint')}</p>
</div>
<div>
<label className="ui-label">
{t('sessions:quickConnect.password')}{' '}
<span className="font-normal text-txt-faint">{t('sessions:quickConnect.passwordOptional')}</span>
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="ui-input"
autoComplete="current-password"
/>
</div>
{error && (
<div className="flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2.5 text-sm text-red-300">
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
<span>{error}</span>
</div>
)}
<div className="border-dark-700/50 flex justify-end gap-2.5 border-t pt-2">
<Button type="button" variant="ghost" size="sm" onClick={onClose} disabled={isConnecting}>
{t('common:action.cancel')}
</Button>
<Button
type="submit"
variant="primary"
size="sm"
loading={isConnecting}
icon={<ArrowRight className="h-3.5 w-3.5" />}
>
{t('sessions:quickConnect.connect')}
</Button>
</div>
</form>
</Modal>
)
}