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