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
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
@@ -15,6 +16,8 @@ import { getSessionConfig } from '../../stores/connection'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import Modal from '../common/Modal'
|
||||
import Button from '../common/Button'
|
||||
import i18n from '../../i18n'
|
||||
import { translateErrorMessage } from '../../i18n/format'
|
||||
|
||||
type BatchCommandStatus = 'idle' | 'running' | 'success' | 'error'
|
||||
|
||||
@@ -42,6 +45,7 @@ export default function BatchCommandDialog({
|
||||
initialSelectedIds = EMPTY_SELECTION,
|
||||
onClose,
|
||||
}: BatchCommandDialogProps) {
|
||||
const { t } = useTranslation(['sessions', 'common'])
|
||||
const [command, setCommand] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
@@ -88,7 +92,7 @@ export default function BatchCommandDialog({
|
||||
|
||||
try {
|
||||
const config = await getSessionConfig(session.id)
|
||||
if (!config) throw new Error('无法读取会话配置')
|
||||
if (!config) throw new Error(i18n.t('sessions:batch.errorReadConfig'))
|
||||
|
||||
const connected = await window.opsxApi.sshConnect({
|
||||
...config,
|
||||
@@ -96,22 +100,22 @@ export default function BatchCommandDialog({
|
||||
keepAlive: false,
|
||||
})
|
||||
if (!connected.success || !connected.connectionId) {
|
||||
throw new Error(connected.error || '连接失败')
|
||||
throw new Error(connected.error || i18n.t('sessions:batch.errorConnectFailed'))
|
||||
}
|
||||
connectionId = connected.connectionId
|
||||
|
||||
const executed = await window.opsxApi.sshExec(connectionId, commandText)
|
||||
if (!executed.success) throw new Error(executed.error || '命令执行失败')
|
||||
if (!executed.success) throw new Error(executed.error || i18n.t('sessions:batch.errorExecFailed'))
|
||||
|
||||
updateResult(session.id, {
|
||||
status: 'success',
|
||||
output: executed.result?.trimEnd() || '(无输出)',
|
||||
output: executed.result?.trimEnd() || t('sessions:batch.noOutput'),
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
} catch (error) {
|
||||
updateResult(session.id, {
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: translateErrorMessage(error instanceof Error ? error.message : String(error)),
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
} finally {
|
||||
@@ -182,8 +186,12 @@ export default function BatchCommandDialog({
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
title="批量命令"
|
||||
description={`已选 ${selectedSessions.length}/${sessions.length} 台主机 · 最大并发 ${MAX_CONCURRENCY}`}
|
||||
title={t('sessions:batch.dialogTitle')}
|
||||
description={t('sessions:batch.dialogDesc', {
|
||||
selected: selectedSessions.length,
|
||||
total: sessions.length,
|
||||
concurrency: MAX_CONCURRENCY,
|
||||
})}
|
||||
icon={<TerminalSquare className="h-5 w-5" />}
|
||||
onClose={handleClose}
|
||||
showFooter
|
||||
@@ -194,12 +202,20 @@ export default function BatchCommandDialog({
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<div className="min-w-0 text-xs text-txt-muted">
|
||||
{running ? `执行中 ${completed}/${selectedSessions.length}` : `目标 ${selectedSessions.length} 台`}
|
||||
{summary.success > 0 && <span className="ml-2 text-accent-green">成功 {summary.success}</span>}
|
||||
{summary.error > 0 && <span className="ml-2 text-red-400">失败 {summary.error}</span>}
|
||||
{running
|
||||
? t('sessions:batch.runningProgress', { completed, total: selectedSessions.length })
|
||||
: t('sessions:batch.targetCount', { count: selectedSessions.length })}
|
||||
{summary.success > 0 && (
|
||||
<span className="ml-2 text-accent-green">{t('sessions:batch.success', { count: summary.success })}</span>
|
||||
)}
|
||||
{summary.error > 0 && (
|
||||
<span className="ml-2 text-red-400">{t('sessions:batch.failed', { count: summary.error })}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={handleClose} disabled={running}>关闭</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleClose} disabled={running}>
|
||||
{t('common:action.close')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@@ -208,7 +224,9 @@ export default function BatchCommandDialog({
|
||||
disabled={!command.trim() || selectedSessions.length === 0}
|
||||
loading={running}
|
||||
>
|
||||
{completed > 0 ? '再次执行' : `执行到 ${selectedSessions.length} 台`}
|
||||
{completed > 0
|
||||
? t('sessions:batch.executeAgain')
|
||||
: t('sessions:batch.executeTo', { count: selectedSessions.length })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,8 +235,10 @@ export default function BatchCommandDialog({
|
||||
<div className="space-y-4">
|
||||
<section className="rounded-xl border border-dark-700/70 bg-dark-900/55 p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<label htmlFor="batch-command" className="text-xs font-medium text-content-secondary">远程命令</label>
|
||||
<span className="text-[10px] text-txt-faint">Ctrl/⌘ + Enter 执行</span>
|
||||
<label htmlFor="batch-command" className="text-xs font-medium text-content-secondary">
|
||||
{t('sessions:batch.command')}
|
||||
</label>
|
||||
<span className="text-[10px] text-txt-faint">{t('sessions:batch.ctrlEnterHint')}</span>
|
||||
</div>
|
||||
<textarea
|
||||
id="batch-command"
|
||||
@@ -234,7 +254,7 @@ export default function BatchCommandDialog({
|
||||
rows={3}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
placeholder="例如:date"
|
||||
placeholder={t('sessions:batch.commandPlaceholder')}
|
||||
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-950 px-3 py-2.5 font-mono text-sm text-content-primary outline-none transition-colors focus:border-accent-blue disabled:opacity-60"
|
||||
/>
|
||||
{(running || completed > 0) && (
|
||||
@@ -249,20 +269,20 @@ export default function BatchCommandDialog({
|
||||
<div className="space-y-2 border-b border-dark-700/60 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-content-secondary">
|
||||
<Server className="h-3.5 w-3.5" /> 目标主机
|
||||
<Server className="h-3.5 w-3.5" /> {t('sessions:batch.targets')}
|
||||
</div>
|
||||
<button type="button" disabled={running || filteredSessions.length === 0} onClick={toggleFiltered} className="text-[11px] text-accent-blue disabled:opacity-40">
|
||||
{allFilteredSelected ? '取消当前结果' : '全选当前结果'}
|
||||
{allFilteredSelected ? t('sessions:sidebar.deselectAllVisible') : t('sessions:sidebar.selectAllVisible')}
|
||||
</button>
|
||||
</div>
|
||||
<label className="relative block">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-txt-faint" />
|
||||
<input
|
||||
aria-label="筛选主机"
|
||||
aria-label={t('sessions:batch.filterAria')}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
disabled={running}
|
||||
placeholder="名称、IP、分组或标签"
|
||||
placeholder={t('sessions:batch.filterPlaceholder')}
|
||||
className="ui-input-sm w-full pl-8"
|
||||
/>
|
||||
</label>
|
||||
@@ -291,18 +311,22 @@ export default function BatchCommandDialog({
|
||||
<span className="block truncate text-xs font-medium text-content-primary">{session.name}</span>
|
||||
<span className="block truncate font-mono text-[10px] text-txt-faint">{session.username}@{session.host}:{session.port || 22}</span>
|
||||
</span>
|
||||
<span className="max-w-20 truncate rounded bg-dark-700/70 px-1.5 py-0.5 text-[9px] text-txt-muted">{session.group || '默认'}</span>
|
||||
<span className="max-w-20 truncate rounded bg-dark-700/70 px-1.5 py-0.5 text-[9px] text-txt-muted">
|
||||
{session.group || t('sessions:groups.default')}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{filteredSessions.length === 0 && <div className="py-12 text-center text-xs text-txt-faint">没有匹配的主机</div>}
|
||||
{filteredSessions.length === 0 && (
|
||||
<div className="py-12 text-center text-xs text-txt-faint">{t('sessions:batch.noMatchHosts')}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-0 flex-col overflow-hidden rounded-xl border border-dark-700/70 bg-dark-900/45">
|
||||
<div className="flex items-center justify-between border-b border-dark-700/60 px-3 py-2.5">
|
||||
<span className="text-xs font-medium text-content-secondary">执行结果</span>
|
||||
<span className="text-[10px] text-txt-faint">输出仅保留在当前弹框内</span>
|
||||
<span className="text-xs font-medium text-content-secondary">{t('sessions:batch.results')}</span>
|
||||
<span className="text-[10px] text-txt-faint">{t('sessions:batch.resultsHint')}</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-2.5">
|
||||
{selectedSessions.map((session) => {
|
||||
@@ -327,7 +351,11 @@ export default function BatchCommandDialog({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{selectedSessions.length === 0 && <div className="flex h-full min-h-64 items-center justify-center text-xs text-txt-faint">请从左侧选择目标主机</div>}
|
||||
{selectedSessions.length === 0 && (
|
||||
<div className="flex h-full min-h-64 items-center justify-center text-xs text-txt-faint">
|
||||
{t('sessions:batch.selectFromLeft')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
@@ -20,7 +21,10 @@ import type { Session } from '../../stores/sessionStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { getConnectionStore, getSessionConfig } from '../../stores/connection'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { isSshSession } from '../../utils/sessionProtocol'
|
||||
import Button from '../common/Button'
|
||||
import i18n from '../../i18n'
|
||||
import { translateErrorMessage } from '../../i18n/format'
|
||||
|
||||
type BatchCommandStatus = 'idle' | 'running' | 'success' | 'error'
|
||||
|
||||
@@ -40,7 +44,9 @@ interface BatchExecViewProps {
|
||||
const MAX_CONCURRENCY = 5
|
||||
|
||||
export default function BatchExecView({ tabId, initialSessionIds = [] }: BatchExecViewProps) {
|
||||
const { t } = useTranslation(['sessions', 'common'])
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
const sshSessions = useMemo(() => sessions.filter((session) => isSshSession(session)), [sessions])
|
||||
const [command, setCommand] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set(initialSessionIds))
|
||||
@@ -50,26 +56,26 @@ export default function BatchExecView({ tabId, initialSessionIds = [] }: BatchEx
|
||||
const timeout = useSettingsStore((state) => state.settings.timeout)
|
||||
|
||||
useEffect(() => {
|
||||
const available = new Set(sessions.map((session) => session.id))
|
||||
const available = new Set(sshSessions.map((session) => session.id))
|
||||
const requested = initialSessionIds.filter((id) => available.has(id))
|
||||
const initial = requested.length > 0 ? requested : []
|
||||
setSelectedIds(new Set(initial))
|
||||
setResults(initial.map((sessionId) => ({ sessionId, status: 'idle' })))
|
||||
}, [initialSessionIds, sessions])
|
||||
}, [initialSessionIds, sshSessions])
|
||||
|
||||
const filteredSessions = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase()
|
||||
if (!normalized) return sessions
|
||||
return sessions.filter((session) =>
|
||||
if (!normalized) return sshSessions
|
||||
return sshSessions.filter((session) =>
|
||||
[session.name, session.host, session.username, session.group, ...(session.tags || [])]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(normalized))
|
||||
)
|
||||
}, [query, sessions])
|
||||
}, [query, sshSessions])
|
||||
|
||||
const selectedSessions = useMemo(
|
||||
() => sessions.filter((session) => selectedIds.has(session.id)),
|
||||
[selectedIds, sessions]
|
||||
() => sshSessions.filter((session) => selectedIds.has(session.id)),
|
||||
[selectedIds, sshSessions]
|
||||
)
|
||||
|
||||
const updateResult = useCallback((sessionId: string, updates: Partial<BatchCommandResult>) => {
|
||||
@@ -87,30 +93,30 @@ export default function BatchExecView({ tabId, initialSessionIds = [] }: BatchEx
|
||||
|
||||
try {
|
||||
const config = await getSessionConfig(session.id)
|
||||
if (!config) throw new Error('无法读取会话配置')
|
||||
if (!config) throw new Error(i18n.t('sessions:batch.errorReadConfig'))
|
||||
|
||||
const created = await getConnectionStore().createConnection(poolTabId, {
|
||||
...config,
|
||||
timeoutSeconds: config.timeoutSeconds ?? timeout,
|
||||
})
|
||||
if (!created.success || !created.connectionId) {
|
||||
throw new Error(created.error || '连接失败')
|
||||
throw new Error(created.error || i18n.t('sessions:batch.errorConnectFailed'))
|
||||
}
|
||||
connectionId = created.connectionId
|
||||
|
||||
const executed = await window.opsxApi.sshExec(connectionId, commandText)
|
||||
if (!executed.success) throw new Error(executed.error || '命令执行失败')
|
||||
if (!executed.success) throw new Error(executed.error || i18n.t('sessions:batch.errorExecFailed'))
|
||||
|
||||
updateResult(session.id, {
|
||||
status: 'success',
|
||||
output: executed.result?.trimEnd() || '(无输出)',
|
||||
output: executed.result?.trimEnd() || t('sessions:batch.noOutput'),
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
setExpanded((prev) => new Set(prev).add(session.id))
|
||||
} catch (error) {
|
||||
updateResult(session.id, {
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: translateErrorMessage(error instanceof Error ? error.message : String(error)),
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
setExpanded((prev) => new Set(prev).add(session.id))
|
||||
@@ -194,17 +200,22 @@ export default function BatchExecView({ tabId, initialSessionIds = [] }: BatchEx
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<TerminalSquare className="h-4 w-4 text-accent-blue" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-content-primary">批量执行</div>
|
||||
<div className="text-sm font-medium text-content-primary">{t('sessions:batch.title')}</div>
|
||||
<div className="text-[11px] text-txt-muted">
|
||||
已选 {selectedSessions.length} 台 · 并发 {MAX_CONCURRENCY} · 关闭标签即丢弃结果
|
||||
{t('sessions:batch.subtitle', {
|
||||
selected: selectedSessions.length,
|
||||
concurrency: MAX_CONCURRENCY,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{summary.success > 0 && (
|
||||
<span className="text-xs text-accent-green">成功 {summary.success}</span>
|
||||
<span className="text-xs text-accent-green">{t('sessions:batch.success', { count: summary.success })}</span>
|
||||
)}
|
||||
{summary.error > 0 && (
|
||||
<span className="text-xs text-red-400">{t('sessions:batch.failed', { count: summary.error })}</span>
|
||||
)}
|
||||
{summary.error > 0 && <span className="text-xs text-red-400">失败 {summary.error}</span>}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@@ -213,7 +224,7 @@ export default function BatchExecView({ tabId, initialSessionIds = [] }: BatchEx
|
||||
disabled={!command.trim() || selectedSessions.length === 0}
|
||||
loading={running}
|
||||
>
|
||||
{completed > 0 ? '再次执行' : `执行 ${selectedSessions.length} 台`}
|
||||
{completed > 0 ? t('sessions:batch.executeAgain') : t('sessions:batch.execute', { count: selectedSessions.length })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -222,7 +233,7 @@ export default function BatchExecView({ tabId, initialSessionIds = [] }: BatchEx
|
||||
<section className="flex w-full flex-col gap-3 lg:w-[320px] lg:flex-shrink-0">
|
||||
<div className="rounded-xl border border-dark-700/70 bg-dark-800/40 p-3">
|
||||
<label htmlFor="batch-command-panel" className="mb-2 block text-xs font-medium text-content-secondary">
|
||||
远程命令
|
||||
{t('sessions:batch.command')}
|
||||
</label>
|
||||
<textarea
|
||||
id="batch-command-panel"
|
||||
@@ -237,7 +248,7 @@ export default function BatchExecView({ tabId, initialSessionIds = [] }: BatchEx
|
||||
disabled={running}
|
||||
rows={4}
|
||||
spellCheck={false}
|
||||
placeholder="例如:uptime"
|
||||
placeholder={t('sessions:batch.commandPlaceholder')}
|
||||
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2.5 font-mono text-sm text-content-primary outline-none focus:border-accent-blue disabled:opacity-60"
|
||||
/>
|
||||
{(running || completed > 0) && (
|
||||
@@ -254,7 +265,7 @@ export default function BatchExecView({ tabId, initialSessionIds = [] }: BatchEx
|
||||
<div className="space-y-2 border-b border-dark-700/60 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-content-secondary">
|
||||
<Server className="h-3.5 w-3.5" /> 目标主机
|
||||
<Server className="h-3.5 w-3.5" /> {t('sessions:batch.targets')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -262,17 +273,17 @@ export default function BatchExecView({ tabId, initialSessionIds = [] }: BatchEx
|
||||
onClick={toggleFiltered}
|
||||
className="text-[11px] text-accent-blue disabled:opacity-40"
|
||||
>
|
||||
{allFilteredSelected ? '取消当前结果' : '全选当前结果'}
|
||||
{allFilteredSelected ? t('sessions:sidebar.deselectAllVisible') : t('sessions:sidebar.selectAllVisible')}
|
||||
</button>
|
||||
</div>
|
||||
<label className="relative block">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-txt-faint" />
|
||||
<input
|
||||
aria-label="筛选主机"
|
||||
aria-label={t('sessions:batch.filterAria')}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
disabled={running}
|
||||
placeholder="名称、IP、分组或标签"
|
||||
placeholder={t('sessions:batch.filterPlaceholder')}
|
||||
className="ui-input-sm w-full pl-8"
|
||||
/>
|
||||
</label>
|
||||
@@ -315,7 +326,7 @@ export default function BatchExecView({ tabId, initialSessionIds = [] }: BatchEx
|
||||
)
|
||||
})}
|
||||
{filteredSessions.length === 0 && (
|
||||
<div className="py-12 text-center text-xs text-txt-faint">没有匹配的主机</div>
|
||||
<div className="py-12 text-center text-xs text-txt-faint">{t('sessions:batch.noMatchHosts')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -323,8 +334,8 @@ export default function BatchExecView({ tabId, initialSessionIds = [] }: BatchEx
|
||||
|
||||
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-xl border border-dark-700/70 bg-dark-800/40">
|
||||
<div className="flex items-center justify-between border-b border-dark-700/60 px-3 py-2.5">
|
||||
<span className="text-xs font-medium text-content-secondary">结果矩阵</span>
|
||||
<span className="text-[10px] text-txt-faint">主机 × 状态 × 输出</span>
|
||||
<span className="text-xs font-medium text-content-secondary">{t('sessions:batch.resultMatrix')}</span>
|
||||
<span className="text-[10px] text-txt-faint">{t('sessions:batch.resultMatrixHint')}</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto p-2.5">
|
||||
{selectedSessions.map((session) => {
|
||||
@@ -382,7 +393,7 @@ export default function BatchExecView({ tabId, initialSessionIds = [] }: BatchEx
|
||||
})}
|
||||
{selectedSessions.length === 0 && (
|
||||
<div className="flex h-full min-h-64 items-center justify-center text-xs text-txt-faint">
|
||||
从左侧选择目标主机,或在侧栏批量模式保留选中后打开
|
||||
{t('sessions:batch.emptySelection')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Zap, ArrowRight, AlertCircle } from 'lucide-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
|
||||
@@ -62,6 +64,7 @@ export function parseSshTarget(raw: string): {
|
||||
}
|
||||
|
||||
export default function QuickConnectDialog({ isOpen, onClose, initialTarget = '' }: QuickConnectDialogProps) {
|
||||
const { t } = useTranslation(['sessions', 'common'])
|
||||
const { addTab } = useTabStore()
|
||||
const [target, setTarget] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -79,7 +82,7 @@ export default function QuickConnectDialog({ isOpen, onClose, initialTarget = ''
|
||||
const handleSubmit = async () => {
|
||||
const parsed = parseSshTarget(target)
|
||||
if (!parsed) {
|
||||
setError('请输入主机,例如 root@192.168.1.10 或 opsbase.cn:5555')
|
||||
setError(t('sessions:quickConnect.errorInvalidTarget'))
|
||||
return
|
||||
}
|
||||
setIsConnecting(true)
|
||||
@@ -101,7 +104,8 @@ export default function QuickConnectDialog({ isOpen, onClose, initialTarget = ''
|
||||
setTarget('')
|
||||
setPassword('')
|
||||
} catch (err: unknown) {
|
||||
setError((err instanceof Error ? err.message : String(err)) || '连接失败')
|
||||
const raw = (err instanceof Error ? err.message : String(err)) || t('sessions:quickConnect.errorConnectFailed')
|
||||
setError(translateErrorMessage(raw))
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
@@ -111,8 +115,8 @@ export default function QuickConnectDialog({ isOpen, onClose, initialTarget = ''
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="快速连接"
|
||||
icon={<Zap className="h-5 w-5" />}
|
||||
title={t('sessions:quickConnect.title')}
|
||||
icon={<LogIn className="h-5 w-5" />}
|
||||
size="md"
|
||||
showFooter={false}
|
||||
>
|
||||
@@ -124,24 +128,25 @@ export default function QuickConnectDialog({ isOpen, onClose, initialTarget = ''
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label className="ui-label">目标</label>
|
||||
<label className="ui-label">{t('sessions:quickConnect.target')}</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
placeholder="root@192.168.1.10:22"
|
||||
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">支持 host、user@host、user@host:port</p>
|
||||
<p className="mt-1.5 text-[11px] leading-relaxed text-txt-faint">{t('sessions:quickConnect.targetHint')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ui-label">
|
||||
密码 <span className="font-normal text-txt-faint">可选 · 用密钥可留空</span>
|
||||
{t('sessions:quickConnect.password')}{' '}
|
||||
<span className="font-normal text-txt-faint">{t('sessions:quickConnect.passwordOptional')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -162,7 +167,7 @@ export default function QuickConnectDialog({ isOpen, onClose, initialTarget = ''
|
||||
|
||||
<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"
|
||||
@@ -171,7 +176,7 @@ export default function QuickConnectDialog({ isOpen, onClose, initialTarget = ''
|
||||
loading={isConnecting}
|
||||
icon={<ArrowRight className="h-3.5 w-3.5" />}
|
||||
>
|
||||
连接
|
||||
{t('sessions:quickConnect.connect')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Cable, Plus, Search, Server, Terminal } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Cable, Plus, Search, LogIn, Server, Terminal } from 'lucide-react'
|
||||
import Modal from '../common/Modal'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
type AvailableLocalShell,
|
||||
} from '../../platform/localTerminal'
|
||||
import { fallbackLocalShells } from '../../utils/platformShells'
|
||||
import { buildOpenTabPayload } from '../../utils/sessionProtocol'
|
||||
import { useEntitlementStore } from '../../stores/entitlementStore'
|
||||
|
||||
interface QuickLauncherProps {
|
||||
isOpen: boolean
|
||||
@@ -24,10 +27,12 @@ export default function QuickLauncher({
|
||||
onQuickConnect,
|
||||
onSerialConnect,
|
||||
}: QuickLauncherProps) {
|
||||
const { t } = useTranslation(['common', 'sessions'])
|
||||
const [query, setQuery] = useState('')
|
||||
const [localShells, setLocalShells] = useState<AvailableLocalShell[]>([])
|
||||
const sessions = useSessionStore((state) => state.sessions)
|
||||
const addTab = useTabStore((state) => state.addTab)
|
||||
const requirePro = useEntitlementStore((state) => state.requirePro)
|
||||
const filteredSessions = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase()
|
||||
if (!normalized) return sessions.slice(0, 8)
|
||||
@@ -64,8 +69,8 @@ export default function QuickLauncher({
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="快速启动"
|
||||
description="会话、本地 Shell 与设备控制台统一入口"
|
||||
title={t('common:launcher.title')}
|
||||
description={t('common:launcher.description')}
|
||||
showFooter={false}
|
||||
size="lg"
|
||||
>
|
||||
@@ -76,28 +81,39 @@ export default function QuickLauncher({
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索主机、分组或标签页"
|
||||
placeholder={t('sessions:launcher.searchPlaceholder')}
|
||||
className="ui-input pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<button type="button" onClick={() => run(onQuickConnect)} className="launcher-card">
|
||||
<Server className="w-4 h-4 text-accent-blue" />
|
||||
<span><strong>快速连接</strong><small>user@host:port</small></span>
|
||||
<LogIn className="w-4 h-4 text-accent-blue" />
|
||||
<span>
|
||||
<strong>{t('common:launcher.cards.quickConnect.title')}</strong>
|
||||
<small>{t('common:launcher.cards.quickConnect.desc')}</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" onClick={() => run(onNewSession)} className="launcher-card">
|
||||
<Plus className="w-4 h-4 text-accent-green" />
|
||||
<span><strong>保存会话</strong><small>凭据、分组、代理</small></span>
|
||||
<span>
|
||||
<strong>{t('common:launcher.cards.saveSession.title')}</strong>
|
||||
<small>{t('common:launcher.cards.saveSession.desc')}</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" onClick={() => run(onSerialConnect)} className="launcher-card">
|
||||
<Cable className="w-4 h-4 text-accent-purple" />
|
||||
<span><strong>串口终端</strong><small>波特率与串口参数</small></span>
|
||||
<span>
|
||||
<strong>{t('common:launcher.cards.serial.title')}</strong>
|
||||
<small>{t('common:launcher.cards.serial.desc')}</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div className="px-1 mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-txt-faint">本地 Shell</div>
|
||||
<div className="px-1 mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
|
||||
{t('common:launcher.sectionLocalShell')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{(localShells.length > 0
|
||||
? localShells.slice(0, 4)
|
||||
@@ -117,7 +133,7 @@ export default function QuickLauncher({
|
||||
>
|
||||
<Terminal className="w-4 h-4 text-emerald-400" />
|
||||
<span className="flex-1 text-left"><strong>{shell.name}</strong><small>{shell.path}</small></span>
|
||||
{shell.isDefault && <span className="launcher-badge">系统默认</span>}
|
||||
{shell.isDefault && <span className="launcher-badge">{t('common:launcher.systemDefault')}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -125,13 +141,21 @@ export default function QuickLauncher({
|
||||
|
||||
{filteredSessions.length > 0 && (
|
||||
<section>
|
||||
<div className="px-1 mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-txt-faint">保存会话</div>
|
||||
<div className="px-1 mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
|
||||
{t('common:launcher.sectionSavedSessions')}
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto space-y-1">
|
||||
{filteredSessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => run(() => addTab({ title: session.name, type: 'terminal', sessionId: session.id }))}
|
||||
onClick={() =>
|
||||
run(() => {
|
||||
const protocol = session.protocol ?? 'ssh'
|
||||
if ((protocol === 'rdp' || protocol === 'vnc') && !requirePro(protocol)) return
|
||||
addTab(buildOpenTabPayload(session))
|
||||
})
|
||||
}
|
||||
className="launcher-row"
|
||||
>
|
||||
<Server className="w-4 h-4 text-accent-blue" />
|
||||
|
||||
@@ -2,14 +2,24 @@
|
||||
* Session editor — right drawer (basic fields first, advanced collapsed)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, type ComponentType } from 'react'
|
||||
import { useState, useEffect, useMemo, type ComponentType } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
X, Server, Key, Lock, Tag, Folder, ChevronDown, Check,
|
||||
} from 'lucide-react'
|
||||
import { useSessionStore, Session, useEntitlementStore } from '../../stores'
|
||||
import type { AuthType, ProxyType } from '../../../shared/types/session.types'
|
||||
import type { AuthType, ProxyType, SessionProtocol } from '../../../shared/types/session.types'
|
||||
import { createLogger } from '../../utils/logger'
|
||||
import { parseSshTarget } from './QuickConnectDialog'
|
||||
import { defaultPortForProtocol } from '../../utils/sessionProtocol'
|
||||
import {
|
||||
collectSessionGroups,
|
||||
DEFAULT_SESSION_GROUP,
|
||||
groupDisplayName,
|
||||
normalizeGroupId,
|
||||
} from '../../utils/sessionGroups'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { usePromptDialog } from '../common/PromptDialog'
|
||||
|
||||
const logger = createLogger('SessionDialog')
|
||||
|
||||
@@ -25,6 +35,16 @@ interface SessionDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
editSession?: Session | null
|
||||
/** Preferred group when creating (not editing). */
|
||||
defaultGroup?: string
|
||||
}
|
||||
|
||||
type KeyInputMode = 'file' | 'text'
|
||||
|
||||
const DEFAULT_KEY_PATH = '~/.ssh/id_ed25519'
|
||||
|
||||
function isPemPrivateKey(value: string | undefined | null): boolean {
|
||||
return Boolean(value && value.includes('-----BEGIN'))
|
||||
}
|
||||
|
||||
const emptyForm = {
|
||||
@@ -33,8 +53,11 @@ const emptyForm = {
|
||||
port: 22,
|
||||
username: 'root',
|
||||
authType: 'password' as AuthType,
|
||||
protocol: 'ssh' as SessionProtocol,
|
||||
password: '',
|
||||
privateKey: '~/.ssh/id_rsa',
|
||||
keyInputMode: 'file' as KeyInputMode,
|
||||
privateKeyPath: DEFAULT_KEY_PATH,
|
||||
privateKeyText: '',
|
||||
passphrase: '',
|
||||
proxy: {
|
||||
type: 'none' as ProxyType,
|
||||
@@ -47,39 +70,67 @@ const emptyForm = {
|
||||
tags: '',
|
||||
startupCommand: '',
|
||||
jumpSessionIds: [] as string[],
|
||||
domain: '',
|
||||
security: 'nla' as 'nla' | 'tls' | 'rdp' | 'auto',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
}
|
||||
|
||||
export default function SessionDialog({ isOpen, onClose, editSession }: SessionDialogProps) {
|
||||
export default function SessionDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
editSession,
|
||||
defaultGroup = DEFAULT_SESSION_GROUP,
|
||||
}: SessionDialogProps) {
|
||||
const { t } = useTranslation(['sessions', 'common'])
|
||||
const { addSession, updateSession, sessions } = useSessionStore()
|
||||
const { requirePro } = useEntitlementStore()
|
||||
const sessionGroups = useSettingsStore((s) => s.settings.sessionGroups)
|
||||
const setSetting = useSettingsStore((s) => s.setSetting)
|
||||
const { prompt, PromptDialogComponent } = usePromptDialog()
|
||||
const [formData, setFormData] = useState(emptyForm)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const isEditing = !!editSession
|
||||
const jumpCandidates = sessions.filter((session) => session.id !== editSession?.id)
|
||||
/** 一行目标:user@host:port */
|
||||
const [target, setTarget] = useState('root@')
|
||||
const isSsh = formData.protocol === 'ssh'
|
||||
const knownGroups = useMemo(
|
||||
() => collectSessionGroups(sessions, sessionGroups),
|
||||
[sessions, sessionGroups]
|
||||
)
|
||||
const jumpCandidates = sessions.filter(
|
||||
(session) => session.id !== editSession?.id && (session.protocol ?? 'ssh') === 'ssh'
|
||||
)
|
||||
/** 主机目标:仅 host 或 host:port(用户名单独填写) */
|
||||
const [target, setTarget] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
if (editSession) {
|
||||
const protocol = (editSession.protocol ?? 'ssh') as SessionProtocol
|
||||
const hasAdvanced =
|
||||
!!(editSession.proxy && editSession.proxy.type !== 'none') ||
|
||||
!!(editSession.jumpSessionId || (editSession.jumpSessionIds && editSession.jumpSessionIds.length)) ||
|
||||
!!editSession.startupCommand ||
|
||||
(!!editSession.group && editSession.group !== 'default') ||
|
||||
!!(editSession.tags && editSession.tags.length)
|
||||
const u = editSession.username || 'root'
|
||||
const h = editSession.host || ''
|
||||
setTarget(`${u}@${h}`)
|
||||
setTarget(h)
|
||||
setFormData({
|
||||
name: editSession.name || '',
|
||||
host: editSession.host || '',
|
||||
port: editSession.port || 22,
|
||||
username: editSession.username || '',
|
||||
port: editSession.port || defaultPortForProtocol(protocol),
|
||||
username:
|
||||
editSession.username ||
|
||||
(protocol === 'rdp' ? 'Administrator' : protocol === 'vnc' ? 'vnc' : 'root'),
|
||||
authType: editSession.authType === 'agent' ? 'key' : editSession.authType || 'password',
|
||||
protocol,
|
||||
password: editSession.password || '',
|
||||
privateKey: editSession.privateKey || '~/.ssh/id_rsa',
|
||||
keyInputMode: isPemPrivateKey(editSession.privateKey) ? 'text' : 'file',
|
||||
privateKeyPath: isPemPrivateKey(editSession.privateKey)
|
||||
? DEFAULT_KEY_PATH
|
||||
: editSession.privateKey || DEFAULT_KEY_PATH,
|
||||
privateKeyText: isPemPrivateKey(editSession.privateKey) ? editSession.privateKey || '' : '',
|
||||
passphrase: editSession.passphrase || '',
|
||||
proxy: editSession.proxy
|
||||
? {
|
||||
@@ -90,7 +141,7 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
password: editSession.proxy.password || '',
|
||||
}
|
||||
: { type: 'none', host: '', port: 0, username: '', password: '' },
|
||||
group: editSession.group || 'default',
|
||||
group: normalizeGroupId(editSession.group),
|
||||
tags: editSession.tags?.join(', ') || '',
|
||||
startupCommand: editSession.startupCommand || '',
|
||||
jumpSessionIds:
|
||||
@@ -99,30 +150,96 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
: editSession.jumpSessionId
|
||||
? [editSession.jumpSessionId]
|
||||
: [],
|
||||
domain: editSession.domain || '',
|
||||
security: editSession.security || 'nla',
|
||||
width: editSession.width || 1920,
|
||||
height: editSession.height || 1080,
|
||||
})
|
||||
setShowAdvanced(hasAdvanced)
|
||||
} else {
|
||||
setFormData({ ...emptyForm, username: 'root', privateKey: '~/.ssh/id_rsa' })
|
||||
setTarget('root@')
|
||||
setFormData({
|
||||
...emptyForm,
|
||||
group: normalizeGroupId(defaultGroup),
|
||||
})
|
||||
setTarget('')
|
||||
setShowAdvanced(false)
|
||||
}
|
||||
}, [editSession, isOpen])
|
||||
}, [editSession, isOpen, defaultGroup])
|
||||
|
||||
const setProtocol = (protocol: SessionProtocol) => {
|
||||
if (protocol !== 'ssh' && !requirePro(protocol)) return
|
||||
setFormData((prev) => {
|
||||
const nextUser =
|
||||
protocol === 'rdp'
|
||||
? prev.username === 'root' || prev.username === 'vnc' || !prev.username
|
||||
? 'Administrator'
|
||||
: prev.username
|
||||
: protocol === 'vnc'
|
||||
? prev.username === 'root' || prev.username === 'Administrator' || !prev.username
|
||||
? 'vnc'
|
||||
: prev.username
|
||||
: prev.username === 'Administrator' || prev.username === 'vnc' || !prev.username
|
||||
? 'root'
|
||||
: prev.username
|
||||
const nextPort =
|
||||
prev.port === defaultPortForProtocol(prev.protocol)
|
||||
? defaultPortForProtocol(protocol)
|
||||
: prev.port
|
||||
return {
|
||||
...prev,
|
||||
protocol,
|
||||
username: nextUser,
|
||||
port: nextPort,
|
||||
authType: 'password',
|
||||
}
|
||||
})
|
||||
// Keep host-only target when switching protocol.
|
||||
setTarget((prev) => {
|
||||
if (!prev.trim()) return prev
|
||||
if (prev.includes('@')) return prev.slice(prev.lastIndexOf('@') + 1).replace(/:\d+$/, '')
|
||||
return prev.replace(/:\d+$/, '')
|
||||
})
|
||||
}
|
||||
|
||||
const applyTarget = (raw: string) => {
|
||||
setTarget(raw)
|
||||
const parsed = parseSshTarget(raw)
|
||||
if (parsed) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
host: parsed.host,
|
||||
port: targetIncludesPort(raw) ? parsed.port : prev.port,
|
||||
username: parsed.username,
|
||||
name:
|
||||
prev.name.trim() && !prev.name.includes('@')
|
||||
? prev.name
|
||||
: `${parsed.username}@${parsed.host}`,
|
||||
}))
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) {
|
||||
setFormData((prev) => ({ ...prev, host: '' }))
|
||||
return
|
||||
}
|
||||
|
||||
// Still accept pasted user@host for convenience, but UI encourages host-only.
|
||||
if (trimmed.includes('@') && formData.protocol !== 'vnc') {
|
||||
const parsed = parseSshTarget(trimmed)
|
||||
if (parsed) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
host: parsed.host,
|
||||
port: targetIncludesPort(trimmed) ? parsed.port : prev.port,
|
||||
username: parsed.username || prev.username,
|
||||
name:
|
||||
prev.name.trim() && !prev.name.includes('@')
|
||||
? prev.name
|
||||
: `${parsed.username || prev.username}@${parsed.host}`,
|
||||
}))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const host = trimmed.replace(/:\d+$/, '')
|
||||
const portMatch = trimmed.match(/:(\d+)$/)
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
host,
|
||||
port: portMatch ? Number(portMatch[1]) : prev.port,
|
||||
name:
|
||||
prev.name.trim() && !prev.name.includes('@') && !prev.name.startsWith('vnc:')
|
||||
? prev.name
|
||||
: formData.protocol === 'vnc'
|
||||
? `vnc:${host}`
|
||||
: `${prev.username || (formData.protocol === 'rdp' ? 'Administrator' : 'root')}@${host}`,
|
||||
}))
|
||||
}
|
||||
|
||||
// ESC close
|
||||
@@ -145,15 +262,54 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const parsed = parseSshTarget(target)
|
||||
if (!parsed?.host) {
|
||||
logger.error('Invalid SSH target')
|
||||
if (formData.protocol !== 'ssh' && !requirePro(formData.protocol)) return
|
||||
|
||||
let host = formData.host.trim()
|
||||
let username = formData.username.trim()
|
||||
let port = formData.port || defaultPortForProtocol(formData.protocol)
|
||||
|
||||
const rawTarget = target.trim()
|
||||
if (rawTarget) {
|
||||
if (rawTarget.includes('@') && formData.protocol !== 'vnc') {
|
||||
const parsed = parseSshTarget(rawTarget)
|
||||
if (!parsed?.host) {
|
||||
logger.error('Invalid target')
|
||||
return
|
||||
}
|
||||
host = parsed.host
|
||||
username = username || parsed.username || (formData.protocol === 'rdp' ? 'Administrator' : 'root')
|
||||
if (targetIncludesPort(rawTarget)) port = parsed.port
|
||||
} else {
|
||||
host = rawTarget.replace(/:\d+$/, '') || host
|
||||
const portMatch = rawTarget.match(/:(\d+)$/)
|
||||
if (portMatch) port = Number(portMatch[1])
|
||||
}
|
||||
}
|
||||
|
||||
if (formData.protocol === 'vnc') {
|
||||
username = username || 'vnc'
|
||||
} else {
|
||||
username = username || (formData.protocol === 'rdp' ? 'Administrator' : 'root')
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
logger.error('Host required')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
if (isSsh && formData.authType === 'key' && formData.keyInputMode === 'text') {
|
||||
const pem = formData.privateKeyText.trim()
|
||||
if (!pem || !isPemPrivateKey(pem)) {
|
||||
logger.error('Private key PEM required')
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const proxyConfig =
|
||||
formData.proxy.type !== 'none' && formData.proxy.host
|
||||
isSsh && formData.proxy.type !== 'none' && formData.proxy.host
|
||||
? {
|
||||
type: formData.proxy.type,
|
||||
host: formData.proxy.host,
|
||||
@@ -164,25 +320,42 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
: undefined
|
||||
|
||||
const sessionData = {
|
||||
name: formData.name.trim() || `${parsed.username}@${parsed.host}`,
|
||||
host: parsed.host,
|
||||
port: formData.port || 22,
|
||||
username: parsed.username || 'root',
|
||||
authType: formData.authType,
|
||||
password: formData.authType === 'password' ? formData.password || undefined : undefined,
|
||||
name:
|
||||
formData.name.trim() ||
|
||||
(formData.protocol === 'vnc' ? `vnc:${host}` : `${username}@${host}`),
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
authType: formData.protocol === 'ssh' ? formData.authType : ('password' as AuthType),
|
||||
protocol: formData.protocol,
|
||||
password: formData.password || undefined,
|
||||
privateKey:
|
||||
formData.authType === 'key'
|
||||
? formData.privateKey.trim() || '~/.ssh/id_rsa'
|
||||
isSsh && formData.authType === 'key'
|
||||
? formData.keyInputMode === 'text'
|
||||
? formData.privateKeyText.trim() || undefined
|
||||
: formData.privateKeyPath.trim() || DEFAULT_KEY_PATH
|
||||
: undefined,
|
||||
passphrase:
|
||||
formData.authType === 'key' ? formData.passphrase || undefined : undefined,
|
||||
isSsh && formData.authType === 'key' ? formData.passphrase || undefined : undefined,
|
||||
proxy: proxyConfig,
|
||||
startupCommand: formData.startupCommand.trim() || undefined,
|
||||
startupCommand: isSsh ? formData.startupCommand.trim() || undefined : undefined,
|
||||
jumpSessionIds:
|
||||
formData.jumpSessionIds.filter(Boolean).length > 0
|
||||
isSsh && formData.jumpSessionIds.filter(Boolean).length > 0
|
||||
? formData.jumpSessionIds.filter(Boolean)
|
||||
: undefined,
|
||||
jumpSessionId: formData.jumpSessionIds.filter(Boolean)[0] || undefined,
|
||||
jumpSessionId: isSsh ? formData.jumpSessionIds.filter(Boolean)[0] || undefined : undefined,
|
||||
domain: formData.protocol === 'rdp' ? formData.domain.trim() || undefined : undefined,
|
||||
security: formData.protocol === 'rdp' ? formData.security : undefined,
|
||||
width: formData.protocol !== 'ssh' ? formData.width : undefined,
|
||||
height: formData.protocol !== 'ssh' ? formData.height : undefined,
|
||||
distro:
|
||||
formData.protocol === 'rdp'
|
||||
? 'windows'
|
||||
: formData.protocol === 'vnc'
|
||||
? 'vnc'
|
||||
: isEditing
|
||||
? editSession?.distro
|
||||
: undefined,
|
||||
group: formData.group || 'default',
|
||||
tags: formData.tags
|
||||
.split(',')
|
||||
@@ -190,6 +363,12 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
.filter(Boolean),
|
||||
}
|
||||
|
||||
const groupId = normalizeGroupId(sessionData.group)
|
||||
sessionData.group = groupId
|
||||
if (groupId !== DEFAULT_SESSION_GROUP && !sessionGroups.includes(groupId)) {
|
||||
await setSetting('sessionGroups', [...sessionGroups, groupId])
|
||||
}
|
||||
|
||||
if (isEditing && editSession) {
|
||||
await updateSession(editSession.id, sessionData)
|
||||
} else {
|
||||
@@ -211,11 +390,12 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
label: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
}[] = [
|
||||
{ value: 'password', label: '密码', icon: Lock },
|
||||
{ value: 'key', label: '密钥', icon: Key },
|
||||
{ value: 'password', label: t('dialog.authPassword'), icon: Lock },
|
||||
{ value: 'key', label: t('dialog.authKey'), icon: Key },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[100] flex justify-end">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
@@ -237,15 +417,15 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 id="session-drawer-title" className="text-sm font-semibold text-content-primary">
|
||||
{isEditing ? '编辑会话' : '新建会话'}
|
||||
{isEditing ? t('dialog.edit') : t('dialog.create')}
|
||||
</h2>
|
||||
<p className="text-[11px] text-txt-faint mt-0.5">目标一行搞定 · 高级可折叠</p>
|
||||
<p className="text-[11px] text-txt-faint mt-0.5">{t('dialog.subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-txt-muted hover:text-content-primary hover:bg-dark-700"
|
||||
aria-label="关闭"
|
||||
aria-label={t('common:action.close')}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -256,37 +436,62 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
{/* 基本:名称 + 目标 */}
|
||||
<section className="rounded-xl border border-dark-700/50 bg-dark-900/30 p-3.5 space-y-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
|
||||
基本
|
||||
{t('dialog.basic')}
|
||||
</div>
|
||||
<div>
|
||||
<label className="ui-label">显示名称(可选)</label>
|
||||
<label className="ui-label">{t('dialog.protocol')}</label>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{(
|
||||
[
|
||||
['ssh', 'SSH'],
|
||||
['rdp', 'RDP'],
|
||||
['vnc', 'VNC'],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setProtocol(value)}
|
||||
className={`rounded-lg border px-2 py-1.5 text-xs transition-colors ${
|
||||
formData.protocol === value
|
||||
? 'border-accent-blue/50 bg-accent-blue/15 text-accent-blue'
|
||||
: 'border-dark-600 text-txt-muted hover:border-dark-500'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ui-label">{t('dialog.displayName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus={!isEditing}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="自动用 user@host"
|
||||
placeholder={t('dialog.displayNamePlaceholder')}
|
||||
className="ui-input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_88px] gap-2">
|
||||
<div>
|
||||
<label htmlFor="session-target" className="ui-label">目标</label>
|
||||
<label htmlFor="session-target" className="ui-label">{t('dialog.host')}</label>
|
||||
<input
|
||||
id="session-target"
|
||||
type="text"
|
||||
required
|
||||
value={target}
|
||||
onChange={(e) => applyTarget(e.target.value)}
|
||||
placeholder="root@192.168.1.10"
|
||||
placeholder={t('dialog.hostPlaceholder')}
|
||||
className="ui-input font-mono"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="session-port" className="ui-label">端口</label>
|
||||
<label htmlFor="session-port" className="ui-label">{t('dialog.port')}</label>
|
||||
<input
|
||||
id="session-port"
|
||||
type="number"
|
||||
@@ -302,7 +507,10 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
}
|
||||
onBlur={() => {
|
||||
if (formData.port < 1 || formData.port > 65535) {
|
||||
setFormData({ ...formData, port: 22 })
|
||||
setFormData({
|
||||
...formData,
|
||||
port: defaultPortForProtocol(formData.protocol),
|
||||
})
|
||||
}
|
||||
}}
|
||||
className="ui-input font-mono"
|
||||
@@ -311,7 +519,101 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-txt-faint">
|
||||
支持 user@host · host;目标中填写端口时会自动同步
|
||||
{t('dialog.hostHint')}
|
||||
</p>
|
||||
</div>
|
||||
{formData.protocol !== 'vnc' && (
|
||||
<div>
|
||||
<label htmlFor="session-username" className="ui-label">{t('dialog.username')}</label>
|
||||
<input
|
||||
id="session-username"
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) => {
|
||||
const username = e.target.value
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
username,
|
||||
name:
|
||||
prev.name.trim() && !prev.name.includes('@')
|
||||
? prev.name
|
||||
: prev.host
|
||||
? `${username || (prev.protocol === 'rdp' ? 'Administrator' : 'root')}@${prev.host}`
|
||||
: prev.name,
|
||||
}))
|
||||
}}
|
||||
placeholder={formData.protocol === 'rdp' ? t('dialog.usernamePlaceholderRdp') : t('dialog.usernamePlaceholderSsh')}
|
||||
className="ui-input font-mono"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{formData.protocol !== 'ssh' && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="ui-label" htmlFor="session-width">{t('dialog.width')}</label>
|
||||
<input
|
||||
id="session-width"
|
||||
type="number"
|
||||
value={formData.width}
|
||||
onChange={(e) => setFormData({ ...formData, width: Number(e.target.value) || 1920 })}
|
||||
className="ui-input font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ui-label" htmlFor="session-height">{t('dialog.height')}</label>
|
||||
<input
|
||||
id="session-height"
|
||||
type="number"
|
||||
value={formData.height}
|
||||
onChange={(e) => setFormData({ ...formData, height: Number(e.target.value) || 1080 })}
|
||||
className="ui-input font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="session-group" className="ui-label">
|
||||
<Folder className="inline w-3 h-3 mr-0.5" />
|
||||
{t('dialog.group')}
|
||||
</label>
|
||||
<select
|
||||
id="session-group"
|
||||
value={normalizeGroupId(formData.group)}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (value !== '__new__') {
|
||||
setFormData((prev) => ({ ...prev, group: normalizeGroupId(value) }))
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
const raw = await prompt({
|
||||
title: t('groups.new.title'),
|
||||
message: t('groups.new.message'),
|
||||
placeholder: t('groups.new.placeholder'),
|
||||
confirmText: t('groups.new.confirm'),
|
||||
})
|
||||
if (raw == null) return
|
||||
setFormData((prev) => ({ ...prev, group: normalizeGroupId(raw) }))
|
||||
})()
|
||||
}}
|
||||
className="ui-input"
|
||||
>
|
||||
{knownGroups.map((group) => (
|
||||
<option key={group} value={group}>
|
||||
{groupDisplayName(group)}
|
||||
</option>
|
||||
))}
|
||||
{!knownGroups.includes(normalizeGroupId(formData.group)) && (
|
||||
<option value={normalizeGroupId(formData.group)}>
|
||||
{groupDisplayName(formData.group)}
|
||||
</option>
|
||||
)}
|
||||
<option value="__new__">{t('groups.newOption')}</option>
|
||||
</select>
|
||||
<p className="mt-1 text-[11px] text-txt-faint">
|
||||
{t('groups.hint')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -319,8 +621,9 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
{/* 凭据 */}
|
||||
<section className="rounded-xl border border-dark-700/50 bg-dark-900/30 p-3.5 space-y-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
|
||||
登录
|
||||
{t('dialog.login')}
|
||||
</div>
|
||||
{isSsh && (
|
||||
<div className="grid grid-cols-2 gap-1.5 p-1 rounded-xl bg-dark-900/60 border border-dark-700/40">
|
||||
{authTypes.map((type) => {
|
||||
const Icon = type.icon
|
||||
@@ -342,10 +645,11 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.authType === 'password' && (
|
||||
{(formData.authType === 'password' || !isSsh) && (
|
||||
<div>
|
||||
<label htmlFor="session-password" className="ui-label">密码</label>
|
||||
<label htmlFor="session-password" className="ui-label">{t('dialog.password')}</label>
|
||||
<input
|
||||
id="session-password"
|
||||
type="password"
|
||||
@@ -358,44 +662,112 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.authType === 'key' && (
|
||||
{formData.protocol === 'rdp' && (
|
||||
<div>
|
||||
<label className="ui-label" htmlFor="session-domain">{t('dialog.domain')}</label>
|
||||
<input
|
||||
id="session-domain"
|
||||
value={formData.domain}
|
||||
onChange={(e) => setFormData({ ...formData, domain: e.target.value })}
|
||||
className="ui-input font-mono"
|
||||
placeholder={t('dialog.domainPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSsh && formData.authType === 'key' && (
|
||||
<div className="space-y-2.5">
|
||||
<div>
|
||||
<label htmlFor="session-private-key" className="ui-label">私钥路径</label>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
id="session-private-key"
|
||||
type="text"
|
||||
value={formData.privateKey}
|
||||
onChange={(e) => setFormData({ ...formData, privateKey: e.target.value })}
|
||||
placeholder="~/.ssh/id_ed25519"
|
||||
className="ui-input flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="px-2.5 text-xs rounded-lg bg-dark-700 border border-dark-600 text-content-secondary hover:bg-dark-600"
|
||||
onClick={async () => {
|
||||
const result = await window.opsxApi?.showOpenDialog({
|
||||
title: '选择私钥文件',
|
||||
filters: [{ name: '所有文件', extensions: ['*'] }],
|
||||
})
|
||||
if (!result?.canceled && result?.filePaths?.[0]) {
|
||||
setFormData({ ...formData, privateKey: result.filePaths[0] })
|
||||
}
|
||||
}}
|
||||
>
|
||||
浏览
|
||||
</button>
|
||||
<div className="ui-label">{t('dialog.keySource')}</div>
|
||||
<div className="grid grid-cols-2 gap-1.5 p-1 rounded-xl bg-dark-900/60 border border-dark-700/40">
|
||||
{(
|
||||
[
|
||||
['file', t('dialog.keyFile')],
|
||||
['text', t('dialog.keyText')],
|
||||
] as const
|
||||
).map(([value, label]) => {
|
||||
const active = formData.keyInputMode === value
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, keyInputMode: value })}
|
||||
className={`flex items-center justify-center py-2 rounded-lg text-xs font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-dark-700 text-content-primary shadow-sm'
|
||||
: 'text-txt-muted hover:text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.keyInputMode === 'file' ? (
|
||||
<div>
|
||||
<label htmlFor="session-private-key" className="ui-label">{t('dialog.privateKeyPath')}</label>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
id="session-private-key"
|
||||
type="text"
|
||||
value={formData.privateKeyPath}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, privateKeyPath: e.target.value })
|
||||
}
|
||||
placeholder={DEFAULT_KEY_PATH}
|
||||
className="ui-input flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="px-2.5 text-xs rounded-lg bg-dark-700 border border-dark-600 text-content-secondary hover:bg-dark-600"
|
||||
onClick={async () => {
|
||||
const result = await window.opsxApi?.showOpenDialog({
|
||||
title: t('dialog.pickKeyDialogTitle'),
|
||||
filters: [{ name: t('dialog.allFilesFilter'), extensions: ['*'] }],
|
||||
})
|
||||
if (!result?.canceled && result?.filePaths?.[0]) {
|
||||
setFormData({
|
||||
...formData,
|
||||
privateKeyPath: result.filePaths[0],
|
||||
keyInputMode: 'file',
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('dialog.pickKeyFile')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label htmlFor="session-private-key-text" className="ui-label">{t('dialog.privateKeyText')}</label>
|
||||
<textarea
|
||||
id="session-private-key-text"
|
||||
value={formData.privateKeyText}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, privateKeyText: e.target.value })
|
||||
}
|
||||
placeholder={'-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----'}
|
||||
rows={6}
|
||||
spellCheck={false}
|
||||
className="ui-input font-mono text-[11px] leading-relaxed resize-y min-h-[7.5rem]"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-txt-faint">
|
||||
{t('dialog.privateKeyTextHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="session-passphrase" className="ui-label">密钥口令(可选)</label>
|
||||
<label htmlFor="session-passphrase" className="ui-label">{t('dialog.passphrase')}</label>
|
||||
<input
|
||||
id="session-passphrase"
|
||||
type="password"
|
||||
value={formData.passphrase}
|
||||
onChange={(e) => setFormData({ ...formData, passphrase: e.target.value })}
|
||||
placeholder="可选"
|
||||
placeholder={t('dialog.passphrasePlaceholder')}
|
||||
className="ui-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -411,9 +783,9 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
className="w-full flex items-center justify-between px-3.5 py-3 text-left bg-dark-900/20 hover:bg-dark-900/40 transition-colors"
|
||||
>
|
||||
<span className="text-xs font-semibold text-content-secondary">高级选项</span>
|
||||
<span className="text-xs font-semibold text-content-secondary">{t('dialog.advanced')}</span>
|
||||
<span className="flex items-center gap-1.5 text-[11px] text-txt-faint">
|
||||
{showAdvanced ? '收起' : '代理 · 跳板 · 分组'}
|
||||
{showAdvanced ? t('dialog.collapse') : t('dialog.advancedCollapsed')}
|
||||
<ChevronDown
|
||||
className={`w-3.5 h-3.5 transition-transform ${showAdvanced ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
@@ -422,52 +794,39 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="px-3.5 pb-3.5 space-y-3 border-t border-dark-700/40 bg-dark-900/20">
|
||||
<div className="grid grid-cols-2 gap-2 pt-3">
|
||||
<div>
|
||||
<label className="ui-label">
|
||||
<Folder className="inline w-3 h-3 mr-0.5" />
|
||||
分组
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.group}
|
||||
onChange={(e) => setFormData({ ...formData, group: e.target.value })}
|
||||
placeholder="default"
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 pt-3">
|
||||
<div>
|
||||
<label className="ui-label">
|
||||
<Tag className="inline w-3 h-3 mr-0.5" />
|
||||
标签
|
||||
{t('dialog.tags')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.tags}
|
||||
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
|
||||
placeholder="生产, web"
|
||||
placeholder={t('dialog.tagsPlaceholder')}
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ui-label">启动命令(可选)</label>
|
||||
<label className="ui-label">{t('dialog.startupCommand')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.startupCommand}
|
||||
onChange={(e) => setFormData({ ...formData, startupCommand: e.target.value })}
|
||||
placeholder="连接后自动执行,如 cd /var/log"
|
||||
placeholder={t('dialog.startupCommandPlaceholder')}
|
||||
className="ui-input-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ui-label">代理</label>
|
||||
<label className="ui-label">{t('dialog.proxy')}</label>
|
||||
<div className="flex gap-1 mb-2">
|
||||
{(
|
||||
[
|
||||
{ value: 'none', label: '无' },
|
||||
{ value: 'none', label: t('dialog.proxyNone') },
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
{ value: 'socks5', label: 'SOCKS5' },
|
||||
] as const
|
||||
@@ -513,7 +872,7 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
proxy: { ...formData.proxy, host: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="代理地址"
|
||||
placeholder={t('dialog.proxyHostPlaceholder')}
|
||||
className="ui-input-sm col-span-2"
|
||||
/>
|
||||
<input
|
||||
@@ -528,7 +887,7 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="端口"
|
||||
placeholder={t('dialog.proxyPortPlaceholder')}
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -542,7 +901,7 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
proxy: { ...formData.proxy, username: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="用户名(可选)"
|
||||
placeholder={t('dialog.proxyUserPlaceholder')}
|
||||
autoComplete="off"
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
@@ -555,7 +914,7 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
proxy: { ...formData.proxy, password: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="密码(可选)"
|
||||
placeholder={t('dialog.proxyPasswordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
@@ -565,7 +924,7 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="session-jump-host" className="ui-label">跳板机(可选)</label>
|
||||
<label htmlFor="session-jump-host" className="ui-label">{t('dialog.jumpHost')}</label>
|
||||
<select
|
||||
id="session-jump-host"
|
||||
value={formData.jumpSessionIds[0] || ''}
|
||||
@@ -580,14 +939,14 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
}
|
||||
className="ui-input-sm"
|
||||
>
|
||||
<option value="">不使用跳板</option>
|
||||
<option value="">{t('dialog.jumpNoneAlt')}</option>
|
||||
{jumpCandidates.map((session) => (
|
||||
<option key={session.id} value={session.id}>
|
||||
{session.name} ({session.username}@{session.host})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-[10px] text-txt-faint">当前支持一个已保存会话作为跳板</p>
|
||||
<p className="mt-1 text-[10px] text-txt-faint">{t('dialog.jumpHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -598,15 +957,19 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
<div className="p-4 border-t border-dark-700/50 flex-shrink-0 bg-dark-800">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !parseSshTarget(target)?.host || !proxyReady}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!(formData.host.trim() || target.trim().replace(/^.*@/, '').replace(/:\d+$/, '')) ||
|
||||
!proxyReady
|
||||
}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-accent-blue hover:bg-accent-blue-hover disabled:opacity-45 text-white text-sm font-medium transition-colors shadow-sm shadow-accent-blue/25"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
'保存中…'
|
||||
t('dialog.saving')
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-4 h-4" />
|
||||
{isEditing ? '保存' : '创建并保存'}
|
||||
{isEditing ? t('dialog.save') : t('dialog.createAndSave')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -614,5 +977,7 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
{PromptDialogComponent}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Plus, Zap, Clock, Server, ChevronRight, Laptop, Terminal, Monitor, Cast } from 'lucide-react'
|
||||
import { Plus, LogIn, Clock, Server, ChevronRight, Laptop, Terminal, Monitor, Cast } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import HostDistroIcon from '../common/HostDistroIcon'
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'
|
||||
import { useSessionStore } from '../../stores'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
@@ -8,10 +10,13 @@ import { openLocalShellTab, type ShellOption } from '../layout/sidebar/LocalShel
|
||||
import appIconUrl from '../../assets/opsxshell-app-icon.png'
|
||||
import { shellDisplayFromPath } from '../../utils/shellName'
|
||||
import { fallbackLocalShells, modKeyLabel } from '../../utils/platformShells'
|
||||
import { buildOpenTabPayload } from '../../utils/sessionProtocol'
|
||||
|
||||
interface WelcomeViewProps {
|
||||
onNewSession?: () => void
|
||||
onQuickConnect?: () => void
|
||||
onRdpConnect?: () => void
|
||||
onVncConnect?: () => void
|
||||
}
|
||||
|
||||
function Kbd({ children }: { children: string }) {
|
||||
@@ -22,10 +27,15 @@ function Kbd({ children }: { children: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeViewProps) {
|
||||
export default function WelcomeView({
|
||||
onNewSession,
|
||||
onQuickConnect,
|
||||
onRdpConnect,
|
||||
onVncConnect,
|
||||
}: WelcomeViewProps) {
|
||||
const { t } = useTranslation('sessions')
|
||||
const { addTab } = useTabStore()
|
||||
const requirePro = useEntitlementStore((s) => s.requirePro)
|
||||
const openUpgradeTab = useEntitlementStore((s) => s.openUpgradeTab)
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
const recentSessions = sessions.slice(-8).reverse()
|
||||
const modKey = useMemo(() => modKeyLabel(), [])
|
||||
@@ -70,11 +80,9 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
}, [defaultShell])
|
||||
|
||||
const handleSessionClick = (session: (typeof sessions)[number]) => {
|
||||
addTab({
|
||||
title: session.name || session.host,
|
||||
type: 'terminal',
|
||||
sessionId: session.id,
|
||||
})
|
||||
const protocol = session.protocol ?? 'ssh'
|
||||
if ((protocol === 'rdp' || protocol === 'vnc') && !requirePro(protocol)) return
|
||||
addTab(buildOpenTabPayload(session))
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -91,11 +99,11 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-semibold tracking-tight text-content-primary">OpsXShell</h1>
|
||||
<p className="text-xs text-txt-muted">远程终端 · SFTP · 端口转发</p>
|
||||
<p className="text-xs text-txt-muted">{t('welcome.tagline')}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section aria-label="开始" className="mb-12">
|
||||
<section aria-label={t('welcome.sectionStart')} className="mb-12">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -104,13 +112,13 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-blue/15 text-accent-blue">
|
||||
<Zap className="h-4 w-4" strokeWidth={2} />
|
||||
<LogIn className="h-4 w-4" strokeWidth={2} />
|
||||
</span>
|
||||
<Kbd>{`${modKey}K`}</Kbd>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-content-primary">快速连接</div>
|
||||
<div className="mt-1 text-xs leading-relaxed text-txt-muted">临时 SSH,不写入会话库</div>
|
||||
<div className="text-sm font-medium text-content-primary">{t('welcome.quickConnect.title')}</div>
|
||||
<div className="mt-1 text-xs leading-relaxed text-txt-muted">{t('welcome.quickConnect.desc')}</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -126,8 +134,8 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
<Kbd>{`${modKey}N`}</Kbd>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-content-primary">新建会话</div>
|
||||
<div className="mt-1 text-xs leading-relaxed text-txt-muted">保存主机,侧栏双击连接</div>
|
||||
<div className="text-sm font-medium text-content-primary">{t('welcome.newSession.title')}</div>
|
||||
<div className="mt-1 text-xs leading-relaxed text-txt-muted">{t('welcome.newSession.desc')}</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -145,7 +153,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
<Terminal className="h-3.5 w-3.5 text-txt-faint" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-content-primary">本地 Shell</div>
|
||||
<div className="text-sm font-medium text-content-primary">{t('welcome.localShell.title')}</div>
|
||||
<div className="mt-1 truncate text-xs leading-relaxed text-txt-muted">
|
||||
{defaultShellLabel}
|
||||
</div>
|
||||
@@ -153,7 +161,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="选择 Shell"
|
||||
aria-label={t('welcome.localShell.selectShell')}
|
||||
aria-expanded={shellMenuOpen}
|
||||
onClick={() => setShellMenuOpen((v) => !v)}
|
||||
className="flex w-9 flex-shrink-0 items-center justify-center border-l border-dark-700/60 text-txt-faint transition-colors hover:bg-dark-700/40 hover:text-content-secondary"
|
||||
@@ -177,7 +185,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
<span className="w-10 font-mono text-[10px] uppercase text-txt-faint">{s.kind}</span>
|
||||
<span className="flex-1 text-sm text-content-primary">{s.name}</span>
|
||||
{s.isDefault && (
|
||||
<span className="text-[10px] text-txt-faint">默认</span>
|
||||
<span className="text-[10px] text-txt-faint">{t('welcome.localShell.default')}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
@@ -187,51 +195,51 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-label="Pro 即将推出" className="mb-10">
|
||||
<section aria-label={t('welcome.sectionDesktop')} className="mb-10">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">Pro · 即将推出</h2>
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">{t('welcome.sectionDesktop')}</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!requirePro('rdp', { comingSoon: true })) return
|
||||
openUpgradeTab('rdp', true)
|
||||
if (!requirePro('rdp')) return
|
||||
onRdpConnect?.()
|
||||
}}
|
||||
className="flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/30 px-4 py-3 text-left transition-colors hover:border-dark-600"
|
||||
className="flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/30 px-4 py-3 text-left transition-colors hover:border-accent-blue/40"
|
||||
>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-dark-700 text-txt-muted">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-blue/15 text-accent-blue">
|
||||
<Monitor className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-content-secondary">RDP 远程桌面</div>
|
||||
<div className="text-[11px] text-txt-faint">Pro · 即将推出</div>
|
||||
<div className="text-sm font-medium text-content-primary">{t('welcome.rdp.title')}</div>
|
||||
<div className="text-[11px] text-txt-faint">{t('welcome.rdp.desc')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!requirePro('vnc', { comingSoon: true })) return
|
||||
openUpgradeTab('vnc', true)
|
||||
if (!requirePro('vnc')) return
|
||||
onVncConnect?.()
|
||||
}}
|
||||
className="flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/30 px-4 py-3 text-left transition-colors hover:border-dark-600"
|
||||
className="flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/30 px-4 py-3 text-left transition-colors hover:border-accent-blue/40"
|
||||
>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-dark-700 text-txt-muted">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-blue/15 text-accent-blue">
|
||||
<Cast className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-content-secondary">VNC 远程桌面</div>
|
||||
<div className="text-[11px] text-txt-faint">Pro · 即将推出</div>
|
||||
<div className="text-sm font-medium text-content-primary">{t('welcome.vnc.title')}</div>
|
||||
<div className="text-[11px] text-txt-faint">{t('welcome.vnc.desc')}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-label="最近会话" className="min-h-0 flex-1">
|
||||
<section aria-label={t('welcome.sectionRecent')} className="min-h-0 flex-1">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Clock className="h-3.5 w-3.5 text-txt-faint" />
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
|
||||
最近会话
|
||||
{t('welcome.sectionRecent')}
|
||||
</h2>
|
||||
{recentSessions.length > 0 && (
|
||||
<span className="text-[11px] text-txt-dim">{recentSessions.length}</span>
|
||||
@@ -242,8 +250,8 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
<div className="rounded-xl border border-dashed border-dark-700/70 px-4 py-10">
|
||||
<EmptyState
|
||||
icon={Server}
|
||||
title="暂无已保存会话"
|
||||
description="用快速连接试连,或新建会话以便复用"
|
||||
title={t('welcome.recentEmpty.title')}
|
||||
description={t('welcome.recentEmpty.desc')}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -255,8 +263,12 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
onClick={() => handleSessionClick(session)}
|
||||
className="group flex w-full items-center gap-3 px-3.5 py-2.5 text-left transition-colors hover:bg-dark-800/80"
|
||||
>
|
||||
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-dark-800 text-txt-muted group-hover:text-accent-blue">
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center">
|
||||
<HostDistroIcon
|
||||
distro={session.distro}
|
||||
protocol={session.protocol}
|
||||
size="md"
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm text-content-primary">{session.name}</span>
|
||||
|
||||
Reference in new issue
Block a user