- 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
186 lines
5.4 KiB
TypeScript
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>
|
|
)
|
|
}
|