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

+103
View File
@@ -0,0 +1,103 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
interface AgentMarkdownProps {
content: string
className?: string
}
/** 简单检测是否含 Markdown 结构(标题/表格/引用/列表/粗体) */
export function looksLikeMarkdown(text: string): boolean {
const t = text.trim()
if (!t) return false
return (
/^#{1,3}\s/m.test(t) ||
/^\|.+\|/m.test(t) ||
/^>\s/m.test(t) ||
/^[-*]\s/m.test(t) ||
/^\d+\.\s/m.test(t) ||
/\*\*[^*]+\*\*/.test(t) ||
/```/.test(t)
)
}
/** Agent 结论富文本:对齐 aiops AgentMarkdown,使用本产品色板 */
export function AgentMarkdown({ content, className = '' }: AgentMarkdownProps) {
return (
<div className={`agent-md min-w-0 ${className}`.trim()}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="mb-2 mt-3 text-base font-bold text-content-primary first:mt-0">{children}</h1>
),
h2: ({ children }) => (
<h2 className="mb-1.5 mt-3 text-sm font-semibold text-content-primary first:mt-0">{children}</h2>
),
h3: ({ children }) => (
<h3 className="mb-1 mt-2 text-sm font-medium text-content-primary first:mt-0">{children}</h3>
),
p: ({ children }) => (
<p className="my-1.5 text-xs leading-relaxed text-content-secondary">{children}</p>
),
ul: ({ children }) => (
<ul className="my-1.5 list-disc space-y-0.5 pl-5 text-xs text-content-secondary">{children}</ul>
),
ol: ({ children }) => (
<ol className="my-1.5 list-decimal space-y-0.5 pl-5 text-xs text-content-secondary">{children}</ol>
),
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
strong: ({ children }) => (
<strong className="font-semibold text-content-primary">{children}</strong>
),
blockquote: ({ children }) => (
<blockquote className="my-2 border-l-2 border-accent-blue/50 pl-3 text-xs italic text-txt-muted">
{children}
</blockquote>
),
table: ({ children }) => (
<div className="border-dark-700/60 my-2 overflow-x-auto rounded-md border">
<table className="w-full border-collapse text-[11px]">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-dark-900/80">{children}</thead>,
th: ({ children }) => (
<th className="border-dark-700/60 whitespace-nowrap border-b px-2.5 py-1.5 text-left font-medium text-content-primary">
{children}
</th>
),
td: ({ children }) => (
<td className="border-dark-700/40 border-b px-2.5 py-1.5 text-content-secondary">{children}</td>
),
tr: ({ children }) => <tr className="hover:bg-dark-800/40">{children}</tr>,
code: ({ className: codeClass, children }) => {
const isBlock = Boolean(codeClass?.includes('language-'))
if (isBlock) {
return (
<pre className="bg-dark-950/60 my-2 overflow-x-auto rounded px-2 py-1.5 text-[11px] text-content-secondary">
<code>{children}</code>
</pre>
)
}
return (
<code className="bg-dark-800 rounded px-1 py-0.5 text-[11px] text-content-primary">{children}</code>
)
},
hr: () => <hr className="border-dark-700/60 my-3" />,
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noreferrer"
className="text-accent-blue underline-offset-2 hover:underline"
>
{children}
</a>
),
}}
>
{content}
</ReactMarkdown>
</div>
)
}
+325 -186
View File
@@ -1,13 +1,12 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Check,
CheckCircle2,
Clipboard,
Loader2,
Play,
Server,
ShieldAlert,
ShieldCheck,
Plus,
Send,
Sparkles,
Square,
TerminalSquare,
@@ -18,15 +17,18 @@ import { extractBashBlocks, singleLineTerminalInsert, terminalContextLineCount }
import { writeClipboardText } from '../../platform/clipboard'
import { analyzeAgentOutput } from '../../utils/agentOutput'
import {
AGENT_RISK_LABEL,
AGENT_SAFETY_MODES,
agentCommandAllowFingerprint,
agentCommandNeedsConfirmation,
assessAgentCommandRisk,
normalizeAgentSafetyMode,
type AgentCommandRisk,
type AgentSafetyMode,
} from '../../utils/agentCommandRisk'
import { useConfirmDialog } from '../common/ConfirmDialog'
import { useSettingsStore } from '../../stores/settingsStore'
import { AgentMarkdown, looksLikeMarkdown } from './AgentMarkdown'
import i18n from '../../i18n'
import { translateErrorMessage } from '../../i18n/format'
export interface TerminalAiContext {
selection: string
@@ -50,16 +52,27 @@ interface AgentStep {
status: 'awaiting' | 'running' | 'done' | 'error'
}
interface ThreadMessage {
id: string
role: 'user' | 'assistant'
content: string
steps?: AgentStep[]
}
const MAX_AGENT_STEPS = 8
const MAX_TOOL_OUTPUT_CHARS = 12_000
const MAX_AGENT_CONTEXT_CHARS = 48_000
const SAFETY_MODE_ORDER: AgentSafetyMode[] = ['strict', 'balanced', 'permissive']
const SAFETY_MODE_ORDER: AgentSafetyMode[] = ['strict', 'relaxed']
function truncateMiddle(value: string, limit: number): string {
if (value.length <= limit) return value
const head = Math.floor(limit * 0.65)
const tail = limit - head
return `${value.slice(0, head)}\n\n… [输出过长,已截断 ${value.length - limit} 个字符] …\n\n${value.slice(-tail)}`
return `${value.slice(0, head)}\n\n… [已截断 ${value.length - limit} 字符] …\n\n${value.slice(-tail)}`
}
function messageId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
}
function agentPrompt(
@@ -67,7 +80,8 @@ function agentPrompt(
targetLabel: string,
round: number,
consecutiveFailures: number,
protocolRetry: boolean
protocolRetry: boolean,
isFollowUp: boolean
): string {
const checkpoint =
consecutiveFailures >= 2
@@ -78,12 +92,13 @@ function agentPrompt(
? '这是最后一轮,除非缺少唯一关键证据,否则必须返回 FINAL 结论。'
: ''
return [
`用户任务:${task}`,
isFollowUp ? `用户追问:${task}` : `用户任务:${task}`,
`执行目标:当前已连接 SSH 主机「${targetLabel}」。`,
`当前为 Agent 第 ${round}/${MAX_AGENT_STEPS} 轮。`,
isFollowUp ? '请结合对话摘要与历史工具结果继续处理;不要重复已完成的无信息量步骤。' : '',
'你只有 ssh-exec 一个工具,可执行当前远端 shell 中的单行命令。',
'如果仍需获取信息或执行操作:只返回一个 bash 代码块,块内只能有一行命令。',
'如果任务已完成、已无需命令,或仅根据已有上下文就能回答:以“FINAL:”开头给出简短结论,不要包含代码块。',
'如果任务已完成、已无需命令,或仅根据已有上下文就能回答:以“FINAL:”开头给出结论,不要包含代码块。列表、表格、标题可用 Markdown,便于阅读。',
'不得声称执行过尚未出现在真实工具结果中的命令。',
protocolRetry ? '上一次回答没有遵循工具协议;这一次必须二选一:唯一单行 bash 代码块,或以 FINAL: 开头的结论。' : '',
checkpoint,
@@ -92,8 +107,28 @@ function agentPrompt(
.join('\n')
}
function agentContext(initialSelection: string, steps: AgentStep[]): string {
function compressHistory(messages: ThreadMessage[]): string {
const parts: string[] = []
for (const message of messages) {
if (message.role === 'user') {
parts.push(`【用户】\n${message.content}`)
continue
}
parts.push(`【助手结论】\n${message.content}`)
for (const step of (message.steps || []).slice(-4)) {
parts.push(
`【历史 ssh-exec】\n$ ${step.command}\n${truncateMiddle(step.output || '无输出', 1_500)}${
step.exitCode === undefined ? '' : `\n[exit_code=${step.exitCode}]`
}`
)
}
}
return truncateMiddle(parts.join('\n\n'), Math.floor(MAX_AGENT_CONTEXT_CHARS * 0.55))
}
function agentContext(initialSelection: string, steps: AgentStep[], history: string): string {
const parts: string[] = []
if (history) parts.push(`【对话与工具摘要】\n${history}`)
if (initialSelection) parts.push(`【用户选中的初始终端内容】\n${initialSelection}`)
for (const step of steps) {
parts.push(
@@ -112,62 +147,93 @@ export default function AiTerminalPanel({
targetLabel = '当前 SSH 会话',
isLocal = false,
}: AiTerminalPanelProps) {
const { t } = useTranslation('ai')
const store = useAiProviderStore()
const safetyMode = useSettingsStore((state) => state.settings.agentSafetyMode)
const safetyMode = normalizeAgentSafetyMode(
useSettingsStore((state) => state.settings.agentSafetyMode)
)
const setSetting = useSettingsStore((state) => state.setSetting)
const [prompt, setPrompt] = useState('')
const [result, setResult] = useState('')
const [contextNote, setContextNote] = useState('仅在开始时读取当前选中内容')
const [messages, setMessages] = useState<ThreadMessage[]>([])
const [liveSteps, setLiveSteps] = useState<AgentStep[]>([])
const [error, setError] = useState('')
const [running, setRunning] = useState(false)
const [copied, setCopied] = useState(false)
const [steps, setSteps] = useState<AgentStep[]>([])
const [copiedId, setCopiedId] = useState('')
const runIdRef = useRef(0)
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
const sessionAllowRef = useRef(new Set<string>())
const timelineRef = useRef<HTMLDivElement>(null)
const initialSelectionRef = useRef('')
const liveStepsRef = useRef<AgentStep[]>([])
const { confirmAgentRisk, dismissAgentRisk, ConfirmDialogComponent } = useConfirmDialog()
liveStepsRef.current = liveSteps
useEffect(() => {
void store.load()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const node = timelineRef.current
if (!node) return
node.scrollTop = node.scrollHeight
}, [messages, liveSteps, error, running])
const provider = store.providers.find((item) => item.id === store.activeProviderId && item.enabled)
const model = provider?.models.find((item) => item.id === provider.defaultModelId)
const sshReady = !!connectionId && !isLocal
const startNewTopic = () => {
if (running) {
runIdRef.current += 1
setRunning(false)
}
setMessages([])
setLiveSteps([])
setError('')
setPrompt('')
setCopiedId('')
sessionAllowRef.current.clear()
initialSelectionRef.current = ''
}
const runAgent = async () => {
setError('')
setResult('')
setSteps([])
if (!provider || !model) {
setError('请先在“设置 → 大模型”添加 Provider 并选择默认模型')
setError(t('error.noProvider'))
return
}
if (!provider.hasApiKey && provider.authStyle !== 'none') {
setError(`Provider「${provider.name}」尚未保存 API Key`)
setError(t('error.noApiKey', { provider: provider.name }))
return
}
const task = prompt.trim()
if (!task) {
setError('请输入要执行的任务')
setError(t('error.emptyTask'))
return
}
if (!connectionId || isLocal) {
setError('Agent CLI 只作用于当前已连接的远程 SSH 会话')
if (!sshReady) {
setError(t('error.sshRequired'))
return
}
const isFollowUp = messages.length > 0
const history = compressHistory(messages)
const userMessage: ThreadMessage = { id: messageId(), role: 'user', content: task }
setMessages((prev) => [...prev, userMessage])
setPrompt('')
const runId = runIdRef.current + 1
runIdRef.current = runId
const selection = truncateMiddle(capture().selection.trim(), MAX_TOOL_OUTPUT_CHARS)
setContextNote(
selection
? `已附带选中内容 · ${terminalContextLineCount(selection)} 行`
: '未附带终端内容 · Agent 将通过 ssh-exec 自主检查'
)
if (!isFollowUp) {
initialSelectionRef.current = truncateMiddle(capture().selection.trim(), MAX_TOOL_OUTPUT_CHARS)
}
const selection = initialSelectionRef.current
setRunning(true)
setLiveSteps([])
const runSafetyMode = safetyMode
const completedSteps: AgentStep[] = []
const commandCounts = new Map<string, number>()
let redactedCount = 0
let consecutiveFailures = 0
let protocolRetry = false
try {
@@ -176,14 +242,13 @@ export default function AiTerminalPanel({
provider,
model,
action: 'execute',
prompt: agentPrompt(task, targetLabel, round, consecutiveFailures, protocolRetry),
context: agentContext(selection, completedSteps),
prompt: agentPrompt(task, targetLabel, round, consecutiveFailures, protocolRetry, isFollowUp),
context: agentContext(selection, completedSteps, history),
})
if (runIdRef.current !== runId) return
if (!completion.success || !completion.text) {
throw new Error(completion.error || '模型未返回内容')
throw new Error(completion.error || i18n.t('ai:error.modelEmpty'))
}
redactedCount += completion.redactedCount || 0
const blocks = extractBashBlocks(completion.text)
if (blocks.length === 0) {
@@ -192,55 +257,80 @@ export default function AiTerminalPanel({
protocolRetry = true
continue
}
throw new Error('模型连续两次未遵循 Agent 工具协议,已停止以避免误判')
throw new Error(i18n.t('ai:error.protocolFailed'))
}
const finalAnswer = completion.text.replace(/^\s*FINAL\s*:\s*/i, '').trim()
if (!finalAnswer) throw new Error('模型未返回可用的最终结论')
setResult(finalAnswer)
if (!finalAnswer) throw new Error(i18n.t('ai:error.noConclusion'))
setMessages((prev) => [
...prev,
{
id: messageId(),
role: 'assistant',
content: finalAnswer,
steps: completedSteps.map((step) => ({ ...step })),
},
])
setLiveSteps([])
return
}
if (blocks.length !== 1) {
throw new Error('模型一次返回了多个命令,已拒绝执行')
throw new Error(i18n.t('ai:error.multipleCommands'))
}
const command = singleLineTerminalInsert(blocks[0])
if (!command) {
throw new Error('模型未返回唯一的单行命令,已拒绝执行')
throw new Error(i18n.t('ai:error.invalidCommand'))
}
protocolRetry = false
const repeated = (commandCounts.get(command) || 0) + 1
commandCounts.set(command, repeated)
if (repeated > 2) {
throw new Error(`检测到重复命令循环,已在执行前停止:${command}`)
throw new Error(i18n.t('ai:error.repeatLoop', { command }))
}
const risk = assessAgentCommandRisk(command)
if (risk === 'critical') {
throw new Error(`命令命中破坏性操作硬红线,不提供确认放行:${command}`)
throw new Error(i18n.t('ai:error.criticalBlocked', { command }))
}
const proposed: AgentStep = { round, command, risk, status: 'running' }
if (agentCommandNeedsConfirmation(risk, runSafetyMode)) {
const awaiting = { ...proposed, status: 'awaiting' as const }
setSteps([...completedSteps, awaiting])
const approved = await confirm({
title: `第 ${round} 步:确认远程执行 · ${AGENT_RISK_LABEL[risk]}`,
message: `目标:SSH · ${targetLabel}\n安全模式:${AGENT_SAFETY_MODES[runSafetyMode].label}\n风险:${AGENT_RISK_LABEL[risk]}\n\n命令:${command}\n\n执行结果将交给 AI 判断下一步;命令和输出不会写入历史。`,
danger: risk === 'high',
confirmText: risk === 'high' ? '确认风险并执行' : '执行',
})
if (!approved) {
setSteps([...completedSteps])
setResult(`任务已停止:你取消了第 ${round} 步命令。`)
return
const fingerprint = agentCommandAllowFingerprint(command, risk)
if (!sessionAllowRef.current.has(fingerprint)) {
const awaiting = { ...proposed, status: 'awaiting' as const }
setLiveSteps([...completedSteps, awaiting])
const choice = await confirmAgentRisk({
title: t('step.confirmTitle', { round, risk: t(`risk.${risk}`) }),
message: t('step.confirmMessage', {
target: targetLabel,
mode: t(`safety.${runSafetyMode}.label`),
command,
}),
danger: risk === 'high',
})
if (runIdRef.current !== runId) return
if (choice === 'reject') {
setLiveSteps([])
setMessages((prev) => [
...prev,
{ id: messageId(), role: 'assistant', content: t('step.rejected', { round }), steps: [...completedSteps] },
])
return
}
if (choice === 'always') {
sessionAllowRef.current.add(fingerprint)
}
}
}
setSteps([...completedSteps, proposed])
setLiveSteps([...completedSteps, proposed])
const executed = await window.opsxApi.sshExec(connectionId, command)
if (runIdRef.current !== runId) return
if (!executed.success && /已终止/.test(executed.error || '')) {
return
}
const rawOutput = executed.success
? executed.result?.trim() || '命令执行完成,无输出'
: `ssh-exec 失败:${executed.error || '未知错误'}`
? executed.result?.trim() || i18n.t('ai:error.execNoOutput')
: i18n.t('ai:error.execFailed', { error: executed.error || i18n.t('ai:error.execUnknown') })
const output =
rawOutput.length > MAX_TOOL_OUTPUT_CHARS / 2
? truncateMiddle(analyzeAgentOutput(rawOutput).text, MAX_TOOL_OUTPUT_CHARS)
@@ -255,70 +345,75 @@ export default function AiTerminalPanel({
}
completedSteps.push(step)
consecutiveFailures = step.status === 'error' ? consecutiveFailures + 1 : 0
setSteps([...completedSteps])
setLiveSteps([...completedSteps])
}
throw new Error(`已达到 ${MAX_AGENT_STEPS} 步上限,任务尚未收敛,请缩小任务范围后重试`)
throw new Error(i18n.t('ai:error.maxSteps', { max: MAX_AGENT_STEPS }))
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught))
const message = translateErrorMessage(caught instanceof Error ? caught.message : String(caught))
setError(message)
if (completedSteps.length > 0) {
setMessages((prev) => [
...prev,
{ id: messageId(), role: 'assistant', content: message, steps: [...completedSteps] },
])
setLiveSteps([])
}
} finally {
if (runIdRef.current === runId) {
setRunning(false)
if (redactedCount > 0) {
setContextNote((current) => `${current} · 发送前本地脱敏 ${redactedCount} 处`)
}
}
}
}
const copyResult = async () => {
await writeClipboardText(result)
setCopied(true)
window.setTimeout(() => setCopied(false), 1200)
const copyMessage = async (id: string, content: string) => {
await writeClipboardText(content)
setCopiedId(id)
window.setTimeout(() => setCopiedId(''), 1200)
}
const stopAgent = () => {
runIdRef.current += 1
dismissAgentRisk()
if (connectionId) {
void window.opsxApi.sshExecCancel(connectionId)
}
setRunning(false)
setResult('任务已停止;正在执行的单条 SSH 命令不会被强制中断,但不会再继续下一步。')
const snapped = liveStepsRef.current
.filter((step) => step.status !== 'awaiting')
.map((step) =>
step.status === 'running'
? { ...step, status: 'error' as const, output: step.output || t('step.terminated') }
: step
)
setLiveSteps([])
setMessages((prev) => [
...prev,
{
id: messageId(),
role: 'assistant',
content: t('step.stoppedMessage'),
steps: snapped.length > 0 ? snapped : undefined,
},
])
}
return (
<aside className="border-dark-700/50 bg-dark-850 flex w-[430px] max-w-[42vw] flex-shrink-0 flex-col border-l">
<header className="border-dark-700/50 flex items-center gap-2 border-b px-3 py-2.5">
<Sparkles className="h-4 w-4 text-accent-blue" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-content-primary">Agent CLI</div>
<div className="truncate text-[10px] text-txt-faint">
{provider && model ? `LLM: ${provider.name} · ${model.label}` : '尚未配置模型'}
<header className="border-dark-700/50 flex-shrink-0 border-b px-3 py-2.5">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-accent-blue" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-content-primary">{t('panel.title')}</div>
<div className="truncate text-[10px] text-txt-faint">
{provider && model
? t('panel.modelConfigured', { provider: provider.name, model: model.label })
: t('panel.modelNotConfigured')}
{' · '}
{sshReady ? t('panel.sshReady', { target: targetLabel }) : t('panel.sshNotConnected')}
</div>
</div>
</div>
<span className="rounded-md border border-dark-600 bg-dark-900/70 px-2 py-1 text-[10px] text-content-secondary">
{AGENT_SAFETY_MODES[safetyMode].label}
</span>
<button type="button" title="关闭" onClick={onClose} className="rounded p-1 text-txt-muted hover:bg-dark-700">
<X className="h-4 w-4" />
</button>
</header>
<div className="flex-1 space-y-3 overflow-y-auto p-3">
<div className="border-accent-blue/20 bg-accent-blue/5 space-y-2 rounded-lg border p-3">
<div className="text-xs font-medium text-content-primary">自然语言驱动 SSH 自主排障与部署</div>
<div className="flex items-center gap-1.5 text-[11px] text-content-secondary">
<Server className="h-3.5 w-3.5 flex-shrink-0" />
<span className="truncate">已连接主机:{connectionId && !isLocal ? targetLabel : '尚未连接远程 SSH'}</span>
</div>
<p className="text-[10px] leading-relaxed text-txt-faint">
Agent 只使用当前 SSH 连接执行、观察并继续处理,不切换目标主机。
</p>
</div>
<div className="rounded-lg border border-dark-700/60 bg-dark-900/45 p-2.5">
<div className="mb-2 flex items-center gap-1.5 text-[11px] font-medium text-content-secondary">
<ShieldCheck className="h-3.5 w-3.5 text-accent-green" /> 执行安全模式
</div>
<div className="grid grid-cols-3 gap-1" role="radiogroup" aria-label="Agent 安全模式">
<div className="flex items-center gap-0.5" role="radiogroup" aria-label={t('safety.aria')}>
{SAFETY_MODE_ORDER.map((mode) => {
const option = AGENT_SAFETY_MODES[mode]
const selected = safetyMode === mode
return (
<button
@@ -326,25 +421,96 @@ export default function AiTerminalPanel({
type="button"
role="radio"
aria-checked={selected}
aria-label={t(`safety.${mode}.label`)}
disabled={running}
title={`${option.short}:${option.description}`}
title={t(`safety.${mode}.description`)}
onClick={() => void setSetting('agentSafetyMode', mode)}
className={`rounded-md border px-2 py-1.5 text-[11px] transition-colors disabled:opacity-50 ${
selected
? 'border-accent-blue/50 bg-accent-blue/15 text-accent-blue'
: 'border-dark-700 bg-dark-800 text-txt-muted hover:text-content-primary'
className={`rounded px-1.5 py-0.5 text-[10px] disabled:opacity-50 ${
selected ? 'bg-accent-blue/15 text-accent-blue' : 'text-txt-faint hover:text-content-secondary'
}`}
>
{option.label}
{t(`safety.${mode}.label`)}
</button>
)
})}
</div>
<p className="mt-2 text-[10px] leading-relaxed text-txt-faint">
{AGENT_SAFETY_MODES[safetyMode].short} · {AGENT_SAFETY_MODES[safetyMode].description}
</p>
<button
type="button"
title={t('panel.newTopic')}
aria-label={t('panel.newTopic')}
disabled={running}
onClick={startNewTopic}
className="rounded p-1 text-txt-muted hover:bg-dark-700 disabled:opacity-40"
>
<Plus className="h-4 w-4" />
</button>
<button type="button" title={t('panel.close')} onClick={onClose} className="rounded p-1 text-txt-muted hover:bg-dark-700">
<X className="h-4 w-4" />
</button>
</div>
</header>
<div ref={timelineRef} className="min-h-0 flex-1 space-y-3 overflow-y-auto px-3 py-3">
{messages.length === 0 && liveSteps.length === 0 && !running && (
<p className="px-1 py-8 text-center text-xs leading-relaxed text-txt-faint">
{t('panel.emptyHint')}
</p>
)}
{messages.map((message) => (
<div key={message.id} className="space-y-2">
{message.role === 'user' ? (
<div className="ml-6 rounded-lg bg-accent-blue/10 px-3 py-2 text-xs leading-relaxed text-content-primary">
{message.content}
</div>
) : (
<div className="space-y-2">
{(message.steps || []).map((step) => (
<StepCard key={`${message.id}-${step.round}-${step.command}`} step={step} />
))}
<div className="mr-4 rounded-lg border border-emerald-500/15 bg-emerald-500/5 px-3 py-2">
<div className="mb-1 flex items-center gap-1.5 text-[10px] text-emerald-400">
<CheckCircle2 className="h-3.5 w-3.5" /> {t('panel.conclusion')}
</div>
{looksLikeMarkdown(message.content) ? (
<AgentMarkdown content={message.content} />
) : (
<div className="whitespace-pre-wrap break-words text-xs leading-relaxed text-content-secondary">
{message.content}
</div>
)}
<button
type="button"
onClick={() => void copyMessage(message.id, message.content)}
className="mt-2 inline-flex items-center gap-1 text-[10px] text-txt-faint hover:text-content-secondary"
>
{copiedId === message.id ? <Check className="h-3 w-3" /> : <Clipboard className="h-3 w-3" />}
{copiedId === message.id ? t('panel.copied') : t('panel.copy')}
</button>
</div>
</div>
)}
</div>
))}
{liveSteps.map((step) => (
<StepCard key={`live-${step.round}-${step.command}`} step={step} />
))}
{running && liveSteps.length === 0 && (
<div className="flex items-center gap-1.5 text-[11px] text-txt-faint">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t('panel.thinking')}
</div>
)}
{error && (
<div role="alert" className="rounded-lg border border-red-500/25 bg-red-500/10 px-3 py-2 text-xs text-red-300">
{error}
</div>
)}
</div>
<div className="border-dark-700/50 flex-shrink-0 border-t px-3 py-2.5">
<textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
@@ -354,91 +520,64 @@ export default function AiTerminalPanel({
if (!running) void runAgent()
}
}}
rows={5}
rows={3}
disabled={running}
placeholder="输入任务描述,回车发送(Shift+回车换行)"
className="ui-input-sm min-h-24 resize-none leading-relaxed disabled:opacity-70"
placeholder={t('panel.placeholder')}
className="ui-input-sm min-h-[72px] resize-none leading-relaxed disabled:opacity-70"
/>
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] text-txt-faint">Shift+回车换行 · 回车发送</span>
<div className="mt-2 flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-[10px] text-txt-faint">
{messages.length > 0
? t('panel.hintFollowUp')
: capture().selection.trim()
? t('panel.hintSelection', { lines: terminalContextLineCount(capture().selection) })
: t('panel.hintSteps', { max: MAX_AGENT_STEPS })}
</span>
{running ? (
<button type="button" onClick={stopAgent} className="ui-btn-secondary flex min-w-20 items-center justify-center gap-1.5">
<Square className="h-3.5 w-3.5" /> 停止
<button type="button" onClick={stopAgent} className="ui-btn-secondary flex items-center gap-1.5">
<Square className="h-3.5 w-3.5" /> {t('panel.stop')}
</button>
) : (
<button type="button" onClick={() => void runAgent()} className="ui-btn-primary flex min-w-20 items-center justify-center gap-1.5">
<Play className="h-3.5 w-3.5" /> 执行
<button type="button" onClick={() => void runAgent()} className="ui-btn-primary flex items-center gap-1.5">
<Send className="h-3.5 w-3.5" /> {t('panel.send')}
</button>
)}
</div>
<div className="text-[10px] text-txt-faint">{contextNote}</div>
{error && (
<div
role="alert"
className="rounded-lg border border-red-500/25 bg-red-500/10 px-3 py-2 text-xs text-red-300"
>
{error}
</div>
)}
{steps.length > 0 && (
<div className="space-y-2" aria-label="Agent 执行步骤">
{steps.map((step) => (
<div
key={`${step.round}-${step.command}`}
className="border-dark-700/50 bg-dark-900/45 rounded-lg border p-2.5"
>
<div className="mb-1.5 flex items-center gap-1.5 text-[10px] text-txt-faint">
{step.status === 'running' || step.status === 'awaiting' ? (
<Loader2 className={`h-3.5 w-3.5 ${step.status === 'running' ? 'animate-spin' : ''}`} />
) : step.status === 'done' ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />
) : (
<TerminalSquare className="h-3.5 w-3.5 text-amber-300" />
)}
第 {step.round} 步 ·{' '}
{step.status === 'awaiting' ? '等待确认' : step.status === 'running' ? '正在执行' : '已观察'}
<span className="ml-auto">{AGENT_RISK_LABEL[step.risk]}</span>
</div>
<code className="block break-all text-xs text-content-secondary">$ {step.command}</code>
{step.output && (
<pre className="border-dark-700/40 mt-2 max-h-44 overflow-auto whitespace-pre-wrap break-words border-t pt-2 text-[11px] text-txt-muted">
{step.output}
</pre>
)}
</div>
))}
</div>
)}
{result && (
<div className="space-y-2">
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3">
<div className="mb-1.5 flex items-center gap-1.5 text-[10px] text-emerald-400">
<CheckCircle2 className="h-3.5 w-3.5" /> Agent 结论
</div>
<div className="whitespace-pre-wrap break-words text-xs leading-relaxed text-content-secondary">
{result}
</div>
</div>
<button
type="button"
onClick={() => void copyResult()}
className="ui-btn-secondary flex items-center gap-1.5"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Clipboard className="h-3.5 w-3.5" />}
{copied ? '已复制' : '复制结论'}
</button>
</div>
)}
<p className="mt-2 text-[10px] text-txt-faint">{t('panel.footerNote')}</p>
</div>
<footer className="border-dark-700/40 flex items-start gap-2 border-t px-3 py-2 text-[10px] leading-relaxed text-txt-faint">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
默认均衡模式仅高风险确认;critical 永久拒绝。最多 {MAX_AGENT_STEPS} 步,命令、对话和输出不落盘。
</footer>
{ConfirmDialogComponent}
</aside>
)
}
function StepCard({ step }: { step: AgentStep }) {
const { t } = useTranslation('ai')
const statusLabel =
step.status === 'awaiting'
? t('step.awaiting')
: step.status === 'running'
? t('step.running')
: t('step.done')
return (
<div className="border-dark-700/50 bg-dark-900/45 rounded-lg border p-2.5">
<div className="mb-1.5 flex items-center gap-1.5 text-[10px] text-txt-faint">
{step.status === 'running' || step.status === 'awaiting' ? (
<Loader2 className={`h-3.5 w-3.5 ${step.status === 'running' ? 'animate-spin' : ''}`} />
) : step.status === 'done' ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />
) : (
<TerminalSquare className="h-3.5 w-3.5 text-amber-300" />
)}
{t('step.round', { round: step.round })}
<span className="text-txt-faint">· {statusLabel}</span>
<span className="ml-auto">{t(`risk.${step.risk}`)}</span>
</div>
<code className="block break-all text-xs text-content-secondary">$ {step.command}</code>
{step.output && (
<pre className="border-dark-700/40 mt-2 max-h-36 overflow-auto whitespace-pre-wrap break-words border-t pt-2 text-[11px] text-txt-muted">
{step.output}
</pre>
)}
</div>
)
}
+48 -48
View File
@@ -7,11 +7,12 @@
*/
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
Search,
Globe,
Code2,
Zap,
LogIn,
Plus,
X,
ChevronRight,
@@ -22,6 +23,7 @@ import {
import { useTabStore } from '../../stores/tabStore'
import { useSessionStore, useSnippetStore, useCommandPaletteStore, useEntitlementStore, type Session } from '../../stores'
import { disposeAndCloseAllTabs, disposeAndCloseTab } from '../../utils/disposeTab'
import { buildOpenTabPayload } from '../../utils/sessionProtocol'
// ─── 类型定义 ────────────────────────────────────────────────────────
@@ -46,10 +48,16 @@ interface CommandPaletteProps {
// ─── 类型 badge 颜色映射 ──────────────────────────────────────────────
const TYPE_BADGE: Record<CommandPaletteItemType, { label: string; className: string }> = {
command: { label: '命令', className: 'bg-accent-blue/20 text-accent-blue' },
session: { label: '会话', className: 'bg-accent-green/20 text-accent-green' },
snippet: { label: '片段', className: 'bg-accent-purple/20 text-accent-purple' },
const TYPE_BADGE_CLASS: Record<CommandPaletteItemType, string> = {
command: 'bg-accent-blue/20 text-accent-blue',
session: 'bg-accent-green/20 text-accent-green',
snippet: 'bg-accent-purple/20 text-accent-purple',
}
const TYPE_BADGE_KEY: Record<CommandPaletteItemType, string> = {
command: 'palette.badge.command',
session: 'palette.badge.session',
snippet: 'palette.badge.snippet',
}
// ─── 组件 ──────────────────────────────────────────────────────────────
@@ -60,6 +68,7 @@ export function CommandPalette({
onOpenSettings,
onEditSession,
}: CommandPaletteProps) {
const { t } = useTranslation('common')
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
@@ -79,8 +88,8 @@ export function CommandPalette({
{
id: 'cmd:new-session',
type: 'command',
label: '新建会话',
description: '创建新的 SSH 会话配置',
label: t('palette.cmd.newSession'),
description: t('palette.cmd.newSessionDesc'),
shortcut: undefined,
icon: <Plus size={16} />,
action: () => onNewSession(),
@@ -88,33 +97,33 @@ export function CommandPalette({
{
id: 'cmd:quick-connect',
type: 'command',
label: '快速连接',
description: '直接输入主机信息连接',
icon: <Zap size={16} />,
label: t('palette.cmd.quickConnect'),
description: t('palette.cmd.quickConnectDesc'),
icon: <LogIn size={16} />,
action: () => onQuickConnect(),
},
{
id: 'cmd:local-terminal',
type: 'command',
label: '本地终端',
description: '打开本机 Shell',
label: t('palette.cmd.localTerminal'),
description: t('palette.cmd.localTerminalDesc'),
shortcut: 'Ctrl+Shift+T',
icon: <Laptop size={16} />,
action: () => addTab({ title: 'Local', type: 'terminal', isLocal: true }),
action: () => addTab({ title: t('palette.cmd.localTerminal'), type: 'terminal', isLocal: true }),
},
{
id: 'cmd:open-settings',
type: 'command',
label: '打开设置',
description: '应用偏好设置',
label: t('palette.cmd.settings'),
description: t('palette.cmd.settingsDesc'),
icon: <Settings size={16} />,
action: () => onOpenSettings(),
},
{
id: 'cmd:toggle-sidebar',
type: 'command',
label: '折叠/展开侧栏',
description: '隐藏侧栏以专心操作终端',
label: t('palette.cmd.toggleSidebar'),
description: t('palette.cmd.toggleSidebarDesc'),
shortcut: 'Ctrl+B',
icon: <PanelLeft size={16} />,
action: () => window.dispatchEvent(new CustomEvent('opsxshell:toggle-sidebar')),
@@ -122,17 +131,15 @@ export function CommandPalette({
{
id: 'cmd:new-tab',
type: 'command',
label: '新建标签页',
description: '打开一个新标签页',
label: t('palette.cmd.newTab'),
shortcut: 'Ctrl+Shift+N',
icon: <Plus size={16} />,
action: () => addTab({ title: '新标签', type: 'welcome' }),
action: () => addTab({ title: t('tab.new'), type: 'welcome' }),
},
{
id: 'cmd:close-tab',
type: 'command',
label: '关闭当前标签',
description: '关闭当前活动的标签页',
label: t('palette.cmd.closeTab'),
shortcut: 'Ctrl+W',
icon: <X size={16} />,
action: () => {
@@ -144,8 +151,7 @@ export function CommandPalette({
{
id: 'cmd:close-all-tabs',
type: 'command',
label: '关闭所有标签',
description: '关闭所有标签页并回到欢迎页',
label: t('palette.cmd.closeAllTabs'),
icon: <X size={16} />,
action: () => {
void disposeAndCloseAllTabs()
@@ -154,19 +160,17 @@ export function CommandPalette({
{
id: 'cmd:new-portforward',
type: 'command',
label: '新建端口转发',
description: '打开端口转发管理标签(Pro)',
label: t('palette.cmd.portForward'),
icon: <ChevronRight size={16} />,
action: () => {
if (!useEntitlementStore.getState().requirePro('portForward')) return
addTab({ title: '端口转发', type: 'portforward' })
addTab({ title: t('tab.portForward'), type: 'portforward' })
},
},
{
id: 'cmd:upgrade-pro',
type: 'command',
label: '升级 OpsXShell Pro',
description: '查看专业能力对比与激活',
label: t('palette.cmd.upgradePro'),
icon: <ChevronRight size={16} />,
action: () => useEntitlementStore.getState().openUpgradeTab(),
},
@@ -183,11 +187,7 @@ export function CommandPalette({
description: `${session.username}@${session.host}:${session.port}`,
icon: <Globe size={16} />,
action: () => {
addTab({
title: session.name,
type: 'terminal',
sessionId: session.id,
})
addTab(buildOpenTabPayload(session))
},
})
// 编辑会话命令
@@ -195,7 +195,7 @@ export function CommandPalette({
items.push({
id: `session-edit:${session.id}`,
type: 'command',
label: `编辑会话: ${session.name}`,
label: t('palette.cmd.editSession', { name: session.name }),
description: `${session.username}@${session.host}:${session.port}`,
icon: <Settings size={16} />,
action: () => onEditSession(session),
@@ -221,7 +221,7 @@ export function CommandPalette({
}
return items
}, [sessions, snippets, addTab, activeTabId, tabs.length, onNewSession, onQuickConnect, onOpenSettings, onEditSession])
}, [sessions, snippets, addTab, activeTabId, tabs.length, onNewSession, onQuickConnect, onOpenSettings, onEditSession, t])
// ── 过滤结果 ──
const filteredItems = useMemo(() => {
@@ -337,7 +337,7 @@ export function CommandPalette({
className="relative w-[600px] max-h-[460px] bg-dark-800 border border-dark-700 rounded-lg shadow-2xl overflow-hidden flex flex-col animate-scale-in"
role="dialog"
aria-modal="true"
aria-label="命令面板"
aria-label={t('palette.aria')}
>
{/* Search input */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-dark-700">
@@ -347,7 +347,7 @@ export function CommandPalette({
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索命令、会话或片段..."
placeholder={t('palette.placeholder')}
className="flex-1 bg-transparent text-content-primary placeholder-ph outline-none text-sm"
autoComplete="off"
spellCheck={false}
@@ -362,8 +362,8 @@ export function CommandPalette({
{filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-txt-muted">
<Search size={32} className="mb-2 opacity-30" />
<p className="text-sm">没有找到匹配的结果</p>
<p className="text-xs text-txt-faint mt-1">试试其他关键词</p>
<p className="text-sm">{t('palette.empty')}</p>
<p className="text-xs text-txt-faint mt-1">{t('palette.emptyHint')}</p>
</div>
) : (
groupedItems.map((group) => {
@@ -375,9 +375,9 @@ export function CommandPalette({
{/* Group header */}
<div className="flex items-center gap-2 px-4 py-1.5 text-[11px] font-medium text-txt-muted uppercase tracking-wider">
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${TYPE_BADGE[group.type].className}`}
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${TYPE_BADGE_CLASS[group.type]}`}
>
{TYPE_BADGE[group.type].label}
{t(TYPE_BADGE_KEY[group.type])}
</span>
<span className="flex-1 h-px bg-dark-700" />
<span className="text-txt-faint">{group.items.length}</span>
@@ -432,9 +432,9 @@ export function CommandPalette({
{/* Type badge */}
<span
className={`flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${TYPE_BADGE[item.type].className}`}
className={`flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${TYPE_BADGE_CLASS[item.type]}`}
>
{TYPE_BADGE[item.type].label}
{t(TYPE_BADGE_KEY[item.type])}
</span>
</div>
)
@@ -450,19 +450,19 @@ export function CommandPalette({
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<kbd className="px-1 py-0.5 bg-dark-700 rounded border border-dark-600">↑↓</kbd>
导航
{t('palette.hint.nav')}
</span>
<span className="flex items-center gap-1">
<kbd className="px-1 py-0.5 bg-dark-700 rounded border border-dark-600">↵</kbd>
执行
{t('palette.hint.run')}
</span>
<span className="flex items-center gap-1">
<kbd className="px-1 py-0.5 bg-dark-700 rounded border border-dark-600">esc</kbd>
关闭
{t('palette.hint.close')}
</span>
</div>
<span>
{filteredItems.length} 个结果
{t('palette.resultCount', { count: filteredItems.length })}
</span>
</div>
</div>
+124 -28
View File
@@ -11,9 +11,12 @@
* <ConfirmDialogComponent />
*/
import { useState, useCallback, useRef } from 'react'
import { useState, useCallback, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import Modal from './Modal'
import Button from './Button'
import { AlertTriangle } from 'lucide-react'
import i18n from '../../i18n'
export interface ConfirmOptions {
/** 对话框标题 */
@@ -28,11 +31,25 @@ export interface ConfirmOptions {
cancelText?: string
}
/** Agent 高风险确认:Reject / Once / Always(Always 仅会话内存) */
export type AgentRiskChoice = 'reject' | 'once' | 'always'
export interface AgentRiskConfirmOptions {
title: string
message: string
/** 高风险时 Once/Always 用危险样式 */
danger?: boolean
}
interface ConfirmState extends ConfirmOptions {
/** 是否显示 */
isOpen: boolean
/** resolve 回调 */
resolver: ((value: boolean) => void) | null
}
interface AgentRiskState {
isOpen: boolean
title: string
message: string
danger: boolean
}
const initialState: ConfirmState = {
@@ -40,9 +57,15 @@ const initialState: ConfirmState = {
title: '',
message: '',
danger: false,
confirmText: '确认',
cancelText: '取消',
resolver: null,
confirmText: i18n.t('common:action.confirm'),
cancelText: i18n.t('common:action.cancel'),
}
const initialAgentRisk: AgentRiskState = {
isOpen: false,
title: '',
message: '',
danger: true,
}
/**
@@ -51,8 +74,11 @@ const initialState: ConfirmState = {
* 返回 confirm 函数和需要渲染的组件
*/
export function useConfirmDialog() {
const { t } = useTranslation(['common', 'ai'])
const [state, setState] = useState<ConfirmState>(initialState)
const [agentRisk, setAgentRisk] = useState<AgentRiskState>(initialAgentRisk)
const resolverRef = useRef<((value: boolean) => void) | null>(null)
const agentResolverRef = useRef<((value: AgentRiskChoice) => void) | null>(null)
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
return new Promise<boolean>((resolve) => {
@@ -61,9 +87,20 @@ export function useConfirmDialog() {
...options,
isOpen: true,
danger: options.danger ?? false,
confirmText: options.confirmText ?? '确认',
cancelText: options.cancelText ?? '取消',
resolver: null,
confirmText: options.confirmText ?? i18n.t('common:action.confirm'),
cancelText: options.cancelText ?? i18n.t('common:action.cancel'),
})
})
}, [])
const confirmAgentRisk = useCallback((options: AgentRiskConfirmOptions): Promise<AgentRiskChoice> => {
return new Promise<AgentRiskChoice>((resolve) => {
agentResolverRef.current = resolve
setAgentRisk({
isOpen: true,
title: options.title,
message: options.message,
danger: options.danger ?? true,
})
})
}, [])
@@ -80,27 +117,86 @@ export function useConfirmDialog() {
resolverRef.current = null
}, [])
const ConfirmDialogComponent = (
<Modal
isOpen={state.isOpen}
title={state.title}
onConfirm={handleConfirm}
onClose={handleClose}
confirmText={state.confirmText}
cancelText={state.cancelText}
isDanger={state.danger}
size="sm"
icon={
state.danger ? (
<AlertTriangle className="w-5 h-5 text-red-400" />
) : undefined
const resolveAgentRisk = useCallback((choice: AgentRiskChoice) => {
setAgentRisk((prev) => ({ ...prev, isOpen: false }))
agentResolverRef.current?.(choice)
agentResolverRef.current = null
}, [])
/** 停止 Agent 时关闭待确认对话框(等同 Reject,不计入用户决策) */
const dismissAgentRisk = useCallback(() => {
if (!agentResolverRef.current) return
resolveAgentRisk('reject')
}, [resolveAgentRisk])
useEffect(() => {
if (!agentRisk.isOpen) return
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
resolveAgentRisk('reject')
} else if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
event.preventDefault()
resolveAgentRisk('once')
}
>
<p className="text-sm text-content-secondary leading-relaxed">{state.message}</p>
</Modal>
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [agentRisk.isOpen, resolveAgentRisk])
const ConfirmDialogComponent = (
<>
<Modal
isOpen={state.isOpen}
title={state.title}
onConfirm={handleConfirm}
onClose={handleClose}
confirmText={state.confirmText}
cancelText={state.cancelText}
isDanger={state.danger}
size="sm"
icon={
state.danger ? (
<AlertTriangle className="w-5 h-5 text-red-400" />
) : undefined
}
>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-content-secondary">{state.message}</p>
</Modal>
<Modal
isOpen={agentRisk.isOpen}
title={agentRisk.title}
onClose={() => resolveAgentRisk('reject')}
showConfirm={false}
size="sm"
disableBackdropClose
icon={<AlertTriangle className="w-5 h-5 text-red-400" />}
footer={
<div className="flex w-full flex-wrap items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => resolveAgentRisk('reject')}>
{t('ai:confirm.reject')}
</Button>
<Button
variant={agentRisk.danger ? 'danger' : 'primary'}
size="sm"
onClick={() => resolveAgentRisk('once')}
>
{t('ai:confirm.once')}
</Button>
<Button variant="secondary" size="sm" onClick={() => resolveAgentRisk('always')}>
{t('ai:confirm.always')}
</Button>
</div>
}
>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-content-secondary">{agentRisk.message}</p>
<p className="mt-3 text-[11px] text-txt-faint">{t('ai:confirm.hint')}</p>
</Modal>
</>
)
return { confirm, ConfirmDialogComponent }
return { confirm, confirmAgentRisk, dismissAgentRisk, ConfirmDialogComponent }
}
export default useConfirmDialog
+8 -6
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import { handleError } from '../../utils/error-handler'
interface DefaultFallbackProps {
@@ -6,6 +7,7 @@ interface DefaultFallbackProps {
}
export default function DefaultFallback({ error, onReset }: DefaultFallbackProps) {
const { t } = useTranslation('common')
const appError = handleError(error)
return (
@@ -18,15 +20,15 @@ export default function DefaultFallback({ error, onReset }: DefaultFallbackProps
</svg>
</div>
<div>
<h2 className="text-lg font-semibold text-content-primary">出错了</h2>
<p className="text-sm text-txt-muted">应用遇到意外问题</p>
<h2 className="text-lg font-semibold text-content-primary">{t('error.title')}</h2>
<p className="text-sm text-txt-muted">{t('error.body')}</p>
</div>
</div>
<div className="bg-dark-900 rounded-lg p-4 mb-4">
<p className="text-sm text-content-secondary mb-2">{appError.message}</p>
{appError.recoverable && (
<p className="text-xs text-green-400">此错误可以恢复,请尝试刷新页面</p>
<p className="text-xs text-green-400">{t('error.recoverable')}</p>
)}
</div>
@@ -35,18 +37,18 @@ export default function DefaultFallback({ error, onReset }: DefaultFallbackProps
onClick={onReset}
className="flex-1 px-4 py-2 bg-accent-blue hover:bg-blue-600 text-content-primary rounded-lg transition-colors text-sm font-medium"
>
刷新页面
{t('error.refreshPage')}
</button>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-content-secondary rounded-lg transition-colors text-sm"
>
重启应用
{t('error.restartApp')}
</button>
</div>
<p className="mt-4 text-xs text-txt-faint text-center">
如果问题持续存在,请检查日志或联系支持
{t('error.supportHint')}
</p>
</div>
</div>
+215
View File
@@ -0,0 +1,215 @@
import type { CSSProperties } from 'react'
import { Server } from 'lucide-react'
import { resolveSessionDistro, type HostDistroId } from '../../utils/hostDistro'
// my-icons OS set (new path → busts stale Vite ?url cache of old mono SVGs)
import ubuntuUrl from '../../assets/os-icons/ubuntu.svg?url'
import debianUrl from '../../assets/os-icons/debian.svg?url'
import deepinUrl from '../../assets/os-icons/deepin.svg?url'
import centosUrl from '../../assets/os-icons/centos.svg?url'
import rockyUrl from '../../assets/os-icons/rocky.svg?url'
import redhatUrl from '../../assets/os-icons/redhat.svg?url'
import opensuseUrl from '../../assets/os-icons/opensuse.svg?url'
import openeulerUrl from '../../assets/os-icons/openeuler.svg?url'
import anolisUrl from '../../assets/os-icons/anolis.svg?url'
import linuxUrl from '../../assets/os-icons/linux.svg?url'
import windowsUrl from '../../assets/os-icons/windows.svg?url'
import macosUrl from '../../assets/os-icons/macos.svg?url'
import freebsdUrl from '../../assets/os-icons/freebsd.svg?url'
import fedoraUrl from '../../assets/os-icons/fedora.svg?url'
import archUrl from '../../assets/os-icons/arch.svg?url'
import almalinuxUrl from '../../assets/os-icons/almalinux.svg?url'
import kaliUrl from '../../assets/os-icons/kali.svg?url'
import alinuxUrl from '../../assets/os-icons/alinux.svg?url'
import amazonUrl from '../../assets/os-icons/amazon.svg?url'
// leftover Netcatty mono glyphs (no my-icons counterpart yet)
import alpineUrl from '../../assets/distro/alpine.svg?url'
import oracleUrl from '../../assets/distro/oracle.svg?url'
const VNC_BLUE = '#278DF3'
/** Distros that use my-icons full-color SVGs — never apply brightness/invert. */
const MY_ICONS_COLOR = new Set<HostDistroId>([
'ubuntu',
'debian',
'deepin',
'centos',
'rocky',
'redhat',
'opensuse',
'openeuler',
'anolis',
'linux',
'windows',
'macos',
'freebsd',
'fedora',
'arch',
'almalinux',
'kali',
'alinux',
'amazon',
])
const DISTRO_LOGOS: Partial<Record<HostDistroId, string>> = {
ubuntu: ubuntuUrl,
debian: debianUrl,
deepin: deepinUrl,
centos: centosUrl,
rocky: rockyUrl,
redhat: redhatUrl,
opensuse: opensuseUrl,
openeuler: openeulerUrl,
anolis: anolisUrl,
linux: linuxUrl,
windows: windowsUrl,
macos: macosUrl,
freebsd: freebsdUrl,
fedora: fedoraUrl,
arch: archUrl,
alpine: alpineUrl,
almalinux: almalinuxUrl,
alinux: alinuxUrl,
amazon: amazonUrl,
oracle: oracleUrl,
kali: kaliUrl,
}
/** Brand tiles only for remaining mono (Netcatty) glyphs. */
const MONO_TILE: Partial<Record<HostDistroId, string>> = {
alpine: '#0D597F',
oracle: '#C74634',
}
type Size = 'xs' | 'sm' | 'md'
const SIZE_BOX: Record<Size, string> = {
xs: 'h-4 w-4 rounded-[4px]',
sm: 'h-5 w-5 rounded-[5px]',
md: 'h-6 w-6 rounded-md',
}
const SIZE_IMG_MONO: Record<Size, string> = {
xs: 'h-2.5 w-2.5',
sm: 'h-3.5 w-3.5',
md: 'h-4 w-4',
}
const SIZE_IMG_COLOR: Record<Size, string> = {
xs: 'h-[90%] w-[90%]',
sm: 'h-[90%] w-[90%]',
md: 'h-[90%] w-[90%]',
}
const SIZE_FALLBACK: Record<Size, string> = {
xs: 'h-2.5 w-2.5',
sm: 'h-3.5 w-3.5',
md: 'h-4 w-4',
}
function VncBadgeIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
className={className}
aria-hidden
xmlns="http://www.w3.org/2000/svg"
>
<rect width="24" height="24" rx="4.5" fill={VNC_BLUE} />
<g
fill="none"
stroke="#FFFFFF"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
transform="translate(2.4 2.6) scale(0.8)"
>
<path d="M3.2 4 L8.15 19.8 L13.1 4" />
<path d="M15.15 10.7 V7.1 C15.15 4.35 20.85 4.35 20.85 7.1 V10.7" />
<path d="M20.7 13.15 C19.35 11.55 15.2 12.15 15.2 16.35 C15.2 20.55 19.35 21.15 20.7 19.55" />
</g>
</svg>
)
}
export interface HostDistroIconProps {
distro?: string | null
protocol?: 'ssh' | 'rdp' | 'vnc' | null
size?: Size
className?: string
accent?: boolean
}
export default function HostDistroIcon({
distro,
protocol,
size = 'sm',
className = '',
accent = false,
}: HostDistroIconProps) {
const id = resolveSessionDistro({ distro, protocol })
const logo = id ? DISTRO_LOGOS[id] : undefined
const isMyIcons = id ? MY_ICONS_COLOR.has(id) : false
if (id === 'vnc') {
return (
<span
className={`inline-flex flex-shrink-0 items-center justify-center overflow-hidden ${SIZE_BOX[size]} ${className}`}
title="vnc"
aria-hidden
>
<VncBadgeIcon className="h-full w-full" />
</span>
)
}
if (!logo || !id) {
return (
<Server
className={`${SIZE_FALLBACK[size]} flex-shrink-0 ${
accent ? 'text-accent-blue' : 'text-accent-green'
} ${className}`}
/>
)
}
// my-icons: transparent tile, original SVG colors, no CSS filter
if (isMyIcons) {
return (
<span
className={`inline-flex flex-shrink-0 items-center justify-center overflow-hidden bg-transparent ${SIZE_BOX[size]} ${className}`}
title={id}
aria-hidden
>
<img
src={logo}
alt=""
className={`object-contain ${SIZE_IMG_COLOR[size]}`}
draggable={false}
/>
</span>
)
}
// leftover mono glyphs: brand tile + white invert
const style: CSSProperties = {
backgroundColor: MONO_TILE[id] || '#475569',
}
return (
<span
className={`inline-flex flex-shrink-0 items-center justify-center overflow-hidden ${SIZE_BOX[size]} ${className}`}
style={style}
title={id}
aria-hidden
>
<img
src={logo}
alt=""
className={`object-contain brightness-0 invert ${SIZE_IMG_MONO[size]}`}
draggable={false}
/>
</span>
)
}
+10 -5
View File
@@ -5,6 +5,7 @@
*/
import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
interface LoadingProps {
/** 加载文本 */
@@ -41,16 +42,18 @@ const sizeMap = {
* <Loading fullscreen text="应用启动中..." />
*/
export default function Loading({
text = '加载中...',
text,
size = 'md',
fullscreen = false,
className = ''
}: LoadingProps) {
const { t } = useTranslation('common')
const label = text ?? t('status.loading')
const content = (
<div className={`flex flex-col items-center gap-3 ${className}`}>
<Loader2 className={`${sizeMap[size].icon} text-accent-blue animate-spin`} />
{text && (
<span className={`${sizeMap[size].text} text-txt-muted`}>{text}</span>
{label && (
<span className={`${sizeMap[size].text} text-txt-muted`}>{label}</span>
)}
</div>
)
@@ -74,11 +77,13 @@ export default function Loading({
* {isLoading ? <ButtonLoading text="保存中..." /> : '保存'}
* </button>
*/
export function ButtonLoading({ text = '处理中...' }: { text?: string }) {
export function ButtonLoading({ text }: { text?: string }) {
const { t } = useTranslation('common')
const label = text ?? t('status.processing')
return (
<span className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{text}
{label}
</span>
)
}
+9 -5
View File
@@ -3,6 +3,7 @@
*/
import { type ReactNode, useEffect, useId } from 'react'
import { useTranslation } from 'react-i18next'
import { X } from 'lucide-react'
import Button from './Button'
@@ -44,8 +45,8 @@ export default function Modal({
description,
children,
icon,
confirmText = '确认',
cancelText = '取消',
confirmText,
cancelText,
onConfirm,
onClose,
showConfirm = true,
@@ -57,6 +58,9 @@ export default function Modal({
footer,
className = '',
}: ModalProps) {
const { t } = useTranslation('common')
const resolvedConfirm = confirmText ?? t('action.confirm')
const resolvedCancel = cancelText ?? t('action.cancel')
const titleId = useId()
useEffect(() => {
@@ -117,7 +121,7 @@ export default function Modal({
type="button"
onClick={onClose}
className="p-1.5 -mr-1 rounded-lg text-txt-muted hover:text-content-primary hover:bg-dark-700 transition-colors flex-shrink-0"
aria-label="关闭"
aria-label={t('action.close')}
>
<X className="w-4 h-4" />
</button>
@@ -132,7 +136,7 @@ export default function Modal({
{footer ?? (
<>
<Button variant="ghost" size="sm" onClick={onClose} disabled={loading}>
{cancelText}
{resolvedCancel}
</Button>
{showConfirm && (
<Button
@@ -141,7 +145,7 @@ export default function Modal({
onClick={onConfirm}
loading={loading}
>
{confirmText}
{resolvedConfirm}
</Button>
)}
</>
+21 -8
View File
@@ -11,6 +11,7 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import Modal from './Modal'
import i18n from '../../i18n'
export interface PromptOptions {
/** 对话框标题 */
@@ -25,6 +26,10 @@ export interface PromptOptions {
confirmText?: string
/** 取消按钮文本 */
cancelText?: string
/** Input type; use password for secrets */
inputType?: 'text' | 'password'
/** When false (default), empty input resolves as null */
allowEmpty?: boolean
}
interface PromptState extends PromptOptions {
@@ -40,8 +45,10 @@ const initialState: PromptState = {
message: '',
placeholder: '',
defaultValue: '',
confirmText: '确认',
cancelText: '取消',
confirmText: i18n.t('common:action.confirm'),
cancelText: i18n.t('common:action.cancel'),
inputType: 'text',
allowEmpty: false,
value: '',
}
@@ -61,8 +68,10 @@ export function usePromptDialog() {
isOpen: true,
placeholder: options.placeholder ?? '',
defaultValue: options.defaultValue ?? '',
confirmText: options.confirmText ?? '确认',
cancelText: options.cancelText ?? '取消',
confirmText: options.confirmText ?? i18n.t('common:action.confirm'),
cancelText: options.cancelText ?? i18n.t('common:action.cancel'),
inputType: options.inputType ?? 'text',
allowEmpty: options.allowEmpty ?? false,
value: options.defaultValue ?? '',
})
})
@@ -82,11 +91,14 @@ export function usePromptDialog() {
}, [])
const handleConfirm = useCallback(() => {
const value = state.value.trim()
const raw = state.inputType === 'password' ? state.value : state.value.trim()
if (!raw && !state.allowEmpty) {
return
}
setState((prev) => ({ ...prev, isOpen: false }))
resolverRef.current?.(value || null)
resolverRef.current?.(raw || null)
resolverRef.current = null
}, [state.value])
}, [state.value, state.inputType, state.allowEmpty])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
@@ -107,7 +119,8 @@ export function usePromptDialog() {
<p className="text-content-secondary mb-3">{state.message}</p>
<input
ref={inputRef}
type="text"
type={state.inputType ?? 'text'}
autoComplete={state.inputType === 'password' ? 'new-password' : 'off'}
value={state.value}
onChange={(e) => setState((prev) => ({ ...prev, value: e.target.value }))}
onKeyDown={handleKeyDown}
+3 -1
View File
@@ -6,6 +6,7 @@
*/
import { useEffect, useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { CheckCircle, XCircle, AlertTriangle, Info, X } from 'lucide-react'
import { useToastStore, type ToastItem, type ToastType } from '../../stores/toastStore'
@@ -56,6 +57,7 @@ interface ToastItemProps {
* 支持自动消失和手动关闭,带入场/退场动画
*/
function ToastItemView({ toast, onClose }: ToastItemProps) {
const { t } = useTranslation('common')
const [visible, setVisible] = useState(false)
const Icon = iconMap[toast.type]
const colors = colorMap[toast.type]
@@ -107,7 +109,7 @@ function ToastItemView({ toast, onClose }: ToastItemProps) {
<button
onClick={handleClose}
className="p-0.5 rounded hover:bg-white/10 transition-colors shrink-0"
aria-label="关闭通知"
aria-label={t('toast.closeAria')}
>
<X className="w-4 h-4 text-txt-muted hover:text-content-primary" />
</button>
+15 -23
View File
@@ -5,6 +5,7 @@
*/
import { useState, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
Wifi,
WifiOff,
@@ -66,18 +67,6 @@ function StatusDot({ state }: StatusDotProps) {
)
}
// ============== Status Label ==============
const STATUS_LABELS: Record<ConnectionState, string> = {
connected: '已连接',
connecting: '连接中',
background: '后台',
suspended: '已挂起',
error: '错误',
disconnected: '已断开',
reconnecting: '重连中',
}
// ============== Connection Card ==============
interface ConnectionCardProps {
@@ -88,6 +77,7 @@ interface ConnectionCardProps {
}
function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: ConnectionCardProps) {
const { t } = useTranslation('common')
const isReconnecting = reconnectingId === conn.connectionId
const canReconnect =
conn.state === 'suspended' || conn.state === 'error' || conn.state === 'disconnected'
@@ -122,7 +112,7 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
: 'bg-surface-active/10 text-txt-muted'
}`}
>
{STATUS_LABELS[conn.state]}
{t(`connectionPanel.status.${conn.state}`)}
</span>
</div>
@@ -156,7 +146,7 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
)}
{/* Ref count */}
{conn.refCount > 1 && <span>×{conn.refCount} 引用</span>}
{conn.refCount > 1 && <span>{t('connectionPanel.refCount', { count: conn.refCount })}</span>}
</div>
{/* Actions */}
@@ -171,7 +161,7 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RefreshCw className={`w-3 h-3 ${isReconnecting ? 'animate-spin' : ''}`} />
{isReconnecting ? '重连中...' : '重连'}
{isReconnecting ? t('connectionPanel.reconnecting') : t('connectionPanel.reconnect')}
</button>
)}
{canDisconnect && (
@@ -182,7 +172,7 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
bg-red-400/10 text-red-400 hover:bg-red-400/20 transition-colors"
>
<WifiOff className="w-3 h-3" />
断开
{t('connectionPanel.disconnect')}
</button>
)}
</div>
@@ -207,11 +197,12 @@ interface StatsBarProps {
}
function StatsOverview({ total, connected, background, error }: StatsBarProps) {
const { t } = useTranslation('common')
const items = [
{ label: '总计', value: total, color: 'text-content-secondary' },
{ label: '活跃', value: connected, color: 'text-green-400' },
{ label: '后台', value: background, color: 'text-yellow-400' },
{ label: '断开/错误', value: error, color: 'text-red-400' },
{ label: t('connectionPanel.stat.total'), value: total, color: 'text-content-secondary' },
{ label: t('connectionPanel.stat.active'), value: connected, color: 'text-green-400' },
{ label: t('connectionPanel.stat.background'), value: background, color: 'text-yellow-400' },
{ label: t('connectionPanel.stat.error'), value: error, color: 'text-red-400' },
]
return (
@@ -229,6 +220,7 @@ function StatsOverview({ total, connected, background, error }: StatsBarProps) {
// ============== Main Panel ==============
export default function ConnectionPanel() {
const { t } = useTranslation('common')
const isOpen = useConnectionPanelStore((s) => s.isOpen)
const close = useConnectionPanelStore((s) => s.close)
@@ -288,7 +280,7 @@ export default function ConnectionPanel() {
<div className="flex items-center justify-between px-4 h-12 border-b border-dark-700/30 shrink-0">
<h2 className="text-sm font-semibold text-content-primary flex items-center gap-2">
<Wifi className="w-4 h-4 text-accent-blue" />
连接管理
{t('connectionPanel.title')}
</h2>
<button
type="button"
@@ -314,8 +306,8 @@ export default function ConnectionPanel() {
{connList.length === 0 ? (
<EmptyState
icon={WifiOff}
title="暂无连接"
description="当前没有任何活跃连接,连接到服务器后将在此显示。"
title={t('connectionPanel.empty')}
description={t('connectionPanel.emptyDesc')}
/>
) : (
connList.map((conn) => (
@@ -0,0 +1,185 @@
/**
* Port-forward traffic diagram.
* Solid = path to firewall; dashed = SSH tunnel (explicit stroke — Tailwind stroke-* is unreliable on SVG).
*/
import { useTranslation } from 'react-i18next'
import appIcon from '../../assets/opsxshell-app-icon.png'
import firewallIcon from '../../assets/diagram/network-firewall.png'
import serverIcon from '../../assets/diagram/ec2.png'
import cloudGcp from '../../assets/diagram/cloud-gcp.svg'
import cloudCf from '../../assets/diagram/cloud-cf.svg'
import type { ForwardRuleType } from './forwardRules'
interface ForwardArchitectureDiagramProps {
type: ForwardRuleType
}
const SOLID = '#fb7185'
const DASH = '#60a5fa'
function AppBadge() {
return (
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-accent-blue/40 bg-[#0b1220] shadow-lg shadow-accent-blue/15">
<img src={appIcon} alt="" className="h-10 w-10 object-cover" draggable={false} />
</div>
)
}
function IconImg({
src,
className,
rounded,
}: {
src: string
className?: string
rounded?: boolean
}) {
return (
<img
src={src}
alt=""
draggable={false}
className={`object-contain drop-shadow-md ${rounded ? 'rounded-xl' : ''} ${className || ''}`}
/>
)
}
function SolidPath({ d }: { d: string }) {
return (
<path
d={d}
fill="none"
stroke={SOLID}
strokeWidth="2.4"
strokeLinecap="round"
/>
)
}
function DashPath({ d }: { d: string }) {
return (
<path
d={d}
fill="none"
stroke={DASH}
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray="6 5"
opacity={0.95}
/>
)
}
export default function ForwardArchitectureDiagram({ type }: ForwardArchitectureDiagramProps) {
const { t } = useTranslation('common')
const caption =
type === 'local'
? t('portForward.diagram.localCaption')
: type === 'remote'
? t('portForward.diagram.remoteCaption')
: t('portForward.diagram.dynamicCaption')
return (
<div className="rounded-xl border border-dark-700/60 bg-dark-900/35 px-3 py-3">
<div className="mb-2 text-[11px] font-medium text-content-secondary">
{t('portForward.diagram.title')}
</div>
<div className="relative mx-auto h-[160px] w-full max-w-[360px] overflow-hidden rounded-lg border border-dark-700/40 bg-gradient-to-b from-dark-900/50 to-dark-950/70">
{/* Lines under icons */}
<svg
className="pointer-events-none absolute inset-0 h-full w-full"
viewBox="0 0 360 160"
preserveAspectRatio="none"
aria-hidden
>
{type === 'local' && (
<>
{/* App → firewall (solid) */}
<SolidPath d="M64 48 H155" />
{/* App → SSH host → remote targets (dashed) */}
<DashPath d="M56 58 C100 95, 150 118, 180 122" />
<DashPath d="M180 122 C235 115, 275 70, 300 48" />
<DashPath d="M180 122 C245 110, 285 75, 318 52" />
</>
)}
{type === 'remote' && (
<>
{/* Same span as Local/Dynamic: ~App→Firewall */}
<SolidPath d="M64 48 H155" />
<DashPath d="M56 58 C100 95, 150 118, 180 122" />
<DashPath d="M304 58 C260 95, 210 118, 180 122" />
</>
)}
{type === 'dynamic' && (
<>
<SolidPath d="M64 48 H155" />
<DashPath d="M56 58 C100 95, 150 118, 180 122" />
<DashPath d="M180 122 C245 110, 285 70, 318 42" />
<DashPath d="M180 122 C245 120, 285 100, 320 88" />
<DashPath d="M180 122 C235 132, 275 138, 318 132" />
</>
)}
</svg>
{type === 'local' && (
<>
<div className="absolute left-3 top-[26px]">
<AppBadge />
</div>
{/* Firewall + SSH host: same column, vertically centered as a pair */}
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-3">
<IconImg src={firewallIcon} rounded className="h-11 w-11" />
<IconImg src={serverIcon} className="h-10 w-10" />
</div>
<div className="absolute right-3 top-[26px] flex items-center gap-1.5 rounded-xl border border-dark-600/60 bg-dark-800/40 px-2 py-1.5">
<IconImg src={serverIcon} className="h-9 w-9" />
<IconImg src={serverIcon} className="h-9 w-9" />
</div>
</>
)}
{type === 'remote' && (
<>
{/* Match Local/Dynamic left inset so solid gap equals App→Firewall */}
<div className="absolute left-3 top-[26px]">
<IconImg src={serverIcon} className="h-11 w-11" />
</div>
<div className="absolute left-1/2 top-[26px] -translate-x-1/2">
<IconImg src={firewallIcon} rounded className="h-11 w-11" />
</div>
<div className="absolute right-3 top-[26px]">
<IconImg src={serverIcon} className="h-11 w-11" />
</div>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2">
<AppBadge />
</div>
</>
)}
{type === 'dynamic' && (
<>
<div className="absolute left-3 top-[26px]">
<AppBadge />
</div>
{/* Firewall + SSH host stacked & vertically centered */}
<div className="absolute left-[42%] top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-3">
<IconImg src={firewallIcon} rounded className="h-11 w-11" />
<IconImg src={serverIcon} className="h-10 w-10" />
</div>
<div className="absolute right-3 top-1/2 flex -translate-y-1/2 flex-col items-center gap-2 rounded-2xl border border-dark-600/60 bg-dark-800/40 px-2.5 py-2.5">
<IconImg src={cloudGcp} className="h-7 w-7" />
<IconImg src={cloudCf} className="h-7 w-7" />
<IconImg src={cloudGcp} className="h-6 w-6 opacity-70" />
</div>
</>
)}
</div>
<p className="mt-2.5 text-[11px] leading-relaxed text-txt-muted">{caption}</p>
</div>
)
}
@@ -0,0 +1,559 @@
/**
* Right drawer for SSH port forwarding (SessionDialog-style).
* Portaled to document.body so TabContent overflow-hidden cannot clip it.
*
* Dynamic is a single SOCKS5 on 127.0.0.1 (default port 1080, editable when stopped).
* Local / Remote rules appear in the list and can be added repeatedly.
*/
import { useEffect } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { ArrowRightLeft, Globe, Play, Server, Square, Trash2, X } from 'lucide-react'
import ForwardArchitectureDiagram from './ForwardArchitectureDiagram'
import type { ForwardRule, ForwardRuleType } from './forwardRules'
export const DEFAULT_DYNAMIC_PORT = 1080
export function resolveDynamicPort(port: unknown): number {
const value = Number(port)
if (Number.isInteger(value) && value >= 1 && value <= 65535) return value
return DEFAULT_DYNAMIC_PORT
}
export interface ForwardRuleDraft {
type: ForwardRuleType
name: string
localPort: number
remoteHost: string
remotePort: number
}
export interface ForwardRuleRuntime {
running: boolean
boundPort?: number
}
interface ForwardRuleDrawerProps {
open: boolean
editing?: boolean
draft: Partial<ForwardRule> & { type?: ForwardRuleType }
error?: string | null
/** When set, drawer shows the live rule list (status-bar / manager mode). */
rules?: ForwardRule[]
runtimes?: Map<string, ForwardRuleRuntime>
busy?: boolean
onChange: (next: Partial<ForwardRule>) => void
onClose: () => void
onSave: () => void
onStartRule?: (rule: ForwardRule) => void
onStopRule?: (rule: ForwardRule) => void
onDeleteRule?: (rule: ForwardRule) => void
/** Persist dynamic listen port while stopped (list mode). */
onDynamicPortChange?: (port: number) => void
}
const EMPTY_DRAFT: ForwardRuleDraft = {
type: 'local',
name: '',
localPort: 0,
remoteHost: '',
remotePort: 0,
}
export function emptyForwardDraft(): ForwardRuleDraft {
return { ...EMPTY_DRAFT }
}
export function emptyDynamicDraft(name = 'SOCKS5'): ForwardRuleDraft {
return {
type: 'dynamic',
name,
localPort: DEFAULT_DYNAMIC_PORT,
remoteHost: '',
remotePort: 0,
}
}
function ruleSummary(rule: ForwardRule, t: (key: string, opts?: Record<string, unknown>) => string): string {
if (rule.type === 'dynamic') {
return t('portForward.socksProxy', { port: resolveDynamicPort(rule.localPort) })
}
if (rule.type === 'remote') {
return t('portForward.remoteToLocal', {
remote: rule.remotePort,
local: rule.localPort,
})
}
const host = rule.remoteHost || 'localhost'
return `127.0.0.1:${rule.localPort} → ${host}:${rule.remotePort}`
}
export default function ForwardRuleDrawer({
open,
editing = false,
draft,
error,
rules,
runtimes,
busy = false,
onChange,
onClose,
onSave,
onStartRule,
onStopRule,
onDeleteRule,
onDynamicPortChange,
}: ForwardRuleDrawerProps) {
const { t } = useTranslation('common')
const type = (draft.type || 'local') as ForwardRuleType
const showList = Array.isArray(rules)
const dynamicRule = rules?.find((rule) => rule.type === 'dynamic')
const localRemoteRules = (rules || []).filter((rule) => rule.type !== 'dynamic')
const dynamicRuntime = dynamicRule ? runtimes?.get(dynamicRule.id) : undefined
const dynamicRunning = Boolean(dynamicRuntime?.running)
const dynamicPort = resolveDynamicPort(
dynamicRuntime?.boundPort || dynamicRule?.localPort || draft.localPort
)
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onClose])
if (!open) return null
const types: Array<{ type: ForwardRuleType; label: string; desc: string }> = [
{ type: 'local', label: t('portForward.typeLocal'), desc: t('portForward.typeLocalDesc') },
{ type: 'remote', label: t('portForward.typeRemote'), desc: t('portForward.typeRemoteDesc') },
{ type: 'dynamic', label: t('portForward.typeDynamic'), desc: t('portForward.typeDynamicDesc') },
]
const selectType = (next: ForwardRuleType) => {
const socksName = t('portForward.socksRuleName')
const localName = t('portForward.defaultLocalName')
const remoteName = t('portForward.defaultRemoteName')
const current = (draft.name || '').trim()
const isPreset =
!current || current === socksName || current === localName || current === remoteName
const nextName = !isPreset
? current
: next === 'dynamic'
? socksName
: next === 'remote'
? remoteName
: localName
if (next === 'dynamic') {
onChange({
...draft,
type: 'dynamic',
name: nextName,
localPort: DEFAULT_DYNAMIC_PORT,
remoteHost: '',
remotePort: 0,
})
return
}
onChange({
...draft,
type: next,
name: nextName,
localPort: draft.type === 'dynamic' ? 0 : draft.localPort,
})
}
const primaryLabel =
type === 'dynamic'
? dynamicRunning
? t('portForward.stopListener')
: t('portForward.socksStart')
: editing
? t('action.save')
: t('portForward.addRule')
return createPortal(
<div className="fixed inset-0 z-[100] flex justify-end">
<div className="absolute inset-0 bg-black/45 backdrop-blur-[1px] animate-fade-in" onClick={onClose} />
<aside
className="relative flex h-full w-full max-w-[420px] flex-col border-l border-dark-700/70 bg-dark-800 shadow-2xl animate-slide-in-right"
role="dialog"
aria-modal="true"
aria-labelledby="forward-drawer-title"
>
<div className="flex flex-shrink-0 items-center gap-3 border-b border-dark-700/50 px-4 py-3.5">
<div className="flex h-9 w-9 items-center justify-center rounded-xl border border-accent-blue/20 bg-accent-blue/15 text-accent-blue">
<ArrowRightLeft className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<h2 id="forward-drawer-title" className="text-sm font-semibold text-content-primary">
{showList ? t('portForward.title') : editing ? t('portForward.editTitle') : t('portForward.addTitle')}
</h2>
<p className="mt-0.5 text-[11px] text-txt-faint">{t('portForward.drawerSubtitle')}</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-lg p-1.5 text-txt-muted hover:bg-dark-700 hover:text-content-primary"
aria-label={t('action.close')}
disabled={busy}
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4">
{showList && (
<section className="space-y-2">
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
{t('portForward.ruleList')}
</div>
{/* Fixed Dynamic slot */}
<div
className={`rounded-xl border px-3 py-2.5 ${
dynamicRunning
? 'border-accent-blue/40 bg-accent-blue/10'
: 'border-dark-700/60 bg-dark-900/30'
}`}
>
<div className="flex items-start gap-2.5">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-orange-500/15 text-orange-300">
<Globe className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-medium text-content-primary">
{t('portForward.typeDynamic')}
</span>
<span className="rounded bg-orange-500/15 px-1.5 py-px text-[10px] text-orange-300">
SOCKS5
</span>
{dynamicRunning && (
<span className="rounded bg-green-500/15 px-1.5 py-px text-[10px] text-green-400">
{t('portForward.running')}
</span>
)}
</div>
<p className="mt-0.5 font-mono text-[11px] text-txt-muted">
127.0.0.1:{dynamicPort}
</p>
{!dynamicRunning && dynamicRule && onDynamicPortChange && (
<div className="mt-2 flex items-center gap-2">
<label className="text-[10px] text-txt-faint">{t('portForward.localListenPort')}</label>
<input
type="number"
min={1}
max={65535}
disabled={busy}
value={resolveDynamicPort(dynamicRule.localPort)}
onChange={(e) => onDynamicPortChange(Number(e.target.value))}
className="ui-input h-7 w-24 py-0 font-mono text-[11px]"
inputMode="numeric"
title={t('portForward.dynamicPortHint')}
/>
</div>
)}
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
{t('portForward.dynamicSingleHint')}
</p>
</div>
<div className="flex shrink-0 gap-1">
{dynamicRule && dynamicRunning && onStopRule && (
<button
type="button"
disabled={busy}
onClick={() => onStopRule(dynamicRule)}
className="rounded-lg p-1.5 text-red-300 hover:bg-red-500/15 disabled:opacity-40"
title={t('portForward.stopListener')}
>
<Square className="h-3.5 w-3.5" />
</button>
)}
{dynamicRule && !dynamicRunning && onStartRule && (
<button
type="button"
disabled={busy}
onClick={() => onStartRule(dynamicRule)}
className="rounded-lg p-1.5 text-accent-blue hover:bg-accent-blue/15 disabled:opacity-40"
title={t('portForward.socksStart')}
>
<Play className="h-3.5 w-3.5" />
</button>
)}
{!dynamicRule && onStartRule && (
<button
type="button"
disabled={busy}
onClick={() =>
onStartRule({
id: '',
type: 'dynamic',
name: t('portForward.socksRuleName'),
localPort: DEFAULT_DYNAMIC_PORT,
})
}
className="rounded-lg p-1.5 text-accent-blue hover:bg-accent-blue/15 disabled:opacity-40"
title={t('portForward.socksStart')}
>
<Play className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
</div>
{localRemoteRules.length === 0 ? (
<p className="px-1 text-[11px] text-txt-faint">{t('portForward.ruleListEmpty')}</p>
) : (
<div className="space-y-1.5">
{localRemoteRules.map((rule) => {
const running = Boolean(runtimes?.get(rule.id)?.running)
return (
<div
key={rule.id}
className={`rounded-xl border px-3 py-2 ${
running
? 'border-green-500/30 bg-green-500/10'
: 'border-dark-700/60 bg-dark-900/30'
}`}
>
<div className="flex items-start gap-2.5">
<div
className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
rule.type === 'local'
? 'bg-blue-500/15 text-blue-300'
: 'bg-purple-500/15 text-purple-300'
}`}
>
{rule.type === 'local' ? (
<ArrowRightLeft className="h-4 w-4" />
) : (
<Server className="h-4 w-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate text-xs font-medium text-content-primary">
{rule.name}
</span>
<span
className={`rounded px-1.5 py-px text-[10px] ${
rule.type === 'local'
? 'bg-blue-500/15 text-blue-300'
: 'bg-purple-500/15 text-purple-300'
}`}
>
{rule.type === 'local'
? t('portForward.typeLocal')
: t('portForward.typeRemote')}
</span>
{running && (
<span className="rounded bg-green-500/15 px-1.5 py-px text-[10px] text-green-400">
{t('portForward.running')}
</span>
)}
</div>
<p className="mt-0.5 truncate font-mono text-[11px] text-txt-muted">
{ruleSummary(rule, t)}
</p>
</div>
<div className="flex shrink-0 gap-0.5">
{running
? onStopRule && (
<button
type="button"
disabled={busy}
onClick={() => onStopRule(rule)}
className="rounded-lg p-1.5 text-red-300 hover:bg-red-500/15 disabled:opacity-40"
title={t('portForward.stopListener')}
>
<Square className="h-3.5 w-3.5" />
</button>
)
: onStartRule && (
<button
type="button"
disabled={busy}
onClick={() => onStartRule(rule)}
className="rounded-lg p-1.5 text-accent-blue hover:bg-accent-blue/15 disabled:opacity-40"
title={t('portForward.start')}
>
<Play className="h-3.5 w-3.5" />
</button>
)}
{onDeleteRule && (
<button
type="button"
disabled={busy}
onClick={() => onDeleteRule(rule)}
className="rounded-lg p-1.5 text-txt-muted hover:bg-dark-700 hover:text-red-300 disabled:opacity-40"
title={t('portForward.delete')}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
</div>
)
})}
</div>
)}
</section>
)}
<section className="space-y-2">
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
{showList ? t('portForward.addSection') : t('portForward.selectType')}
</div>
<div className="grid grid-cols-3 gap-0 overflow-hidden rounded-lg border border-dark-600 bg-dark-900/40 p-0.5">
{types.map((item) => (
<button
key={item.type}
type="button"
onClick={() => selectType(item.type)}
className={`rounded-md px-2 py-2 text-center transition-colors ${
type === item.type
? 'bg-accent-blue text-white shadow-sm'
: 'text-txt-muted hover:text-content-secondary'
}`}
>
<div className="text-xs font-semibold">{item.label}</div>
<div className={`mt-0.5 text-[10px] ${type === item.type ? 'text-white/80' : 'opacity-70'}`}>
{item.desc}
</div>
</button>
))}
</div>
</section>
<ForwardArchitectureDiagram type={type} />
{type === 'dynamic' ? (
<section className="space-y-2 rounded-xl border border-dark-700/50 bg-dark-900/30 p-3.5">
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
{t('portForward.typeDynamic')}
</div>
<p className="text-[11px] leading-relaxed text-txt-muted">{t('portForward.dynamicHint')}</p>
<div>
<label className="ui-label">{t('portForward.localListenPort')}</label>
<input
type="number"
min={1}
max={65535}
value={resolveDynamicPort(draft.localPort)}
onChange={(e) => onChange({ ...draft, localPort: Number(e.target.value) })}
placeholder="1080"
className="ui-input font-mono"
inputMode="numeric"
/>
<p className="mt-1 text-[10px] text-txt-faint">{t('portForward.dynamicPortHint')}</p>
</div>
<p className="font-mono text-xs text-content-secondary">
socks5://127.0.0.1:{resolveDynamicPort(draft.localPort)}
</p>
</section>
) : (
<section className="space-y-3 rounded-xl border border-dark-700/50 bg-dark-900/30 p-3.5">
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
{t('portForward.ruleFields')}
</div>
<div>
<label className="ui-label">{t('portForward.ruleName')}</label>
<input
type="text"
autoFocus={!editing}
value={draft.name || ''}
onChange={(e) => onChange({ ...draft, name: e.target.value })}
placeholder={t('portForward.ruleNamePlaceholder')}
className="ui-input"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="ui-label">
{type === 'remote' ? t('portForward.localTargetPort') : t('portForward.localListenPort')}
</label>
<input
type="number"
min={1}
max={65535}
value={draft.localPort || ''}
onChange={(e) => onChange({ ...draft, localPort: Number(e.target.value) })}
placeholder={t('portForward.portPlaceholder')}
className="ui-input font-mono"
inputMode="numeric"
/>
</div>
<div>
<label className="ui-label">
{type === 'remote' ? t('portForward.remoteListenPort') : t('portForward.remoteTargetPort')}
</label>
<input
type="number"
min={1}
max={65535}
value={draft.remotePort || ''}
onChange={(e) => onChange({ ...draft, remotePort: Number(e.target.value) })}
placeholder={t('portForward.portPlaceholder')}
className="ui-input font-mono"
inputMode="numeric"
/>
</div>
</div>
{type === 'local' && (
<div>
<label className="ui-label">{t('portForward.remoteHost')}</label>
<input
type="text"
value={draft.remoteHost || ''}
onChange={(e) => onChange({ ...draft, remoteHost: e.target.value })}
placeholder={t('portForward.remoteHostPlaceholder')}
className="ui-input font-mono"
spellCheck={false}
/>
</div>
)}
</section>
)}
{error && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
{error}
</div>
)}
</div>
<div className="flex flex-shrink-0 gap-2 border-t border-dark-700/50 px-4 py-3">
<button
type="button"
onClick={onClose}
disabled={busy}
className="flex-1 rounded-lg bg-dark-700 px-4 py-2 text-sm font-medium text-content-secondary transition-colors hover:bg-dark-600 disabled:opacity-40"
>
{t('action.cancel')}
</button>
<button
type="button"
onClick={onSave}
disabled={busy}
className="flex-1 rounded-lg bg-accent-blue px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-600 disabled:opacity-40"
>
{primaryLabel}
</button>
</div>
</aside>
</div>,
document.body
)
}
+108 -174
View File
@@ -5,11 +5,17 @@
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTabStore } from '../../stores/tabStore'
import { useConnectionPool } from '../../stores/connection'
import { ArrowRightLeft, Copy, CopyPlus, Globe, Pencil, Server, Plus, Trash2, Play, Square } from 'lucide-react'
import { useConfirmDialog } from '../common/ConfirmDialog'
import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
import ForwardRuleDrawer, {
DEFAULT_DYNAMIC_PORT,
emptyForwardDraft,
resolveDynamicPort,
} from './ForwardRuleDrawer'
import {
formatTransferBytes,
duplicateForwardRule,
@@ -25,6 +31,7 @@ interface PortForwardViewProps {
}
export default function PortForwardView({ tabId, isActive = true }: PortForwardViewProps) {
const { t } = useTranslation(['common', 'errors'])
const { tabs, updateTab } = useTabStore()
const pool = useConnectionPool()
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
@@ -33,19 +40,14 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
const needsReconnect = currentTab?.needsReconnect
const connectionId = currentTab?.connectionInstance?.connectionId
const connection = connectionId ? pool.getConnection(connectionId) : null
const storageKey = portForwardStorageKey(currentTab?.sessionId, tabId)
const storageKey = portForwardStorageKey(currentTab?.sessionId, tabId, connectionId)
const [rules, setRules] = useState<ForwardRule[]>([])
const [tunnels, setTunnels] = useState<SSHForwardStatus[]>([])
const [showAddModal, setShowAddModal] = useState(false)
const [editingRuleId, setEditingRuleId] = useState<string | null>(null)
const [newRule, setNewRule] = useState<Partial<ForwardRule>>({
type: 'local',
name: '',
localPort: 0,
remoteHost: '',
remotePort: 0,
})
const [newRule, setNewRule] = useState<Partial<ForwardRule>>(emptyForwardDraft())
const [drawerError, setDrawerError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -98,28 +100,46 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
const resetRuleForm = () => {
setEditingRuleId(null)
setNewRule({ type: 'local', name: '', localPort: 0, remoteHost: '', remotePort: 0 })
setNewRule(emptyForwardDraft())
setDrawerError(null)
}
const closeRuleDrawer = () => {
setShowAddModal(false)
resetRuleForm()
}
const openAddRule = () => {
resetRuleForm()
setError(null)
setNewRule({
...emptyForwardDraft(),
type: 'local',
name: t('common:portForward.defaultLocalName'),
})
setShowAddModal(true)
}
// Persist a new rule or update an existing stopped rule.
const handleSaveRule = async () => {
const handleSaveRule = () => {
if (!newRule.name?.trim() || !validPort(newRule.localPort)) {
setError('请填写规则名称和 1-65535 范围内的端口')
setDrawerError(t('common:portForward.errorNamePort'))
return
}
if (newRule.type !== 'dynamic' && !validPort(newRule.remotePort)) {
setError('请填写 1-65535 范围内的目标端口')
setDrawerError(t('common:portForward.errorTargetPort'))
return
}
if (newRule.type === 'local' && !newRule.remoteHost?.trim()) {
setError('请填写远程主机')
setDrawerError(t('common:portForward.errorRemoteHost'))
return
}
if (
newRule.type === 'dynamic' &&
!editingRuleId &&
rules.some((item) => item.type === 'dynamic')
) {
setDrawerError(t('common:portForward.errorDynamicExists'))
return
}
@@ -127,21 +147,19 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
id: editingRuleId || `pf-${crypto.randomUUID()}`,
type: newRule.type as 'local' | 'remote' | 'dynamic',
name: newRule.name.trim(),
localPort: newRule.localPort,
localPort: newRule.type === 'dynamic' ? resolveDynamicPort(newRule.localPort) : newRule.localPort!,
remoteHost: newRule.remoteHost?.trim(),
remotePort: newRule.remotePort,
}
saveRules(editingRuleId ? rules.map((item) => (item.id === editingRuleId ? rule : item)) : [...rules, rule])
setShowAddModal(false)
resetRuleForm()
setError(null)
closeRuleDrawer()
}
// Start port forward
const startForward = async (rule: ForwardRule) => {
if (!connectionId) {
setError('未连接到服务器')
setError(t('common:portForward.errorNotConnected'))
return false
}
@@ -167,16 +185,20 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
rule.id
)
} else {
result = await window.opsxApi?.sshForwardDynamic(connectionId, rule.localPort, rule.id)
result = await window.opsxApi?.sshForwardDynamic(
connectionId,
resolveDynamicPort(rule.localPort),
rule.id
)
}
if (result?.success) {
await refreshTunnels()
return true
}
setError(result?.error || '启动失败')
setError(result?.error || t('common:portForward.errorStartFailed'))
} catch (err: unknown) {
setError((err instanceof Error ? err.message : String(err)) || '启动失败')
setError((err instanceof Error ? err.message : String(err)) || t('common:portForward.errorStartFailed'))
} finally {
setIsLoading(false)
}
@@ -187,7 +209,7 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
const existing = rules.find((rule) => rule.type === 'dynamic')
if (existing) {
if (runtimes.has(existing.id)) {
setError('SOCKS5 代理已在运行')
setError(t('common:portForward.errorSocksRunning'))
return
}
await startForward(existing)
@@ -196,8 +218,8 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
const rule: ForwardRule = {
id: `pf-${crypto.randomUUID()}`,
type: 'dynamic',
name: 'SOCKS5 代理',
localPort: 1080,
name: t('common:portForward.socksRuleName'),
localPort: DEFAULT_DYNAMIC_PORT,
}
saveRules([...rules, rule])
await startForward(rule)
@@ -210,13 +232,13 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
try {
const result = await window.opsxApi?.sshForwardStop(rule.id)
if (!result?.success) {
setError(result?.error || '停止失败')
setError(result?.error || t('common:portForward.errorStopFailed'))
return false
}
await refreshTunnels()
return true
} catch (err: unknown) {
setError((err instanceof Error ? err.message : String(err)) || '停止失败')
setError((err instanceof Error ? err.message : String(err)) || t('common:portForward.errorStopFailed'))
return false
} finally {
setIsLoading(false)
@@ -230,15 +252,15 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
const editRule = async (rule: ForwardRule) => {
if (runtimes.has(rule.id)) {
const confirmed = await confirm({
title: '停止后编辑',
message: '该转发正在运行。编辑前需要停止真实监听,是否继续?',
confirmText: '停止并编辑',
title: t('common:portForward.stopEditTitle'),
message: t('common:portForward.stopEditMessage'),
confirmText: t('common:portForward.stopEditConfirm'),
})
if (!confirmed || !(await stopForward(rule))) return
}
setEditingRuleId(rule.id)
setNewRule({ ...rule })
setError(null)
setDrawerError(null)
setShowAddModal(true)
}
@@ -250,10 +272,10 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
// Delete rule
const deleteRule = async (rule: ForwardRule) => {
const confirmed = await confirm({
title: '确认删除',
message: runtimes.has(rule.id) ? '该转发正在运行,将先停止再删除。' : '确定删除此转发规则?',
title: t('common:portForward.deleteTitle'),
message: runtimes.has(rule.id) ? t('common:portForward.deleteRunning') : t('common:portForward.deleteConfirm'),
danger: true,
confirmText: '删除',
confirmText: t('common:action.delete'),
})
if (!confirmed) return
if (runtimes.has(rule.id) && !(await stopForward(rule))) return
@@ -270,20 +292,17 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
<div className="bg-accent-blue/20 mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl">
<ArrowRightLeft className="h-8 w-8 text-accent-blue" />
</div>
<div className="mb-2 text-lg text-content-primary">会话已恢复</div>
<div className="mb-6 text-sm text-txt-muted">
上次关闭时的端口转发标签已恢复
<br />
需要重新建立 SSH 连接才能使用
<div className="mb-2 text-lg text-content-primary">{t('common:portForward.sessionRestoredTitle')}</div>
<div className="mb-6 whitespace-pre-line text-sm text-txt-muted">
{t('common:portForward.sessionRestoredDesc')}
</div>
<button
onClick={() => {
updateTab(tabId, { needsReconnect: false })
// 刷新页面或触发连接逻辑
}}
className="rounded-lg bg-accent-blue px-6 py-2.5 text-content-primary transition-colors hover:bg-blue-500"
>
重新连接
{t('common:action.reconnect')}
</button>
</div>
</div>
@@ -294,11 +313,13 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
<div className="flex items-center gap-3">
<ArrowRightLeft className="h-6 w-6 text-accent-blue" />
<div>
<h2 className="text-lg font-semibold text-content-primary">端口转发</h2>
<h2 className="text-lg font-semibold text-content-primary">{t('common:portForward.title')}</h2>
<p className="text-sm text-txt-muted">
{connection
? `已连接: ${connection.config.username}@${connection.config.host}`
: '未连接 - 请先建立 SSH 连接'}
? t('common:portForward.connectedAs', {
target: `${connection.config.username}@${connection.config.host}`,
})
: t('common:portForward.notConnected')}
</p>
</div>
</div>
@@ -307,10 +328,10 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
onClick={() => void quickStartSocks()}
disabled={!connection || isLoading || socksRunning}
className="flex items-center gap-2 rounded-lg bg-orange-500/15 px-3 py-2 text-sm font-medium text-orange-300 transition-colors hover:bg-orange-500/25 disabled:opacity-40"
title="默认仅监听 127.0.0.1:1080"
title={t('common:portForward.socksDefaultHint')}
>
<Globe className="h-4 w-4" />
{socksRunning ? 'SOCKS5 已启动' : '启动 SOCKS5'}
{socksRunning ? t('common:portForward.socksRunning') : t('common:portForward.socksStart')}
</button>
<button
onClick={openAddRule}
@@ -318,7 +339,7 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
className="flex items-center gap-2 rounded-lg bg-accent-blue px-4 py-2 text-sm font-medium transition-colors hover:bg-blue-600 disabled:bg-dark-700 disabled:text-txt-muted"
>
<Plus className="h-4 w-4" />
添加规则
{t('common:portForward.addRule')}
</button>
</div>
</div>
@@ -328,14 +349,14 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
{rules.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-txt-muted">
<Globe className="mb-4 h-16 w-16 opacity-50" />
<p className="text-lg">暂无端口转发规则</p>
<p className="mb-4 mt-1 text-sm">可以一键启动本机 SOCKS5 代理</p>
<p className="text-lg">{t('common:portForward.emptyTitle')}</p>
<p className="mb-4 mt-1 text-sm">{t('common:portForward.emptyDesc')}</p>
<button
onClick={() => void quickStartSocks()}
disabled={!connection || isLoading}
className="rounded-lg bg-orange-500/15 px-4 py-2 text-sm text-orange-300 hover:bg-orange-500/25 disabled:opacity-40"
>
启动 127.0.0.1:1080
{t('common:portForward.emptyAction')}
</button>
</div>
) : (
@@ -378,29 +399,41 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
: 'bg-orange-500/20 text-orange-400'
}`}
>
{rule.type === 'local' ? '本地转发' : rule.type === 'remote' ? '远程转发' : '动态 SOCKS5'}
{rule.type === 'local'
? t('common:portForward.typeLocal')
: rule.type === 'remote'
? t('common:portForward.typeRemote')
: t('common:portForward.typeDynamic')}
</span>
{running && (
<span className="rounded bg-green-500/20 px-2 py-0.5 text-xs text-green-400">运行中</span>
<span className="rounded bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
{t('common:portForward.running')}
</span>
)}
</div>
<div className="mt-1 text-sm text-txt-muted">
{rule.type === 'dynamic' ? (
<span>SOCKS5 代理: 127.0.0.1:{boundPort}</span>
<span>{t('common:portForward.socksProxy', { port: boundPort })}</span>
) : rule.type === 'local' ? (
<span>
127.0.0.1:{boundPort} → {rule.remoteHost}:{rule.remotePort}
</span>
) : (
<span>
远程 {runtime?.boundPort || rule.remotePort} → 127.0.0.1:{rule.localPort}
{t('common:portForward.remoteToLocal', {
remote: runtime?.boundPort || rule.remotePort,
local: rule.localPort,
})}
</span>
)}
</div>
{runtime && (
<div className="mt-1 text-xs text-txt-muted">
{runtime.activeConnections} 个连接 · 上行 {formatTransferBytes(runtime.bytesUp)} · 下行{' '}
{formatTransferBytes(runtime.bytesDown)}
{t('common:portForward.stats', {
connections: runtime.activeConnections,
up: formatTransferBytes(runtime.bytesUp),
down: formatTransferBytes(runtime.bytesDown),
})}
</div>
)}
</div>
@@ -411,7 +444,7 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
<button
onClick={() => void copyProxyValue(`socks5://127.0.0.1:${boundPort}`)}
className="rounded p-2 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
title="复制代理地址"
title={t('common:portForward.copyProxy')}
>
<Copy className="h-4 w-4" />
</button>
@@ -419,7 +452,7 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
<button
onClick={() => duplicateRule(rule)}
className="rounded p-2 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
title="复制规则"
title={t('common:portForward.copyRule')}
>
<CopyPlus className="h-4 w-4" />
</button>
@@ -427,7 +460,7 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
onClick={() => void editRule(rule)}
disabled={isLoading}
className="rounded p-2 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary disabled:opacity-50"
title="编辑规则"
title={t('common:portForward.editRule')}
>
<Pencil className="h-4 w-4" />
</button>
@@ -436,7 +469,7 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
onClick={() => void stopForward(rule)}
disabled={isLoading}
className="rounded p-2 text-yellow-400 transition-colors hover:bg-yellow-400/10 disabled:opacity-50"
title="停止真实监听"
title={t('common:portForward.stopListener')}
>
<Square className="h-5 w-5" />
</button>
@@ -445,7 +478,7 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
onClick={() => void startForward(rule)}
disabled={isLoading || !connection}
className="rounded p-2 text-green-400 transition-colors hover:bg-green-400/10 disabled:opacity-50"
title="启动"
title={t('common:portForward.start')}
>
<Play className="h-5 w-5" />
</button>
@@ -453,7 +486,7 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
<button
onClick={() => void deleteRule(rule)}
className="rounded p-2 text-red-400 transition-colors hover:bg-red-400/10"
title="删除"
title={t('common:portForward.delete')}
>
<Trash2 className="h-5 w-5" />
</button>
@@ -473,119 +506,20 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
</div>
)}
{/* Add Rule Modal */}
{showAddModal && (
<div className="bg-dark-900/90 absolute inset-0 z-50 flex items-center justify-center">
<div className="w-full max-w-md rounded-xl border border-dark-700 bg-dark-800 p-6 shadow-2xl">
<h3 className="mb-4 text-lg font-semibold text-content-primary">
{editingRuleId ? '编辑端口转发规则' : '添加端口转发规则'}
</h3>
{/* Type Selection */}
<div className="mb-4 grid grid-cols-3 gap-2">
{[
{ type: 'local', label: '本地转发', desc: '本地→远程' },
{ type: 'remote', label: '远程转发', desc: '远程→本地' },
{ type: 'dynamic', label: '动态 SOCKS5', desc: '代理服务器' },
].map((t) => (
<button
key={t.type}
onClick={() => setNewRule({ ...newRule, type: t.type as 'local' | 'remote' | 'dynamic' })}
className={`rounded-lg border p-3 text-left transition-colors ${
newRule.type === t.type
? 'bg-accent-blue/20 border-accent-blue text-content-primary'
: 'border-dark-600 bg-dark-700 text-txt-muted hover:bg-dark-600'
}`}
>
<div className="text-sm font-medium">{t.label}</div>
<div className="text-xs opacity-70">{t.desc}</div>
</button>
))}
</div>
{/* Form Fields */}
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm text-txt-muted">规则名称</label>
<input
type="text"
value={newRule.name}
onChange={(e) => setNewRule({ ...newRule, name: e.target.value })}
placeholder="例如: MySQL 转发"
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-content-primary placeholder-ph focus:border-accent-blue focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm text-txt-muted">
{newRule.type === 'remote' ? '本地目标端口' : '本地监听端口'}
</label>
<input
type="number"
min={1}
max={65535}
value={newRule.localPort || ''}
onChange={(e) => setNewRule({ ...newRule, localPort: Number(e.target.value) })}
placeholder="例如: 3306"
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-content-primary placeholder-ph focus:border-accent-blue focus:outline-none"
/>
</div>
{newRule.type !== 'dynamic' && (
<div>
<label className="mb-1 block text-sm text-txt-muted">
{newRule.type === 'remote' ? '远程监听端口' : '远程目标端口'}
</label>
<input
type="number"
min={1}
max={65535}
value={newRule.remotePort || ''}
onChange={(e) => setNewRule({ ...newRule, remotePort: Number(e.target.value) })}
placeholder="例如: 3306"
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-content-primary placeholder-ph focus:border-accent-blue focus:outline-none"
/>
</div>
)}
</div>
{newRule.type === 'local' && (
<div>
<label className="mb-1 block text-sm text-txt-muted">远程主机</label>
<input
type="text"
value={newRule.remoteHost}
onChange={(e) => setNewRule({ ...newRule, remoteHost: e.target.value })}
placeholder="例如: localhost 或 192.168.1.100"
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-content-primary placeholder-ph focus:border-accent-blue focus:outline-none"
/>
</div>
)}
</div>
{/* Actions */}
<div className="mt-6 flex gap-3">
<button
onClick={() => {
setShowAddModal(false)
resetRuleForm()
}}
className="flex-1 rounded-lg bg-dark-700 px-4 py-2 text-sm font-medium transition-colors hover:bg-dark-600"
>
取消
</button>
<button
onClick={handleSaveRule}
className="flex-1 rounded-lg bg-accent-blue px-4 py-2 text-sm font-medium transition-colors hover:bg-blue-600"
>
{editingRuleId ? '保存' : '添加'}
</button>
</div>
</div>
</div>
)}
</div>
<ForwardRuleDrawer
open={showAddModal}
editing={Boolean(editingRuleId)}
draft={newRule}
error={drawerError}
onChange={(next) => {
setNewRule(next)
setDrawerError(null)
}}
onClose={closeRuleDrawer}
onSave={handleSaveRule}
/>
{ConfirmDialogComponent}
</>
)
+11 -3
View File
@@ -1,4 +1,5 @@
import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
import i18n from '../../i18n'
export type ForwardRuleType = 'local' | 'remote' | 'dynamic'
@@ -11,8 +12,14 @@ export interface ForwardRule {
remotePort?: number
}
export function portForwardStorageKey(sessionId: string | undefined, tabId: string): string {
return `portForwards_${sessionId ? `session_${sessionId}` : `tab_${tabId}`}`
export function portForwardStorageKey(
sessionId: string | undefined,
tabId: string,
connectionId?: string
): string {
if (sessionId) return `portForwards_session_${sessionId}`
if (connectionId) return `portForwards_conn_${connectionId}`
return `portForwards_tab_${tabId}`
}
/** Migrates the old JSON-string format and deliberately drops persisted isActive. */
@@ -64,7 +71,8 @@ export function runtimeMap(tunnels: SSHForwardStatus[] | undefined): Map<string,
/** Create a persisted rule copy without carrying over any process-local state. */
export function duplicateForwardRule(rule: ForwardRule, rules: ForwardRule[], id: string): ForwardRule {
const baseName = `${rule.name} 副本`
const copySuffix = i18n.t('common:portForward.copySuffix')
const baseName = `${rule.name} ${copySuffix}`
let name = baseName
let suffix = 2
const names = new Set(rules.map((item) => item.name))
@@ -0,0 +1,176 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Monitor, Cast } from 'lucide-react'
import Modal from '../common/Modal'
import type { DesktopProtocol, RdpConfig, VncConfig } from '../../../shared/types/desktop.types'
interface DesktopConnectDialogProps {
protocol: DesktopProtocol
isOpen: boolean
onClose: () => void
onConnect: (protocol: DesktopProtocol, config: RdpConfig | VncConfig) => void
}
export default function DesktopConnectDialog({
protocol,
isOpen,
onClose,
onConnect,
}: DesktopConnectDialogProps) {
const { t } = useTranslation('desktop')
const isRdp = protocol === 'rdp'
const [host, setHost] = useState('')
const [port, setPort] = useState(isRdp ? 3389 : 5900)
const [username, setUsername] = useState('Administrator')
const [password, setPassword] = useState('')
const [domain, setDomain] = useState('')
const [width, setWidth] = useState(1920)
const [height, setHeight] = useState(1080)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!isOpen) return
setPort(isRdp ? 3389 : 5900)
setError(null)
}, [isOpen, isRdp])
const title = isRdp ? t('connect.rdpTitle') : t('connect.vncTitle')
const description = isRdp ? t('connect.rdpDesc') : t('connect.vncDesc')
const handleConnect = useCallback(() => {
if (!host.trim()) {
setError(t('connect.errorHostRequired'))
return
}
if (isRdp && !username.trim()) {
setError(t('connect.errorUsernameRequired'))
return
}
if (isRdp) {
onConnect('rdp', {
host: host.trim(),
port,
username: username.trim(),
password,
domain: domain.trim() || undefined,
width,
height,
security: 'nla',
})
} else {
onConnect('vnc', {
host: host.trim(),
port,
password: password || undefined,
width,
height,
})
}
onClose()
}, [domain, height, host, isRdp, onClose, onConnect, password, port, t, username, width])
const icon = useMemo(
() => (isRdp ? <Monitor className="w-5 h-5" /> : <Cast className="w-5 h-5" />),
[isRdp]
)
return (
<Modal
isOpen={isOpen}
onClose={onClose}
onConfirm={handleConnect}
title={title}
description={description}
icon={icon}
confirmText={t('connect.confirm')}
size="lg"
>
<div className="space-y-4">
<section className="grid grid-cols-1 sm:grid-cols-3 gap-3 rounded-xl border border-dark-700/60 bg-dark-900/30 p-3.5">
<div className="sm:col-span-2">
<label htmlFor="desktop-host" className="ui-label">{t('connect.host')}</label>
<input
id="desktop-host"
value={host}
onChange={(event) => setHost(event.target.value)}
placeholder={isRdp ? t('connect.hostPlaceholderRdp') : t('connect.hostPlaceholderVnc')}
className="ui-input font-mono"
spellCheck={false}
/>
</div>
<div>
<label htmlFor="desktop-port" className="ui-label">{t('connect.port')}</label>
<input
id="desktop-port"
type="number"
value={port}
onChange={(event) => setPort(Number(event.target.value) || (isRdp ? 3389 : 5900))}
className="ui-input font-mono"
/>
</div>
{isRdp && (
<>
<div>
<label htmlFor="desktop-user" className="ui-label">{t('connect.username')}</label>
<input
id="desktop-user"
value={username}
onChange={(event) => setUsername(event.target.value)}
className="ui-input"
spellCheck={false}
/>
</div>
<div>
<label htmlFor="desktop-domain" className="ui-label">{t('connect.domain')}</label>
<input
id="desktop-domain"
value={domain}
onChange={(event) => setDomain(event.target.value)}
className="ui-input"
spellCheck={false}
/>
</div>
</>
)}
<div className={isRdp ? '' : 'sm:col-span-2'}>
<label htmlFor="desktop-password" className="ui-label">
{isRdp ? t('connect.password') : t('connect.passwordOptional')}
</label>
<input
id="desktop-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
className="ui-input"
autoComplete="off"
/>
</div>
<div>
<label htmlFor="desktop-width" className="ui-label">{t('connect.width')}</label>
<input
id="desktop-width"
type="number"
value={width}
onChange={(event) => setWidth(Number(event.target.value) || 1920)}
className="ui-input font-mono"
/>
</div>
<div>
<label htmlFor="desktop-height" className="ui-label">{t('connect.height')}</label>
<input
id="desktop-height"
type="number"
value={height}
onChange={(event) => setHeight(Number(event.target.value) || 1080)}
className="ui-input font-mono"
/>
</div>
</section>
<p className="text-[11px] text-txt-faint leading-relaxed">
{isRdp ? t('connect.rdpFootnote') : t('connect.vncFootnote')}
</p>
{error && <p className="text-sm text-red-400">{error}</p>}
</div>
</Modal>
)
}
@@ -0,0 +1,559 @@
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
import { useTranslation } from 'react-i18next'
import {
Cast,
ClipboardPaste,
FolderInput,
FolderOpen,
Monitor,
RefreshCw,
Unplug,
} from 'lucide-react'
import { useTabStore } from '../../stores/tabStore'
import { useSessionStore } from '../../stores/sessionStore'
import type {
DesktopFrameEvent,
DesktopPointerButton,
DesktopProtocol,
RdpConfig,
VncConfig,
} from '../../../shared/types/desktop.types'
import {
onRdpClipboard,
onRdpFileDownload,
onRdpFrame,
onRdpStatus,
rdpClipboardSet,
rdpConnect,
rdpDisconnect,
rdpInputKey,
rdpInputPointer,
rdpUploadFile,
} from '../../platform/rdp'
import {
onVncClipboard,
onVncFrame,
onVncStatus,
vncClipboardSet,
vncConnect,
vncDisconnect,
vncInputKey,
vncInputPointer,
} from '../../platform/vnc'
import { sessionToRdpConfig, sessionToVncConfig } from '../../utils/sessionProtocol'
interface DesktopSessionViewProps {
tabId: string
isActive: boolean
}
function decodeBase64Bytes(base64: string): Uint8Array<ArrayBuffer> {
const binary = atob(base64)
const buffer = new ArrayBuffer(binary.length)
const bytes = new Uint8Array(buffer)
const len = binary.length
let i = 0
for (; i + 4 <= len; i += 4) {
bytes[i] = binary.charCodeAt(i)
bytes[i + 1] = binary.charCodeAt(i + 1)
bytes[i + 2] = binary.charCodeAt(i + 2)
bytes[i + 3] = binary.charCodeAt(i + 3)
}
for (; i < len; i++) bytes[i] = binary.charCodeAt(i)
return bytes
}
function decodeRgbaBase64(base64: string): Uint8ClampedArray<ArrayBuffer> {
return new Uint8ClampedArray(decodeBase64Bytes(base64).buffer)
}
export default function DesktopSessionView({ tabId, isActive }: DesktopSessionViewProps) {
const { t } = useTranslation('desktop')
const canvasRef = useRef<HTMLCanvasElement>(null)
const canvasCtxRef = useRef<CanvasRenderingContext2D | null>(null)
const nativeSessionIdRef = useRef<string | null>(null)
const isActiveRef = useRef(isActive)
const fileInputRef = useRef<HTMLInputElement>(null)
const buttonsRef = useRef({ left: false, middle: false, right: false })
const pointerRafRef = useRef<number | null>(null)
const paintRafRef = useRef<number | null>(null)
const pendingFramesRef = useRef<DesktopFrameEvent[]>([])
const pendingPointerRef = useRef<{
x: number
y: number
button?: DesktopPointerButton
pressed?: boolean
} | null>(null)
const tab = useTabStore((state) => state.tabs.find((item) => item.id === tabId))
const sessions = useSessionStore((state) => state.sessions)
const updateTabStatus = useTabStore((state) => state.updateTabStatus)
const [statusMessage, setStatusMessage] = useState<string>(() => t('status.connecting'))
const [error, setError] = useState<string | null>(null)
const [fbSize, setFbSize] = useState({ width: 1920, height: 1080 })
const [resolvedConfig, setResolvedConfig] = useState<RdpConfig | VncConfig | null>(null)
const protocol: DesktopProtocol = tab?.type === 'vnc' ? 'vnc' : 'rdp'
isActiveRef.current = isActive
useEffect(() => {
let cancelled = false
const resolve = async () => {
if (protocol === 'rdp' && tab?.rdpConfig) {
if (!cancelled) setResolvedConfig(tab.rdpConfig)
return
}
if (protocol === 'vnc' && tab?.vncConfig) {
if (!cancelled) setResolvedConfig(tab.vncConfig)
return
}
if (tab?.sessionId) {
const local = sessions.find((item) => item.id === tab.sessionId)
if (local) {
if (!cancelled) {
setResolvedConfig(
protocol === 'rdp' ? sessionToRdpConfig(local) : sessionToVncConfig(local)
)
}
return
}
const response = await window.opsxApi?.sessionGetById?.(tab.sessionId)
if (!cancelled && response?.success && response.session) {
setResolvedConfig(
protocol === 'rdp'
? sessionToRdpConfig(response.session)
: sessionToVncConfig(response.session)
)
}
}
}
void resolve()
return () => {
cancelled = true
}
}, [protocol, sessions, tab?.rdpConfig, tab?.sessionId, tab?.vncConfig])
useEffect(() => {
if (!resolvedConfig) return
let disposed = false
let unlistenStatus: (() => void) | undefined
let unlistenFrame: (() => void) | undefined
let unlistenClipboard: (() => void) | undefined
let unlistenFile: (() => void) | undefined
const ensureCanvas = (fbWidth: number, fbHeight: number) => {
const canvas = canvasRef.current
if (!canvas) return null
if (canvas.width !== fbWidth || canvas.height !== fbHeight) {
canvas.width = fbWidth
canvas.height = fbHeight
canvasCtxRef.current = null
setFbSize({ width: fbWidth, height: fbHeight })
}
if (!canvasCtxRef.current) {
canvasCtxRef.current = canvas.getContext('2d', { alpha: false })
}
return canvasCtxRef.current
}
const paintOne = async (payload: DesktopFrameEvent) => {
const ctx = ensureCanvas(payload.fbWidth, payload.fbHeight)
if (!ctx) return
if (payload.encoding === 'jpeg') {
const bytes = decodeBase64Bytes(payload.dataBase64)
const blob = new Blob([bytes], { type: 'image/jpeg' })
const bitmap = await createImageBitmap(blob)
if (disposed) {
bitmap.close()
return
}
ctx.drawImage(bitmap, payload.x, payload.y)
bitmap.close()
return
}
const rgba = decodeRgbaBase64(payload.dataBase64)
const image = new ImageData(rgba, payload.width, payload.height)
ctx.putImageData(image, payload.x, payload.y)
}
const flushPendingFrames = () => {
paintRafRef.current = null
const batch = pendingFramesRef.current
pendingFramesRef.current = []
if (!batch.length || !isActiveRef.current) return
void (async () => {
for (const frame of batch) {
if (disposed) return
try {
await paintOne(frame)
} catch {
// Ignore a single bad frame; keep painting the rest.
}
}
})()
}
const queueFrame = (payload: DesktopFrameEvent) => {
if (!isActiveRef.current) return
pendingFramesRef.current.push(payload)
if (paintRafRef.current == null) {
paintRafRef.current = window.requestAnimationFrame(flushPendingFrames)
}
}
updateTabStatus(tabId, 'connecting')
setStatusMessage(t('status.connecting'))
setError(null)
void (async () => {
try {
if (protocol === 'rdp') {
unlistenStatus = await onRdpStatus((payload) => {
if (nativeSessionIdRef.current && payload.sessionId !== nativeSessionIdRef.current) return
if (!nativeSessionIdRef.current) nativeSessionIdRef.current = payload.sessionId
if (payload.status === 'connected') {
updateTabStatus(tabId, 'connected')
setStatusMessage(payload.message || t('status.connected'))
} else if (payload.status === 'error') {
updateTabStatus(tabId, 'error')
setError(payload.message || t('status.connectFailed'))
} else if (payload.status === 'disconnected') {
updateTabStatus(tabId, 'disconnected')
setStatusMessage(t('status.disconnected'))
} else if (payload.message) {
setStatusMessage(payload.message)
}
})
unlistenFrame = await onRdpFrame((payload) => {
if (payload.sessionId !== nativeSessionIdRef.current) return
queueFrame(payload)
})
unlistenClipboard = await onRdpClipboard((payload) => {
if (payload.sessionId !== nativeSessionIdRef.current) return
if (payload.text) {
void navigator.clipboard.writeText(payload.text).catch(() => undefined)
setStatusMessage(t('status.clipboardSynced'))
}
})
unlistenFile = await onRdpFileDownload((payload) => {
if (payload.sessionId !== nativeSessionIdRef.current) return
setStatusMessage(t('status.savedToDownloads', { name: payload.name }))
})
const sessionId = await rdpConnect(resolvedConfig as RdpConfig)
if (disposed) {
await rdpDisconnect(sessionId)
return
}
nativeSessionIdRef.current = sessionId
} else {
unlistenStatus = await onVncStatus((payload) => {
if (nativeSessionIdRef.current && payload.sessionId !== nativeSessionIdRef.current) return
if (!nativeSessionIdRef.current) nativeSessionIdRef.current = payload.sessionId
if (payload.status === 'connected') {
updateTabStatus(tabId, 'connected')
setStatusMessage(payload.message || t('status.connected'))
} else if (payload.status === 'error') {
updateTabStatus(tabId, 'error')
setError(payload.message || t('status.connectFailed'))
} else if (payload.status === 'disconnected') {
updateTabStatus(tabId, 'disconnected')
setStatusMessage(t('status.disconnected'))
} else if (payload.message) {
setStatusMessage(payload.message)
}
})
unlistenFrame = await onVncFrame((payload) => {
if (payload.sessionId !== nativeSessionIdRef.current) return
queueFrame(payload)
})
unlistenClipboard = await onVncClipboard((payload) => {
if (payload.sessionId !== nativeSessionIdRef.current) return
if (payload.text) {
void navigator.clipboard.writeText(payload.text).catch(() => undefined)
setStatusMessage(t('status.clipboardSynced'))
}
})
const sessionId = await vncConnect(resolvedConfig as VncConfig)
if (disposed) {
await vncDisconnect(sessionId)
return
}
nativeSessionIdRef.current = sessionId
}
} catch (reason) {
const message = reason instanceof Error ? reason.message : String(reason)
setError(message)
updateTabStatus(tabId, 'error')
}
})()
return () => {
disposed = true
if (paintRafRef.current != null) {
window.cancelAnimationFrame(paintRafRef.current)
paintRafRef.current = null
}
pendingFramesRef.current = []
unlistenStatus?.()
unlistenFrame?.()
unlistenClipboard?.()
unlistenFile?.()
const sessionId = nativeSessionIdRef.current
nativeSessionIdRef.current = null
if (sessionId) {
if (protocol === 'rdp') void rdpDisconnect(sessionId)
else void vncDisconnect(sessionId)
}
}
}, [protocol, resolvedConfig, tabId, updateTabStatus, t])
useEffect(() => {
if (!isActive) return
canvasRef.current?.focus()
}, [isActive])
const flushPointer = () => {
pointerRafRef.current = null
const sessionId = nativeSessionIdRef.current
const payload = pendingPointerRef.current
pendingPointerRef.current = null
if (!sessionId || !payload) return
if (protocol === 'rdp') void rdpInputPointer(sessionId, payload)
else void vncInputPointer(sessionId, payload)
}
const mapPointer = (event: MouseEvent<HTMLCanvasElement>, pressed?: boolean) => {
const canvas = canvasRef.current
const sessionId = nativeSessionIdRef.current
if (!canvas || !sessionId) return
const rect = canvas.getBoundingClientRect()
const scaleX = canvas.width / Math.max(rect.width, 1)
const scaleY = canvas.height / Math.max(rect.height, 1)
const x = Math.max(0, Math.min(canvas.width - 1, Math.round((event.clientX - rect.left) * scaleX)))
const y = Math.max(0, Math.min(canvas.height - 1, Math.round((event.clientY - rect.top) * scaleY)))
const isMove = pressed === undefined
let button: DesktopPointerButton | undefined
let pressedOut: boolean | undefined = pressed
if (!isMove) {
button = event.button === 1 ? 'middle' : event.button === 2 ? 'right' : 'left'
if (button === 'left') buttonsRef.current.left = !!pressed
if (button === 'middle') buttonsRef.current.middle = !!pressed
if (button === 'right') buttonsRef.current.right = !!pressed
} else if (buttonsRef.current.left) {
button = 'left'
pressedOut = true
} else if (buttonsRef.current.right) {
button = 'right'
pressedOut = true
} else if (buttonsRef.current.middle) {
button = 'middle'
pressedOut = true
}
const payload = { x, y, button, pressed: pressedOut, moving: isMove }
if (!isMove) {
// Position first, then button — improves Windows hit-testing reliability.
if (protocol === 'rdp') {
void rdpInputPointer(sessionId, { x, y, moving: true })
void rdpInputPointer(sessionId, payload)
} else {
void vncInputPointer(sessionId, payload)
}
return
}
pendingPointerRef.current = payload
if (pointerRafRef.current == null) {
pointerRafRef.current = window.requestAnimationFrame(flushPointer)
}
}
const pasteToRemote = async () => {
const sessionId = nativeSessionIdRef.current
if (!sessionId) return
const text = await navigator.clipboard.readText()
if (!text) return
if (protocol === 'rdp') await rdpClipboardSet(sessionId, text)
else await vncClipboardSet(sessionId, text)
setStatusMessage(t('status.pastedToRemote'))
}
const onKey = (event: KeyboardEvent<HTMLCanvasElement>, pressed: boolean) => {
const sessionId = nativeSessionIdRef.current
if (!sessionId) return
event.preventDefault()
const isPasteChord =
pressed &&
(event.metaKey || event.ctrlKey) &&
event.code === 'KeyV' &&
!event.altKey &&
!event.shiftKey
if (isPasteChord) {
// Do not forward Ctrl/Cmd+V — sync remote clipboard then native injects Ctrl+V.
void pasteToRemote()
return
}
const payload = {
key: event.key,
code: event.code,
pressed,
altKey: event.altKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
}
if (protocol === 'rdp') void rdpInputKey(sessionId, payload)
else void vncInputKey(sessionId, payload)
}
const onShareFileSelected = async (fileList: FileList | null) => {
const sessionId = nativeSessionIdRef.current
const file = fileList?.[0]
if (!sessionId || !file || protocol !== 'rdp') return
if (file.size > 50 * 1024 * 1024) {
setError(t('status.fileTooLarge'))
return
}
const buffer = new Uint8Array(await file.arrayBuffer())
let binary = ''
for (let i = 0; i < buffer.length; i++) binary += String.fromCharCode(buffer[i])
await rdpUploadFile(sessionId, file.name, btoa(binary))
setStatusMessage(t('status.fileShared', { name: file.name }))
}
const openSharedFolder = async () => {
try {
await window.opsxApi?.openDownloadsFolder?.()
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason))
}
}
const disconnectSession = async () => {
const sessionId = nativeSessionIdRef.current
if (!sessionId) return
try {
if (protocol === 'rdp') await rdpDisconnect(sessionId)
else await vncDisconnect(sessionId)
nativeSessionIdRef.current = null
updateTabStatus(tabId, 'disconnected')
setStatusMessage(t('status.disconnected'))
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason))
}
}
const Icon = protocol === 'rdp' ? Monitor : Cast
const hostLabel =
resolvedConfig && 'host' in resolvedConfig
? resolvedConfig.host
: tab?.title
const connected = tab?.connectionStatus === 'connected'
const toolBtn =
'inline-flex h-7 w-7 items-center justify-center rounded-md text-content-secondary transition-colors hover:bg-dark-700 hover:text-content-primary disabled:cursor-not-allowed disabled:opacity-40'
return (
<div className="h-full flex flex-col bg-dark-900">
<div className="flex items-center gap-2 border-b border-dark-700/50 bg-dark-800/80 px-2.5 py-1.5 text-xs">
<Icon className="h-3.5 w-3.5 flex-shrink-0 text-accent-blue" />
<span className="max-w-[10rem] truncate font-mono text-content-secondary" title={hostLabel}>
{hostLabel}
</span>
<span className="tabular-nums text-txt-faint">
{fbSize.width}×{fbSize.height}
</span>
{tab?.connectionStatus === 'connecting' ? (
<RefreshCw className="h-3 w-3 flex-shrink-0 animate-spin text-yellow-400" />
) : (
<span
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${
connected ? 'bg-green-500' : tab?.connectionStatus === 'error' ? 'bg-red-500' : 'bg-dark-500'
}`}
/>
)}
<span className="min-w-0 flex-1 truncate text-txt-muted">{statusMessage}</span>
{error && <span className="max-w-[30%] truncate text-red-400">{error}</span>}
<div className="ml-1 flex flex-shrink-0 items-center gap-0.5">
<button
type="button"
className={toolBtn}
disabled={!connected}
onClick={() => void pasteToRemote()}
title={t('toolbar.pasteClipboard')}
aria-label={t('toolbar.pasteAria')}
>
<ClipboardPaste className="h-3.5 w-3.5" />
</button>
{protocol === 'rdp' && (
<>
<button
type="button"
className={toolBtn}
disabled={!connected}
onClick={() => fileInputRef.current?.click()}
title={t('toolbar.shareDrive')}
aria-label={t('toolbar.shareDriveAria')}
>
<FolderInput className="h-3.5 w-3.5" />
</button>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(event) => {
void onShareFileSelected(event.target.files)
event.target.value = ''
}}
/>
<button
type="button"
className={toolBtn}
onClick={() => void openSharedFolder()}
title={t('toolbar.openShareDir')}
aria-label={t('toolbar.openShareDirAria')}
>
<FolderOpen className="h-3.5 w-3.5" />
</button>
</>
)}
<button
type="button"
className={`${toolBtn} text-red-400 hover:bg-red-500/15 hover:text-red-300`}
disabled={!connected}
onClick={() => void disconnectSession()}
title={t('toolbar.disconnect')}
aria-label={t('toolbar.disconnectAria')}
>
<Unplug className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto bg-black p-2">
<canvas
ref={canvasRef}
tabIndex={0}
className="max-h-full max-w-full cursor-default outline-none shadow-lg shadow-black/40"
onContextMenu={(event) => event.preventDefault()}
onMouseMove={(event) => mapPointer(event)}
onMouseDown={(event) => mapPointer(event, true)}
onMouseUp={(event) => mapPointer(event, false)}
onMouseLeave={() => {
buttonsRef.current = { left: false, middle: false, right: false }
}}
onKeyDown={(event) => onKey(event, true)}
onKeyUp={(event) => onKey(event, false)}
/>
</div>
</div>
)
}
+2
View File
@@ -0,0 +1,2 @@
export { default as DesktopConnectDialog } from './DesktopConnectDialog'
export { default as DesktopSessionView } from './DesktopSessionView'
+3
View File
@@ -18,6 +18,9 @@ export { SessionDialog, QuickConnectDialog, QuickLauncher, WelcomeView } from '.
// Serial
export { SerialConnectDialog, SerialTerminalView } from './serial'
// Desktop (RDP / VNC)
export { DesktopConnectDialog, DesktopSessionView } from './desktop'
// Settings
export { SettingsView } from './settings'
+226 -121
View File
@@ -3,45 +3,57 @@
* 支持宽度拖拽调整
*/
import { useState, useEffect, useRef, useCallback } from 'react'
import { Plus, Zap, Settings, ListChecks, Terminal, FolderSync, X, TerminalSquare, PanelLeftClose } from 'lucide-react'
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Plus, LogIn, Settings, ListChecks, X, TerminalSquare, ChevronsLeft } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useSessionStore, Session, useEntitlementStore } from '../../stores'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTabStore } from '../../stores/tabStore'
import { useTerminalBroadcastStore } from '../../stores/terminalBroadcastStore'
import { SessionTree } from './sidebar/SessionTree'
import { SearchBox } from './sidebar/SearchBox'
import { useConfirmDialog } from '../common/ConfirmDialog'
import { usePromptDialog } from '../common/PromptDialog'
import appIconUrl from '../../assets/opsxshell-app-icon.png'
import { buildOpenTabPayload, isSshSession } from '../../utils/sessionProtocol'
import {
DEFAULT_SESSION_GROUP,
groupDisplayName,
normalizeGroupId,
} from '../../utils/sessionGroups'
const MIN_SIDEBAR_WIDTH = 180
const MAX_SIDEBAR_WIDTH = 400
interface SidebarProps {
onNewSession: () => void
onNewSession: (group?: string) => void
onQuickConnect: () => void
onEditSession: (session: Session) => void
onCollapse?: () => void
}
export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, onCollapse }: SidebarProps) {
const { sessions, setSessions, addSession, removeSession } = useSessionStore()
const { t } = useTranslation(['sessions', 'common'])
const { sessions, setSessions, addSession, removeSession, updateSession } = useSessionStore()
const { settings, setSetting } = useSettingsStore()
const { addTab } = useTabStore()
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
const { prompt, PromptDialogComponent } = usePromptDialog()
const [searchTerm, setSearchTerm] = useState('')
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['default']))
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; session: Session } | null>(null)
const [contextMenu, setContextMenu] = useState<
| { kind: 'session'; x: number; y: number; session: Session }
| { kind: 'group'; x: number; y: number; group: string }
| { kind: 'blank'; x: number; y: number }
| null
>(null)
const [isResizing, setIsResizing] = useState(false)
const [sidebarWidth, setSidebarWidth] = useState(settings.sidebarWidth || 256)
const [sessionsLoading, setSessionsLoading] = useState(true)
const [batchMode, setBatchMode] = useState(false)
const [batchSelection, setBatchSelection] = useState<Set<string>>(new Set())
const requirePro = useEntitlementStore((s) => s.requirePro)
const setBroadcastSelectedSessionIds = useTerminalBroadcastStore((s) => s.setSelectedSessionIds)
const setBroadcastScope = useTerminalBroadcastStore((s) => s.setScope)
const contextMenuRef = useRef<HTMLDivElement>(null)
const sidebarRef = useRef<HTMLDivElement>(null)
@@ -125,14 +137,13 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
const handleConnect = (session: Session) => {
setSelectedSessionId(session.id)
addTab({
title: session.name,
type: 'terminal',
sessionId: session.id,
})
const protocol = session.protocol ?? 'ssh'
if ((protocol === 'rdp' || protocol === 'vnc') && !requirePro(protocol)) return
addTab(buildOpenTabPayload(session))
}
const handleOpenSftp = (session: Session) => {
if ((session.protocol ?? 'ssh') !== 'ssh') return
setSelectedSessionId(session.id)
addTab({
title: `SFTP: ${session.name}`,
@@ -142,6 +153,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
}
const toggleBatchSelection = useCallback((session: Session) => {
if (!isSshSession(session)) return
setBatchSelection((current) => {
const next = new Set(current)
if (next.has(session.id)) next.delete(session.id)
@@ -150,7 +162,13 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
})
}, [])
const visibleSessions = sessions.filter((session) => {
/** Batch mode only lists SSH targets (RDP / VNC are unsupported). */
const batchableSessions = useMemo(
() => sessions.filter((session) => isSshSession(session)),
[sessions]
)
const visibleSessions = batchableSessions.filter((session) => {
const query = searchTerm.trim().toLowerCase()
return !query || session.name.toLowerCase().includes(query) ||
session.host.toLowerCase().includes(query) ||
@@ -170,64 +188,148 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
})
}, [visibleSessions])
const selectedSessions = sessions.filter((session) => batchSelection.has(session.id))
const selectedSessions = batchableSessions.filter((session) => batchSelection.has(session.id))
useEffect(() => {
if (!batchMode) return
setBatchSelection((current) => {
const allowed = new Set(batchableSessions.map((session) => session.id))
let changed = false
const next = new Set<string>()
for (const id of current) {
if (allowed.has(id)) next.add(id)
else changed = true
}
return changed ? next : current
})
}, [batchMode, batchableSessions])
const finishBatchMode = useCallback(() => {
setBatchMode(false)
setBatchSelection(new Set())
setBroadcastSelectedSessionIds([])
}, [setBroadcastSelectedSessionIds])
useEffect(() => {
if (!batchMode) return
setBroadcastSelectedSessionIds(Array.from(batchSelection))
setBroadcastScope('selected-sessions')
}, [batchMode, batchSelection, setBroadcastScope, setBroadcastSelectedSessionIds])
const handleBatchTerminal = useCallback(() => {
for (const session of selectedSessions) {
addTab({
title: session.name,
type: 'terminal',
sessionId: session.id,
connectOnMount: true,
})
}
// Keep selection for sync-input / further batch actions
}, [addTab, selectedSessions])
const handleBatchSftp = useCallback(() => {
for (const session of selectedSessions) {
addTab({ title: `SFTP: ${session.name}`, type: 'sftp', sessionId: session.id })
}
}, [addTab, selectedSessions])
}, [])
const handleBatchCommand = useCallback(() => {
if (!requirePro('batchExec')) return
addTab({
title: '批量执行',
title: t('sessions:batch.title'),
type: 'batch',
batchSessionIds: selectedSessions.map((session) => session.id),
})
}, [addTab, requirePro, selectedSessions])
const handleBatchSyncInput = useCallback(() => {
if (!requirePro('broadcast')) return
setBroadcastSelectedSessionIds(selectedSessions.map((s) => s.id))
setBroadcastScope('selected-sessions')
useTerminalBroadcastStore.getState().setEnabled(true)
}, [requirePro, selectedSessions, setBroadcastScope, setBroadcastSelectedSessionIds])
}, [addTab, requirePro, selectedSessions, t])
const handleContextMenu = (e: React.MouseEvent, session: Session) => {
e.preventDefault()
setSelectedSessionId(session.id)
setContextMenu({ x: e.clientX, y: e.clientY, session })
setContextMenu({ kind: 'session', x: e.clientX, y: e.clientY, session })
}
const handleGroupContextMenu = (e: React.MouseEvent, group: string) => {
e.preventDefault()
setContextMenu({ kind: 'group', x: e.clientX, y: e.clientY, group })
}
const handleBlankContextMenu = (e: React.MouseEvent) => {
e.preventDefault()
setContextMenu({ kind: 'blank', x: e.clientX, y: e.clientY })
}
const persistSessionGroups = useCallback(
async (next: string[]) => {
const cleaned = next
.map((item) => normalizeGroupId(item))
.filter((item) => item !== DEFAULT_SESSION_GROUP)
const unique = Array.from(new Set(cleaned))
await setSetting('sessionGroups', unique)
},
[setSetting]
)
const createGroup = useCallback(async () => {
const raw = await prompt({
title: t('sessions:groups.new.title'),
message: t('sessions:groups.new.message'),
placeholder: t('sessions:groups.new.placeholder'),
confirmText: t('sessions:groups.new.confirm'),
})
if (raw == null) return
const group = normalizeGroupId(raw)
if (group === DEFAULT_SESSION_GROUP) return
const extras = settings.sessionGroups || []
if (!extras.includes(group)) {
await persistSessionGroups([...extras, group])
}
setExpandedGroups((current) => new Set(current).add(group))
}, [persistSessionGroups, prompt, settings.sessionGroups, t])
const renameGroup = useCallback(
async (group: string) => {
if (group === DEFAULT_SESSION_GROUP) return
const raw = await prompt({
title: t('sessions:groups.rename.title'),
message: t('sessions:groups.rename.message', { name: groupDisplayName(group) }),
defaultValue: groupDisplayName(group),
placeholder: t('sessions:groups.rename.placeholder'),
confirmText: t('sessions:groups.rename.confirm'),
})
if (raw == null) return
const next = normalizeGroupId(raw)
if (!next || next === group) return
const targets = sessions.filter((session) => normalizeGroupId(session.group) === group)
for (const session of targets) {
await updateSession(session.id, { group: next })
}
const extras = (settings.sessionGroups || []).filter((item) => item !== group)
if (next !== DEFAULT_SESSION_GROUP && !extras.includes(next)) extras.push(next)
await persistSessionGroups(extras)
setExpandedGroups((current) => {
const copy = new Set(current)
copy.delete(group)
copy.add(next)
return copy
})
},
[persistSessionGroups, prompt, sessions, settings.sessionGroups, updateSession, t]
)
const deleteEmptyGroup = useCallback(
async (group: string) => {
if (group === DEFAULT_SESSION_GROUP) return
const hasSessions = sessions.some((session) => normalizeGroupId(session.group) === group)
if (hasSessions) {
await confirm({
title: t('sessions:groups.deleteBlocked.title'),
message: t('sessions:groups.deleteBlocked.message', { name: groupDisplayName(group) }),
confirmText: t('sessions:groups.deleteBlocked.confirm'),
})
return
}
await persistSessionGroups((settings.sessionGroups || []).filter((item) => item !== group))
},
[confirm, persistSessionGroups, sessions, settings.sessionGroups, t]
)
const handleContextMenuAction = async (action: string) => {
if (!contextMenu) return
const session = contextMenu.session
if (contextMenu.kind === 'blank') {
if (action === 'new-group') await createGroup()
if (action === 'new-session') onNewSession()
setContextMenu(null)
return
}
if (contextMenu.kind === 'group') {
const group = contextMenu.group
if (action === 'new-session') onNewSession(group)
if (action === 'new-group') await createGroup()
if (action === 'rename-group') await renameGroup(group)
if (action === 'delete-group') await deleteEmptyGroup(group)
setContextMenu(null)
return
}
const session = contextMenu.session
switch (action) {
case 'connect':
handleConnect(session)
@@ -242,7 +344,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
const { id: _id, ...sessionWithoutId } = session
window.opsxApi?.sessionCreate({
...sessionWithoutId,
name: `${session.name} (副本)`,
name: `${session.name} (${t('common:action.clone')})`,
}).then((result) => {
if (result?.success && result.session) {
addSession(result.session as Session)
@@ -252,10 +354,10 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
}
case 'delete': {
const confirmed = await confirm({
title: '确认删除',
message: `确定删除会话 "${session.name}"?`,
title: t('sessions:deleteSession.title'),
message: t('sessions:deleteSession.message', { name: session.name }),
danger: true,
confirmText: '删除',
confirmText: t('sessions:deleteSession.confirm'),
})
if (confirmed) {
removeSession(session.id)
@@ -290,35 +392,39 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
<div className="font-semibold text-sm text-content-primary tracking-tight leading-none">
OpsXShell
</div>
<div className="text-[10px] text-txt-faint mt-1">SSH / SFTP 终端</div>
<div className="text-[10px] text-txt-faint mt-1">{t('common:sidebar.brandSub')}</div>
</div>
{onCollapse && (
<button
type="button"
onClick={onCollapse}
title="折叠侧栏 (Ctrl/Cmd+B)"
aria-label="折叠侧栏"
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
title={t('common:sidebar.collapse')}
aria-label={t('common:sidebar.collapseAria')}
className="group flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full text-txt-faint transition-colors hover:bg-dark-700/70 hover:text-txt-muted"
>
<PanelLeftClose className="h-4 w-4" />
<ChevronsLeft className="h-3.5 w-3.5 opacity-70 transition-opacity group-hover:opacity-100" strokeWidth={1.75} />
</button>
)}
</div>
<div className="flex gap-1.5">
<button
type="button"
onClick={onQuickConnect}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-accent-blue hover:bg-accent-blue-hover rounded-lg text-xs font-medium text-white transition-colors shadow-sm shadow-accent-blue/20"
title={t('sidebar.quickConnect')}
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-dark-600/60 bg-dark-700 px-2 py-2 text-xs font-medium text-content-primary transition-colors hover:border-accent-blue/40 hover:bg-dark-600"
>
<Zap className="w-3.5 h-3.5" />
快速连接
<LogIn className="h-3.5 w-3.5 text-accent-blue" />
{t('sidebar.quickConnectShort')}
</button>
<button
onClick={onNewSession}
title="新建会话"
className="flex items-center justify-center px-2.5 py-2 bg-dark-700 hover:bg-dark-600 border border-dark-600/60 rounded-lg text-sm font-medium transition-colors text-content-primary"
type="button"
onClick={() => onNewSession()}
title={t('sidebar.newSession')}
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-dark-600/60 bg-dark-700 px-2 py-2 text-xs font-medium text-content-primary transition-colors hover:border-accent-green/40 hover:bg-dark-600"
>
<Plus className="w-4 h-4" />
<Plus className="h-3.5 w-3.5 text-accent-green" />
{t('sidebar.newSessionShort')}
</button>
</div>
</div>
@@ -330,13 +436,13 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
<SearchBox
value={searchTerm}
onChange={setSearchTerm}
placeholder="搜索会话..."
placeholder={t('sidebar.searchPlaceholder')}
/>
</div>
<button
type="button"
title={batchMode ? '退出批量操作' : '批量操作'}
aria-label={batchMode ? '退出批量操作' : '批量操作'}
title={batchMode ? t('sidebar.exitBatchMode') : t('sidebar.batchMode')}
aria-label={batchMode ? t('sidebar.exitBatchMode') : t('sidebar.batchMode')}
onClick={() => batchMode ? finishBatchMode() : setBatchMode(true)}
className={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded border transition-colors ${
batchMode
@@ -348,67 +454,47 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
</button>
</div>
{batchMode && (
<div className="flex items-center justify-between text-[11px]">
<span className="text-txt-muted">已选 {batchSelection.size} 个会话</span>
<button type="button" onClick={toggleSelectVisible} className="text-accent-blue hover:underline">
<div className="flex items-center justify-between gap-2 text-[11px]">
<span className="text-txt-muted truncate">
{t('sidebar.batchSelected', { count: batchSelection.size })}
{sessions.length !== batchableSessions.length ? t('sidebar.batchExcludedDesktop') : ''}
</span>
<button type="button" onClick={toggleSelectVisible} className="flex-shrink-0 text-accent-blue hover:underline">
{visibleSessions.length > 0 && visibleSessions.every((s) => batchSelection.has(s.id))
? '取消当前结果'
: '全选当前结果'}
? t('sidebar.deselectAllVisible')
: t('sidebar.selectAllVisible')}
</button>
</div>
)}
</div>
<SessionTree
sessions={sessions}
sessions={batchMode ? batchableSessions : sessions}
extraGroups={settings.sessionGroups}
expandedGroups={expandedGroups}
selectedSessionId={selectedSessionId}
onToggleGroup={toggleGroup}
onSelectSession={handleSelect}
onConnectSession={handleConnect}
onContextMenu={handleContextMenu}
onGroupContextMenu={handleGroupContextMenu}
onBlankContextMenu={handleBlankContextMenu}
searchTerm={searchTerm}
loading={sessionsLoading}
onNewSession={onNewSession}
onNewSession={() => onNewSession()}
multiSelect={batchMode}
selectedSessionIds={batchSelection}
onToggleSelection={toggleBatchSelection}
/>
{batchMode && (
<div className="grid grid-cols-2 gap-2 border-t border-dark-700/30 p-3">
<div className="grid grid-cols-1 gap-2 border-t border-dark-700/30 p-3">
<button
type="button"
disabled={selectedSessions.length === 0}
onClick={handleBatchCommand}
className="col-span-2 flex h-9 items-center justify-center gap-1.5 rounded bg-accent-blue text-xs font-medium text-white disabled:cursor-not-allowed disabled:opacity-40"
className="flex h-9 items-center justify-center gap-1.5 rounded bg-accent-blue text-xs font-medium text-white disabled:cursor-not-allowed disabled:opacity-40"
>
<TerminalSquare className="h-3.5 w-3.5" />
批量执行 · Pro
</button>
<button
type="button"
disabled={selectedSessions.length === 0}
onClick={handleBatchSyncInput}
className="col-span-2 flex h-9 items-center justify-center gap-1.5 rounded border border-accent-orange/40 bg-accent-orange/10 text-xs font-medium text-accent-orange disabled:cursor-not-allowed disabled:opacity-40"
>
同步输入 · Pro
</button>
<button
type="button"
disabled={selectedSessions.length === 0}
onClick={handleBatchTerminal}
className="flex h-9 items-center justify-center gap-1.5 rounded bg-dark-700 text-xs font-medium text-content-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<Terminal className="h-3.5 w-3.5" />
打开终端
</button>
<button
type="button"
disabled={selectedSessions.length === 0}
onClick={handleBatchSftp}
className="flex h-9 items-center justify-center gap-1.5 rounded border border-dark-600 bg-dark-700 text-xs font-medium text-content-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<FolderSync className="h-3.5 w-3.5" />
打开 SFTP
{t('sidebar.batchExec')}
</button>
</div>
)}
@@ -417,11 +503,11 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
{/* Footer */}
<div className="p-3 border-t border-dark-700/20">
<button
onClick={() => addTab({ title: '设置', type: 'settings' })}
onClick={() => addTab({ title: t('common:tab.settings'), type: 'settings' })}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-txt-muted hover:bg-dark-700 rounded transition-colors"
>
<Settings className="w-4 h-4" />
<span>设置</span>
<span>{t('sidebar.settings')}</span>
</button>
</div>
@@ -430,23 +516,41 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
<div
ref={contextMenuRef}
style={{ left: contextMenu.x, top: contextMenu.y }}
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[140px]"
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[148px]"
>
{[
{ label: '打开终端', action: 'connect' },
{ label: 'SFTP 文件', action: 'sftp' },
{ label: '编辑', action: 'edit' },
{ label: '克隆', action: 'duplicate' },
{ label: '删除', action: 'delete', danger: true },
].map((item) => (
{(contextMenu.kind === 'blank'
? [
{ label: t('contextMenu.blank.newGroup'), action: 'new-group' },
{ label: t('contextMenu.blank.newSession'), action: 'new-session' },
]
: contextMenu.kind === 'group'
? [
{ label: t('contextMenu.group.newSessionHere'), action: 'new-session' },
{ label: t('contextMenu.group.newGroup'), action: 'new-group' },
...(contextMenu.group !== DEFAULT_SESSION_GROUP
? [
{ label: t('contextMenu.group.renameGroup'), action: 'rename-group' },
{ label: t('contextMenu.group.deleteEmptyGroup'), action: 'delete-group', danger: true },
]
: []),
]
: [
{ label: t('contextMenu.session.open'), action: 'connect' },
{ label: t('common:tab.kind.sftp'), action: 'sftp' },
{ label: t('contextMenu.session.edit'), action: 'edit' },
{ label: t('contextMenu.session.duplicate'), action: 'duplicate' },
{ label: t('contextMenu.session.delete'), action: 'delete', danger: true },
]
).map((item) => (
<button
key={item.action}
onClick={() => handleContextMenuAction(item.action)}
className={`
w-full px-4 py-2 text-left text-sm transition-colors
${item.danger
? 'text-red-400 hover:bg-red-500/20'
: 'text-content-secondary hover:bg-dark-700'
${
item.danger
? 'text-red-400 hover:bg-red-500/20'
: 'text-content-secondary hover:bg-dark-700'
}
`}
>
@@ -464,7 +568,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
e.preventDefault()
onCollapse?.()
}}
title="拖拽调整宽度,双击折叠"
title={t('sidebar.resizeHint')}
className={`
absolute top-0 right-0 bottom-0 w-1 cursor-col-resize
hover:bg-accent-blue/50 active:bg-accent-blue/70
@@ -475,6 +579,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
/>
</div>
{ConfirmDialogComponent}
{PromptDialogComponent}
</>
)
}
File diff suppressed because it is too large. Load diff
+31 -27
View File
@@ -4,6 +4,7 @@
import { Plus } from 'lucide-react'
import React, { useState, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useTabStore } from '../../stores/tabStore'
import {
disposeAndCloseAllTabs,
@@ -20,6 +21,7 @@ interface TabBarProps {
}
function TabBar({ onOpenLauncher }: TabBarProps) {
const { t } = useTranslation('common')
const {
tabs,
activeTabId,
@@ -115,44 +117,46 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
return (
<div
data-tauri-drag-region
className="bg-dark-800/90 border-dark-700/40 flex items-stretch border-b"
className="bg-dark-800/90 border-dark-700/40 flex min-w-0 items-stretch border-b"
>
{/* Tab List — 可点击,禁止窗口拖拽 */}
<div className="titlebar-no-drag flex min-w-0 items-stretch overflow-x-auto">
{tabs.map((tab, index) => (
<TabItem
key={tab.id}
tab={tab}
index={index}
isActive={tab.id === activeTabId}
isDragging={dragFromIndex === index}
dragOverIndex={dragOverIndex}
onActivate={() => activateTab(tab.id)}
onClose={(e) => {
e.stopPropagation()
handleCloseTab(tab.id)
}}
onContextMenu={(e) => handleContextMenu(e, tab.id)}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
/>
))}
{/* Tab List — 可滚动;+ 固定在外侧,避免标签多时被挤出视口 */}
<div className="titlebar-no-drag flex min-w-0 flex-1 items-stretch">
<div className="flex min-w-0 flex-1 items-stretch overflow-x-auto">
{tabs.map((tab, index) => (
<TabItem
key={tab.id}
tab={tab}
index={index}
isActive={tab.id === activeTabId}
isDragging={dragFromIndex === index}
dragOverIndex={dragOverIndex}
onActivate={() => activateTab(tab.id)}
onClose={(e) => {
e.stopPropagation()
handleCloseTab(tab.id)
}}
onContextMenu={(e) => handleContextMenu(e, tab.id)}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
/>
))}
</div>
<button
type="button"
onClick={onOpenLauncher}
className="bg-accent-blue/90 shadow-accent-blue/20 mx-1 my-auto flex h-9 w-10 flex-shrink-0 items-center justify-center rounded-md text-white shadow-sm transition-colors hover:bg-accent-blue"
title="快速启动"
aria-label="快速启动"
title={t('launcher.title')}
aria-label={t('launcher.title')}
>
<Plus className="h-4 w-4" />
</button>
</div>
{/* 右侧空白:拖动窗口 */}
<div data-tauri-drag-region className="min-w-[48px] flex-1" aria-hidden />
<div data-tauri-drag-region className="min-w-[48px] flex-shrink-0 basis-12" aria-hidden />
<TabContextMenu
isOpen={menuState.isOpen}
@@ -166,7 +170,7 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
onClone={() => {
if (currentTab) {
addTab({
title: `${currentTab.title} (副本)`,
title: `${currentTab.title} (${t('action.clone')})`,
type: currentTab.type,
sessionId: currentTab.sessionId,
connectionConfig: currentTab.connectionConfig,
+32 -2
View File
@@ -6,6 +6,7 @@ import SFTPView from '../sftp/SFTPView'
import PortForwardView from '../connection/PortForwardView'
import SettingsView from '../settings/SettingsView'
import SerialTerminalView from '../serial/SerialTerminalView'
import DesktopSessionView from '../desktop/DesktopSessionView'
import UpgradeView from '../pro/UpgradeView'
import BatchExecView from '../sessions/BatchExecView'
import { useEntitlementStore, type ProFeature } from '../../stores/entitlementStore'
@@ -14,6 +15,8 @@ interface TabContentProps {
activeTabId: string | null
onNewSession?: () => void
onQuickConnect?: () => void
onRdpConnect?: () => void
onVncConnect?: () => void
}
function ProGate({
@@ -30,7 +33,13 @@ function ProGate({
return <>{children}</>
}
function TabContent({ activeTabId, onNewSession, onQuickConnect }: TabContentProps) {
function TabContent({
activeTabId,
onNewSession,
onQuickConnect,
onRdpConnect,
onVncConnect,
}: TabContentProps) {
const tabs = useTabStore((state) => state.tabs)
return (
@@ -61,6 +70,20 @@ function TabContent({ activeTabId, onNewSession, onQuickConnect }: TabContentPro
</ProGate>
)
break
case 'rdp':
content = (
<ProGate feature="rdp">
<DesktopSessionView tabId={tab.id} isActive={isActive} />
</ProGate>
)
break
case 'vnc':
content = (
<ProGate feature="vnc">
<DesktopSessionView tabId={tab.id} isActive={isActive} />
</ProGate>
)
break
case 'settings':
content = <SettingsView />
break
@@ -81,7 +104,14 @@ function TabContent({ activeTabId, onNewSession, onQuickConnect }: TabContentPro
break
case 'welcome':
default:
content = <WelcomeView onNewSession={onNewSession} onQuickConnect={onQuickConnect} />
content = (
<WelcomeView
onNewSession={onNewSession}
onQuickConnect={onQuickConnect}
onRdpConnect={onRdpConnect}
onVncConnect={onVncConnect}
/>
)
}
return (
@@ -3,6 +3,7 @@
*/
import { useEffect, useRef, useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Laptop, ChevronDown } from 'lucide-react'
import { useTabStore } from '../../../stores/tabStore'
import { shellDisplayFromPath } from '../../../utils/shellName'
@@ -22,6 +23,7 @@ interface LocalShellMenuProps {
}
export default function LocalShellMenu({ compact = false, className = '' }: LocalShellMenuProps) {
const { t } = useTranslation('common')
const { addTab } = useTabStore()
const [open, setOpen] = useState(false)
const [shells, setShells] = useState<ShellOption[]>([])
@@ -88,7 +90,7 @@ export default function LocalShellMenu({ compact = false, className = '' }: Loca
<button
type="button"
onClick={() => openShell()}
title="打开默认本地 Shell"
title={t('sidebar.localShell.openDefault')}
className="flex items-center justify-center px-2.5 py-2 bg-dark-700 hover:bg-dark-600 border border-dark-600/60 rounded-l-lg text-sm font-medium transition-colors text-content-primary border-r-0"
>
<Laptop className="w-4 h-4" />
@@ -96,7 +98,7 @@ export default function LocalShellMenu({ compact = false, className = '' }: Loca
<button
type="button"
onClick={() => setOpen((v) => !v)}
title="选择 Shell"
title={t('sidebar.localShell.choose')}
className="flex items-center justify-center px-1.5 py-2 bg-dark-700 hover:bg-dark-600 border border-dark-600/60 rounded-r-lg text-content-secondary"
>
<ChevronDown className="w-3 h-3" />
@@ -112,8 +114,8 @@ export default function LocalShellMenu({ compact = false, className = '' }: Loca
<Laptop className="w-5 h-5 text-accent-blue" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-content-primary">本地 Shell</div>
<div className="text-xs text-txt-muted mt-0.5">打开本机终端</div>
<div className="font-medium text-content-primary">{t('sidebar.localShell.title')}</div>
<div className="text-xs text-txt-muted mt-0.5">{t('sidebar.localShell.subtitle')}</div>
</div>
<ChevronDown className={`w-4 h-4 text-txt-faint transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
@@ -128,7 +130,7 @@ export default function LocalShellMenu({ compact = false, className = '' }: Loca
`}
>
<div className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-txt-faint">
本地 Shell
{t('sidebar.localShell.title')}
</div>
{shells.map((s) => (
<button
@@ -145,7 +147,7 @@ export default function LocalShellMenu({ compact = false, className = '' }: Loca
<span className="text-sm text-content-primary flex-1">{s.name}</span>
{s.isDefault && (
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-dark-700 text-txt-muted border border-dark-600">
默认
{t('sidebar.localShell.default')}
</span>
)}
</button>
+5 -2
View File
@@ -3,6 +3,7 @@
*/
import { Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
interface SearchBoxProps {
value: string
@@ -10,13 +11,15 @@ interface SearchBoxProps {
placeholder?: string
}
export function SearchBox({ value, onChange, placeholder = '搜索...' }: SearchBoxProps) {
export function SearchBox({ value, onChange, placeholder }: SearchBoxProps) {
const { t } = useTranslation('common')
const resolvedPlaceholder = placeholder ?? t('sidebar.searchPlaceholder')
return (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-txt-muted" />
<input
type="text"
placeholder={placeholder}
placeholder={resolvedPlaceholder}
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full pl-9 pr-3 py-2 bg-dark-900 border border-dark-700 rounded text-sm focus:outline-none focus:border-accent-blue transition-colors duration-150"
+121 -77
View File
@@ -3,19 +3,27 @@
* 单击选中 · 双击打开终端 · 右键更多操作
*/
import { Check, ChevronRight, Folder, Server, ServerOff } from 'lucide-react'
import { Check, ChevronRight, Folder, ServerOff } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { SessionTreeSkeleton } from '../../common/Skeleton'
import HostDistroIcon from '../../common/HostDistroIcon'
import type { Session } from '../../../stores/sessionStore'
import EmptyState from '../../common/EmptyState'
import { collectSessionGroups, groupDisplayName } from '../../../utils/sessionGroups'
import { localeCompareText } from '../../../i18n/format'
interface SessionTreeProps {
sessions: Session[]
/** Persisted empty groups + any extras to always show. */
extraGroups?: string[]
expandedGroups: Set<string>
selectedSessionId: string | null
onToggleGroup: (group: string) => void
onSelectSession: (session: Session) => void
onConnectSession: (session: Session) => void
onContextMenu: (e: React.MouseEvent, session: Session) => void
onGroupContextMenu?: (e: React.MouseEvent, group: string) => void
onBlankContextMenu?: (e: React.MouseEvent) => void
searchTerm: string
loading?: boolean
onNewSession?: () => void
@@ -26,12 +34,15 @@ interface SessionTreeProps {
export function SessionTree({
sessions,
extraGroups = [],
expandedGroups,
selectedSessionId,
onToggleGroup,
onSelectSession,
onConnectSession,
onContextMenu,
onGroupContextMenu,
onBlankContextMenu,
searchTerm,
loading = false,
onNewSession,
@@ -39,6 +50,8 @@ export function SessionTree({
selectedSessionIds = new Set(),
onToggleSelection,
}: SessionTreeProps) {
const { t } = useTranslation('sessions')
if (loading) {
return (
<div className="flex-1 overflow-y-auto">
@@ -47,11 +60,13 @@ export function SessionTree({
)
}
const query = searchTerm.toLowerCase()
const filteredSessions = sessions.filter(
(s) =>
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.host.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.username?.toLowerCase().includes(searchTerm.toLowerCase())
!query ||
s.name.toLowerCase().includes(query) ||
s.host.toLowerCase().includes(query) ||
s.username?.toLowerCase().includes(query)
)
const grouped = filteredSessions.reduce(
@@ -64,15 +79,41 @@ export function SessionTree({
{} as Record<string, Session[]>
)
// Keep empty known groups visible (unless searching).
if (!query) {
for (const group of collectSessionGroups(sessions, extraGroups)) {
if (!grouped[group]) grouped[group] = []
}
}
const groupKeys = Object.keys(grouped).sort((a, b) => {
if (a === 'default') return -1
if (b === 'default') return 1
return localeCompareText(a, b)
})
return (
<div className="flex-1 overflow-y-auto">
{Object.entries(grouped).map(([group, groupSessions]) => {
<div
className="flex-1 overflow-y-auto"
onContextMenu={(e) => {
if ((e.target as HTMLElement).closest('[data-session-row],[data-group-row]')) return
onBlankContextMenu?.(e)
}}
>
{groupKeys.map((group) => {
const groupSessions = grouped[group]
const isExpanded = expandedGroups.has(group)
return (
<div key={group} className="border-b border-dark-700/20 last:border-b-0">
<button
type="button"
data-group-row
onClick={() => onToggleGroup(group)}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
onGroupContextMenu?.(e, group)
}}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-txt-muted hover:bg-dark-700/50 transition-colors"
>
<ChevronRight
@@ -80,9 +121,7 @@ export function SessionTree({
style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
/>
<Folder className="w-4 h-4" />
<span className="flex-1 text-left">
{group === 'default' ? '默认分组' : group}
</span>
<span className="flex-1 text-left">{groupDisplayName(group)}</span>
<span className="text-xs text-txt-faint tabular-nums">{groupSessions.length}</span>
</button>
@@ -91,91 +130,96 @@ export function SessionTree({
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
>
<div className="overflow-hidden">
{groupSessions.map((session) => {
const selected = multiSelect
? selectedSessionIds.has(session.id)
: selectedSessionId === session.id
return (
<div
key={session.id}
role="button"
tabIndex={0}
onClick={() => {
if (multiSelect) onToggleSelection?.(session)
else onSelectSession(session)
}}
onDoubleClick={() => {
if (!multiSelect) onConnectSession(session)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || (multiSelect && e.key === ' ')) {
e.preventDefault()
{groupSessions.length === 0 ? (
<div className="px-9 py-2 text-[11px] text-txt-faint">{t('tree.emptyTitle')}</div>
) : (
groupSessions.map((session) => {
const selected = multiSelect
? selectedSessionIds.has(session.id)
: selectedSessionId === session.id
return (
<div
key={session.id}
data-session-row
role="button"
tabIndex={0}
onClick={() => {
if (multiSelect) onToggleSelection?.(session)
else onConnectSession(session)
else onSelectSession(session)
}}
onDoubleClick={() => {
if (!multiSelect) onConnectSession(session)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || (multiSelect && e.key === ' ')) {
e.preventDefault()
if (multiSelect) onToggleSelection?.(session)
else onConnectSession(session)
}
}}
onContextMenu={(e) => onContextMenu(e, session)}
title={
multiSelect
? t('tree.selectSession')
: `${session.username}@${session.host}:${session.port || 22}`
}
}}
onContextMenu={(e) => onContextMenu(e, session)}
title={multiSelect ? '选择会话' : '双击打开终端 · 右键更多操作'}
className={`
group flex items-center gap-2 px-3 py-2 text-sm cursor-pointer
className={`
group flex items-center gap-1.5 px-3 py-1.5 text-sm cursor-pointer
transition-colors duration-150 pl-9 pr-2
${selected
? 'bg-accent-blue/10 border-l-2 border-accent-blue'
: 'border-l-2 border-transparent hover:bg-dark-700/40'
${
selected
? 'bg-accent-blue/10 border-l-2 border-accent-blue'
: 'border-l-2 border-transparent hover:bg-dark-700/40'
}
`}
>
{multiSelect ? (
<span
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border ${
selected
? 'border-accent-blue bg-accent-blue text-white'
: 'border-dark-500 bg-dark-900'
}`}
aria-hidden
>
{selected && <Check className="h-3 w-3" />}
</span>
) : (
<Server
className={`w-4 h-4 flex-shrink-0 ${
selected ? 'text-accent-blue' : 'text-accent-green'
}`}
/>
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-content-primary truncate text-[13px]">
>
{multiSelect ? (
<span
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border ${
selected
? 'border-accent-blue bg-accent-blue text-white'
: 'border-dark-500 bg-dark-900'
}`}
aria-hidden
>
{selected && <Check className="h-3 w-3" />}
</span>
) : (
<HostDistroIcon
distro={session.distro}
protocol={session.protocol}
size="xs"
accent={selected}
/>
)}
<div className="min-w-0 flex-1 truncate text-[13px] font-medium text-content-primary">
{session.name}
</div>
<div className="text-[11px] text-txt-faint font-mono truncate">
{session.username}@{session.host}:{session.port || 22}
</div>
</div>
</div>
)
})}
)
})
)}
</div>
</div>
</div>
)
})}
{filteredSessions.length === 0 && (
{filteredSessions.length === 0 && query && (
<EmptyState
icon={ServerOff}
title={searchTerm ? '未找到匹配的会话' : '暂无会话'}
description={
searchTerm
? '尝试其他关键词'
: '快速连接或新建会话,双击即可进终端'
}
title={t('tree.noMatchTitle')}
description={t('tree.noMatchDesc')}
/>
)}
{sessions.length === 0 && !query && groupKeys.every((g) => (grouped[g] || []).length === 0) && (
<EmptyState
icon={ServerOff}
title={t('tree.emptyTitle')}
description={t('tree.emptyDesc')}
action={
searchTerm
? undefined
: onNewSession
? { label: '新建会话', onClick: onNewSession }
: undefined
onNewSession ? { label: t('tree.emptyAction'), onClick: onNewSession } : undefined
}
/>
)}
@@ -3,6 +3,7 @@
*/
import { useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
interface MenuItem {
label: string
@@ -34,6 +35,7 @@ export function TabContextMenu({
onCloseAll,
onClone,
}: TabContextMenuProps) {
const { t } = useTranslation('common')
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -49,15 +51,15 @@ export function TabContextMenu({
if (!isOpen) return null
const items: MenuItem[] = [
{ label: '关闭', action: onCloseTab },
{ label: '关闭其他', action: onCloseOthers },
{ label: '关闭右侧标签', action: onCloseRight },
{ label: '关闭左侧标签', action: onCloseLeft },
{ label: '关闭全部', action: onCloseAll, danger: true },
{ label: t('action.close'), action: onCloseTab },
{ label: t('tab.menu.closeOthers'), action: onCloseOthers },
{ label: t('tab.menu.closeRight'), action: onCloseRight },
{ label: t('tab.menu.closeLeft'), action: onCloseLeft },
{ label: t('tab.menu.closeAll'), action: onCloseAll, danger: true },
]
if (onClone) {
items.unshift({ label: '克隆标签', action: onClone })
items.unshift({ label: t('tab.menu.clone'), action: onClone })
}
return (
+62 -88
View File
@@ -1,9 +1,13 @@
/**
* TabItem — local shell shows zsh/bash badge like professional terminal UIs
* TabItem — local shell uses the same Terminal icon as the launcher (+)
*/
import { X, Terminal, FolderSync, Cable, Crown, TerminalSquare } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { Tab } from '../../../stores/tabStore'
import { useSessionStore } from '../../../stores/sessionStore'
import HostDistroIcon from '../../common/HostDistroIcon'
import { resolveTabTitle } from '../../../utils/tabTitle'
interface TabItemProps {
tab: Tab
@@ -20,45 +24,6 @@ interface TabItemProps {
onDragEnd: () => void
}
function LocalShellBadge({ kind }: { kind?: string }) {
const k = (kind || 'unknown').toLowerCase()
if (k === 'zsh') {
return (
<span
className="inline-flex items-center justify-center w-6 h-6 rounded-md bg-teal-800/90 text-[8px] font-bold text-teal-100 flex-shrink-0 border border-teal-600/40"
title="Zsh"
>
zsh
</span>
)
}
if (k === 'bash') {
return (
<span
className="inline-flex items-center justify-center w-6 h-6 rounded-md bg-dark-600 text-emerald-400 flex-shrink-0 border border-dark-500"
title="Bash"
>
<span className="text-[11px] font-bold leading-none">$_</span>
</span>
)
}
if (k === 'fish') {
return (
<span
className="inline-flex items-center justify-center w-6 h-6 rounded-md bg-violet-800 text-[8px] font-bold text-violet-100 flex-shrink-0"
title="Fish"
>
fi
</span>
)
}
return (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-md bg-dark-600 text-[9px] font-bold text-content-secondary flex-shrink-0">
sh
</span>
)
}
export function TabItem({
tab,
index,
@@ -73,23 +38,47 @@ export function TabItem({
onDrop,
onDragEnd,
}: TabItemProps) {
const { t } = useTranslation('terminal')
const displayTitle = resolveTabTitle(tab)
const isLocalTerm = tab.type === 'terminal' && tab.isLocal
const session = useSessionStore((state) =>
tab.sessionId ? state.sessions.find((item) => item.id === tab.sessionId) : undefined
)
const getIcon = () => {
if (isLocalTerm) {
return <LocalShellBadge kind={tab.localShellKind} />
return (
<Terminal
className={`h-3.5 w-3.5 flex-shrink-0 ${isActive ? 'text-emerald-400' : 'text-emerald-500/80'}`}
aria-label={t('toolbar.labelLocal')}
/>
)
}
if (tab.type === 'rdp' || tab.type === 'vnc' || (tab.type === 'terminal' && session)) {
return (
<HostDistroIcon
distro={
session?.distro ??
(tab.type === 'rdp' ? 'windows' : tab.type === 'vnc' ? 'vnc' : undefined)
}
protocol={
tab.type === 'rdp' ? 'rdp' : tab.type === 'vnc' ? 'vnc' : session?.protocol ?? 'ssh'
}
size="xs"
/>
)
}
switch (tab.type) {
case 'terminal':
return <Terminal className={`w-3.5 h-3.5 ${isActive ? 'text-accent-blue' : ''}`} />
return <Terminal className={`h-3.5 w-3.5 ${isActive ? 'text-accent-blue' : ''}`} />
case 'sftp':
return <FolderSync className={`w-3.5 h-3.5 ${isActive ? 'text-accent-purple' : ''}`} />
return <FolderSync className={`h-3.5 w-3.5 ${isActive ? 'text-accent-purple' : ''}`} />
case 'serial':
return <Cable className={`w-3.5 h-3.5 ${isActive ? 'text-accent-green' : ''}`} />
return <Cable className={`h-3.5 w-3.5 ${isActive ? 'text-accent-green' : ''}`} />
case 'upgrade':
return <Crown className={`w-3.5 h-3.5 ${isActive ? 'text-accent-blue' : ''}`} />
return <Crown className={`h-3.5 w-3.5 ${isActive ? 'text-accent-blue' : ''}`} />
case 'batch':
return <TerminalSquare className={`w-3.5 h-3.5 ${isActive ? 'text-accent-blue' : ''}`} />
return <TerminalSquare className={`h-3.5 w-3.5 ${isActive ? 'text-accent-blue' : ''}`} />
default:
return null
}
@@ -98,6 +87,15 @@ export function TabItem({
const showLeftIndicator = dragOverIndex === index && !isDragging
const showRightIndicator = dragOverIndex === index + 1 && !isDragging
const statusDot =
tab.connectionStatus === 'connecting' ? (
<span className="h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-yellow-400" />
) : tab.connectionStatus === 'connected' ? (
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-green-500" />
) : tab.connectionStatus === 'error' ? (
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-red-500" />
) : null
return (
<div
data-tab-item
@@ -109,69 +107,45 @@ export function TabItem({
onDrop={(e) => onDrop(e, index)}
onDragEnd={onDragEnd}
className={`
group flex items-center gap-2 px-3 py-2 min-w-[120px] max-w-[220px]
border-r border-dark-700/20 cursor-pointer select-none
transition-all duration-150 relative titlebar-no-drag
${isActive
? 'bg-dark-900 text-content-primary'
: 'bg-dark-800 text-txt-muted hover:bg-dark-700 hover:text-txt-muted'
group relative flex min-w-[120px] max-w-[220px] cursor-pointer select-none items-center gap-2
border-r border-dark-700/20 px-3 py-2 titlebar-no-drag transition-all duration-150
${
isActive
? 'bg-dark-900 text-content-primary'
: 'bg-dark-800 text-txt-muted hover:bg-dark-700 hover:text-txt-muted'
}
${isDragging ? 'opacity-50' : 'opacity-100'}
`}
>
{showLeftIndicator && (
<div className="absolute left-0 top-0 bottom-0 w-0.5 bg-accent-blue z-10" />
<div className="absolute bottom-0 left-0 top-0 z-10 w-0.5 bg-accent-blue" />
)}
{showRightIndicator && (
<div className="absolute right-0 top-0 bottom-0 w-0.5 bg-accent-blue z-10" />
<div className="absolute bottom-0 right-0 top-0 z-10 w-0.5 bg-accent-blue" />
)}
{isLocalTerm ? (
<>
{getIcon()}
{tab.connectionStatus === 'connected' && (
<span className="w-1.5 h-1.5 rounded-full bg-green-500 flex-shrink-0" />
)}
{tab.connectionStatus === 'connecting' && (
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse flex-shrink-0" />
)}
{tab.connectionStatus === 'error' && (
<span className="w-1.5 h-1.5 rounded-full bg-red-500 flex-shrink-0" />
)}
</>
) : (
<>
{tab.connectionStatus === 'connecting' && (
<span className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse" />
)}
{tab.connectionStatus === 'connected' && (
<span className="w-2 h-2 rounded-full bg-green-500" />
)}
{tab.connectionStatus === 'error' && (
<span className="w-2 h-2 rounded-full bg-red-500" />
)}
{getIcon()}
</>
)}
{statusDot}
{getIcon()}
<span className="flex-1 truncate text-sm font-medium">{tab.title}</span>
<span className="flex-1 truncate text-sm font-medium" title={displayTitle}>
{displayTitle}
</span>
<button
onClick={onClose}
className={`
p-0.5 rounded opacity-0 group-hover:opacity-100
hover:bg-dark-600 transition-opacity
rounded p-0.5 opacity-0 transition-opacity hover:bg-dark-600 group-hover:opacity-100
${isActive ? 'opacity-100' : ''}
`}
>
<X className="w-3.5 h-3.5" />
<X className="h-3.5 w-3.5" />
</button>
<div
className={`
absolute bottom-0 left-0 right-0 h-0.5 bg-accent-blue
transition-[opacity,transform] duration-150 origin-left
${isActive ? 'opacity-100 scale-x-100' : 'opacity-0 scale-x-0'}
absolute bottom-0 left-0 right-0 h-0.5 origin-left bg-accent-blue
transition-[opacity,transform] duration-150
${isActive ? 'scale-x-100 opacity-100' : 'scale-x-0 opacity-0'}
`}
/>
</div>
+35 -46
View File
@@ -3,17 +3,17 @@
*/
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Check,
Crown,
ExternalLink,
Loader2,
Minus,
Shield,
} from 'lucide-react'
import {
licenseServerBaseUrl,
PRO_FEATURE_LABELS,
proFeatureLabel,
useEntitlementStore,
type ProFeature,
} from '../../stores/entitlementStore'
@@ -26,18 +26,11 @@ interface UpgradeViewProps {
comingSoon?: boolean
}
const FREE_ITEMS = ['SSH 终端', 'SFTP 文件管理', '本地终端', '会话 CRUD', '单条 / ssh_config 导入']
const PRO_ITEMS = [
'端口转发',
'会话代理 / SOCKS',
'串口终端',
'终端 AI 助手',
'批量执行 / 广播同步',
'批量导入导出主机清单',
'RDP / VNC(即将推出)',
]
const FREE_ITEM_KEYS = ['ssh', 'sftp', 'local', 'crud', 'import'] as const
const PRO_ITEM_KEYS = ['forward', 'proxy', 'serial', 'ai', 'batch', 'importExport', 'desktop'] as const
export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
const { t } = useTranslation('common')
const plan = useEntitlementStore((s) => s.plan)
const deviceId = useEntitlementStore((s) => s.deviceId)
const expiresAt = useEntitlementStore((s) => s.expiresAt)
@@ -56,7 +49,7 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
void hydrate()
}, [hydrate])
const highlight = feature ? PRO_FEATURE_LABELS[feature] : null
const highlight = feature ? proFeatureLabel(feature) : null
const startCheckout = useCallback(async () => {
setCheckoutLoading(true)
@@ -72,7 +65,7 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
})
const data = (await res.json()) as { checkoutUrl?: string; error?: string }
if (!res.ok || !data.checkoutUrl) {
throw new Error(data.error || `结账服务不可用 (${res.status})`)
throw new Error(data.error || t('pro.checkoutUnavailable', { status: res.status }))
}
await window.opsxApi.openExternal(data.checkoutUrl)
} catch (error) {
@@ -82,7 +75,7 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
} finally {
setCheckoutLoading(false)
}
}, [deviceId])
}, [deviceId, t])
const handleActivate = useCallback(async () => {
setActivateError(null)
@@ -92,9 +85,9 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
setActivateOk(true)
setLicenseInput('')
} else {
setActivateError(result.error || '激活失败')
setActivateError(result.error || t('pro.activateFailed'))
}
}, [activate, licenseInput])
}, [activate, licenseInput, t])
return (
<div className="flex h-full flex-col overflow-y-auto bg-dark-900">
@@ -105,21 +98,21 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
</div>
<div className="min-w-0 flex-1">
<h1 className="text-lg font-semibold tracking-tight text-content-primary">
OpsXShell Pro · 解锁专业运维能力
{t('pro.title')}
</h1>
<p className="mt-1 text-sm text-txt-muted">
{comingSoon && highlight
? `${highlight} 即将推出 · 升级 Pro 可优先体验`
? t('pro.subtitleComingSoon', { feature: highlight })
: highlight
? `正在解锁:${highlight}`
: '转发、代理、串口、AI、批量与广播'}
? t('pro.subtitleUnlock', { feature: highlight })
: t('pro.subtitleDefault')}
</p>
{plan === 'pro' && (
<p className="mt-2 text-xs text-accent-green">
已激活 Pro
{t('pro.activated')}
{expiresAt
? ` · 有效期至 ${new Date(expiresAt * 1000).toLocaleDateString()}`
: ' · 终身授权'}
? t('pro.expiresAt', { date: new Date(expiresAt * 1000).toLocaleDateString() })
: t('pro.lifetime')}
</p>
)}
</div>
@@ -127,27 +120,23 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
<div className="mb-8 grid gap-4 sm:grid-cols-2">
<div className="rounded-xl border border-dark-700/60 bg-dark-800/40 p-5">
<div className="mb-3 text-xs font-medium uppercase tracking-wide text-txt-faint">Free</div>
<div className="mb-3 text-xs font-medium uppercase tracking-wide text-txt-faint">{t('pro.freeTier')}</div>
<ul className="space-y-2">
{FREE_ITEMS.map((item) => (
<li key={item} className="flex items-start gap-2 text-sm text-content-secondary">
{FREE_ITEM_KEYS.map((key) => (
<li key={key} className="flex items-start gap-2 text-sm text-content-secondary">
<Check className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-txt-muted" />
{item}
{t(`pro.freeItems.${key}`)}
</li>
))}
</ul>
</div>
<div className="rounded-xl border border-accent-blue/35 bg-accent-blue/5 p-5">
<div className="mb-3 text-xs font-medium uppercase tracking-wide text-accent-blue">Pro</div>
<div className="mb-3 text-xs font-medium uppercase tracking-wide text-accent-blue">{t('pro.proTier')}</div>
<ul className="space-y-2">
{PRO_ITEMS.map((item) => (
<li key={item} className="flex items-start gap-2 text-sm text-content-primary">
{item.includes('即将推出') ? (
<Minus className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-txt-faint" />
) : (
<Check className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-accent-blue" />
)}
<span className={item.includes('即将推出') ? 'text-txt-muted' : ''}>{item}</span>
{PRO_ITEM_KEYS.map((key) => (
<li key={key} className="flex items-start gap-2 text-sm text-content-primary">
<Check className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-accent-blue" />
{t(`pro.proItems.${key}`)}
</li>
))}
</ul>
@@ -157,7 +146,7 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
{plan !== 'pro' && (
<>
<p className="mb-3 text-center text-sm text-txt-muted">
一次性费用 · 本机终身授权 · 无需订阅
{t('pro.oneTimeFee')}
</p>
<button
@@ -171,7 +160,7 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
) : (
<ExternalLink className="h-4 w-4" />
)}
去付费升级
{t('pro.goUpgrade')}
</button>
{checkoutError && (
<p className="mt-2 text-xs text-red-400">{checkoutError}</p>
@@ -182,13 +171,13 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
onClick={() => setShowActivate((v) => !v)}
className="mt-3 w-full text-center text-sm text-txt-muted underline-offset-2 hover:text-content-secondary hover:underline"
>
已有许可证?激活
{t('pro.haveLicense')}
</button>
{showActivate && (
<div className="mt-3 space-y-2 rounded-xl border border-dark-700/60 bg-dark-800/50 p-4">
<label className="ui-label" htmlFor="license-token">
粘贴许可证
{t('pro.pasteLicense')}
</label>
<textarea
id="license-token"
@@ -205,10 +194,10 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
onClick={() => void handleActivate()}
className="rounded-lg bg-dark-700 px-3 py-2 text-sm text-content-primary hover:bg-dark-600 disabled:opacity-40"
>
激活
{t('pro.activateButton')}
</button>
{activateError && <p className="text-xs text-red-400">{activateError}</p>}
{activateOk && <p className="text-xs text-accent-green">激活成功</p>}
{activateOk && <p className="text-xs text-accent-green">{t('pro.activateSuccess')}</p>}
</div>
)}
</>
@@ -220,16 +209,16 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
onClick={() => void clear()}
className="rounded-lg border border-dark-600 px-3 py-2 text-sm text-txt-muted hover:bg-dark-800"
>
清除本机许可证
{t('pro.clearLicense')}
</button>
)}
<footer className="mt-10 space-y-2 border-t border-dark-700/40 pt-5 text-[11px] leading-relaxed text-txt-faint">
<p className="flex items-start gap-1.5">
<Shield className="mt-0.5 h-3 w-3 flex-shrink-0" />
支付由 Waffo 托管收银台处理;本应用不收集或保存银行卡信息。
{t('pro.paymentNote')}
</p>
<p>测试环境 · 设备 ID:{deviceId || '…'}</p>
<p>{t('pro.testDeviceId', { deviceId: deviceId || '…' })}</p>
</footer>
</div>
</div>
+9 -9
View File
@@ -2,14 +2,16 @@
* UpgradeWall — intercepts Pro features for Free users
*/
import { useTranslation } from 'react-i18next'
import { Crown, Sparkles, X } from 'lucide-react'
import {
PRO_FEATURE_LABELS,
proFeatureLabel,
useEntitlementStore,
type ProFeature,
} from '../../stores/entitlementStore'
export default function UpgradeWall() {
const { t } = useTranslation('common')
const wall = useEntitlementStore((s) => s.wall)
const closeWall = useEntitlementStore((s) => s.closeWall)
const openUpgradeTab = useEntitlementStore((s) => s.openUpgradeTab)
@@ -17,7 +19,7 @@ export default function UpgradeWall() {
if (!wall.open) return null
const feature = wall.feature as ProFeature | undefined
const label = feature ? PRO_FEATURE_LABELS[feature] : '专业功能'
const label = feature ? proFeatureLabel(feature) : t('pro.feature')
const comingSoon = wall.comingSoon
return (
@@ -36,7 +38,7 @@ export default function UpgradeWall() {
type="button"
onClick={closeWall}
className="absolute right-3 top-3 rounded-lg p-1.5 text-txt-muted hover:bg-dark-700 hover:text-content-primary"
aria-label="关闭"
aria-label={t('action.close')}
>
<X className="h-4 w-4" />
</button>
@@ -46,13 +48,11 @@ export default function UpgradeWall() {
<Crown className="h-5 w-5" />
</div>
<h2 id="upgrade-wall-title" className="text-base font-semibold text-content-primary">
{comingSoon ? `${label} · 即将推出` : `解锁 ${label}`}
{comingSoon ? `${label}${t('pro.wallComingSoonSuffix')}` : t('pro.wallUnlock', { feature: label })}
</h2>
<p className="mt-1.5 text-sm leading-relaxed text-txt-muted">
{wall.message ||
(comingSoon
? '该能力属于 OpsXShell Pro,协议实现尚在开发中。升级后可优先体验。'
: '此功能属于 OpsXShell Pro。升级后即可在本机使用。')}
(comingSoon ? t('pro.wallMessageComingSoon') : t('pro.wallMessageDefault'))}
</p>
</div>
@@ -62,7 +62,7 @@ export default function UpgradeWall() {
onClick={closeWall}
className="flex-1 rounded-lg border border-dark-600 px-3 py-2.5 text-sm text-content-secondary transition-colors hover:bg-dark-700"
>
稍后再说
{t('pro.later')}
</button>
<button
type="button"
@@ -70,7 +70,7 @@ export default function UpgradeWall() {
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-accent-blue px-3 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-600"
>
<Sparkles className="h-3.5 w-3.5" />
查看 Pro
{t('pro.viewPro')}
</button>
</div>
</div>
+58 -19
View File
@@ -1,10 +1,12 @@
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Cable, RefreshCw } from 'lucide-react'
import Modal from '../common/Modal'
import { listSerialPorts } from '../../platform/serial'
import type {
SerialConfig,
SerialFlowControl,
SerialLineEnding,
SerialParity,
SerialPortSummary,
} from '../../../shared/types/serial.types'
@@ -30,6 +32,7 @@ export default function SerialConnectDialog({
onClose,
onConnect,
}: SerialConnectDialogProps) {
const { t } = useTranslation(['serial', 'common'])
const [ports, setPorts] = useState<SerialPortSummary[]>([])
const [path, setPath] = useState(defaultSerialPath)
const [baudRate, setBaudRate] = useState(115200)
@@ -37,6 +40,8 @@ export default function SerialConnectDialog({
const [stopBits, setStopBits] = useState<1 | 2>(1)
const [parity, setParity] = useState<SerialParity>('none')
const [flowControl, setFlowControl] = useState<SerialFlowControl>('none')
const [lineEnding, setLineEnding] = useState<SerialLineEnding>('lf')
const [localEcho, setLocalEcho] = useState(false)
const [loadingPorts, setLoadingPorts] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -64,7 +69,7 @@ export default function SerialConnectDialog({
const handleConnect = () => {
if (!path.trim()) {
setError('请输入串口设备路径')
setError(t('serial:connect.errorPathRequired'))
return
}
onConnect({
@@ -74,6 +79,8 @@ export default function SerialConnectDialog({
stopBits,
parity,
flowControl,
lineEnding,
localEcho,
})
onClose()
}
@@ -83,23 +90,23 @@ export default function SerialConnectDialog({
isOpen={isOpen}
onClose={onClose}
onConfirm={handleConnect}
title="连接串口"
description="原生串口终端 · 适用于路由器、交换机、开发板与调试控制台"
title={t('serial:connect.title')}
description={t('serial:connect.description')}
icon={<Cable className="w-5 h-5" />}
confirmText="打开串口"
confirmText={t('serial:connect.confirm')}
size="lg"
>
<div className="space-y-4">
<section className="rounded-xl border border-dark-700/60 bg-dark-900/30 p-3.5 space-y-3">
<div className="flex items-center justify-between">
<label htmlFor="serial-path" className="ui-label mb-0">设备</label>
<label htmlFor="serial-path" className="ui-label mb-0">{t('serial:connect.device')}</label>
<button
type="button"
onClick={() => void refreshPorts()}
className="inline-flex items-center gap-1 text-[11px] text-txt-muted hover:text-content-primary"
>
<RefreshCw className={`w-3 h-3 ${loadingPorts ? 'animate-spin' : ''}`} />
刷新
{t('serial:connect.refresh')}
</button>
</div>
<input
@@ -130,15 +137,22 @@ export default function SerialConnectDialog({
</div>
) : (
<p className="text-[11px] text-txt-faint">
未枚举到设备时可直接输入路径,例如 {defaultSerialPath()}
{getDesktopPlatform() === 'windows' ? '、COM3' : getDesktopPlatform() === 'linux' ? '、/dev/ttyACM0' : '、/dev/tty.usbserial-*'}。
{t('serial:connect.noDevicesHint', {
examples: `${defaultSerialPath()}${
getDesktopPlatform() === 'windows'
? '、COM3'
: getDesktopPlatform() === 'linux'
? '、/dev/ttyACM0'
: '、/dev/tty.usbserial-*'
}`,
})}
</p>
)}
</section>
<section className="grid grid-cols-2 sm:grid-cols-3 gap-3 rounded-xl border border-dark-700/60 bg-dark-900/30 p-3.5">
<div>
<label htmlFor="serial-baud" className="ui-label">波特率</label>
<label htmlFor="serial-baud" className="ui-label">{t('serial:connect.baudRate')}</label>
<select
id="serial-baud"
value={baudRate}
@@ -149,7 +163,7 @@ export default function SerialConnectDialog({
</select>
</div>
<div>
<label htmlFor="serial-data-bits" className="ui-label">数据位</label>
<label htmlFor="serial-data-bits" className="ui-label">{t('serial:connect.dataBits')}</label>
<select
id="serial-data-bits"
value={dataBits}
@@ -160,7 +174,7 @@ export default function SerialConnectDialog({
</select>
</div>
<div>
<label htmlFor="serial-stop-bits" className="ui-label">停止位</label>
<label htmlFor="serial-stop-bits" className="ui-label">{t('serial:connect.stopBits')}</label>
<select
id="serial-stop-bits"
value={stopBits}
@@ -172,31 +186,56 @@ export default function SerialConnectDialog({
</select>
</div>
<div>
<label htmlFor="serial-parity" className="ui-label">校验</label>
<label htmlFor="serial-parity" className="ui-label">{t('serial:connect.parity')}</label>
<select
id="serial-parity"
value={parity}
onChange={(event) => setParity(event.target.value as SerialParity)}
className="ui-input-sm"
>
<option value="none">无</option>
<option value="odd">奇校验</option>
<option value="even">偶校验</option>
<option value="none">{t('serial:connect.parityNone')}</option>
<option value="odd">{t('serial:connect.parityOdd')}</option>
<option value="even">{t('serial:connect.parityEven')}</option>
</select>
</div>
<div className="sm:col-span-2">
<label htmlFor="serial-flow-control" className="ui-label">流控</label>
<label htmlFor="serial-flow-control" className="ui-label">{t('serial:connect.flowControl')}</label>
<select
id="serial-flow-control"
value={flowControl}
onChange={(event) => setFlowControl(event.target.value as SerialFlowControl)}
className="ui-input-sm"
>
<option value="none">无</option>
<option value="software">软件 XON/XOFF</option>
<option value="hardware">硬件 RTS/CTS</option>
<option value="none">{t('serial:connect.flowNone')}</option>
<option value="software">{t('serial:connect.flowSoftware')}</option>
<option value="hardware">{t('serial:connect.flowHardware')}</option>
</select>
</div>
<div>
<label htmlFor="serial-line-ending" className="ui-label">{t('serial:connect.lineEnding')}</label>
<select
id="serial-line-ending"
value={lineEnding}
onChange={(event) => setLineEnding(event.target.value as SerialLineEnding)}
className="ui-input-sm"
>
<option value="lf">{t('serial:connect.lineEndingLf')}</option>
<option value="cr">{t('serial:connect.lineEndingCr')}</option>
<option value="crlf">{t('serial:connect.lineEndingCrlf')}</option>
</select>
</div>
<div className="flex items-end pb-1">
<label className="inline-flex items-center gap-2 text-xs text-content-secondary cursor-pointer">
<input
type="checkbox"
checked={localEcho}
onChange={(event) => setLocalEcho(event.target.checked)}
className="rounded border-dark-600"
/>
{t('serial:connect.localEcho')}
<span className="text-txt-faint">{t('serial:connect.localEchoHint')}</span>
</label>
</div>
</section>
{error && <p className="text-sm text-red-400">{error}</p>}
+77 -13
View File
@@ -1,12 +1,17 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import { Cable, RefreshCw } from 'lucide-react'
import { useTabStore } from '../../stores/tabStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { themes } from '../../themes/terminal'
import { resolveTerminalTheme, toXtermTheme, XTERM_RENDER_OPTIONS } from '../../themes/terminal'
import { buildTerminalFontFamily } from '../../utils/terminalFont'
import { TerminalPipeline } from '../../utils/terminalPipeline'
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
import { isTerminalCopyShortcut, isTerminalPasteShortcut } from '../../utils/terminalShortcuts'
import type { SerialLineEnding } from '../../../shared/types/serial.types'
import {
closeSerial,
onSerialData,
@@ -20,11 +25,22 @@ interface SerialTerminalViewProps {
isActive: boolean
}
function applyLineEnding(data: string, ending: SerialLineEnding): string {
if (!data.includes('\r') && !data.includes('\n')) return data
// Normalize any Enter-like input from xterm (`\r`) to the configured UART ending.
const withoutLf = data.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
if (ending === 'lf') return withoutLf
if (ending === 'cr') return withoutLf.replace(/\n/g, '\r')
return withoutLf.replace(/\n/g, '\r\n')
}
export default function SerialTerminalView({ tabId, isActive }: SerialTerminalViewProps) {
const { t } = useTranslation(['serial', 'common'])
const containerRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<Terminal | null>(null)
const fitRef = useRef<FitAddon | null>(null)
const sessionIdRef = useRef<string | null>(null)
const pipelineRef = useRef<TerminalPipeline | null>(null)
const { settings } = useSettingsStore()
const tab = useTabStore((state) => state.tabs.find((item) => item.id === tabId))
const updateTabStatus = useTabStore((state) => state.updateTabStatus)
@@ -38,42 +54,77 @@ export default function SerialTerminalView({ tabId, isActive }: SerialTerminalVi
let disposed = false
let unlistenData: (() => void) | undefined
let unlistenError: (() => void) | undefined
const currentTheme = themes[settings.terminalTheme] || themes.dracula
const currentTheme = resolveTerminalTheme(settings.terminalTheme)
const lineEnding: SerialLineEnding = config.lineEnding || 'lf'
const localEcho = !!config.localEcho
const terminal = new Terminal({
cursorBlink: true,
fontFamily: buildTerminalFontFamily(settings.fontFamily),
fontSize: settings.fontSize,
scrollback: settings.scrollback,
theme: currentTheme,
theme: toXtermTheme(currentTheme),
...XTERM_RENDER_OPTIONS,
allowProposedApi: true,
})
const fit = new FitAddon()
terminal.loadAddon(fit)
terminal.open(container)
terminalRef.current = terminal
fitRef.current = fit
requestAnimationFrame(() => fit.fit())
fit.fit()
const dataDisposable = terminal.onData((data) => {
const sessionId = sessionIdRef.current
if (sessionId) void writeSerial(sessionId, data)
const pipeline = new TerminalPipeline({
input: async (data) => {
const sessionId = sessionIdRef.current
if (!sessionId) return
const payload = applyLineEnding(data, lineEnding)
if (localEcho) {
// Optional serial-only echo; PTY backends must never do this.
terminal.write(data.replace(/\r/g, '\r\n'))
}
await writeSerial(sessionId, payload)
},
output: {
write: (text) => terminal.write(text),
},
})
pipelineRef.current = pipeline
const dataDisposable = terminal.onData((data) => pipeline.input(data))
const handleKeyDown = (event: KeyboardEvent) => {
if (isTerminalCopyShortcut(event) && terminal.hasSelection()) {
event.preventDefault()
void writeClipboardText(terminal.getSelection())
return
}
if (isTerminalPasteShortcut(event)) {
event.preventDefault()
void (async () => {
const text = await readClipboardText()
if (text) terminal.paste(text)
})()
}
}
container.addEventListener('keydown', handleKeyDown)
const handleResize = () => fit.fit()
window.addEventListener('resize', handleResize)
updateTabStatus(tabId, 'connecting')
terminal.writeln(`\x1b[36m正在打开 ${config.path} @ ${config.baudRate}...\x1b[0m`)
terminal.writeln(`\x1b[36m${t('serial:terminal.opening', { path: config.path, baud: config.baudRate })}\x1b[0m`)
void (async () => {
try {
unlistenData = await onSerialData((payload) => {
if (payload.sessionId === sessionIdRef.current) {
terminal.write(new Uint8Array(payload.data))
pipeline.output(new Uint8Array(payload.data))
}
})
unlistenError = await onSerialError((payload) => {
if (payload.sessionId === sessionIdRef.current) {
setError(payload.message)
terminal.writeln(`\r\n\x1b[31m串口错误: ${payload.message}\x1b[0m`)
terminal.writeln(`\r\n\x1b[31m${t('serial:terminal.error')}: ${payload.message}\x1b[0m`)
updateTabStatus(tabId, 'error')
}
})
@@ -84,22 +135,35 @@ export default function SerialTerminalView({ tabId, isActive }: SerialTerminalVi
}
sessionIdRef.current = sessionId
updateTabStatus(tabId, 'connected')
terminal.writeln(`\x1b[32m已连接 ${config.path} · ${config.baudRate} ${config.dataBits}${config.parity === 'none' ? 'N' : config.parity === 'odd' ? 'O' : 'E'}${config.stopBits}\x1b[0m\r\n`)
const frame = `${config.dataBits}${config.parity === 'none' ? 'N' : config.parity === 'odd' ? 'O' : 'E'}${config.stopBits}`
const endHint = lineEnding === 'lf' ? 'LF' : lineEnding === 'cr' ? 'CR' : 'CRLF'
terminal.writeln(
`\x1b[32m${t('serial:terminal.connectedBanner', {
path: config.path,
baud: config.baudRate,
frame,
lineEnding: endHint,
echo: localEcho ? t('serial:terminal.localEchoSuffix') : '',
})}\x1b[0m\r\n`
)
terminal.focus()
} catch (reason) {
const message = reason instanceof Error ? reason.message : String(reason)
setError(message)
updateTabStatus(tabId, 'error')
terminal.writeln(`\r\n\x1b[31m打开串口失败: ${message}\x1b[0m`)
terminal.writeln(`\r\n\x1b[31m${t('serial:terminal.openFailed', { message })}\x1b[0m`)
}
})()
return () => {
disposed = true
window.removeEventListener('resize', handleResize)
container.removeEventListener('keydown', handleKeyDown)
dataDisposable.dispose()
unlistenData?.()
unlistenError?.()
pipeline.dispose()
pipelineRef.current = null
const sessionId = sessionIdRef.current
sessionIdRef.current = null
if (sessionId) void closeSerial(sessionId)
@@ -107,7 +171,7 @@ export default function SerialTerminalView({ tabId, isActive }: SerialTerminalVi
terminalRef.current = null
fitRef.current = null
}
}, [settings.fontFamily, settings.fontSize, settings.scrollback, settings.terminalTheme, tab?.serialConfig, tabId, updateTabStatus])
}, [settings.fontFamily, settings.fontSize, settings.scrollback, settings.terminalTheme, tab?.serialConfig, tabId, updateTabStatus, t])
useEffect(() => {
if (isActive) {
+52 -24
View File
@@ -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>
+38 -27
View File
@@ -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>
+16 -11
View File
@@ -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>
+36 -12
View File
@@ -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" />
+485 -120
View File
@@ -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}
</>
)
}
+50 -38
View File
@@ -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>
+143 -117
View File
@@ -1,16 +1,19 @@
import { useEffect, useMemo, useState } from 'react'
import { Check, CheckCircle2, ChevronDown, KeyRound, Loader2, Plus, Save, Trash2, Wifi } from 'lucide-react'
import type { AiModelConfig, AiProviderConfig } from '../../../shared/types/ai.types'
import { useTranslation } from 'react-i18next'
import { Check, CheckCircle2, ChevronDown, Loader2, Plus, Save, Trash2, Wifi } from 'lucide-react'
import type { AiBillingMode, AiProviderConfig } from '../../../shared/types/ai.types'
import {
AI_PROVIDER_PRESETS,
AI_PROVIDER_FAMILY_ORDER,
createCustomProvider,
createProviderFromPreset,
presetsByFamily,
resolveProviderFamily,
} from '../../ai/providerPresets'
import { useAiProviderStore } from '../../stores/aiProviderStore'
type TestState = 'idle' | 'testing' | 'success' | 'error'
function selectedModel(provider: AiProviderConfig | null): AiModelConfig | null {
function selectedModel(provider: AiProviderConfig | null) {
if (!provider) return null
return provider.models.find((model) => model.id === provider.defaultModelId) || null
}
@@ -35,6 +38,7 @@ function validDraft(provider: AiProviderConfig | null, apiKey: string): boolean
}
export default function AiProviderSettings() {
const { t } = useTranslation(['settings', 'common'])
const store = useAiProviderStore()
const [selectedId, setSelectedId] = useState('')
const [presetId, setPresetId] = useState('deepseek')
@@ -62,6 +66,7 @@ export default function AiProviderSettings() {
setDraft(structuredClone(provider))
setApiKey('')
setManualModel(provider.models.length === 0)
setAdvancedOpen(resolveProviderFamily(provider) === 'custom' || provider.presetId === 'vllm')
setTestState('idle')
setVerifiedSnapshot(null)
setMessage('')
@@ -73,16 +78,18 @@ export default function AiProviderSettings() {
const currentModel = selectedModel(draft)
const isStored = !!draft && store.providers.some((provider) => provider.id === draft.id)
const canTest = validDraft(draft, apiKey) && testState !== 'testing'
const family = draft ? resolveProviderFamily(draft) : null
const needsEndpoint = family === 'custom' || family === 'local' || draft?.presetId === 'vllm'
const chooseDraft = (provider: AiProviderConfig) => {
setSelectedId(provider.id)
setDraft(provider)
setApiKey('')
setManualModel(provider.models.length === 0)
setAdvancedOpen(false)
setAdvancedOpen(resolveProviderFamily(provider) === 'custom' || provider.presetId === 'vllm')
setTestState('idle')
setVerifiedSnapshot(null)
setMessage('请填写配置并测试连接;测试通过前不会保存任何内容')
setMessage('')
setTestAnswer('')
}
@@ -92,7 +99,7 @@ export default function AiProviderSettings() {
const updateDraft = (changes: Partial<AiProviderConfig>) => {
if (!draft) return
setDraft({ ...draft, ...changes })
if (testState !== 'idle') setMessage('连接字段已变更,请重新测试')
if (testState !== 'idle') setMessage(t('ai.retest'))
}
const chooseModel = (modelId: string) => {
@@ -105,19 +112,28 @@ export default function AiProviderSettings() {
updateDraft({ defaultModelId: modelId })
}
const defaultManualBilling = (): AiBillingMode => {
if (!draft) return 'payg'
const resolved = resolveProviderFamily(draft)
if (resolved === 'local') return 'local'
if (resolved === 'plan') return 'plan'
return 'payg'
}
const updateManualModel = (modelId: string) => {
if (!draft) return
const active = selectedModel(draft)
if (active) {
const models = draft.models.map((model) =>
model.id === active.id ? { ...model, model: modelId, label: modelId || '自定义模型' } : model
)
updateDraft({ models })
updateDraft({
models: draft.models.map((model) =>
model.id === active.id ? { ...model, model: modelId, label: modelId || t('ai.customModel') } : model
),
})
return
}
const id = `manual-${Date.now().toString(36)}`
updateDraft({
models: [{ id, model: modelId, label: modelId || '自定义模型', capability: 'chat', billing: 'payg' }],
models: [{ id, model: modelId, label: modelId || t('ai.customModel'), capability: 'chat', billing: defaultManualBilling() }],
defaultModelId: id,
})
}
@@ -126,7 +142,7 @@ export default function AiProviderSettings() {
if (!draft || !currentModel || !canTest) return
setTestState('testing')
setVerifiedSnapshot(null)
setMessage('正在发送最小请求验证 Endpoint、模型和凭据...')
setMessage(t('ai.testingMessage'))
setTestAnswer('')
const snapshot = currentSnapshot
try {
@@ -135,11 +151,11 @@ export default function AiProviderSettings() {
model: currentModel,
apiKey: apiKey.trim() || undefined,
})
if (!result.success) throw new Error(result.error || '连接测试失败')
if (!result.success) throw new Error(result.error || t('ai.testFailed'))
setTestState('success')
setVerifiedSnapshot(snapshot)
setTestAnswer(result.answer?.trim() || '')
setMessage(`测试成功 · HTTP ${result.status ?? 200} · ${result.model || currentModel.model} · ${result.latencyMs ?? '-'} ms`)
setMessage(t('ai.available', { latency: result.latencyMs ?? '-' }))
} catch (error) {
setTestState('error')
setMessage(error instanceof Error ? error.message : String(error))
@@ -153,7 +169,7 @@ export default function AiProviderSettings() {
await store.saveProvider(draft, apiKey)
if (!store.activeProviderId || !isStored) await store.setActiveProvider(draft.id)
setApiKey('')
setMessage('配置已安全保存,并可用于终端助手')
setMessage(t('ai.saved'))
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error))
setTestState('error')
@@ -174,82 +190,96 @@ export default function AiProviderSettings() {
}
return (
<div className="space-y-4">
<section className="border-dark-700/50 bg-dark-800/45 rounded-xl border p-4">
<div className="flex flex-wrap items-center gap-2">
<select
aria-label="供应商预设"
value={presetId}
onChange={(event) => setPresetId(event.target.value)}
className="ui-input-sm min-w-52 flex-1"
>
{AI_PROVIDER_PRESETS.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.name} · {preset.description}
</option>
))}
</select>
<button type="button" onClick={addPreset} className="ui-btn-primary flex items-center gap-1.5">
<Plus className="h-3.5 w-3.5" /> 添加
</button>
<button type="button" onClick={addCustom} className="ui-btn-secondary">
自定义
</button>
</div>
<p className="mt-2 text-xs text-txt-muted">
新配置只在当前表单中暂存。真实请求测试成功后才可保存;API Key 仅由 Rust 加密存储。
</p>
</section>
<div className="space-y-5">
<div className="flex flex-wrap items-center gap-2">
<select
aria-label={t('ai.presets')}
value={presetId}
onChange={(event) => setPresetId(event.target.value)}
className="ui-input-sm min-w-52 flex-1"
>
{AI_PROVIDER_FAMILY_ORDER.map((familyId) => (
<optgroup key={familyId} label={t(`ai.family.${familyId}`)}>
{presetsByFamily(familyId).map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.name}
</option>
))}
</optgroup>
))}
</select>
<button
type="button"
onClick={() => (presetId === 'custom' ? addCustom() : addPreset())}
className="ui-btn-primary flex items-center gap-1.5"
>
<Plus className="h-3.5 w-3.5" /> {t('ai.add')}
</button>
</div>
<div className="border-dark-700/50 bg-dark-800/45 grid min-h-[440px] grid-cols-[180px_minmax(0,1fr)] overflow-hidden rounded-xl border">
<aside className="border-dark-700/50 border-r p-2">
<div className="px-2 pb-2 text-[11px] font-medium text-txt-faint">已配置</div>
<div className="grid min-h-[400px] grid-cols-[168px_minmax(0,1fr)] gap-6">
<aside className="space-y-0.5">
<div className="px-2 pb-2 text-[11px] font-medium text-txt-faint">{t('ai.configured')}</div>
{store.providers.length === 0 ? (
<p className="px-2 py-4 text-xs leading-relaxed text-txt-faint">尚无配置,从上方选择供应商添加。</p>
<p className="px-2 py-6 text-xs leading-relaxed text-txt-faint">{t('ai.configuredHint')}</p>
) : (
store.providers.map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => setSelectedId(provider.id)}
className={`mb-1 w-full rounded-lg px-3 py-2 text-left ${selectedId === provider.id ? 'bg-accent-blue/15 text-accent-blue' : 'text-content-secondary hover:bg-dark-700/60'}`}
>
<div className="flex items-center gap-1.5">
{store.activeProviderId === provider.id && <Check className="h-3 w-3 flex-shrink-0" />}
<span className="truncate text-sm font-medium">{provider.name}</span>
</div>
<div className={`mt-0.5 truncate text-[10px] ${provider.hasApiKey || provider.authStyle === 'none' ? 'text-emerald-400' : 'text-amber-400'}`}>
{provider.models.find((model) => model.id === provider.defaultModelId)?.label || '未选择模型'}
</div>
</button>
))
store.providers.map((provider) => {
const model = provider.models.find((item) => item.id === provider.defaultModelId)
return (
<button
key={provider.id}
type="button"
onClick={() => setSelectedId(provider.id)}
className={`w-full rounded-lg px-2.5 py-2 text-left transition-colors ${
selectedId === provider.id
? 'bg-accent-blue/12 text-accent-blue'
: 'text-content-secondary hover:bg-dark-700/50'
}`}
>
<div className="flex items-center gap-1.5">
{store.activeProviderId === provider.id && <Check className="h-3 w-3 flex-shrink-0" />}
<span className="truncate text-sm font-medium">{provider.name}</span>
</div>
<div className="mt-0.5 truncate pl-4 text-[10px] text-txt-faint">
{model?.label || t('ai.noModel')}
</div>
</button>
)
})
)}
</aside>
<main className="min-w-0 p-5">
<main className="min-w-0">
{!draft ? (
<div className="flex h-full items-center justify-center text-sm text-txt-faint">选择已有配置,或添加新供应商</div>
<div className="flex h-full items-center justify-center text-sm text-txt-faint">{t('ai.emptyHint')}</div>
) : (
<div className="mx-auto max-w-2xl space-y-4">
<div className="max-w-xl space-y-3.5">
<div className="grid grid-cols-2 gap-3">
<Field label="供应商">
<Field label={t('ai.name')}>
<input value={draft.name} onChange={(event) => updateDraft({ name: event.target.value })} className="ui-input-sm" />
</Field>
<Field label="模型">
<Field label={t('ai.model')}>
{manualModel || draft.models.length === 0 ? (
<input
aria-label="模型"
aria-label={t('ai.model')}
value={currentModel?.model || ''}
onChange={(event) => updateManualModel(event.target.value)}
placeholder="例如 deepseek-chat"
placeholder="deepseek-v4-pro"
className="ui-input-sm font-mono"
/>
) : (
<select value={draft.defaultModelId} onChange={(event) => chooseModel(event.target.value)} className="ui-input-sm">
<select
aria-label={t('ai.model')}
value={draft.defaultModelId}
onChange={(event) => chooseModel(event.target.value)}
className="ui-input-sm"
>
{draft.models.map((model) => (
<option key={model.id} value={model.id}>{model.label || model.model}</option>
<option key={model.id} value={model.id}>
{model.label || model.model}
</option>
))}
<option value="__manual">手动输入模型...</option>
<option value="__manual">{t('ai.otherModels')}</option>
</select>
)}
</Field>
@@ -257,49 +287,47 @@ export default function AiProviderSettings() {
{manualModel && draft.models.length > 0 && (
<button type="button" className="text-xs text-accent-blue hover:underline" onClick={() => setManualModel(false)}>
返回预设模型列表
{t('ai.backToList')}
</button>
)}
<Field label="Endpoint">
<Field label={draft.authStyle === 'none' ? t('ai.apiKeyOptional') : 'API Key'}>
<input
value={draft.baseUrl}
onChange={(event) => updateDraft({ baseUrl: event.target.value })}
placeholder="https://api.example.com/v1"
className="ui-input-sm font-mono"
type="password"
autoComplete="off"
value={apiKey}
disabled={draft.authStyle === 'none'}
onChange={(event) => setApiKey(event.target.value)}
placeholder={draft.hasApiKey ? t('ai.savedKeepBlank') : draft.authStyle === 'none' ? t('ai.notRequired') : 'sk-...'}
className="ui-input-sm"
/>
</Field>
{currentModel?.baseUrl && (
<p className="-mt-2 text-[10px] text-txt-faint">当前模型使用专用 Endpoint:{currentModel.baseUrl}</p>
)}
<Field label={draft.authStyle === 'none' ? 'API Key(无需填写)' : 'API Key'}>
<div className="relative">
<KeyRound className="absolute left-2.5 top-2 h-3.5 w-3.5 text-txt-faint" />
{(needsEndpoint || advancedOpen) && (
<Field label="Endpoint">
<input
type="password"
autoComplete="off"
value={apiKey}
disabled={draft.authStyle === 'none'}
onChange={(event) => setApiKey(event.target.value)}
placeholder={draft.hasApiKey ? '已保存;留空使用现有密钥' : draft.authStyle === 'none' ? '本地服务无需认证' : 'sk-...'}
className="ui-input-sm !pl-8"
aria-label="Endpoint"
value={currentModel?.baseUrl || draft.baseUrl}
onChange={(event) => updateDraft({ baseUrl: event.target.value })}
placeholder="https://api.example.com/v1"
className="ui-input-sm font-mono"
disabled={!!currentModel?.baseUrl && !needsEndpoint}
/>
</div>
</Field>
</Field>
)}
<button
type="button"
onClick={() => setAdvancedOpen((open) => !open)}
className="flex items-center gap-1 text-xs text-txt-muted hover:text-content-secondary"
className="flex items-center gap-1 text-xs text-txt-faint hover:text-txt-muted"
>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${advancedOpen ? 'rotate-180' : ''}`} />
高级设置
{t('ai.advanced')}
</button>
{advancedOpen && (
<div className="border-dark-700/40 bg-dark-900/30 grid grid-cols-2 gap-3 rounded-lg border p-3">
<Field label="接口协议">
<div className="grid grid-cols-2 gap-3">
<Field label={t('ai.protocol')}>
<select
value={draft.protocol}
onChange={(event) => updateDraft({ protocol: event.target.value as AiProviderConfig['protocol'] })}
@@ -307,34 +335,33 @@ export default function AiProviderSettings() {
>
<option value="openai-chat">OpenAI Chat</option>
<option value="openai-responses">OpenAI Responses</option>
<option value="anthropic">Anthropic Messages</option>
<option value="anthropic">Anthropic</option>
</select>
</Field>
<Field label="认证方式">
<Field label={t('ai.auth')}>
<select
value={draft.authStyle}
onChange={(event) => updateDraft({ authStyle: event.target.value as AiProviderConfig['authStyle'] })}
className="ui-input-sm"
>
<option value="bearer">Bearer Token</option>
<option value="bearer">Bearer</option>
<option value="x-api-key">x-api-key</option>
<option value="both">Bearer + x-api-key</option>
<option value="none">无需认证</option>
<option value="none">{t('ai.authNone')}</option>
</select>
</Field>
<label className="col-span-2 flex items-start gap-2 text-xs text-txt-muted">
<label className="col-span-2 flex items-center gap-2 text-xs text-txt-muted">
<input
type="checkbox"
checked={draft.allowInsecureHttp}
onChange={(event) => updateDraft({ allowInsecureHttp: event.target.checked })}
className="mt-0.5"
/>
允许 HTTP(只用于可信本机或内网模型服务)
{t('ai.allowHttp')}
</label>
</div>
)}
<div className="border-dark-700/40 flex flex-wrap items-center gap-2 border-t pt-4">
<div className="flex flex-wrap items-center gap-2 pt-1">
<button
type="button"
onClick={() => void testConnection()}
@@ -342,7 +369,7 @@ export default function AiProviderSettings() {
className="ui-btn-secondary flex items-center gap-1.5"
>
{testState === 'testing' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wifi className="h-3.5 w-3.5" />}
{testState === 'testing' ? '测试中...' : '测试连接'}
{testState === 'testing' ? t('ai.testing') : t('ai.test')}
</button>
<button
type="button"
@@ -351,28 +378,27 @@ export default function AiProviderSettings() {
className="ui-btn-primary flex items-center gap-1.5"
>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
保存
{t('common:action.save')}
</button>
{isStored && store.activeProviderId !== draft.id && (
<button type="button" onClick={() => void store.setActiveProvider(draft.id)} className="ui-btn-secondary">
设为当前
{t('ai.setActive')}
</button>
)}
<div className="flex-1" />
<button type="button" title="删除配置" onClick={() => void remove()} className="ui-btn-secondary text-red-300">
<button type="button" title={t('ai.deleteTitle')} onClick={() => void remove()} className="rounded p-1.5 text-txt-faint hover:bg-dark-700 hover:text-red-300">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<div className={`flex min-h-5 items-center gap-1.5 text-xs ${verified ? 'text-emerald-400' : testState === 'error' ? 'text-red-300' : 'text-txt-muted'}`} role={testState === 'error' ? 'alert' : 'status'}>
<div
className={`flex min-h-5 items-center gap-1.5 text-xs ${verified ? 'text-emerald-400' : testState === 'error' ? 'text-red-300' : 'text-txt-faint'}`}
role={testState === 'error' ? 'alert' : 'status'}
>
{verified && <CheckCircle2 className="h-3.5 w-3.5" />}
<span>{message || '修改连接字段后需要重新测试'}</span>
<span>{message}</span>
</div>
{verified && testAnswer && (
<div className="border-emerald-500/20 bg-emerald-500/5 rounded-lg border px-3 py-2 text-xs text-content-secondary">
<span className="text-txt-faint">模型回答:</span>{testAnswer}
</div>
)}
{verified && testAnswer && <p className="text-xs text-content-secondary">{testAnswer}</p>}
</div>
)}
</main>
@@ -383,7 +409,7 @@ export default function AiProviderSettings() {
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label>
<label className="block">
<span className="mb-1 block text-[11px] text-txt-muted">{label}</span>
{children}
</label>
+134 -114
View File
@@ -1,6 +1,8 @@
import { useEffect, useState, useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useSettingsStore } from '../../stores/settingsStore'
import { useKeybindingStore } from '../../stores/keybindingStore'
import type { AppLanguage } from '../../i18n/detectLocale'
import {
Monitor,
Palette,
@@ -18,7 +20,7 @@ import {
Sparkles,
} from 'lucide-react'
import AiProviderSettings from './AiProviderSettings'
import { themes } from '../../themes/terminal'
import { themes, type TerminalThemeId } from '../../themes/terminal'
import { getKeybindingCategories, getKeybindingsByCategory, type KeybindingActionId } from '../../constants/keybindings'
// ─── Tab 类型 ───────────────────────────────────────────────
@@ -26,8 +28,8 @@ type SettingsTab = 'general' | 'appearance' | 'terminal' | 'ai' | 'shortcuts' |
interface TabMeta {
id: SettingsTab
label: string
description: string
labelKey: string
descriptionKey: string
icon: React.ComponentType<{ className?: string }>
}
@@ -42,14 +44,14 @@ interface RuntimeInfo {
}
const TAB_LIST: TabMeta[] = [
{ id: 'general', label: '工作区', description: '布局与资源', icon: Monitor },
{ id: 'appearance', label: '外观', description: '主题与终端配色', icon: Palette },
{ id: 'terminal', label: '终端', description: '字体与配色', icon: Terminal },
{ id: 'ai', label: '大模型', description: 'Provider、模型与测试', icon: Sparkles },
{ id: 'shortcuts', label: '快捷键', description: '键盘快捷键', icon: Keyboard },
{ id: 'ssh', label: 'SSH', description: '连接(高级)', icon: Shield },
{ id: 'security', label: '安全', description: '加密与隐私', icon: Database },
{ id: 'about', label: '关于', description: '版本与支持', icon: Info },
{ id: 'general', labelKey: 'nav.general', descriptionKey: 'nav.generalDesc', icon: Monitor },
{ id: 'appearance', labelKey: 'nav.appearance', descriptionKey: 'nav.appearanceDesc', icon: Palette },
{ id: 'terminal', labelKey: 'nav.terminal', descriptionKey: 'nav.terminalDesc', icon: Terminal },
{ id: 'shortcuts', labelKey: 'nav.shortcuts', descriptionKey: 'nav.shortcutsDesc', icon: Keyboard },
{ id: 'ssh', labelKey: 'nav.ssh', descriptionKey: 'nav.sshDesc', icon: Shield },
{ id: 'security', labelKey: 'nav.security', descriptionKey: 'nav.securityDesc', icon: Database },
{ id: 'ai', labelKey: 'nav.ai', descriptionKey: 'nav.aiDesc', icon: Sparkles },
{ id: 'about', labelKey: 'nav.about', descriptionKey: 'nav.aboutDesc', icon: Info },
]
// ─── 快捷键录入 Hook ─────────────────────────────────────────
@@ -110,6 +112,7 @@ function useKeyRecording() {
// ─── 主组件 ─────────────────────────────────────────────────
export default function SettingsView() {
const { t } = useTranslation(['settings', 'common'])
const { settings, isLoaded, loadSettings, setSetting } = useSettingsStore()
const keybindingState = useKeybindingStore()
const [activeTab, setActiveTab] = useState<SettingsTab>('general')
@@ -147,23 +150,23 @@ export default function SettingsView() {
// ── 搜索过滤 ──────────────────────────────────────────────
const filtered = useMemo((): SettingsTab[] | null => {
if (!searchQuery.trim()) return null // null 表示不过滤,正常显示
if (!searchQuery.trim()) return null
const q = searchQuery.toLowerCase()
const keywords: Record<SettingsTab, string[]> = {
general: ['工作区', '侧栏', '宽度', '资源', 'workspace', 'sidebar'],
appearance: ['主题', '深色', '浅色', 'theme'],
terminal: ['字体', 'font', '配色', 'terminal', 'monokai', 'dracula'],
ai: ['ai', '大模型', 'provider', '模型', 'deepseek', 'glm', 'minimax', 'new-api'],
shortcuts: ['快捷键', '键盘', 'keyboard', 'shortcut', 'hotkey'],
ssh: ['ssh', '连接', '端口', '超时', '心跳', '连接池', '重连', '快照'],
security: ['加密', '安全', '密码', '主密钥', 'known hosts', 'encrypt'],
about: ['版本', '关于', '许可', 'version', 'about', 'license'],
general: [t('nav.general'), t('general.sidebarWidth'), 'workspace', 'sidebar'],
appearance: [t('nav.appearance'), t('appearance.theme'), t('language.label'), 'theme', 'appearance'],
terminal: [t('nav.terminal'), t('terminal.fontFamily'), 'font', 'terminal', 'nord', 'tokyo', 'dracula', 'one-dark', 'default'],
ai: ['ai', t('nav.ai'), 'provider', 'deepseek', 'glm', 'minimax', 'custom'],
shortcuts: [t('nav.shortcuts'), 'keyboard', 'shortcut', 'hotkey'],
ssh: ['ssh', t('ssh.connection'), t('ssh.pool'), 'timeout', 'reconnect'],
security: [t('nav.security'), t('security.credentials'), 'encrypt', 'known hosts'],
about: [t('nav.about'), 'version', 'about', 'license'],
}
const matchIds = (Object.entries(keywords) as [SettingsTab, string[]][])
.filter(([, kws]) => kws.some((k: string) => k.includes(q) || q.includes(k)))
.map(([id]) => id)
return matchIds.length > 0 ? matchIds : []
}, [searchQuery])
}, [searchQuery, t])
// 如果搜索过滤后有结果,自动跳转到第一个匹配 tab
useEffect(() => {
@@ -186,7 +189,7 @@ export default function SettingsView() {
if (!isLoaded) {
return (
<div className="flex h-full items-center justify-center bg-dark-900">
<div className="text-txt-muted">加载中...</div>
<div className="text-txt-muted">{t('common:status.loading')}</div>
</div>
)
}
@@ -198,8 +201,8 @@ export default function SettingsView() {
case 'general':
return (
<div className="space-y-6">
<SettingGroup title="工作区布局">
<SettingRow label="侧栏宽度" description="会话侧栏的默认宽度">
<SettingGroup title={t('general.layout')}>
<SettingRow label={t('general.sidebarWidth')} description={t('general.sidebarWidthDesc')}>
<div className="flex items-center gap-2">
<input
type="range"
@@ -209,12 +212,12 @@ export default function SettingsView() {
value={settings.sidebarWidth}
onChange={(e) => setSetting('sidebarWidth', Number(e.target.value))}
className="w-28 accent-blue-500"
aria-label="侧栏宽度"
aria-label={t('general.sidebarWidth')}
/>
<span className="w-12 text-right text-xs tabular-nums text-txt-muted">{settings.sidebarWidth}px</span>
</div>
</SettingRow>
<SettingRow label="折叠侧栏" description="隐藏会话树以专心操作终端,可用 Ctrl/Cmd+B 切换">
<SettingRow label={t('general.collapseSidebar')} description={t('general.collapseSidebarDesc')}>
<button
type="button"
role="switch"
@@ -239,16 +242,31 @@ export default function SettingsView() {
case 'appearance':
return (
<div className="space-y-6">
<SettingGroup title="外观">
<SettingRow label="主题" description="应用界面配色">
<SettingGroup title={t('appearance.themeGroup')}>
<SettingRow label={t('appearance.theme')} description={t('appearance.themeDesc')}>
<select
value={settings.theme}
onChange={(e) => setSetting('theme', e.target.value as 'dark' | 'light' | 'system')}
className="ui-select-pill"
>
<option value="dark">深色</option>
<option value="light">浅色</option>
<option value="system">跟随系统</option>
<option value="dark">{t('appearance.dark')}</option>
<option value="light">{t('appearance.light')}</option>
<option value="system">{t('appearance.system')}</option>
</select>
</SettingRow>
<SettingRow label={t('language.label')} description={t('language.description')}>
<select
value={settings.language}
onChange={(e) => setSetting('language', e.target.value as AppLanguage)}
className="ui-select-pill"
>
<option value="zh-CN">{t('language.zhCN')}</option>
<option value="zh-TW">{t('language.zhTW')}</option>
<option value="en">{t('language.en')}</option>
<option value="ja">{t('language.ja')}</option>
<option value="ko">{t('language.ko')}</option>
<option value="vi">{t('language.vi')}</option>
<option value="ru">{t('language.ru')}</option>
</select>
</SettingRow>
</SettingGroup>
@@ -259,12 +277,12 @@ export default function SettingsView() {
case 'terminal':
return (
<div className="space-y-6">
<SettingGroup title="字体">
<SettingRow label="字体" description="需 Nerd Font 才能正确显示 Zsh 提示符图标">
<SettingGroup title={t('terminal.fontGroup')}>
<SettingRow label={t('terminal.fontFamily')} description={t('terminal.fontFamilyDesc')}>
<select
value={settings.fontFamily}
onChange={(e) => setSetting('fontFamily', e.target.value)}
className="ui-select-pill"
className="ui-select-pill max-w-[220px]"
>
<option>MesloLGS NF</option>
<option>JetBrainsMono Nerd Font</option>
@@ -277,7 +295,7 @@ export default function SettingsView() {
<option>Consolas</option>
</select>
</SettingRow>
<SettingRow label="字号" description="终端文字大小">
<SettingRow label={t('terminal.fontSize')} description={t('terminal.fontSizeDesc')}>
<div className="flex items-center gap-1">
<button
type="button"
@@ -298,7 +316,7 @@ export default function SettingsView() {
</button>
</div>
</SettingRow>
<SettingRow label="回滚缓冲" description="每个终端保留的历史行数">
<SettingRow label={t('terminal.scrollback')} description={t('terminal.scrollbackDesc')}>
<input
type="number"
min={1000}
@@ -310,23 +328,21 @@ export default function SettingsView() {
/>
</SettingRow>
<SettingToggle
label="关键词高亮"
description="突出显示错误、警告、IP 地址和成功状态"
label={t('terminal.keywordHighlight')}
description={t('terminal.keywordHighlightDesc')}
checked={settings.keywordHighlightEnabled}
onChange={(value) => setSetting('keywordHighlightEnabled', value)}
/>
</SettingGroup>
<SettingGroup title="配色">
<SettingGroup title={t('terminal.colorTheme')}>
<div className="px-1 py-1">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{Object.entries(themes).map(([key, theme]) => (
<button
key={key}
type="button"
onClick={() =>
setSetting('terminalTheme', key as 'dracula' | 'monokai' | 'solarized' | 'one-dark')
}
onClick={() => setSetting('terminalTheme', key as TerminalThemeId)}
className={`rounded-xl border p-2.5 text-left text-xs transition-colors ${
settings.terminalTheme === key
? 'bg-accent-blue/10 ring-accent-blue/30 border-accent-blue ring-1'
@@ -334,9 +350,15 @@ export default function SettingsView() {
}`}
>
<div
className="mb-1.5 h-7 w-full rounded-md border border-black/20"
className="mb-1.5 flex h-7 w-full overflow-hidden rounded-md border border-black/20"
style={{ backgroundColor: theme.background }}
/>
aria-hidden
>
<span className="h-full w-1/4" style={{ backgroundColor: theme.blue }} />
<span className="h-full w-1/4" style={{ backgroundColor: theme.cyan }} />
<span className="h-full w-1/4" style={{ backgroundColor: theme.green }} />
<span className="h-full w-1/4" style={{ backgroundColor: theme.magenta }} />
</div>
<span className="text-content-secondary">{theme.name}</span>
</button>
))}
@@ -369,7 +391,7 @@ export default function SettingsView() {
className="hover:bg-dark-700/50 flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs text-txt-muted transition-colors hover:text-accent-blue"
>
<RotateCcw className="h-3.5 w-3.5" />
重置全部为默认
{t('shortcuts.resetAll')}
</button>
</div>
)}
@@ -378,7 +400,7 @@ export default function SettingsView() {
const defs = getKeybindingsByCategory(category)
return (
<SettingCard key={category}>
<h3 className="mb-3 text-sm font-semibold text-content-secondary">{category}</h3>
<h3 className="mb-3 text-sm font-semibold text-content-secondary">{t(`shortcuts.category.${category}`)}</h3>
<div className="space-y-1">
{defs.map((def) => {
const currentKeys = allKeys[def.id]
@@ -393,10 +415,10 @@ export default function SettingsView() {
}`}
>
<div className="min-w-0 flex-1">
<span className="text-sm text-content-primary">{def.label}</span>
<span className="text-sm text-content-primary">{t(def.labelKey)}</span>
{isCustom && (
<span className="bg-accent-blue/10 ml-2 rounded px-1.5 py-0.5 text-[10px] text-accent-blue">
自定义
{t('shortcuts.custom')}
</span>
)}
</div>
@@ -405,18 +427,18 @@ export default function SettingsView() {
{isRecordingThis ? (
<div className="flex items-center gap-2">
<kbd className="bg-accent-blue/20 border-accent-blue/40 inline-flex animate-pulse items-center rounded border px-3 py-1 font-mono text-xs text-accent-blue">
{recordedKeys || '按下快捷键...'}
{recordedKeys || t('shortcuts.pressKeys')}
</kbd>
<button
onClick={() => stopRecording()}
className="text-xs text-txt-muted hover:text-content-secondary"
>
取消
{t('common:action.cancel')}
</button>
{recordedKeys && recordedConflict.length > 0 && (
<div className="flex items-center gap-1 text-xs text-yellow-500">
<AlertTriangle className="h-3 w-3" />
<span>与「{recordedConflict[0].label}」冲突</span>
<span>{t('shortcuts.conflict', { label: t(recordedConflict[0].labelKey) })}</span>
</div>
)}
</div>
@@ -442,7 +464,7 @@ export default function SettingsView() {
<button
onClick={() => keybindingState.resetKeybinding(def.id)}
className="text-txt-faint transition-colors hover:text-txt-muted"
title="重置为默认"
title={t('shortcuts.resetDefault')}
>
<RotateCcw className="h-3 w-3" />
</button>
@@ -462,10 +484,7 @@ export default function SettingsView() {
{recordingId && recordedKeys && (
<div className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-xl border border-dark-600 bg-dark-800 px-5 py-3 shadow-2xl">
<span className="text-sm text-content-secondary">
设置为{' '}
<kbd className="rounded bg-dark-700 px-1.5 py-0.5 font-mono text-xs text-accent-blue">
{recordedKeys}
</kbd>
{t('shortcuts.setTo', { keys: recordedKeys })}
</span>
<button
onClick={async () => {
@@ -474,18 +493,18 @@ export default function SettingsView() {
}}
className="hover:bg-accent-blue/80 rounded-lg bg-accent-blue px-3 py-1 text-xs text-content-primary transition-colors"
>
确认
{t('common:action.confirm')}
</button>
<button
onClick={stopRecording}
className="rounded-lg px-3 py-1 text-xs text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
>
取消
{t('common:action.cancel')}
</button>
</div>
)}
<p className="text-xs text-txt-faint">点击快捷键可重新绑定 · 按 Esc 取消录入</p>
<p className="text-xs text-txt-faint">{t('shortcuts.hint')}</p>
</div>
)
}
@@ -494,20 +513,20 @@ export default function SettingsView() {
case 'ssh':
return (
<div className="space-y-6">
<SettingGroup title="连接">
<SettingGroup title={t('ssh.connection')}>
<SettingToggle
label="保持心跳"
description="空闲时发送心跳,减少断线"
label={t('ssh.keepAlive')}
description={t('ssh.keepAliveDesc')}
checked={settings.keepAlive}
onChange={(v) => setSetting('keepAlive', v)}
/>
<SettingToggle
label="自动重连"
description="意外断开后自动尝试重连"
label={t('ssh.autoReconnect')}
description={t('ssh.autoReconnectDesc')}
checked={settings.autoReconnect !== false}
onChange={(v) => setSetting('autoReconnect', v)}
/>
<SettingRow label="连接超时" description="建立 SSH 连接的最长等待时间(秒)">
<SettingRow label={t('ssh.timeout')} description={t('ssh.timeoutDesc')}>
<input
type="number"
min={5}
@@ -517,7 +536,7 @@ export default function SettingsView() {
className="ui-input-sm w-20 text-center"
/>
</SettingRow>
<SettingRow label="自动重连次数" description="意外断线后的最大尝试次数">
<SettingRow label={t('ssh.maxReconnect')} description={t('ssh.maxReconnectDesc')}>
<input
type="number"
min={1}
@@ -529,8 +548,8 @@ export default function SettingsView() {
</SettingRow>
</SettingGroup>
<SettingGroup title="连接池">
<SettingRow label="最大连接数" description="SSH、SFTP 与批量连接共享此上限">
<SettingGroup title={t('ssh.pool')}>
<SettingRow label={t('ssh.maxConnections')} description={t('ssh.maxConnectionsDesc')}>
<input
type="number"
min={2}
@@ -540,7 +559,7 @@ export default function SettingsView() {
className="ui-input-sm w-20 text-center"
/>
</SettingRow>
<SettingRow label="后台连接上限" description="超过上限时优先回收最久未使用的连接">
<SettingRow label={t('ssh.maxBackground')} description={t('ssh.maxBackgroundDesc')}>
<input
type="number"
min={1}
@@ -550,7 +569,7 @@ export default function SettingsView() {
className="ui-input-sm w-20 text-center"
/>
</SettingRow>
<SettingRow label="后台挂起" description="标签进入后台后多少分钟释放活跃通道">
<SettingRow label={t('ssh.suspend')} description={t('ssh.suspendDesc')}>
<input
type="number"
min={1}
@@ -568,15 +587,15 @@ export default function SettingsView() {
case 'security':
return (
<div className="space-y-6">
<SettingGroup title="凭据保护">
<SettingRow label="会话密码与密钥口令" description="保存前由 Rust 使用 AES-256-GCM 加密,无法关闭">
<SettingGroup title={t('security.credentials')}>
<SettingRow label={t('security.sessionPassword')} description={t('security.sessionPasswordDesc')}>
<span className="border-accent-green/25 bg-accent-green/10 rounded-md border px-2 py-1 text-xs font-medium text-accent-green">
已启用
{t('security.enabled')}
</span>
</SettingRow>
<SettingRow label="本地主密钥" description="仅当前系统用户可读取">
<SettingRow label={t('security.masterKey')} description={t('security.masterKeyDesc')}>
<code className="text-xs text-txt-muted break-all">
{runtimeInfo?.credentialPath ?? '~/.opsxshsell/master.key'}
{runtimeInfo?.credentialPath ?? '~/.opsxshell/master.key'}
</code>
</SettingRow>
</SettingGroup>
@@ -595,23 +614,23 @@ export default function SettingsView() {
</div>
<div className="min-w-0 flex-1">
<h3 className="text-lg font-semibold text-content-primary">OpsXShell</h3>
<p className="mt-0.5 text-sm text-txt-muted">轻量原生的 SSH / SFTP 运维终端</p>
<p className="mt-0.5 text-sm text-txt-muted">{t('about.tagline')}</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="bg-accent-blue/15 border-accent-blue/20 inline-flex items-center rounded-md border px-2.5 py-1 font-mono text-xs font-medium text-accent-blue">
应用 v{appVersion}
{t('about.appVersion', { version: appVersion })}
</span>
<button
type="button"
onClick={() => copyText('version', diagnosticText)}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
title="复制版本信息"
title={t('about.copyVersion')}
>
{copiedField === 'version' ? (
<Check className="h-3.5 w-3.5 text-accent-green" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{copiedField === 'version' ? '已复制' : '复制'}
{copiedField === 'version' ? t('common:action.copied') : t('common:action.copy')}
</button>
</div>
</div>
@@ -619,37 +638,35 @@ export default function SettingsView() {
</SettingCard>
<SettingCard>
<SettingSectionTitle>运行环境</SettingSectionTitle>
<SettingSectionTitle>{t('about.runtime')}</SettingSectionTitle>
<div className="divide-dark-700/30 mt-3 divide-y text-sm">
<div className="flex items-center justify-between gap-4 py-2">
<span className="text-txt-muted">系统</span>
<span className="text-txt-muted">{t('about.system')}</span>
<code className="text-right text-xs text-content-secondary">
{runtimeInfo ? `${runtimeInfo.os} ${runtimeInfo.arch}` : '读取中...'}
{runtimeInfo ? `${runtimeInfo.os} ${runtimeInfo.arch}` : t('common:status.reading')}
</code>
</div>
</div>
</SettingCard>
<SettingCard>
<SettingSectionTitle>本地数据</SettingSectionTitle>
<SettingSectionTitle>{t('about.localData')}</SettingSectionTitle>
<div className="mt-3 space-y-3 text-xs">
<div>
<div className="mb-1 text-txt-muted">会话与设置</div>
<code className="block break-all text-content-secondary">{runtimeInfo?.dataPath ?? '读取中...'}</code>
<div className="mb-1 text-txt-muted">{t('about.sessionsSettings')}</div>
<code className="block break-all text-content-secondary">{runtimeInfo?.dataPath ?? t('common:status.reading')}</code>
</div>
<div>
<div className="mb-1 text-txt-muted">加密主密钥</div>
<div className="mb-1 text-txt-muted">{t('about.masterKeyPath')}</div>
<code className="block break-all text-content-secondary">
{runtimeInfo?.credentialPath ?? '~/.opsxshsell/master.key'}
{runtimeInfo?.credentialPath ?? '~/.opsxshell/master.key'}
</code>
</div>
<p className="leading-relaxed text-txt-faint">
密码与密钥口令由 Rust 使用 AES-256-GCM 加密。OpsXShell 不保存终端命令历史。
</p>
<p className="leading-relaxed text-txt-faint">{t('about.privacyNote')}</p>
</div>
</SettingCard>
<p className="px-1 text-[11px] text-txt-faint">MIT License · Rust · XTerm.js</p>
<p className="px-1 text-[11px] text-txt-faint">{t('about.license')}</p>
</div>
)
@@ -664,12 +681,12 @@ export default function SettingsView() {
{/* ── 左侧导航 ── */}
<aside className="bg-dark-800/80 border-dark-700/40 flex w-60 flex-shrink-0 flex-col border-r">
<div className="px-4 pb-3 pt-5">
<div className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-txt-faint">设置</div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-txt-faint">{t('title')}</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-txt-muted" />
<input
type="text"
placeholder="搜索设置..."
placeholder={t('search.placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="ui-input-sm !pl-8 !pr-8"
@@ -697,10 +714,10 @@ export default function SettingsView() {
className={`ui-nav-item ${active ? 'ui-nav-item-active' : ''} ${tab.id === 'ai' && !active ? 'text-accent-blue' : ''}`}
>
<Icon className={`h-4 w-4 flex-shrink-0 ${active || tab.id === 'ai' ? 'text-accent-blue' : ''}`} />
<div className="min-w-0">
<div className="truncate text-sm font-medium">{tab.label}</div>
<div className={`truncate text-[11px] ${active ? 'text-txt-muted' : 'text-txt-faint'}`}>
{tab.description}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium leading-5">{t(tab.labelKey)}</div>
<div className={`truncate text-[11px] leading-4 ${active ? 'text-txt-muted' : 'text-txt-faint'}`}>
{t(tab.descriptionKey)}
</div>
</div>
</button>
@@ -712,8 +729,8 @@ export default function SettingsView() {
{/* ── 右侧内容区 ── */}
<div className="to-dark-900/95 flex min-w-0 flex-1 flex-col bg-gradient-to-b from-dark-900">
<div className="border-dark-700/30 flex-shrink-0 border-b px-8 pb-4 pt-6">
<h1 className="text-xl font-semibold tracking-tight text-content-primary">{currentMeta.label}</h1>
<p className="mt-1 text-sm text-txt-muted">{currentMeta.description}</p>
<h1 className="text-xl font-semibold tracking-tight text-content-primary">{t(currentMeta.labelKey)}</h1>
<p className="mt-1 text-sm text-txt-muted">{t(currentMeta.descriptionKey)}</p>
</div>
<div className="max-w-3xl flex-1 overflow-y-auto px-8 py-6">{renderContent()}</div>
@@ -725,6 +742,7 @@ export default function SettingsView() {
// ─── 子组件 ─────────────────────────────────────────────────
function KnownHostsSettingsCard() {
const { t } = useTranslation(['settings', 'common'])
const [entries, setEntries] = useState<Array<{ host: string; port: number; keyType: string; fingerprint: string }>>(
[]
)
@@ -752,22 +770,22 @@ function KnownHostsSettingsCard() {
}
const handleClear = async () => {
if (!confirm('确定清空全部已知主机密钥?下次连接将重新确认。')) return
if (!confirm(t('security.clearConfirm'))) return
await window.opsxApi?.knownHostsClear?.()
await load()
}
return (
<SettingGroup title="已知主机">
<SettingGroup title={t('security.knownHosts')}>
<div className="flex items-center justify-between gap-2 px-4 py-3">
<p className="flex-1 text-xs text-txt-muted">首次连接会确认主机指纹。此处可管理已信任记录。</p>
<p className="flex-1 text-xs text-txt-muted">{t('security.knownHostsHint')}</p>
<div className="flex flex-shrink-0 gap-1.5">
<button
type="button"
onClick={() => void load()}
className="rounded-lg bg-dark-700 px-2 py-1 text-[11px] text-txt-muted hover:bg-dark-600"
>
刷新
{t('common:action.refresh')}
</button>
<button
type="button"
@@ -775,14 +793,14 @@ function KnownHostsSettingsCard() {
className="rounded-lg bg-dark-700 px-2 py-1 text-[11px] text-red-300 hover:bg-red-900/40"
disabled={entries.length === 0}
>
清空
{t('security.clearAll')}
</button>
</div>
</div>
{loading ? (
<div className="px-4 pb-3 text-xs text-txt-muted">加载中...</div>
<div className="px-4 pb-3 text-xs text-txt-muted">{t('common:status.loading')}</div>
) : entries.length === 0 ? (
<div className="px-4 pb-3 text-xs text-txt-faint">暂无记录</div>
<div className="px-4 pb-3 text-xs text-txt-faint">{t('security.empty')}</div>
) : (
<div className="divide-dark-700/30 max-h-48 divide-y overflow-y-auto">
{entries.map((e) => (
@@ -796,9 +814,11 @@ function KnownHostsSettingsCard() {
<button
type="button"
onClick={() => void handleRemove(e.host, e.port)}
className="flex-shrink-0 px-1 text-[11px] text-red-300 hover:text-red-200"
title={t('security.removeHost')}
aria-label={t('security.removeHost')}
className="flex-shrink-0 rounded-full bg-red-500/10 px-2 py-1 text-[11px] text-red-300 hover:bg-red-500/20 hover:text-red-200"
>
删除
{t('common:action.delete')}
</button>
</div>
))}
@@ -849,12 +869,12 @@ function SettingRow({
children: React.ReactNode
}) {
return (
<div className="flex items-center justify-between gap-4 px-4 py-3.5">
<div className="mr-2 min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5">
<div className="min-w-[11rem] flex-1 basis-[14rem]">
<div className="text-sm font-medium text-content-primary">{label}</div>
{description && <div className="mt-0.5 text-xs leading-relaxed text-txt-muted">{description}</div>}
</div>
<div className="flex-shrink-0">{children}</div>
<div className="ml-auto flex-shrink-0">{children}</div>
</div>
)
}
@@ -872,8 +892,8 @@ function SettingToggle({
onChange: (value: boolean) => void
}) {
return (
<div className="flex items-center justify-between gap-4 px-4 py-3.5">
<div className="mr-2 min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5">
<div className="min-w-[11rem] flex-1 basis-[14rem]">
<div className="text-sm font-medium text-content-primary">{label}</div>
<div className="mt-0.5 text-xs leading-relaxed text-txt-muted">{description}</div>
</div>
@@ -882,7 +902,7 @@ function SettingToggle({
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative h-6 w-11 flex-shrink-0 rounded-full transition-colors ${
className={`relative ml-auto h-6 w-11 flex-shrink-0 rounded-full transition-colors ${
checked ? 'bg-accent-blue' : 'bg-dark-600'
}`}
>
+3 -1
View File
@@ -3,6 +3,7 @@
*/
import { ChevronRight, Home } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { splitPathSegments } from '../../utils/localPath'
interface BreadcrumbProps {
@@ -13,6 +14,7 @@ interface BreadcrumbProps {
}
export function Breadcrumb({ path, isLocal = false, onNavigate, onHome }: BreadcrumbProps) {
const { t } = useTranslation('sftp')
const { root, parts, join } = splitPathSegments(path || (isLocal ? '' : '/'), isLocal)
const rootLabel = isLocal && root !== '/' ? root.replace(/\\$/, '') || root : '/'
@@ -22,7 +24,7 @@ export function Breadcrumb({ path, isLocal = false, onNavigate, onHome }: Breadc
type="button"
onClick={onHome}
className="flex-shrink-0 rounded p-1.5 transition-colors hover:bg-dark-700"
title="主目录"
title={t('nav.home')}
>
<Home className="h-4 w-4 text-txt-muted" />
</button>
+14 -12
View File
@@ -5,6 +5,7 @@
*/
import { useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Shield } from 'lucide-react'
import Modal from '../common/Modal'
@@ -67,6 +68,7 @@ export default function ChmodDialog({
initialMode = 0o644,
onConfirm,
}: ChmodDialogProps) {
const { t } = useTranslation('sftp')
const [groups, setGroups] = useState(modeToPermGroups(initialMode))
const [saving, setSaving] = useState(false)
@@ -121,19 +123,19 @@ export default function ChmodDialog({
}) => (
<div className="flex items-center gap-4">
<span className="text-txt-muted w-16 text-sm">{label}</span>
<PermCheckbox scope={scope} perm="read" label="读取" />
<PermCheckbox scope={scope} perm="write" label="写入" />
<PermCheckbox scope={scope} perm="execute" label="执行" />
<PermCheckbox scope={scope} perm="read" label={t('chmod.read')} />
<PermCheckbox scope={scope} perm="write" label={t('chmod.write')} />
<PermCheckbox scope={scope} perm="execute" label={t('chmod.execute')} />
</div>
)
return (
<Modal
isOpen={isOpen}
title="权限编辑"
title={t('dialog.chmodTitle')}
onClose={onClose}
onConfirm={handleConfirm}
confirmText="应用"
confirmText={t('dialog.chmodApply')}
loading={saving}
>
<div className="space-y-4 min-w-[360px]">
@@ -148,7 +150,7 @@ export default function ChmodDialog({
{/* Current octal mode display */}
<div className="flex items-center justify-center gap-2 py-2">
<span className="text-txt-muted text-sm">当前权限:</span>
<span className="text-txt-muted text-sm">{t('chmod.current')}</span>
<span className="text-accent-blue font-mono font-bold text-lg">{octalString}</span>
</div>
@@ -156,13 +158,13 @@ export default function ChmodDialog({
<div className="space-y-3 bg-dark-700 rounded-lg p-4">
<div className="flex items-center gap-4">
<span className="w-16" />
<span className="text-txt-muted text-xs w-16">读取 (r)</span>
<span className="text-txt-muted text-xs w-16">写入 (w)</span>
<span className="text-txt-muted text-xs w-16">执行 (x)</span>
<span className="text-txt-muted text-xs w-16">{t('chmod.readR')}</span>
<span className="text-txt-muted text-xs w-16">{t('chmod.writeW')}</span>
<span className="text-txt-muted text-xs w-16">{t('chmod.executeX')}</span>
</div>
<PermRow scope="owner" label="所有者" />
<PermRow scope="group" label="所属组" />
<PermRow scope="other" label="其他人" />
<PermRow scope="owner" label={t('chmod.owner')} />
<PermRow scope="group" label={t('chmod.group')} />
<PermRow scope="other" label={t('chmod.other')} />
</div>
</div>
</Modal>
+7 -4
View File
@@ -4,6 +4,7 @@
* 显示连接状态或连接选择界面(已连接时不占位,避免与双栏重复)
*/
import { useTranslation } from 'react-i18next'
import { AlertCircle, Loader2 } from 'lucide-react'
import { ConnectionSelector } from './ConnectionSelector'
import type { Session } from '../../../shared/types/session.types'
@@ -29,6 +30,8 @@ export function ConnectionPanel({
onShowSelector,
onCloseSelector,
}: ConnectionPanelProps) {
const { t } = useTranslation('sftp')
if (isConnected) {
return null
}
@@ -37,7 +40,7 @@ export function ConnectionPanel({
return (
<div className="flex items-center gap-2 border-b border-accent-blue/20 bg-accent-blue/10 px-4 py-2">
<Loader2 className="h-4 w-4 animate-spin text-accent-blue" />
<span className="text-sm text-accent-blue">连接中...</span>
<span className="text-sm text-accent-blue">{t('connection.connecting')}</span>
</div>
)
}
@@ -54,7 +57,7 @@ export function ConnectionPanel({
onClick={onShowSelector}
className="rounded bg-dark-700 px-3 py-1 text-sm transition-colors hover:bg-dark-600"
>
重新连接
{t('connection.reconnect')}
</button>
</div>
)
@@ -63,13 +66,13 @@ export function ConnectionPanel({
return (
<>
<div className="flex items-center justify-between border-b border-dark-700/30 bg-dark-800 px-4 py-2">
<span className="text-sm text-txt-muted">未连接</span>
<span className="text-sm text-txt-muted">{t('connection.notConnected')}</span>
<button
type="button"
onClick={onShowSelector}
className="rounded bg-accent-blue px-3 py-1.5 text-sm font-medium transition-colors hover:bg-blue-600"
>
选择服务器
{t('connection.selectServer')}
</button>
</div>
+6 -3
View File
@@ -3,6 +3,7 @@
*/
import { Monitor, ChevronRight, X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { Session } from '../../stores/connection'
interface ConnectionSelectorProps {
@@ -12,11 +13,13 @@ interface ConnectionSelectorProps {
}
export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSelectorProps) {
const { t } = useTranslation('sftp')
return (
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-40">
<div className="bg-dark-800 rounded-xl border border-dark-700 shadow-2xl w-full max-w-md overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-700/30">
<h3 className="text-lg font-medium text-content-primary">选择服务器</h3>
<h3 className="text-lg font-medium text-content-primary">{t('connection.selectServer')}</h3>
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded">
<X className="w-5 h-5 text-txt-muted" />
</button>
@@ -25,8 +28,8 @@ export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSe
{sessions.length === 0 ? (
<div className="text-center py-8 text-txt-muted">
<Monitor className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>暂无保存的会话</p>
<p className="text-sm mt-1">请先在侧边栏创建会话</p>
<p>{t('connection.noSessions')}</p>
<p className="text-sm mt-1">{t('connection.createSessionHint')}</p>
</div>
) : (
<div className="space-y-2">
+30 -26
View File
@@ -7,6 +7,7 @@
*/
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
Folder, HardDrive, ArrowLeft, ArrowRight, ChevronDown, Upload, FolderOpen,
Search, X, Eye, EyeOff,
@@ -78,6 +79,7 @@ export function FileListPanel({
onDropEntry,
loading = false,
}: FileListPanelProps) {
const { t } = useTranslation('sftp')
const canGoBack = historyIndex > 0
const canGoForward = historyIndex < history.length - 1
@@ -216,16 +218,16 @@ export function FileListPanel({
}, [acceptsDrag, isLocal, onDropEntry, onDropFiles, resetDragState])
const statusText = (() => {
if (loading) return '加载中...'
if (selected.size > 0) return `已选择 ${selected.size} 项`
if (isFiltering) return `显示 ${filteredFiles.length} / ${visibleFiles.length} 项`
if (!showHidden && hiddenCount > 0) return `共 ${visibleFiles.length} 项(已隐藏 ${hiddenCount})`
return `共 ${visibleFiles.length} 项`
if (loading) return t('status.loading')
if (selected.size > 0) return t('status.selected', { count: selected.size })
if (isFiltering) return t('status.filtered', { shown: filteredFiles.length, total: visibleFiles.length })
if (!showHidden && hiddenCount > 0) return t('status.hiddenCount', { total: visibleFiles.length, hidden: hiddenCount })
return t('status.total', { count: visibleFiles.length })
})()
return (
<div
aria-label={`${title}文件面板`}
aria-label={t('panel.ariaLabel', { title })}
className={`flex-1 flex flex-col bg-dark-800 rounded-lg border overflow-hidden transition-colors ${
isDragOver ? 'border-accent-blue shadow-lg shadow-accent-blue/20' : 'border-dark-700/40'
}`}
@@ -251,8 +253,8 @@ export function FileListPanel({
? 'bg-accent-blue/20 text-accent-blue hover:bg-accent-blue/30'
: 'text-txt-muted hover:bg-dark-600 hover:text-content-primary'
}`}
title={showHidden ? '隐藏点文件(.xxx)' : '显示隐藏文件(.xxx)'}
aria-label={showHidden ? '隐藏点文件' : '显示隐藏文件'}
title={showHidden ? t('nav.hideHidden') : t('nav.showHidden')}
aria-label={showHidden ? t('nav.hideHiddenAria') : t('nav.showHiddenAria')}
aria-pressed={showHidden}
>
{showHidden ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
@@ -261,7 +263,7 @@ export function FileListPanel({
onClick={onNavigateBack}
disabled={!canGoBack}
className="p-1.5 hover:bg-dark-600 rounded disabled:opacity-30"
title="后退"
title={t('nav.back')}
>
<ArrowLeft className="w-4 h-4" />
</button>
@@ -269,14 +271,14 @@ export function FileListPanel({
onClick={onNavigateForward}
disabled={!canGoForward}
className="p-1.5 hover:bg-dark-600 rounded disabled:opacity-30"
title="前进"
title={t('nav.forward')}
>
<ArrowRight className="w-4 h-4" />
</button>
<button
onClick={onNavigateUp}
className="p-1.5 hover:bg-dark-600 rounded"
title="上级目录"
title={t('nav.up')}
>
<ChevronDown className="w-4 h-4 rotate-180" />
</button>
@@ -301,8 +303,8 @@ export function FileListPanel({
value={nameFilter}
onChange={(e) => setNameFilter(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder="搜索文件名"
aria-label={`${title} 当前文件夹搜索`}
placeholder={t('nav.searchPlaceholder')}
aria-label={t('nav.searchAria', { title })}
className="w-full h-7 pl-7 pr-7 text-xs bg-dark-900 border border-dark-600 rounded-md text-content-primary placeholder:text-txt-faint focus:outline-none focus:border-accent-blue/70 focus:ring-1 focus:ring-accent-blue/30"
/>
{isFiltering && (
@@ -310,8 +312,8 @@ export function FileListPanel({
type="button"
onClick={clearFilter}
className="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded text-txt-muted hover:text-content-primary hover:bg-dark-700"
title="清除搜索"
aria-label="清除搜索"
title={t('nav.clearSearch')}
aria-label={t('nav.clearSearchAria')}
>
<X className="w-3.5 h-3.5" />
</button>
@@ -331,30 +333,30 @@ export function FileListPanel({
) : files.length === 0 ? (
<EmptyState
icon={FolderOpen}
title="目录为空"
description={isLocal ? '此目录下没有文件' : '可尝试返回上级或刷新'}
title={t('empty.dirTitle')}
description={isLocal ? t('empty.dirDescLocal') : t('empty.dirDescRemote')}
action={
path !== '/'
? { label: '返回上级', onClick: onNavigateUp }
? { label: t('empty.goUp'), onClick: onNavigateUp }
: undefined
}
/>
) : visibleFiles.length === 0 ? (
<EmptyState
icon={EyeOff}
title="仅有隐藏文件"
description="点击标题栏眼睛图标显示点文件"
title={t('empty.hiddenOnlyTitle')}
description={t('empty.hiddenOnlyDesc')}
action={{
label: '显示隐藏文件',
label: t('empty.showHiddenAction'),
onClick: () => setShowHidden(true),
}}
/>
) : filteredFiles.length === 0 ? (
<EmptyState
icon={Search}
title="无匹配文件"
description={`没有包含「${nameFilter.trim()}」的文件`}
action={{ label: '清除搜索', onClick: clearFilter }}
title={t('empty.noMatchTitle')}
description={t('empty.noMatchDesc', { query: nameFilter.trim() })}
action={{ label: t('nav.clearSearch'), onClick: clearFilter }}
/>
) : (
filteredFiles.map((file) => (
@@ -406,7 +408,9 @@ export function FileListPanel({
<div className="flex flex-col items-center gap-2 text-accent-blue">
<Upload className="w-10 h-10" />
<span className="text-sm font-medium">
{isLocal || dragSource === 'remote' ? '下载到' : '上传到'} {dropTargetPath || path}
{isLocal || dragSource === 'remote'
? t('transfer.dropDownload', { path: dropTargetPath || path })
: t('transfer.dropUpload', { path: dropTargetPath || path })}
</span>
</div>
</div>
@@ -417,7 +421,7 @@ export function FileListPanel({
<div className="px-4 py-2 border-t border-dark-700/20 text-xs text-txt-muted flex items-center justify-between gap-2">
<span>{statusText}</span>
{isFiltering && (
<span className="text-txt-faint truncate">筛选: {nameFilter.trim()}</span>
<span className="text-txt-faint truncate">{t('status.filterHint', { query: nameFilter.trim() })}</span>
)}
</div>
+19 -16
View File
@@ -5,8 +5,10 @@
*/
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { File, Folder, Link2, HardDrive, Clock, Shield, Info } from 'lucide-react'
import Modal from '../common/Modal'
import { formatLocaleDateTime } from '../../i18n/format'
interface FileStats {
size: number
@@ -44,8 +46,7 @@ function formatFileSize(bytes: number): string {
function formatTimestamp(ts: number): string {
if (!ts) return '-'
const date = new Date(ts * 1000)
return date.toLocaleString('zh-CN', {
return formatLocaleDateTime(new Date(ts * 1000), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
@@ -82,6 +83,7 @@ export default function FilePropertiesDialog({
initialModified,
onFetchStat,
}: FilePropertiesDialogProps) {
const { t } = useTranslation('sftp')
const [stats, setStats] = useState<FileStats | null>(null)
const [loading, setLoading] = useState(false)
@@ -101,11 +103,12 @@ export default function FilePropertiesDialog({
const atime = stats?.atime ?? 0
const octalMode = stats ? modeToOctal(stats.mode) : null
const stringMode = stats ? modeToString(stats.mode) : (initialPermissions ?? '-')
const typeLabel = fileType === 'directory'
? '文件夹'
: fileType === 'symlink'
? '符号链接'
: '文件'
const typeLabel =
fileType === 'directory'
? t('fileType.folder')
: fileType === 'symlink'
? t('properties.symlink')
: t('fileType.file')
const TypeIcon = fileType === 'directory'
? Folder
@@ -116,10 +119,10 @@ export default function FilePropertiesDialog({
return (
<Modal
isOpen={isOpen}
title="文件属性"
title={t('dialog.propertiesTitle')}
onClose={onClose}
showConfirm={false}
cancelText="关闭"
cancelText={t('dialog.propertiesClose')}
>
<div className="space-y-4 min-w-[320px]">
{/* File icon + name header */}
@@ -136,19 +139,19 @@ export default function FilePropertiesDialog({
{loading && (
<div className="flex items-center gap-2 text-txt-muted text-sm">
<div className="animate-spin h-4 w-4 border-2 border-accent-blue border-t-transparent rounded-full" />
<span>加载详细信息...</span>
<span>{t('properties.loadingDetails')}</span>
</div>
)}
{/* Properties table */}
<div className="space-y-2 text-sm">
<PropertyRow icon={<Info className="w-4 h-4" />} label="名称" value={fileName} />
<PropertyRow icon={<HardDrive className="w-4 h-4" />} label="路径" value={filePath} monospace />
<PropertyRow icon={<HardDrive className="w-4 h-4" />} label="大小" value={formatFileSize(size)} />
<PropertyRow icon={<Shield className="w-4 h-4" />} label="权限" value={stringMode + (octalMode ? ` (${octalMode})` : '')} monospace />
<PropertyRow icon={<Clock className="w-4 h-4" />} label="修改时间" value={formatTimestamp(mtime)} />
<PropertyRow icon={<Info className="w-4 h-4" />} label={t('properties.name')} value={fileName} />
<PropertyRow icon={<HardDrive className="w-4 h-4" />} label={t('properties.path')} value={filePath} monospace />
<PropertyRow icon={<HardDrive className="w-4 h-4" />} label={t('properties.size')} value={formatFileSize(size)} />
<PropertyRow icon={<Shield className="w-4 h-4" />} label={t('properties.permissions')} value={stringMode + (octalMode ? ` (${octalMode})` : '')} monospace />
<PropertyRow icon={<Clock className="w-4 h-4" />} label={t('properties.modified')} value={formatTimestamp(mtime)} />
{stats && (
<PropertyRow icon={<Clock className="w-4 h-4" />} label="访问时间" value={formatTimestamp(atime)} />
<PropertyRow icon={<Clock className="w-4 h-4" />} label={t('properties.accessed')} value={formatTimestamp(atime)} />
)}
</div>
</div>
+4 -2
View File
@@ -3,6 +3,7 @@
*/
import { Folder, File } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { FileItem } from '../../../shared/types/sftp.types'
import { formatDate, formatSize, getFileExtension } from './utils'
@@ -35,6 +36,7 @@ export function FileRow({
onDrop,
isDropTarget = false,
}: FileRowProps) {
const { t } = useTranslation('sftp')
return (
<div
onClick={onSelect}
@@ -72,9 +74,9 @@ export function FileRow({
</div>
<div className="w-24 text-right text-xs text-txt-muted font-mono">
{file.type === 'directory'
? '文件夹'
? t('fileType.folder')
: file.type === 'symlink'
? '链接'
? t('fileType.symlink')
: getFileExtension(file.name)}
</div>
</div>
+11 -9
View File
@@ -3,6 +3,7 @@
*/
import { useEffect, useState, useCallback, useMemo, lazy, Suspense } from 'react'
import { useTranslation } from 'react-i18next'
import { X, Save, FileCode } from 'lucide-react'
import { guessLanguageFromPath } from '../../utils/fileLanguage'
import { readRemoteTextFile, remotePath as toRemotePath, writeRemoteTextFile } from '../../platform/sftp'
@@ -26,6 +27,7 @@ export default function RemoteTextEditor({
onClose,
onSaved,
}: RemoteTextEditorProps) {
const { t } = useTranslation(['sftp', 'common'])
const [content, setContent] = useState('')
const [original, setOriginal] = useState('')
const [loading, setLoading] = useState(false)
@@ -42,7 +44,7 @@ export default function RemoteTextEditor({
try {
const result = await readRemoteTextFile({ sftpId, remotePath: toRemotePath(remotePath) })
if (!result.success || result.content === undefined) {
throw new Error(result?.error || '读取失败')
throw new Error(result?.error || t('editor.readFailed'))
}
setContent(result.content)
setOriginal(result.content)
@@ -83,7 +85,7 @@ export default function RemoteTextEditor({
content,
})
if (!result.success) {
throw new Error(result?.error || '保存失败')
throw new Error(result?.error || t('editor.saveFailed'))
}
setOriginal(content)
onSaved?.()
@@ -95,7 +97,7 @@ export default function RemoteTextEditor({
}
const handleClose = () => {
if (dirty && !confirm('有未保存的修改,确定关闭?')) return
if (dirty && !confirm(t('editor.unsavedClose'))) return
onClose()
}
@@ -124,7 +126,7 @@ export default function RemoteTextEditor({
className="inline-flex items-center gap-1.5 rounded-lg bg-accent-blue px-3 py-1.5 text-sm text-white hover:bg-blue-500 disabled:opacity-40"
>
<Save className="h-3.5 w-3.5" />
{saving ? '保存中...' : '保存'}
{saving ? t('editor.saving') : t('editor.save')}
</button>
<button type="button" onClick={handleClose} className="rounded-lg p-2 text-txt-muted hover:bg-dark-700">
<X className="h-4 w-4" />
@@ -138,11 +140,11 @@ export default function RemoteTextEditor({
<div className="min-h-0 flex-1">
{loading ? (
<div className="flex h-full items-center justify-center text-sm text-txt-muted">加载中...</div>
<div className="flex h-full items-center justify-center text-sm text-txt-muted">{t('common:status.loading')}</div>
) : (
<Suspense
fallback={
<div className="flex h-full items-center justify-center text-sm text-txt-muted">加载编辑器...</div>
<div className="flex h-full items-center justify-center text-sm text-txt-muted">{t('editor.loadingEditor')}</div>
}
>
<MonacoEditor
@@ -168,10 +170,10 @@ export default function RemoteTextEditor({
</div>
<div className="flex justify-between border-t border-dark-700 px-4 py-2 text-xs text-txt-faint">
<span>UTF-8 · Monaco · ⌘/Ctrl+S 保存 · 最大约 2MB</span>
<span>{t('editor.footer')}</span>
<span>
{content.length.toLocaleString()} 字符
{dirty ? ' · 未保存' : ' · 已保存'}
{t('editor.charCount', { count: content.length.toLocaleString() })}
{dirty ? t('editor.unsavedMark') : t('editor.savedMark')}
</span>
</div>
</div>
+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'))}
/>
)}
+10 -7
View File
@@ -3,6 +3,7 @@
*/
import { ArrowUp, ArrowDown } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { SortField, SortOrder } from '../../../shared/types/sftp.types'
interface SortHeaderProps {
@@ -11,17 +12,19 @@ interface SortHeaderProps {
sortOrder: SortOrder
}
const FIELDS: { key: SortField; label: string; className: string }[] = [
{ key: 'name', label: '名称', className: 'flex-1 min-w-0 justify-start' },
{ key: 'modified', label: '修改时间', className: 'w-32 justify-end' },
{ key: 'size', label: '大小', className: 'w-24 justify-end' },
{ key: 'type', label: '类型', className: 'w-24 justify-end' },
const FIELDS: { key: SortField; className: string }[] = [
{ key: 'name', className: 'flex-1 min-w-0 justify-start' },
{ key: 'modified', className: 'w-32 justify-end' },
{ key: 'size', className: 'w-24 justify-end' },
{ key: 'type', className: 'w-24 justify-end' },
]
export function SortHeader({ onSort, sortField, sortOrder }: SortHeaderProps) {
const { t } = useTranslation('sftp')
return (
<div className="h-8 flex items-center w-full text-xs text-txt-muted select-none">
{FIELDS.map(({ key, label, className }) => {
{FIELDS.map(({ key, className }) => {
const active = sortField === key
return (
<button
@@ -32,7 +35,7 @@ export function SortHeader({ onSort, sortField, sortOrder }: SortHeaderProps) {
active ? 'text-content-primary' : ''
}`}
>
<span>{label}</span>
<span>{t(`sort.${key}`)}</span>
{active &&
(sortOrder === 'asc' ? (
<ArrowUp className="w-3 h-3 flex-shrink-0" />
+11 -8
View File
@@ -2,6 +2,7 @@
* Toolbar - SFTP 工具栏(图标优先,文案放 title/aria-label)
*/
import { useTranslation } from 'react-i18next'
import { Upload, Download, RefreshCw, FolderPlus } from 'lucide-react'
interface ToolbarProps {
@@ -24,14 +25,16 @@ export function Toolbar({
onRefresh,
onNewFolder,
}: ToolbarProps) {
const { t } = useTranslation('sftp')
return (
<div className="flex items-center gap-1 border-b border-dark-700/30 bg-dark-800 px-3 py-1.5">
<button
type="button"
onClick={onUpload}
disabled={!isConnected}
title="上传"
aria-label="上传"
title={t('toolbar.upload')}
aria-label={t('toolbar.uploadAria')}
className={`${iconBtn} bg-accent-blue text-white hover:bg-blue-600 disabled:bg-dark-700 disabled:text-txt-muted`}
>
<Upload className="h-4 w-4" />
@@ -40,8 +43,8 @@ export function Toolbar({
type="button"
onClick={onDownload}
disabled={!isConnected || !canDownload}
title={canDownload ? '下载选中的远程文件' : '请先选择远程文件'}
aria-label="下载"
title={canDownload ? t('toolbar.downloadHint') : t('toolbar.downloadNeedSelection')}
aria-label={t('toolbar.downloadAria')}
className={`${iconBtn} text-content-secondary hover:bg-dark-700 hover:text-content-primary`}
>
<Download className="h-4 w-4" />
@@ -50,8 +53,8 @@ export function Toolbar({
type="button"
onClick={onRefresh}
disabled={!isConnected}
title="刷新"
aria-label="刷新"
title={t('toolbar.refresh')}
aria-label={t('toolbar.refreshAria')}
className={`${iconBtn} text-content-secondary hover:bg-dark-700 hover:text-content-primary`}
>
<RefreshCw className="h-4 w-4" />
@@ -60,8 +63,8 @@ export function Toolbar({
type="button"
onClick={onNewFolder}
disabled={!isConnected}
title="新建文件夹"
aria-label="新建文件夹"
title={t('toolbar.newFolder')}
aria-label={t('toolbar.newFolderAria')}
className={`${iconBtn} text-content-secondary hover:bg-dark-700 hover:text-content-primary`}
>
<FolderPlus className="h-4 w-4" />
+8 -6
View File
@@ -3,6 +3,7 @@
*/
import { Download, Upload, Clock, CheckCircle, XCircle, X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { TransferTask } from '../../../shared/types/sftp.types'
import { formatSpeed } from './utils'
@@ -13,6 +14,7 @@ interface TransferItemProps {
}
export function TransferItem({ task, onCancel, onRemove }: TransferItemProps) {
const { t } = useTranslation('sftp')
const isActive = task.status === 'transferring' || task.status === 'pending'
return (
@@ -35,25 +37,25 @@ export function TransferItem({ task, onCancel, onRemove }: TransferItemProps) {
{task.status === 'pending' && (
<span className="text-xs text-txt-muted flex items-center gap-1">
<Clock className="w-3 h-3" />
等待中
{t('transfer.statusWaiting')}
</span>
)}
{task.status === 'completed' && (
<span className="text-xs text-green-400 flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
完成
{t('transfer.statusDone')}
</span>
)}
{task.status === 'failed' && (
<span className="text-xs text-red-400 flex items-center gap-1" title={task.error}>
<XCircle className="w-3 h-3" />
失败
{t('transfer.statusFailed')}
</span>
)}
{task.status === 'canceled' && (
<span className="text-xs text-txt-muted flex items-center gap-1">
<XCircle className="w-3 h-3" />
已取消
{t('transfer.statusCancelled')}
</span>
)}
</div>
@@ -63,7 +65,7 @@ export function TransferItem({ task, onCancel, onRemove }: TransferItemProps) {
<button
onClick={onCancel}
className="p-1.5 hover:bg-dark-700 rounded text-txt-muted hover:text-red-400"
title="取消"
title={t('transfer.cancel')}
>
<X className="w-3.5 h-3.5" />
</button>
@@ -72,7 +74,7 @@ export function TransferItem({ task, onCancel, onRemove }: TransferItemProps) {
<button
onClick={onRemove}
className="p-1.5 hover:bg-dark-700 rounded text-txt-muted hover:text-content-primary"
title="移除"
title={t('transfer.remove')}
>
<X className="w-3.5 h-3.5" />
</button>
+7 -7
View File
@@ -4,6 +4,7 @@
* 显示文件上传/下载的传输队列
*/
import { useTranslation } from 'react-i18next'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { TransferItem } from './TransferItem'
import type { TransferTask } from '../../../shared/types/sftp.types'
@@ -17,36 +18,35 @@ interface TransferPanelProps {
}
export function TransferPanel({ tasks, isOpen, onToggle, onCancel, onRemove }: TransferPanelProps) {
const { t } = useTranslation('sftp')
const activeCount = tasks.filter(t => t.status === 'pending' || t.status === 'transferring').length
const completedCount = tasks.filter(t => t.status === 'completed').length
return (
<div className="bg-dark-800 border-t border-dark-700/30">
{/* Header */}
<button
onClick={onToggle}
className="w-full flex items-center justify-between px-4 py-2 hover:bg-dark-700 transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">传输队列</span>
<span className="text-sm font-medium">{t('transfer.queue')}</span>
{activeCount > 0 && (
<span className="px-2 py-0.5 bg-accent-blue/20 text-accent-blue text-xs rounded-full">
{activeCount} 进行中
{t('transfer.active', { count: activeCount })}
</span>
)}
{completedCount > 0 && (
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded-full">
{completedCount} 已完成
{t('transfer.completed', { count: completedCount })}
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-txt-muted">{tasks.length} 个任务</span>
<span className="text-xs text-txt-muted">{t('transfer.taskCount', { count: tasks.length })}</span>
{isOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
</div>
</button>
{/* Transfer List */}
{isOpen && tasks.length > 0 && (
<div className="max-h-48 overflow-y-auto border-t border-dark-700/30">
{tasks.map((task) => (
@@ -62,7 +62,7 @@ export function TransferPanel({ tasks, isOpen, onToggle, onCancel, onRemove }: T
{isOpen && tasks.length === 0 && (
<div className="py-4 text-center text-txt-muted text-sm">
暂无传输任务
{t('transfer.empty')}
</div>
)}
</div>
+23 -21
View File
@@ -1,13 +1,14 @@
/**
* SFTP Component Utilities
* SFTPView 工具函数
*/
import i18n from '../../i18n'
import { formatLocaleDate, localeCompareText } from '../../i18n/format'
import type { SortField, SortOrder } from '../../../shared/types/sftp.types'
import type { FileItem } from './types'
/**
* 格式化文件大小
* Format file size
*/
export function formatSize(bytes: number): string {
if (bytes === 0) return '--'
@@ -18,7 +19,7 @@ export function formatSize(bytes: number): string {
}
/**
* 格式化传输速度
* Format transfer speed
*/
export function formatSpeed(bytesPerSecond: number): string {
if (bytesPerSecond === 0) return '0 B/s'
@@ -29,29 +30,29 @@ export function formatSpeed(bytesPerSecond: number): string {
}
/**
* 格式化日期
* Format date relative to now
*/
export function formatDate(timestamp: number): string {
const d = new Date(timestamp * 1000)
const now = new Date()
const diff = now.getTime() - d.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) return '今天'
if (days === 1) return '昨天'
if (days < 7) return `${days} 天前`
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
if (days === 0) return i18n.t('sftp:relativeDate.today')
if (days === 1) return i18n.t('sftp:relativeDate.yesterday')
if (days < 7) return i18n.t('sftp:relativeDate.daysAgo', { count: days })
return formatLocaleDate(d, { month: 'short', day: 'numeric' })
}
/**
* 获取文件扩展名描述
* Human-readable file extension label
*/
export function getFileExtension(name: string): string {
const parts = name.split('.')
if (parts.length < 2) return '文件'
if (parts.length < 2) return i18n.t('sftp:fileType.file')
const ext = parts.pop()?.toLowerCase() || ''
const extMap: Record<string, string> = {
pdf: 'PDF', doc: 'Word', docx: 'Word', xls: 'Excel', xlsx: 'Excel',
ppt: 'PPT', pptx: 'PPT', txt: '文本', md: 'Markdown',
ppt: 'PPT', pptx: 'PPT', txt: i18n.t('sftp:fileType.text'), md: 'Markdown',
js: 'JavaScript', ts: 'TypeScript', py: 'Python', java: 'Java',
go: 'Go', rs: 'Rust', c: 'C', cpp: 'C++', h: 'C Header',
json: 'JSON', xml: 'XML', yaml: 'YAML', yml: 'YAML', sql: 'SQL',
@@ -60,34 +61,36 @@ export function getFileExtension(name: string): string {
mp3: 'MP3', mp4: 'MP4', avi: 'AVI', mkv: 'MKV',
conf: 'Config', cfg: 'Config', ini: 'Config', env: 'Env',
sh: 'Shell', bash: 'Shell', zsh: 'Shell',
log: '日志', db: '数据库', iso: '镜像', exe: '可执行', dll: '库',
log: i18n.t('sftp:fileType.log'),
db: i18n.t('sftp:fileType.database'),
iso: i18n.t('sftp:fileType.imageIso'),
exe: i18n.t('sftp:fileType.executable'),
dll: i18n.t('sftp:fileType.library'),
}
return extMap[ext] || ext.toUpperCase()
}
/**
* 排序文件列表
* Sort file list
*/
export function sortFiles(files: FileItem[], sortField: SortField, sortOrder: SortOrder): FileItem[] {
return [...files].sort((a, b) => {
// 目录优先
if (a.type !== 'file' && b.type === 'file') return -1
if (a.type === 'file' && b.type !== 'file') return 1
let cmp = 0
switch (sortField) {
case 'name': cmp = a.name.localeCompare(b.name); break
case 'name': cmp = localeCompareText(a.name, b.name); break
case 'size': cmp = a.size - b.size; break
case 'modified': cmp = a.modified - b.modified; break
case 'type': cmp = a.name.localeCompare(b.name); break
case 'type': cmp = localeCompareText(a.name, b.name); break
}
return sortOrder === 'asc' ? cmp : -cmp
})
}
/**
* 按文件名过滤当前目录列表(不区分大小写,子串匹配)
* query 为空时返回原数组引用
* Filter list by filename (case-insensitive substring)
*/
export function filterFilesByName(files: FileItem[], query: string): FileItem[] {
const q = query.trim().toLowerCase()
@@ -96,7 +99,7 @@ export function filterFilesByName(files: FileItem[], query: string): FileItem[]
}
/**
* 是否为隐藏项(Unix 风格:以 . 开头;始终视为隐藏的 . / ..)
* Unix-style hidden name (leading .)
*/
export function isHiddenFileName(name: string): boolean {
if (!name) return false
@@ -105,8 +108,7 @@ export function isHiddenFileName(name: string): boolean {
}
/**
* 按是否显示隐藏文件过滤列表
* showHidden=true 时仍排除 . 与 ..
* Filter by show-hidden preference (. / .. always excluded)
*/
export function filterHiddenFiles(files: FileItem[], showHidden: boolean): FileItem[] {
return files.filter((f) => {
+33 -27
View File
@@ -5,12 +5,13 @@
*/
import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Code2, Plus, Search, Trash2, Copy, Pencil, X } from 'lucide-react'
import { useSnippetStore, type Snippet } from '../../stores/snippetStore'
import { useTabStore } from '../../stores/tabStore'
import { expandSnippetVariables, SNIPPET_VARIABLE_HINTS } from '../../utils/snippetVariables'
import { shellWrite } from '../../utils/shellIO'
import { localShellEnterKey } from '../../utils/platformShells'
import { localShellEnterKey, normalizeTerminalInject } from '../../utils/platformShells'
import Button from '../common/Button'
import Modal from '../common/Modal'
import EmptyState from '../common/EmptyState'
@@ -33,6 +34,7 @@ interface SnippetFormProps {
}
function SnippetFormModal({ isOpen, initial, title, confirmText, onConfirm, onClose }: SnippetFormProps) {
const { t } = useTranslation(['snippets', 'common'])
const [form, setForm] = useState<SnippetFormData>(initial)
useEffect(() => {
@@ -57,34 +59,36 @@ function SnippetFormModal({ isOpen, initial, title, confirmText, onConfirm, onCl
<div className="space-y-4">
{/* 名称 */}
<div>
<label className="block text-sm text-txt-muted mb-1">名称</label>
<label className="block text-sm text-txt-muted mb-1">{t('form.name')}</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="例如:重启 Nginx"
placeholder={t('form.namePlaceholder')}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-content-primary placeholder-ph focus:outline-none focus:border-accent-blue text-sm"
/>
</div>
{/* 命令内容 */}
<div>
<label className="block text-sm text-txt-muted mb-1">命令</label>
<label className="block text-sm text-txt-muted mb-1">{t('form.command')}</label>
<textarea
value={form.content}
onChange={(e) => setForm((f) => ({ ...f, content: e.target.value }))}
placeholder={"sudo systemctl restart nginx\n# 支持 {{host}} {{username}} {{date}}"}
placeholder={t('form.commandPlaceholder')}
rows={4}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-content-primary placeholder-ph focus:outline-none focus:border-accent-blue text-sm font-mono resize-none"
/>
<p className="text-xs text-txt-faint mt-1">
变量:{SNIPPET_VARIABLE_HINTS.slice(0, 6).join(' ')}
{t('form.variables')}{SNIPPET_VARIABLE_HINTS.slice(0, 6).join(' ')}
</p>
</div>
{/* 标签 */}
<div>
<label className="block text-sm text-txt-muted mb-1">标签 <span className="text-txt-faint">(逗号分隔)</span></label>
<label className="block text-sm text-txt-muted mb-1">
{t('form.tags')} <span className="text-txt-faint">{t('form.tagsHint')}</span>
</label>
<input
type="text"
value={form.tags}
@@ -103,6 +107,7 @@ const EMPTY_FORM: SnippetFormData = { name: '', content: '', tags: '' }
// ─── 主面板组件 ─────────────────────────────────────────────────
export default function SnippetPanel() {
const { t } = useTranslation(['snippets', 'common'])
const { snippets, isLoaded, loadSnippets, addSnippet, updateSnippet, deleteSnippet, searchSnippets } = useSnippetStore()
const { tabs, activeTabId } = useTabStore()
@@ -113,8 +118,8 @@ export default function SnippetPanel() {
const [formOpen, setFormOpen] = useState(false)
const [editingSnippet, setEditingSnippet] = useState<Snippet | null>(null)
const [formData, setFormData] = useState<SnippetFormData>(EMPTY_FORM)
const [formTitle, setFormTitle] = useState('新建片段')
const [formConfirmText, setFormConfirmText] = useState('创建')
const [formTitle, setFormTitle] = useState('')
const [formConfirmText, setFormConfirmText] = useState('')
// 初始化加载
useEffect(() => {
@@ -154,8 +159,8 @@ export default function SnippetPanel() {
const enter =
activeTab?.isLocal || shellId.startsWith('local-')
? localShellEnterKey(activeTab?.localShellKind)
: '\n'
await shellWrite(shellId, expanded + enter)
: '\r'
await shellWrite(shellId, normalizeTerminalInject(expanded, enter))
} catch (err) {
console.error('Failed to insert snippet:', err)
}
@@ -176,8 +181,8 @@ export default function SnippetPanel() {
const handleCreate = () => {
setEditingSnippet(null)
setFormData(EMPTY_FORM)
setFormTitle('新建片段')
setFormConfirmText('创建')
setFormTitle(t('dialog.createTitle'))
setFormConfirmText(t('common:action.create'))
setFormOpen(true)
}
@@ -189,8 +194,8 @@ export default function SnippetPanel() {
content: snippet.content,
tags: snippet.tags.join(', '),
})
setFormTitle('编辑片段')
setFormConfirmText('保存')
setFormTitle(t('dialog.editTitle'))
setFormConfirmText(t('common:action.save'))
setFormOpen(true)
}
@@ -224,7 +229,7 @@ export default function SnippetPanel() {
{/* 头部 */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-dark-700/30">
<Code2 className="w-5 h-5 text-accent-blue flex-shrink-0" />
<h2 className="text-sm font-semibold text-content-primary">命令片段</h2>
<h2 className="text-sm font-semibold text-content-primary">{t('panel.title')}</h2>
</div>
{/* 搜索框 */}
@@ -235,7 +240,7 @@ export default function SnippetPanel() {
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索片段..."
placeholder={t('search.placeholder')}
className="w-full pl-9 pr-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-sm text-content-primary placeholder-ph focus:outline-none focus:border-accent-blue"
/>
{query && (
@@ -254,11 +259,11 @@ export default function SnippetPanel() {
{isLoaded && filteredSnippets.length === 0 ? (
<EmptyState
icon={Code2}
title={query ? '没有匹配的片段' : '暂无片段'}
description={query ? '尝试其他关键词搜索' : '保存常用命令,快速插入到终端'}
title={query ? t('empty.noMatch') : t('empty.noSnippets')}
description={query ? t('empty.noMatchDesc') : t('empty.noSnippetsDesc')}
action={
!query
? { label: '新建片段', onClick: handleCreate }
? { label: t('action.create'), onClick: handleCreate }
: undefined
}
/>
@@ -289,7 +294,7 @@ export default function SnippetPanel() {
icon={<Plus className="w-4 h-4" />}
onClick={handleCreate}
>
新建片段
{t('action.create')}
</Button>
</div>
@@ -319,6 +324,7 @@ interface SnippetItemProps {
}
function SnippetItem({ snippet, copied, canInsert, onInsert, onCopy, onEdit, onDelete }: SnippetItemProps) {
const { t } = useTranslation(['snippets', 'common'])
const [confirmDelete, setConfirmDelete] = useState(false)
// 命令预览:显示第一行,截断到 60 字符
@@ -336,7 +342,7 @@ function SnippetItem({ snippet, copied, canInsert, onInsert, onCopy, onEdit, onD
<button
onClick={() => onInsert(snippet.content)}
disabled={!canInsert}
title={canInsert ? '插入到终端' : '无活跃终端'}
title={canInsert ? t('action.insert') : t('action.noTerminal')}
className="p-1 rounded hover:bg-accent-blue/20 text-accent-blue disabled:text-txt-faint disabled:hover:bg-transparent transition-colors"
>
<Code2 className="w-3.5 h-3.5" />
@@ -344,7 +350,7 @@ function SnippetItem({ snippet, copied, canInsert, onInsert, onCopy, onEdit, onD
{/* 复制 */}
<button
onClick={() => onCopy(snippet)}
title="复制"
title={t('common:action.copy')}
className="p-1 rounded hover:bg-dark-600 text-txt-muted transition-colors"
>
{copied ? (
@@ -356,7 +362,7 @@ function SnippetItem({ snippet, copied, canInsert, onInsert, onCopy, onEdit, onD
{/* 编辑 */}
<button
onClick={() => onEdit(snippet)}
title="编辑"
title={t('common:action.edit')}
className="p-1 rounded hover:bg-dark-600 text-txt-muted transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
@@ -371,19 +377,19 @@ function SnippetItem({ snippet, copied, canInsert, onInsert, onCopy, onEdit, onD
}}
className="px-1.5 py-0.5 text-xs bg-red-600 hover:bg-red-500 text-content-primary rounded transition-colors"
>
确认
{t('common:action.confirm')}
</button>
<button
onClick={() => setConfirmDelete(false)}
className="px-1.5 py-0.5 text-xs text-txt-muted hover:text-content-primary transition-colors"
>
取消
{t('common:action.cancel')}
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(true)}
title="删除"
title={t('common:action.delete')}
className="p-1 rounded hover:bg-red-500/20 text-txt-muted hover:text-red-400 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
+12 -13
View File
@@ -3,6 +3,7 @@
*/
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { ShieldAlert, ShieldCheck } from 'lucide-react'
import Modal from '../common/Modal'
import Button from '../common/Button'
@@ -28,16 +29,14 @@ function HostKeyDialog({
previousFingerprint,
onDecision,
}: HostKeyDialogProps) {
const { t } = useTranslation('terminal')
return (
<Modal
isOpen={open}
onClose={() => onDecision('reject')}
title={isChanged ? '主机密钥已变更' : '确认主机密钥'}
description={
isChanged
? '该主机的 SSH 密钥与本地记录不一致,可能存在中间人攻击风险。'
: '首次连接此主机,请核对指纹后信任。'
}
title={isChanged ? t('hostKey.titleChanged') : t('hostKey.titleNew')}
description={isChanged ? t('hostKey.descChanged') : t('hostKey.descNew')}
icon={
isChanged ? (
<ShieldAlert className="w-5 h-5 text-red-400" />
@@ -53,24 +52,24 @@ function HostKeyDialog({
<div className="space-y-4">
<div className="rounded-xl bg-dark-900/80 border border-dark-600/70 p-3.5 space-y-2.5 text-sm">
<div className="flex justify-between gap-3">
<span className="text-txt-muted text-xs">主机</span>
<span className="text-txt-muted text-xs">{t('hostKey.host')}</span>
<span className="text-content-primary text-right break-all font-mono text-xs">
{host}:{port}
</span>
</div>
<div className="flex justify-between gap-3">
<span className="text-txt-muted text-xs">类型</span>
<span className="text-txt-muted text-xs">{t('hostKey.type')}</span>
<span className="ui-chip font-mono">{keyType || 'unknown'}</span>
</div>
<div className="ui-divider pt-2">
<div className="text-txt-muted text-xs mb-1.5">SHA256 指纹</div>
<div className="text-txt-muted text-xs mb-1.5">{t('hostKey.fingerprint')}</div>
<div className="text-accent-green break-all text-[11px] leading-relaxed font-mono select-all">
SHA256:{fingerprint}
</div>
</div>
{isChanged && previousFingerprint && (
<div>
<div className="text-txt-muted text-xs mb-1.5">先前指纹</div>
<div className="text-txt-muted text-xs mb-1.5">{t('hostKey.previousFingerprint')}</div>
<div className="text-red-300/80 break-all text-[11px] leading-relaxed font-mono select-all">
SHA256:{previousFingerprint}
</div>
@@ -80,10 +79,10 @@ function HostKeyDialog({
<div className="flex flex-col sm:flex-row gap-2">
<Button variant="ghost" size="sm" className="flex-1" onClick={() => onDecision('reject')}>
拒绝
{t('hostKey.reject')}
</Button>
<Button variant="secondary" size="sm" className="flex-1" onClick={() => onDecision('accept-once')}>
仅本次
{t('hostKey.acceptOnce')}
</Button>
<Button
variant={isChanged ? 'danger' : 'primary'}
@@ -91,7 +90,7 @@ function HostKeyDialog({
className="flex-1"
onClick={() => onDecision('accept-always')}
>
{isChanged ? '仍要信任并更新' : '始终信任'}
{isChanged ? t('hostKey.acceptAlwaysChanged') : t('hostKey.acceptAlways')}
</Button>
</div>
</div>
@@ -1,4 +1,5 @@
import { useEffect, type ComponentType } from 'react'
import { useTranslation } from 'react-i18next'
import {
Clipboard,
ClipboardPaste,
@@ -57,6 +58,8 @@ export default function TerminalContextMenu({
onToggleMaximize,
onClosePane,
}: TerminalContextMenuProps) {
const { t } = useTranslation('terminal')
useEffect(() => {
const dismiss = () => onClose()
const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose() }
@@ -73,14 +76,14 @@ export default function TerminalContextMenu({
}, [onClose])
const actions: MenuAction[] = [
{ label: '复制', icon: Clipboard, onSelect: onCopy, disabled: !hasSelection },
{ label: '粘贴', icon: ClipboardPaste, onSelect: onPaste },
{ label: '全选终端内容', icon: MousePointer2, onSelect: onSelectAll },
{ label: '清空显示', icon: Eraser, onSelect: onClear },
{ label: '向右分屏', icon: SplitSquareHorizontal, onSelect: onSplitRight, disabled: !canSplit, dividerBefore: true },
{ label: '向下分屏', icon: SeparatorHorizontal, onSelect: onSplitDown, disabled: !canSplit },
{ label: t('contextMenu.copy'), icon: Clipboard, onSelect: onCopy, disabled: !hasSelection },
{ label: t('contextMenu.paste'), icon: ClipboardPaste, onSelect: onPaste },
{ label: t('contextMenu.selectAll'), icon: MousePointer2, onSelect: onSelectAll },
{ label: t('contextMenu.clear'), icon: Eraser, onSelect: onClear },
{ label: t('contextMenu.splitRight'), icon: SplitSquareHorizontal, onSelect: onSplitRight, disabled: !canSplit, dividerBefore: true },
{ label: t('contextMenu.splitDown'), icon: SeparatorHorizontal, onSelect: onSplitDown, disabled: !canSplit },
{
label: maximized ? '恢复分屏布局' : '最大化当前分屏',
label: maximized ? t('contextMenu.restoreLayout') : t('contextMenu.maximizePane'),
icon: maximized ? Minimize2 : Maximize2,
onSelect: onToggleMaximize,
disabled: !canClosePane,
@@ -94,7 +97,7 @@ export default function TerminalContextMenu({
return (
<div
role="menu"
aria-label="终端操作"
aria-label={t('contextMenu.aria')}
style={{ left, top }}
onMouseDown={(event) => event.stopPropagation()}
className="fixed z-[140] w-52 overflow-hidden rounded-lg border border-dark-600/90 bg-dark-800 py-1.5 shadow-2xl shadow-black/45"
+23 -15
View File
@@ -1,4 +1,5 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { Terminal as TerminalIcon, WifiOff, RefreshCw, AlertTriangle, Clock3 } from 'lucide-react'
import type { ConnectionState } from '../../stores/connection'
@@ -10,6 +11,8 @@ export interface ShellClosedOverlayProps {
}
export function ShellClosedOverlay({ reason, idleTimeout, restarting = false, onReopen }: ShellClosedOverlayProps) {
const { t } = useTranslation('terminal')
return (
<div className="bg-dark-900/92 absolute inset-0 z-20 flex items-center justify-center backdrop-blur-[2px]">
<div className="max-w-sm px-5 text-center">
@@ -21,10 +24,10 @@ export function ShellClosedOverlay({ reason, idleTimeout, restarting = false, on
)}
</div>
<div className="mb-1.5 text-sm font-medium text-content-primary">
{idleTimeout ? '空闲超时,Shell 已退出' : 'Shell 已退出'}
{idleTimeout ? t('overlay.shellClosedIdle') : t('overlay.shellClosed')}
</div>
<div className="mb-2 text-xs leading-relaxed text-txt-muted">{reason}</div>
<div className="mb-4 text-[11px] text-emerald-400">SSH 连接仍可用,其他分屏、SFTP 和隧道不受影响</div>
<div className="mb-4 text-[11px] text-emerald-400">{t('overlay.shellClosedHint')}</div>
<button
type="button"
disabled={restarting}
@@ -32,7 +35,7 @@ export function ShellClosedOverlay({ reason, idleTimeout, restarting = false, on
className="inline-flex items-center gap-2 rounded-lg bg-accent-blue px-4 py-2 text-xs font-medium text-white transition-colors hover:bg-accent-blue-hover disabled:opacity-60"
>
<RefreshCw className={`h-3.5 w-3.5 ${restarting ? 'animate-spin' : ''}`} />
{restarting ? '正在打开…' : '重新打开 Shell'}
{restarting ? t('overlay.reopening') : t('overlay.reopenShell')}
</button>
</div>
</div>
@@ -58,6 +61,7 @@ function TerminalOverlay({
onReconnect,
onResume,
}: TerminalOverlayProps) {
const { t } = useTranslation(['terminal', 'common'])
const showDisconnectBanner = connectionState === 'disconnected' && needsReconnect
return (
@@ -68,9 +72,9 @@ function TerminalOverlay({
<div className="bg-accent-blue/15 border-accent-blue/25 mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border">
<WifiOff className="h-7 w-7 text-accent-blue" />
</div>
<div className="mb-1.5 text-base font-medium text-content-primary">连接已断开</div>
<div className="mb-1.5 text-base font-medium text-content-primary">{t('overlay.disconnected')}</div>
<div className="mb-6 text-sm leading-relaxed text-txt-muted">
{statusMessage?.trim() ? statusMessage : 'SSH 会话已结束或网络中断。点击下方一键重新连接。'}
{statusMessage?.trim() ? statusMessage : t('overlay.disconnectedHint')}
</div>
<button
type="button"
@@ -78,7 +82,7 @@ function TerminalOverlay({
className="shadow-accent-blue/20 inline-flex items-center gap-2 rounded-xl bg-accent-blue px-6 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-accent-blue-hover"
>
<RefreshCw className="h-4 w-4" />
重新连接
{t('overlay.reconnect')}
</button>
</div>
</div>
@@ -87,22 +91,22 @@ function TerminalOverlay({
{showSuspendedOverlay && (
<div className="bg-dark-900/90 absolute inset-0 z-10 flex items-center justify-center">
<div className="max-w-sm px-6 text-center">
<div className="mb-2 text-base font-medium text-yellow-400">连接已挂起</div>
<div className="mb-5 text-sm text-txt-muted">长时间未操作,连接已进入挂起以节省资源</div>
<div className="mb-2 text-base font-medium text-yellow-400">{t('overlay.suspended')}</div>
<div className="mb-5 text-sm text-txt-muted">{t('overlay.suspendedHint')}</div>
<div className="flex items-center justify-center gap-2">
<button
type="button"
onClick={onResume}
className="rounded-lg bg-accent-blue px-4 py-2 text-sm text-white transition-colors hover:bg-accent-blue-hover"
>
恢复连接
{t('overlay.resume')}
</button>
<button
type="button"
onClick={onReconnect}
className="rounded-lg bg-dark-700 px-4 py-2 text-sm text-content-primary transition-colors hover:bg-dark-600"
>
重新连接
{t('overlay.reconnect')}
</button>
</div>
</div>
@@ -115,9 +119,9 @@ function TerminalOverlay({
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-red-500/25 bg-red-500/15">
<AlertTriangle className="h-6 w-6 text-red-400" />
</div>
<div className="mb-1.5 font-medium text-red-300">连接失败</div>
<div className="mb-1.5 font-medium text-red-300">{t('overlay.connectFailed')}</div>
<div className="mb-5 text-sm leading-relaxed text-txt-muted">
{statusMessage || '请检查主机、端口、用户名与认证方式'}
{statusMessage || t('overlay.connectFailedHint')}
</div>
<button
type="button"
@@ -125,7 +129,7 @@ function TerminalOverlay({
className="inline-flex items-center gap-2 rounded-xl bg-accent-blue px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-blue-hover"
>
<RefreshCw className="h-4 w-4" />
重试连接
{t('overlay.retryConnect')}
</button>
</div>
</div>
@@ -137,8 +141,12 @@ function TerminalOverlay({
<div className="inline-flex animate-pulse items-center gap-2 text-sm font-medium text-accent-blue">
<TerminalIcon className="h-4 w-4" />
{connectionState === 'reconnecting'
? `自动重连中${reconnectAttempt ? ` (${reconnectAttempt})` : ''}…`
: '正在连接…'}
? t('overlay.reconnecting', {
attempt: reconnectAttempt
? t('overlay.reconnectingAttempt', { count: reconnectAttempt })
: '',
})
: t('overlay.connecting')}
</div>
{statusMessage && connectionState === 'reconnecting' && (
<div className="mt-2 max-w-sm text-xs text-txt-muted">{statusMessage}</div>
@@ -1,4 +1,5 @@
import React, { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { Search, ChevronUp, ChevronDown, X } from 'lucide-react'
export interface TerminalSearchBarProps {
@@ -22,6 +23,8 @@ function TerminalSearchBar({
onSearchPrevious,
onClose,
}: TerminalSearchBarProps) {
const { t } = useTranslation('terminal')
return (
<div className="absolute top-0 left-0 right-0 z-30 flex items-center gap-2 px-3 py-2 bg-dark-800/95 border-b border-dark-600 backdrop-blur-sm">
<Search size={14} className="text-txt-muted shrink-0" />
@@ -40,7 +43,7 @@ function TerminalSearchBar({
}
e.stopPropagation()
}}
placeholder="搜索..."
placeholder={t('search.placeholder')}
className="flex-1 bg-dark-900 text-content-primary text-sm px-2 py-1 rounded border border-dark-600 focus:border-accent-blue focus:outline-none placeholder-ph"
/>
{searchResultCount !== undefined && (
@@ -51,21 +54,21 @@ function TerminalSearchBar({
<button
onClick={onSearchPrevious}
className="p-1 hover:bg-dark-600 rounded text-txt-muted hover:text-content-primary transition-colors"
title="上一个 (Shift+Enter)"
title={t('search.previous')}
>
<ChevronUp size={16} />
</button>
<button
onClick={onSearchNext}
className="p-1 hover:bg-dark-600 rounded text-txt-muted hover:text-content-primary transition-colors"
title="下一个 (Enter)"
title={t('search.next')}
>
<ChevronDown size={16} />
</button>
<button
onClick={onClose}
className="p-1 hover:bg-dark-600 rounded text-txt-muted hover:text-content-primary transition-colors"
title="关闭 (Escape)"
title={t('search.close')}
>
<X size={16} />
</button>
+70 -29
View File
@@ -1,10 +1,11 @@
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { SearchAddon } from '@xterm/addon-search'
import { WebLinksAddon } from '@xterm/addon-web-links'
import '@xterm/xterm/css/xterm.css'
import { themes } from '../../themes/terminal'
import { resolveTerminalTheme, toXtermTheme, XTERM_RENDER_OPTIONS } from '../../themes/terminal'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTabStore } from '../../stores/tabStore'
import { getConnectionStore } from '../../stores/connection'
@@ -13,6 +14,10 @@ import { createLogger } from '../../utils/logger'
import { applyKeywordHighlights } from '../../utils/keywordHighlight'
import { TerminalPipeline } from '../../utils/terminalPipeline'
import { buildTerminalFontFamily } from '../../utils/terminalFont'
import {
ZmodemSftpBridge,
type ZmodemBridgeStatus,
} from '../../utils/zmodemSftpBridge'
import { useTerminalConnection } from '../../hooks/useTerminalConnection'
import { useTerminalSearch } from '../../hooks/useTerminalSearch'
import TerminalOverlay from './TerminalOverlay'
@@ -22,6 +27,7 @@ import type { TerminalViewProps } from './types'
const logger = createLogger('TerminalView')
export default function TerminalView({ tabId, isActive = true }: TerminalViewProps) {
const { t } = useTranslation('terminal')
const terminalRef = useRef<HTMLDivElement>(null)
const xtermRef = useRef<Terminal | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
@@ -33,8 +39,11 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
((_event: unknown, _connId: string, shellId: string, data: Uint8Array) => void) | null
>(null)
const pipelineRef = useRef<TerminalPipeline | null>(null)
const zmodemRef = useRef<ZmodemSftpBridge | null>(null)
const listenerRegisteredRef = useRef(false)
const shellListenReadyRef = useRef<Promise<void>>(Promise.resolve())
const settingsRef = useRef(useSettingsStore.getState().settings)
const [zmodemStatus, setZmodemStatus] = useState<ZmodemBridgeStatus>({ state: 'idle' })
const { settings, loadSettings } = useSettingsStore()
const { getTabById } = useTabStore()
@@ -42,7 +51,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
settingsRef.current = settings
const currentTheme = themes[settings.terminalTheme] || themes['dracula']
const currentTheme = resolveTerminalTheme(settings.terminalTheme)
const {
connectionState,
@@ -104,27 +113,8 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
cursorStyle: 'block',
fontSize: settings.fontSize,
fontFamily: buildTerminalFontFamily(settings.fontFamily),
theme: {
background: currentTheme.background,
foreground: currentTheme.foreground,
cursor: currentTheme.cursor,
black: currentTheme.black,
red: currentTheme.red,
green: currentTheme.green,
yellow: currentTheme.yellow,
blue: currentTheme.blue,
magenta: currentTheme.magenta,
cyan: currentTheme.cyan,
white: currentTheme.white,
brightBlack: currentTheme.brightBlack,
brightRed: currentTheme.brightRed,
brightGreen: currentTheme.brightGreen,
brightYellow: currentTheme.brightYellow,
brightBlue: currentTheme.brightBlue,
brightMagenta: currentTheme.brightMagenta,
brightCyan: currentTheme.brightCyan,
brightWhite: currentTheme.brightWhite,
},
theme: toXtermTheme(currentTheme),
...XTERM_RENDER_OPTIONS,
scrollback: settings.scrollback || 10000,
rows: 30,
allowProposedApi: true,
@@ -175,15 +165,34 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
onError: (error) => logger.warn('终端输入失败', error),
})
term.onData((data) => pipelineRef.current?.input(data))
zmodemRef.current?.dispose()
zmodemRef.current = new ZmodemSftpBridge({
writeToTerminal: (data) => {
pipelineRef.current?.output(typeof data === 'string' ? new TextEncoder().encode(data) : data)
},
sendToPeer: (data) => {
if (!shellIdRef.current || !window.opsxApi) return
return window.opsxApi.sshShellWrite(shellIdRef.current, data)
},
getConnectionId: () => connectionIdRef.current,
onStatus: setZmodemStatus,
})
term.onData((data) => {
zmodemRef.current?.noteInput(data)
pipelineRef.current?.input(data)
})
}
window.addEventListener('resize', handleResize)
handleShellDataRef.current = (_event: unknown, _connId: string, shellId: string, data: Uint8Array) => {
// IPC may deliver Buffer as number[] — Array#toString would print "27,91,..."
if (shellId === shellIdRef.current && xtermRef.current) {
pipelineRef.current?.output(data)
if (zmodemRef.current) {
zmodemRef.current.consume(data)
} else {
pipelineRef.current?.output(data)
}
}
}
@@ -191,7 +200,8 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
const handler = (event: unknown, connectionId: string, shellId: string, data: Uint8Array) => {
handleShellDataRef.current?.(event, connectionId, shellId, data)
}
window.opsxApi?.onShellData(handler)
const ready = window.opsxApi?.onShellData(handler)
shellListenReadyRef.current = Promise.resolve(ready).then(() => undefined)
registeredShellHandlerRef.current = handler
listenerRegisteredRef.current = true
}
@@ -202,6 +212,17 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
const term = xtermRef.current
if (!term) return
term.options.theme = toXtermTheme(currentTheme)
term.options.fontSize = settings.fontSize
term.options.fontFamily = buildTerminalFontFamily(settings.fontFamily)
term.options.scrollback = settings.scrollback || 10000
Object.assign(term.options, XTERM_RENDER_OPTIONS)
fitAddonRef.current?.fit()
}, [currentTheme, settings.fontFamily, settings.fontSize, settings.scrollback])
// 初始连接
useEffect(() => {
if (!hasConnectedRef.current && isActive) {
@@ -216,7 +237,9 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
restoreSnapshot()
}
connect()
void shellListenReadyRef.current.finally(() => {
if (!hasConnectedRef.current) connect()
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@@ -250,6 +273,8 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
return () => {
isConnectingRef.current = false
listenerRegisteredRef.current = false
zmodemRef.current?.dispose()
zmodemRef.current = null
pipelineRef.current?.dispose()
pipelineRef.current = null
if (registeredShellHandlerRef.current) {
@@ -264,12 +289,28 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
}, [])
const currentTab = getTabById(tabId)
const zmodemBanner =
zmodemStatus.state === 'transferring'
? zmodemStatus.role === 'upload'
? t('zmodem.uploadTransferring', { detail: zmodemStatus.detail })
: t('zmodem.downloadTransferring', { detail: zmodemStatus.detail })
: zmodemStatus.state === 'done'
? zmodemStatus.detail
: zmodemStatus.state === 'error'
? zmodemStatus.detail
: null
return (
<div className="flex h-full flex-col bg-dark-900">
<div className="relative flex-1 overflow-hidden p-2">
<div ref={terminalRef} className="h-full w-full rounded bg-dark-900" />
{zmodemBanner && (
<div className="pointer-events-none absolute bottom-3 left-1/2 z-20 -translate-x-1/2 rounded-md border border-accent-blue/30 bg-dark-800/95 px-3 py-1 text-[11px] text-content-secondary shadow-lg">
{zmodemBanner}
</div>
)}
{showSearch && (
<TerminalSearchBar
searchQuery={searchQuery}
+338 -146
View File
@@ -3,7 +3,8 @@
* One SSH connection per tab; each pane has its own shell + xterm.
*/
import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import { useTranslation } from 'react-i18next'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { SearchAddon } from '@xterm/addon-search'
@@ -20,7 +21,7 @@ import {
Radio,
Sparkles,
} from 'lucide-react'
import { themes } from '../../themes/terminal'
import { resolveTerminalTheme, toXtermTheme, XTERM_RENDER_OPTIONS } from '../../themes/terminal'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTabStore } from '../../stores/tabStore'
import { getConnectionStore } from '../../stores/connection'
@@ -32,9 +33,9 @@ import {
} from '../../stores/terminalBroadcastStore'
import { useEntitlementStore } from '../../stores/entitlementStore'
import { useTerminalSnapshotStore } from '../../stores/terminalSnapshotStore'
import { applyKeywordHighlights } from '../../utils/keywordHighlight'
import { StreamingKeywordHighlight } from '../../utils/streamingKeywordHighlight'
import { shellPayloadByteLength } from '../../utils/shellData'
import { shellWrite, shellResize, shellCreate, shellKill } from '../../utils/shellIO'
import { shellWrite, shellResize, shellCreate, shellKill, flushSshShellBootstrap } from '../../utils/shellIO'
import { buildTerminalFontFamily } from '../../utils/terminalFont'
import { useTerminalConnection } from '../../hooks/useTerminalConnection'
import TerminalOverlay, { ShellClosedOverlay } from './TerminalOverlay'
@@ -44,8 +45,13 @@ import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
import { isTerminalCopyShortcut, isTerminalPasteShortcut, isTerminalSplitShortcut } from '../../utils/terminalShortcuts'
import { resolveTerminalInputTargets, shouldBroadcastTerminalInput } from '../../utils/terminalPanes'
import { TerminalPipeline } from '../../utils/terminalPipeline'
import {
ZmodemSftpBridge,
type ZmodemBridgeStatus,
} from '../../utils/zmodemSftpBridge'
import AiTerminalPanel, { type TerminalAiContext } from '../ai/AiTerminalPanel'
import TerminalContextMenu from './TerminalContextMenu'
import i18n from '../../i18n'
const logger = createLogger('TerminalWorkspace')
const MAX_PANES = 4
@@ -63,6 +69,7 @@ interface PaneState {
interface PaneRuntime {
capture: () => TerminalAiContext
getSize: () => { cols: number; rows: number }
getTerminal: () => Terminal | null
copySelection: () => void
pasteClipboard: () => void
selectAll: () => void
@@ -121,21 +128,26 @@ function PaneTerminal({
registerRuntime,
unregisterRuntime,
}: PaneTerminalProps) {
const { t } = useTranslation('terminal')
const containerRef = useRef<HTMLDivElement>(null)
const xtermRef = useRef<Terminal | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
const shellIdRef = useRef(shellId)
const connectionIdRef = useRef(connectionId)
const onInputRef = useRef(onInput)
const onContextMenuRef = useRef(onContextMenu)
const [xtermReady, setXtermReady] = useState(false)
const creatingRef = useRef(false)
const [zmodemStatus, setZmodemStatus] = useState<ZmodemBridgeStatus>({ state: 'idle' })
const { settings } = useSettingsStore()
const settingsRef = useRef(settings)
settingsRef.current = settings
shellIdRef.current = shellId
connectionIdRef.current = connectionId
onInputRef.current = onInput
onContextMenuRef.current = onContextMenu
const currentTheme = themes[settings.terminalTheme] || themes['dracula']
const currentTheme = resolveTerminalTheme(settings.terminalTheme)
useEffect(() => {
if (!containerRef.current || xtermRef.current) return
@@ -146,27 +158,8 @@ function PaneTerminal({
fontSize: settings.fontSize,
// Nerd Font stack so Powerline / Oh-My-Zsh / p10k glyphs render (not □)
fontFamily: buildTerminalFontFamily(settings.fontFamily),
theme: {
background: currentTheme.background,
foreground: currentTheme.foreground,
cursor: currentTheme.cursor,
black: currentTheme.black,
red: currentTheme.red,
green: currentTheme.green,
yellow: currentTheme.yellow,
blue: currentTheme.blue,
magenta: currentTheme.magenta,
cyan: currentTheme.cyan,
white: currentTheme.white,
brightBlack: currentTheme.brightBlack,
brightRed: currentTheme.brightRed,
brightGreen: currentTheme.brightGreen,
brightYellow: currentTheme.brightYellow,
brightBlue: currentTheme.brightBlue,
brightMagenta: currentTheme.brightMagenta,
brightCyan: currentTheme.brightCyan,
brightWhite: currentTheme.brightWhite,
},
theme: toXtermTheme(currentTheme),
...XTERM_RENDER_OPTIONS,
scrollback: settings.scrollback || 10000,
allowProposedApi: true,
})
@@ -228,7 +221,9 @@ function PaneTerminal({
xtermRef.current = term
fitAddonRef.current = fitAddon
setXtermReady(true)
const highlightStream = new StreamingKeywordHighlight()
const pipeline = new TerminalPipeline({
input: (data) => {
if (shellIdRef.current) onInputRef.current(paneId, data)
@@ -237,14 +232,33 @@ function PaneTerminal({
write: (text) => term.write(text),
transform: (text) => {
const currentSettings = settingsRef.current
return currentSettings.keywordHighlightEnabled && currentSettings.keywordHighlightRules?.length
? applyKeywordHighlights(text, currentSettings.keywordHighlightRules)
: text
if (!currentSettings.keywordHighlightEnabled || !currentSettings.keywordHighlightRules?.length) {
return text
}
return highlightStream.push(text, currentSettings.keywordHighlightRules)
},
},
onError: (error) => logger.warn('终端输入失败', error),
})
term.onData((data) => pipeline.input(data))
const zmodem =
isLocal
? null
: new ZmodemSftpBridge({
writeToTerminal: (data) => {
pipeline.output(typeof data === 'string' ? new TextEncoder().encode(data) : data)
},
sendToPeer: (data) => {
const sid = shellIdRef.current
if (!sid) return
return shellWrite(sid, data)
},
getConnectionId: () => connectionIdRef.current,
onStatus: setZmodemStatus,
})
term.onData((data) => {
zmodem?.noteInput(data)
pipeline.input(data)
})
const onResize = () => {
fitAddon.fit()
@@ -255,13 +269,27 @@ function PaneTerminal({
}
window.addEventListener('resize', onResize)
// Fit before advertising size so the primary shell is not born at 80×24.
fitAddon.fit()
registerShellHandler(paneId, (sid, data) => {
if (sid !== shellIdRef.current || !xtermRef.current) return
pipeline.output(data)
if (zmodem) {
const bytes =
data instanceof Uint8Array
? data
: Array.isArray(data)
? new Uint8Array(data as number[])
: new TextEncoder().encode(String(data))
zmodem.consume(bytes)
} else {
pipeline.output(data)
}
})
registerRuntime(paneId, {
capture: () => captureTerminal(term),
getSize: () => ({ cols: term.cols, rows: term.rows }),
getTerminal: () => xtermRef.current,
copySelection,
pasteClipboard: () => { void pasteClipboard() },
selectAll: () => term.selectAll(),
@@ -269,10 +297,12 @@ function PaneTerminal({
})
return () => {
setXtermReady(false)
window.removeEventListener('resize', onResize)
container.removeEventListener('contextmenu', handleContextMenu)
unregisterShellHandler(paneId)
unregisterRuntime(paneId)
zmodem?.dispose()
pipeline.dispose()
term.dispose()
xtermRef.current = null
@@ -280,6 +310,18 @@ function PaneTerminal({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Keep xterm theme / font in sync with settings (VS Code does this live).
useEffect(() => {
const term = xtermRef.current
if (!term) return
term.options.theme = toXtermTheme(currentTheme)
term.options.fontSize = settings.fontSize
term.options.fontFamily = buildTerminalFontFamily(settings.fontFamily)
term.options.scrollback = settings.scrollback || 10000
Object.assign(term.options, XTERM_RENDER_OPTIONS)
fitAddonRef.current?.fit()
}, [currentTheme, settings.fontFamily, settings.fontSize, settings.scrollback])
// Resize when shell id attaches
useEffect(() => {
if (!shellId || !xtermRef.current) return
@@ -291,7 +333,7 @@ function PaneTerminal({
// Auto-create shell for secondary panes
useEffect(() => {
if (!autoCreateShell) return
if (!connectionId || shellId || creatingRef.current || !xtermRef.current) return
if (!connectionId || shellId || creatingRef.current || !xtermReady || !xtermRef.current) return
creatingRef.current = true
const pendingId = isLocal
@@ -318,16 +360,28 @@ function PaneTerminal({
}
shellIdRef.current = result.shellId
onShellReady(paneId, result.shellId)
// Secondary panes must open output or MOTD stays buffered and the
// reader never emits ssh:shell-data (blank cursor forever).
if (!isLocal) {
try {
const boot = await flushSshShellBootstrap(result.shellId)
if (boot && xtermRef.current) xtermRef.current.write(boot)
} catch (err) {
logger.warn('Failed to flush split-pane shell bootstrap', err)
}
}
} else {
xtermRef.current?.writeln(`\r\n\x1b[31m创建 Shell 失败: ${result?.error || 'unknown'}\x1b[0m`)
xtermRef.current?.writeln(`\r\n\x1b[31m${t('zmodem.createShellFailed', { error: result?.error || 'unknown' })}\x1b[0m`)
}
} catch (e) {
logger.error('create shell failed', e)
const message = e instanceof Error ? e.message : String(e)
xtermRef.current?.writeln(`\r\n\x1b[31m${t('zmodem.createShellFailed', { error: message })}\x1b[0m`)
} finally {
creatingRef.current = false
}
})()
}, [autoCreateShell, connectionId, shellId, paneId, onShellReady, isLocal, tabId])
}, [autoCreateShell, connectionId, shellId, paneId, onShellReady, isLocal, tabId, xtermReady])
useEffect(() => {
if (isActive && isFocused) {
@@ -336,22 +390,40 @@ function PaneTerminal({
}
}, [isActive, isFocused])
const zmodemBanner =
zmodemStatus.state === 'transferring'
? t(zmodemStatus.role === 'upload' ? 'zmodem.uploadTransferring' : 'zmodem.downloadTransferring', {
detail: zmodemStatus.detail,
})
: zmodemStatus.state === 'done'
? zmodemStatus.detail
: zmodemStatus.state === 'error'
? zmodemStatus.detail
: null
return (
<div className={`relative h-full w-full ${isFocused ? 'ring-accent-blue/40 ring-1' : ''}`} onMouseDown={onFocus}>
<div ref={containerRef} className="h-full w-full" />
{zmodemBanner && (
<div className="pointer-events-none absolute bottom-3 left-1/2 z-20 -translate-x-1/2 rounded-md border border-accent-blue/30 bg-dark-800/95 px-3 py-1 text-[11px] text-content-secondary shadow-lg">
{zmodemBanner}
</div>
)}
</div>
)
}
export default function TerminalWorkspace({ tabId, isActive = true }: TerminalViewProps) {
const { t } = useTranslation('terminal')
const { loadSettings } = useSettingsStore()
const { getTabById, updateTab, updateTabConnectionInstance, closeTab, tabs } = useTabStore()
const snapshotStore = useTerminalSnapshotStore()
const currentTab = getTabById(tabId)
const savedLayout = currentTab?.splitLayout
// useTerminalConnection needs an xterm ref for initial shell size; panes own real terminals
const dummyXtermRef = useRef<Terminal | null>(null)
// Primary pane owns the real xterm; keep this ref in sync for connect size / snapshot / focus.
const primaryXtermRef = useRef<Terminal | null>(null)
const connectArmedRef = useRef(false)
const initialCount = Math.min(MAX_PANES, Math.max(1, savedLayout?.paneCount || 1))
const [panes, setPanes] = useState<PaneState[]>(() =>
@@ -360,7 +432,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
const panesRef = useRef(panes)
panesRef.current = panes
const [focusedPaneId, setFocusedPaneId] = useState(() => panes[0]?.id || '')
const [direction, setDirection] = useState<'horizontal' | 'vertical'>(savedLayout?.direction || 'horizontal')
const [direction, setDirection] = useState<'horizontal' | 'vertical'>(savedLayout?.direction || 'vertical')
const [ratio, setRatio] = useState(savedLayout?.ratio ?? 0.5)
const [maximizedPaneId, setMaximizedPaneId] = useState<string | null>(null)
const broadcastInput = useTerminalBroadcastStore((state) => state.enabled)
@@ -369,7 +441,19 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
const setBroadcastScope = useTerminalBroadcastStore((state) => state.setScope)
const registerBroadcastWorkspace = useTerminalBroadcastStore((state) => state.registerWorkspace)
const unregisterBroadcastWorkspace = useTerminalBroadcastStore((state) => state.unregisterWorkspace)
const broadcastTargetCount = useTerminalBroadcastStore(selectBroadcastTargetCount(tabId))
const broadcastTargetCount = useTerminalBroadcastStore(
useCallback((state) => selectBroadcastTargetCount(tabId)(state), [tabId])
)
const readyPaneCount = useMemo(
() => panes.filter((pane) => !!pane.shellId).length,
[panes]
)
// current-tab: prefer live pane shells so the button enables as soon as splits are ready
const effectiveBroadcastCount =
broadcastScope === 'current-tab'
? Math.max(broadcastTargetCount, readyPaneCount)
: broadcastTargetCount
const canEnableBroadcast = effectiveBroadcastCount >= 2
const requirePro = useEntitlementStore((state) => state.requirePro)
const [aiPanelOpen, setAiPanelOpen] = useState(false)
const isLocalTab = !!currentTab?.isLocal
@@ -403,7 +487,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
saveSnapshot,
restoreSnapshot,
setShowSuspendedOverlay,
} = useTerminalConnection({ tabId, xtermRef: dummyXtermRef })
} = useTerminalConnection({ tabId, xtermRef: primaryXtermRef })
// Persist layout to tab store
useEffect(() => {
@@ -431,18 +515,17 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
}
}, [connectionState, shellIdRef])
// A single global broadcast scope covers every ready SSH shell in every
// currently open terminal tab. Local host shells are intentionally excluded.
// Register ready shells (SSH + local) so broadcast can cover this tab's panes.
useEffect(() => {
const connectionId = connectionIdRef.current
const targets = connectionState === 'connected' && connectionId && connectionId !== 'local' && !currentTab?.isLocal
const targets = connectionState === 'connected' && connectionId
? panes.flatMap((pane) => pane.shellId
? [{
tabId,
paneId: pane.id,
shellId: pane.shellId,
connectionId,
label: currentTab?.title || 'SSH 终端',
label: currentTab?.title || (currentTab?.isLocal ? t('toolbar.labelLocal') : t('toolbar.labelSsh')),
sessionId: currentTab?.sessionId,
}]
: [])
@@ -453,8 +536,8 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
useEffect(() => () => unregisterBroadcastWorkspace(tabId), [tabId, unregisterBroadcastWorkspace])
useEffect(() => {
if (broadcastInput && broadcastTargetCount < 2) setBroadcastInput(false)
}, [broadcastInput, broadcastTargetCount, setBroadcastInput])
if (broadcastInput && effectiveBroadcastCount < 2) setBroadcastInput(false)
}, [broadcastInput, effectiveBroadcastCount, setBroadcastInput])
// Fan-out shell data to panes (SSH + local)
useEffect(() => {
@@ -522,6 +605,41 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
}
}, [connectionIdRef, shellIdRef, tabId, updateTabConnectionInstance])
// Local PTY child exit (Ctrl+D / exit) — mirror SSH shell-closed pane overlay.
useEffect(() => {
if (!currentTab?.isLocal) return
const handler = (_event: unknown, closedShellId: string) => {
const pane = panesRef.current.find((item) => item.shellId === closedShellId)
if (!pane) return
setPanes((current) =>
current.map((item) =>
item.shellId === closedShellId
? {
...item,
shellId: null,
closed: {
reason: i18n.t('terminal:overlay.localShellExited'),
idleTimeout: false,
},
}
: item
)
)
if (shellIdRef.current === closedShellId) {
const fallback = panesRef.current.find((item) => item.shellId && item.shellId !== closedShellId)
shellIdRef.current = fallback?.shellId || null
updateTabConnectionInstance(tabId, {
connectionId: 'local',
shellId: fallback?.shellId || undefined,
})
}
}
window.opsxApi?.onLocalExit?.(handler)
return () => {
window.opsxApi?.removeListener?.('local:exit', handler as (...args: unknown[]) => void)
}
}, [currentTab?.isLocal, shellIdRef, tabId, updateTabConnectionInstance])
useEffect(() => {
return () => {
if (!currentTab?.isLocal) return
@@ -535,19 +653,44 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
loadSettings()
}, [loadSettings])
useEffect(() => {
const currentTab = getTabById(tabId)
if (!hasConnectedRef.current && (isActive || currentTab?.connectOnMount)) {
if (currentTab?.needsReconnect) return
if (snapshotStore.hasSnapshot(tabId) && isActive) {
restoreSnapshot()
}
if (currentTab?.connectOnMount) {
updateTab(tabId, { connectOnMount: undefined })
}
connect()
const armInitialConnect = useCallback(() => {
if (connectArmedRef.current || hasConnectedRef.current) return
const current = getTabById(tabId)
if (!current) return
if (!(isActive || current.connectOnMount)) return
if (current.needsReconnect) return
connectArmedRef.current = true
if (snapshotStore.hasSnapshot(tabId) && isActive) {
restoreSnapshot()
}
if (current.connectOnMount) {
updateTab(tabId, { connectOnMount: undefined })
}
// One more frame so FitAddon geometry is committed before PTY create.
requestAnimationFrame(() => {
void connect()
})
}, [connect, getTabById, isActive, restoreSnapshot, snapshotStore, tabId, updateTab, hasConnectedRef])
const registerShellHandler = useCallback((paneId: string, handler: (shellId: string, data: unknown) => void) => {
shellHandlersRef.current.set(paneId, handler)
}, [])
const unregisterShellHandler = useCallback((paneId: string) => {
shellHandlersRef.current.delete(paneId)
}, [])
const registerRuntime = useCallback((paneId: string, runtime: PaneRuntime) => {
paneRuntimesRef.current.set(paneId, runtime)
if (panesRef.current[0]?.id === paneId) {
primaryXtermRef.current = runtime.getTerminal()
armInitialConnect()
}
}, [armInitialConnect])
const unregisterRuntime = useCallback((paneId: string) => {
paneRuntimesRef.current.delete(paneId)
if (panesRef.current[0]?.id === paneId) {
primaryXtermRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
@@ -565,18 +708,12 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
}
}, [isActive, tabId, saveSnapshot, setShowSuspendedOverlay, connectionIdRef, hasConnectedRef])
const registerShellHandler = useCallback((paneId: string, handler: (shellId: string, data: unknown) => void) => {
shellHandlersRef.current.set(paneId, handler)
}, [])
const unregisterShellHandler = useCallback((paneId: string) => {
shellHandlersRef.current.delete(paneId)
}, [])
const registerRuntime = useCallback((paneId: string, runtime: PaneRuntime) => {
paneRuntimesRef.current.set(paneId, runtime)
}, [])
const unregisterRuntime = useCallback((paneId: string) => {
paneRuntimesRef.current.delete(paneId)
}, [])
// When a background tab becomes active before the primary pane armed connect
// (e.g. connectOnMount cleared elsewhere), retry arming once geometry exists.
useEffect(() => {
if (!isActive || hasConnectedRef.current || connectArmedRef.current) return
if (primaryXtermRef.current) armInitialConnect()
}, [isActive, armInitialConnect, hasConnectedRef])
const onShellReady = useCallback(
(paneId: string, shellId: string) => {
@@ -594,7 +731,9 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
const reopenPaneShell = useCallback(
async (paneId: string) => {
const connectionId = connectionIdRef.current
if (!connectionId || connectionId === 'local' || connectionState !== 'connected') return
const isLocal = !!currentTab?.isLocal || connectionId === 'local'
if (!connectionId) return
if (!isLocal && connectionState !== 'connected') return
setPanes((current) =>
current.map((pane) =>
@@ -603,13 +742,25 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
)
const size = paneRuntimesRef.current.get(paneId)?.getSize() || { cols: 80, rows: 24 }
const preferredId = `shell-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
const preferredId = isLocal
? `local-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
: `shell-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
try {
const result = await shellCreate(false, connectionId, size, preferredId)
const result = await shellCreate(
isLocal,
isLocal ? null : connectionId,
{
...size,
shell: isLocal ? currentTab?.localShellPath : undefined,
},
preferredId
)
if (!result.success || !result.shellId) {
throw new Error(result.error || '创建远端 Shell 失败')
throw new Error(result.error || (isLocal ? i18n.t('terminal:zmodem.createLocalShellFailed') : i18n.t('terminal:zmodem.createRemoteShellFailed')))
}
if (!isLocal) {
getConnectionStore().addShell(connectionId, result.shellId)
}
getConnectionStore().addShell(connectionId, result.shellId)
setPanes((current) =>
current.map((pane) =>
pane.id === paneId ? { ...pane, shellId: result.shellId || null, closed: undefined } : pane
@@ -617,7 +768,19 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
)
if (focusedPaneId === paneId || !shellIdRef.current) {
shellIdRef.current = result.shellId
updateTabConnectionInstance(tabId, { connectionId, shellId: result.shellId })
updateTabConnectionInstance(tabId, {
connectionId: isLocal ? 'local' : connectionId,
shellId: result.shellId,
})
}
if (!isLocal && result.shellId) {
try {
const boot = await flushSshShellBootstrap(result.shellId)
const term = paneRuntimesRef.current.get(paneId)?.getTerminal?.()
if (boot && term) term.write(boot)
} catch (err) {
logger.warn('Failed to flush reopened shell bootstrap', err)
}
}
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
@@ -630,7 +793,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
)
}
},
[connectionIdRef, connectionState, focusedPaneId, shellIdRef, tabId, updateTabConnectionInstance]
[connectionIdRef, connectionState, currentTab?.isLocal, currentTab?.localShellPath, focusedPaneId, shellIdRef, tabId, updateTabConnectionInstance]
)
const handlePaneInput = useCallback(
@@ -662,22 +825,32 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
[broadcastInput, connectionIdRef, currentTab?.isLocal, tabId]
)
const split = useCallback(
const setSplitDirection = useCallback(
(dir: 'horizontal' | 'vertical') => {
if (!connectionIdRef.current || connectionState !== 'connected') return
setDirection(dir)
setPanes((prev) => {
if (prev.length >= MAX_PANES) return prev
// Already split: only flip orientation (toolbar tags light up the active direction).
if (prev.length !== 1) return prev
const id = newPaneId()
setFocusedPaneId(id)
if (prev.length === 1) setRatio(0.5)
setRatio(0.5)
return [...prev, { id, shellId: null }]
})
},
[connectionIdRef, connectionState]
)
const addPane = useCallback(() => split(direction), [direction, split])
const addPane = useCallback(() => {
if (!connectionIdRef.current || connectionState !== 'connected') return
setPanes((prev) => {
if (prev.length >= MAX_PANES) return prev
const id = newPaneId()
setFocusedPaneId(id)
if (prev.length === 1) setRatio(0.5)
return [...prev, { id, shellId: null }]
})
}, [connectionIdRef, connectionState])
useEffect(() => {
if (!isActive) return
@@ -685,12 +858,20 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
if (isTerminalSplitShortcut(event)) {
event.preventDefault()
event.stopPropagation()
split('horizontal')
if (panesRef.current.length >= MAX_PANES) {
setSplitDirection(direction)
return
}
if (panesRef.current.length === 1) {
setSplitDirection(direction)
} else {
addPane()
}
}
}
window.addEventListener('keydown', handleSplitShortcut, true)
return () => window.removeEventListener('keydown', handleSplitShortcut, true)
}, [isActive, split])
}, [addPane, direction, isActive, setSplitDirection])
const closePane = useCallback((paneId: string) => {
if (panesRef.current.length <= 1) return
@@ -835,25 +1016,35 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
<div className="border-dark-700/35 bg-dark-800/40 flex items-center gap-0.5 border-b px-2 py-1">
<button
type="button"
title="左右分屏"
disabled={connectionState !== 'connected' || panes.length >= MAX_PANES}
onClick={() => split('horizontal')}
className="rounded-md p-1.5 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary disabled:opacity-30"
title={t('toolbar.splitHorizontal')}
disabled={connectionState !== 'connected'}
onClick={() => setSplitDirection('horizontal')}
aria-pressed={multi && direction === 'horizontal'}
className={`rounded-md p-1.5 transition-colors disabled:opacity-30 ${
multi && direction === 'horizontal'
? 'bg-accent-blue/15 text-accent-blue'
: 'text-txt-muted hover:bg-dark-700 hover:text-content-primary'
}`}
>
<SplitSquareHorizontal className="h-3.5 w-3.5" />
</button>
<button
type="button"
title="上下分屏"
disabled={connectionState !== 'connected' || panes.length >= MAX_PANES}
onClick={() => split('vertical')}
className="rounded-md p-1.5 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary disabled:opacity-30"
title={t('toolbar.splitVertical')}
disabled={connectionState !== 'connected'}
onClick={() => setSplitDirection('vertical')}
aria-pressed={multi && direction === 'vertical'}
className={`rounded-md p-1.5 transition-colors disabled:opacity-30 ${
multi && direction === 'vertical'
? 'bg-accent-blue/15 text-accent-blue'
: 'text-txt-muted hover:bg-dark-700 hover:text-content-primary'
}`}
>
<SeparatorHorizontal className="h-3.5 w-3.5" />
</button>
<button
type="button"
title="再加一屏"
title={t('toolbar.addPane')}
disabled={connectionState !== 'connected' || panes.length >= MAX_PANES}
onClick={addPane}
className="rounded-md p-1.5 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary disabled:opacity-30"
@@ -863,7 +1054,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
{multi && (
<button
type="button"
title="关闭当前分屏"
title={t('toolbar.closePane')}
onClick={() => closePane(focusedPaneId)}
className="rounded-md p-1.5 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
>
@@ -873,7 +1064,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
{multi && (
<button
type="button"
title={maximizedPaneId ? '恢复分屏布局' : '最大化当前分屏'}
title={maximizedPaneId ? t('toolbar.restoreLayout') : t('toolbar.maximizePane')}
aria-pressed={!!maximizedPaneId}
onClick={() => setMaximizedPaneId((current) => (current ? null : focusedPaneId))}
className={`rounded-md p-1.5 transition-colors ${
@@ -886,54 +1077,55 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
</button>
)}
<div className="bg-dark-600/70 mx-1 h-4 w-px" />
{!isLocal && (
<>
<button
type="button"
title={broadcastInput
? `广播已开启:输入将发送到范围内 ${broadcastTargetCount} 个 SSH 终端`
: `广播同步输入(Pro)· 当前范围 ${broadcastTargetCount} 个目标`}
aria-label="广播输入"
aria-pressed={broadcastInput}
disabled={connectionState !== 'connected' || (broadcastInput ? false : broadcastTargetCount < 2)}
onClick={() => {
if (broadcastInput) {
setBroadcastInput(false)
return
}
if (!requirePro('broadcast')) return
setBroadcastInput(true)
}}
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] transition-colors disabled:opacity-30 ${
broadcastInput
? 'bg-accent-orange/15 text-accent-orange'
: 'text-txt-muted hover:bg-dark-700 hover:text-content-primary'
}`}
>
<Radio className="h-3.5 w-3.5" />
广播
</button>
<select
aria-label="广播目标范围"
value={broadcastScope}
onChange={(event) => {
if (!requirePro('broadcast')) return
setBroadcastScope(event.target.value as BroadcastScope)
}}
className="max-w-[7.5rem] truncate rounded-md border border-dark-600 bg-dark-800 px-1.5 py-1 text-[10px] text-txt-muted outline-none hover:text-content-secondary"
>
<option value="current-tab">当前标签</option>
<option value="selected-sessions">侧栏选中</option>
<option value="all">全部已开 SSH</option>
</select>
</>
)}
<button
type="button"
title={
broadcastInput
? t('toolbar.broadcastOn', { count: effectiveBroadcastCount })
: canEnableBroadcast
? t('toolbar.broadcastOffPro', { count: effectiveBroadcastCount })
: broadcastScope === 'current-tab'
? t('toolbar.broadcastNeedPanesTab')
: t('toolbar.broadcastNeedPanesAll')
}
aria-label={t('toolbar.broadcastAria')}
aria-pressed={broadcastInput}
disabled={connectionState !== 'connected' || (broadcastInput ? false : !canEnableBroadcast)}
onClick={() => {
if (broadcastInput) {
setBroadcastInput(false)
return
}
if (!requirePro('broadcast')) return
setBroadcastInput(true)
}}
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] transition-colors disabled:opacity-30 ${
broadcastInput
? 'bg-accent-orange/15 text-accent-orange'
: 'text-txt-muted hover:bg-dark-700 hover:text-content-primary'
}`}
>
<Radio className="h-3.5 w-3.5" />
{t('toolbar.broadcast')}
</button>
<select
aria-label={t('toolbar.broadcastScopeAria')}
value={broadcastScope}
onChange={(event) => {
if (!requirePro('broadcast')) return
setBroadcastScope(event.target.value as BroadcastScope)
}}
className="max-w-[8.5rem] truncate rounded-md border border-dark-600 bg-dark-800 px-1.5 py-1 text-[10px] text-txt-muted outline-none hover:text-content-secondary"
>
<option value="current-tab">{t('toolbar.broadcastScopeTab')}</option>
<option value="all">{t('toolbar.broadcastScopeAll')}</option>
</select>
<div className="flex-1" />
{!isLocal && (
<button
type="button"
title="终端 AI 助手(Pro)"
aria-label={aiPanelOpen ? '关闭终端 AI 助手' : '打开终端 AI 助手'}
title={t('toolbar.aiTitle')}
aria-label={aiPanelOpen ? t('toolbar.aiClose') : t('toolbar.aiOpen')}
aria-pressed={aiPanelOpen}
data-testid="terminal-ai-button"
onClick={() => {
@@ -946,17 +1138,17 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
}}
className={`flex flex-shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1 text-[11px] font-medium transition-colors ${aiPanelOpen ? 'border-accent-blue/40 bg-accent-blue/20 text-accent-blue' : 'border-accent-blue/20 bg-accent-blue/10 hover:border-accent-blue/40 hover:bg-accent-blue/15 text-accent-blue'}`}
>
<Sparkles className="h-3.5 w-3.5" /> AI 助手
<Sparkles className="h-3.5 w-3.5" /> {t('toolbar.aiAssistant')}
</button>
)}
{broadcastInput && !isLocal && (
{broadcastInput && (
<div className="ui-chip border-accent-orange/30 text-accent-orange">
广播中 · {broadcastTargetCount}
{t('toolbar.broadcastActive', { count: effectiveBroadcastCount })}
</div>
)}
<div className="ui-chip tabular-nums">
<Square className="h-2.5 w-2.5" />
{panes.length}/{MAX_PANES}
{t('toolbar.paneCount', { current: panes.length, max: MAX_PANES })}
</div>
</div>
@@ -1043,7 +1235,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
capture={() => paneRuntimesRef.current.get(focusedPaneId)?.capture() || { selection: '', viewport: '' }}
onClose={() => setAiPanelOpen(false)}
connectionId={connectionState === 'connected' ? connectionIdRef.current : null}
targetLabel={currentTab?.title || '当前 SSH 会话'}
targetLabel={currentTab?.title || t('toolbar.targetSshSession')}
isLocal={false}
/>
)}
@@ -1052,18 +1244,18 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
<TerminalContextMenu
position={contextMenu.position}
hasSelection={contextMenu.hasSelection}
canSplit={connectionState === 'connected' && panes.length < MAX_PANES}
canSplit={connectionState === 'connected'}
canClosePane={panes.length > 1}
canCloseTerminal={panes.length > 1 || tabs.length > 1}
closeLabel={panes.length > 1 ? '关闭当前分屏' : '关闭当前终端标签'}
closeLabel={panes.length > 1 ? t('toolbar.closePaneLabel') : t('toolbar.closeTabLabel')}
maximized={maximizedPaneId === contextMenu.paneId}
onClose={() => setContextMenu(null)}
onCopy={() => paneRuntimesRef.current.get(contextMenu.paneId)?.copySelection()}
onPaste={() => paneRuntimesRef.current.get(contextMenu.paneId)?.pasteClipboard()}
onSelectAll={() => paneRuntimesRef.current.get(contextMenu.paneId)?.selectAll()}
onClear={() => paneRuntimesRef.current.get(contextMenu.paneId)?.clear()}
onSplitRight={() => split('horizontal')}
onSplitDown={() => split('vertical')}
onSplitRight={() => setSplitDirection('horizontal')}
onSplitDown={() => setSplitDirection('vertical')}
onToggleMaximize={() => setMaximizedPaneId((current) => current ? null : contextMenu.paneId)}
onClosePane={() => closePaneOrTab(contextMenu.paneId)}
/>