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
No files matched your search
@@ -1,165 +1,335 @@
|
||||
import type { AiModelConfig, AiProviderConfig } from '../../shared/types/ai.types'
|
||||
import type {
|
||||
AiBillingMode,
|
||||
AiModelConfig,
|
||||
AiProviderConfig,
|
||||
AiProviderFamily,
|
||||
} from '../../shared/types/ai.types'
|
||||
|
||||
export interface AiProviderPreset extends Omit<AiProviderConfig, 'id' | 'enabled' | 'hasApiKey'> {
|
||||
id: string
|
||||
description: string
|
||||
family: AiProviderFamily
|
||||
}
|
||||
|
||||
export const AI_PROVIDER_FAMILY_META: Record<
|
||||
AiProviderFamily,
|
||||
{ label: string; description: string }
|
||||
> = {
|
||||
openai_compatible: {
|
||||
label: 'OpenAI兼容',
|
||||
description: '按量',
|
||||
},
|
||||
standalone: {
|
||||
label: '────────',
|
||||
description: '',
|
||||
},
|
||||
plan: {
|
||||
label: 'Plan计划',
|
||||
description: '套餐',
|
||||
},
|
||||
local: {
|
||||
label: '私有化本地',
|
||||
description: '本地',
|
||||
},
|
||||
custom: {
|
||||
label: 'Custom自定义',
|
||||
description: '自定义',
|
||||
},
|
||||
}
|
||||
|
||||
export const AI_BILLING_LABEL: Record<AiBillingMode, string> = {
|
||||
payg: '按量',
|
||||
plan: 'Plan',
|
||||
local: '本地',
|
||||
}
|
||||
|
||||
const model = (
|
||||
id: string,
|
||||
upstreamModel: string,
|
||||
label: string,
|
||||
capability: AiModelConfig['capability'] = 'chat',
|
||||
billing: AiModelConfig['billing'] = 'payg',
|
||||
billing: AiBillingMode = 'payg',
|
||||
overrides: Pick<AiModelConfig, 'baseUrl' | 'protocol'> = {}
|
||||
): AiModelConfig => ({ id, model: id, label, capability, billing, ...overrides })
|
||||
): AiModelConfig => ({
|
||||
id,
|
||||
model: upstreamModel,
|
||||
label,
|
||||
capability,
|
||||
billing,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [
|
||||
// ——— OpenAI 兼容(按量) ———
|
||||
{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
description: '官方 OpenAI 兼容接口',
|
||||
description: '官方 API',
|
||||
family: 'openai_compatible',
|
||||
presetId: 'deepseek',
|
||||
protocol: 'openai-chat',
|
||||
authStyle: 'bearer',
|
||||
baseUrl: 'https://api.deepseek.com/v1',
|
||||
allowInsecureHttp: false,
|
||||
models: [model('deepseek-chat', 'DeepSeek Chat'), model('deepseek-reasoner', 'DeepSeek Reasoner', 'reasoning')],
|
||||
defaultModelId: 'deepseek-chat',
|
||||
models: [
|
||||
model('deepseek-v4-pro', 'deepseek-v4-pro', 'DeepSeek V4 Pro', 'reasoning'),
|
||||
model('deepseek-v4-flash', 'deepseek-v4-flash', 'DeepSeek V4 Flash'),
|
||||
],
|
||||
defaultModelId: 'deepseek-v4-pro',
|
||||
},
|
||||
{
|
||||
id: 'zai',
|
||||
name: 'Z.AI / GLM',
|
||||
description: 'GLM 按量与 Coding Plan 路由',
|
||||
presetId: 'zai',
|
||||
protocol: 'openai-chat',
|
||||
id: 'openai',
|
||||
name: 'OpenAI',
|
||||
description: '官方 Responses API',
|
||||
family: 'openai_compatible',
|
||||
presetId: 'openai',
|
||||
protocol: 'openai-responses',
|
||||
authStyle: 'bearer',
|
||||
baseUrl: 'https://api.z.ai/api/paas/v4',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
allowInsecureHttp: false,
|
||||
models: [
|
||||
model('glm-5.2-payg', 'GLM-5.2 · 按量', 'reasoning', 'payg', {
|
||||
baseUrl: 'https://api.z.ai/api/paas/v4',
|
||||
}),
|
||||
{
|
||||
...model('glm-5.2-plan', 'GLM-5.2 · Plan', 'plan', 'plan', {
|
||||
baseUrl: 'https://api.z.ai/api/coding/paas/v4',
|
||||
}),
|
||||
model: 'glm-5.2',
|
||||
},
|
||||
model('glm-5', 'GLM-5', 'reasoning'),
|
||||
].map((item) => (item.id === 'glm-5.2-payg' ? { ...item, model: 'glm-5.2' } : item)),
|
||||
defaultModelId: 'glm-5.2-payg',
|
||||
model('gpt-5.6-sol', 'gpt-5.6-sol', 'GPT-5.6 Sol', 'reasoning'),
|
||||
model('gpt-5.6-terra', 'gpt-5.6-terra', 'GPT-5.6 Terra', 'reasoning'),
|
||||
model('gpt-5.6-luna', 'gpt-5.6-luna', 'GPT-5.6 Luna'),
|
||||
model('gpt-5.5', 'gpt-5.5', 'GPT-5.5', 'reasoning'),
|
||||
],
|
||||
defaultModelId: 'gpt-5.6-sol',
|
||||
},
|
||||
{
|
||||
id: 'qwen',
|
||||
name: 'Qwen',
|
||||
description: '阿里云百炼',
|
||||
family: 'openai_compatible',
|
||||
presetId: 'qwen',
|
||||
protocol: 'openai-chat',
|
||||
authStyle: 'bearer',
|
||||
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
allowInsecureHttp: false,
|
||||
models: [
|
||||
model('qwen3.8-max', 'qwen3.8-max', 'Qwen3.8 Max', 'reasoning'),
|
||||
model('qwen3.7-plus', 'qwen3.7-plus', 'Qwen3.7 Plus', 'reasoning'),
|
||||
model('qwen3.7-flash', 'qwen3.7-flash', 'Qwen3.7 Flash'),
|
||||
],
|
||||
defaultModelId: 'qwen3.8-max',
|
||||
},
|
||||
{
|
||||
id: 'kimi',
|
||||
name: 'Kimi',
|
||||
description: 'Moonshot',
|
||||
family: 'openai_compatible',
|
||||
presetId: 'kimi',
|
||||
protocol: 'openai-chat',
|
||||
authStyle: 'bearer',
|
||||
baseUrl: 'https://api.moonshot.cn/v1',
|
||||
allowInsecureHttp: false,
|
||||
models: [
|
||||
model('kimi-k3', 'kimi-k3', 'Kimi K3', 'reasoning'),
|
||||
model('kimi-k2.6', 'kimi-k2.6', 'Kimi K2.6', 'reasoning'),
|
||||
model('kimi-k2.7-code', 'kimi-k2.7-code', 'Kimi K2.7 Code', 'reasoning'),
|
||||
],
|
||||
defaultModelId: 'kimi-k3',
|
||||
},
|
||||
{
|
||||
id: 'mimo',
|
||||
name: 'Xiaomi MiMo',
|
||||
description: '小米 MiMo',
|
||||
family: 'openai_compatible',
|
||||
presetId: 'mimo',
|
||||
protocol: 'openai-chat',
|
||||
authStyle: 'bearer',
|
||||
baseUrl: 'https://api.xiaomimimo.com/v1',
|
||||
allowInsecureHttp: false,
|
||||
models: [
|
||||
model('mimo-v2.5-pro', 'mimo-v2.5-pro', 'MiMo V2.5 Pro', 'reasoning'),
|
||||
model('mimo-v2.5-flash', 'mimo-v2.5-flash', 'MiMo V2.5 Flash'),
|
||||
],
|
||||
defaultModelId: 'mimo-v2.5-pro',
|
||||
},
|
||||
{
|
||||
id: 'minimax',
|
||||
name: 'MiniMax',
|
||||
description: 'OpenAI Compatible;支持 M2.7 / M3',
|
||||
description: '按量',
|
||||
family: 'openai_compatible',
|
||||
presetId: 'minimax',
|
||||
protocol: 'openai-chat',
|
||||
authStyle: 'bearer',
|
||||
baseUrl: 'https://api.minimax.io/v1',
|
||||
allowInsecureHttp: false,
|
||||
models: [
|
||||
model('MiniMax-M2.7', 'MiniMax M2.7', 'reasoning'),
|
||||
model('MiniMax-M2.7-highspeed', 'MiniMax M2.7 Highspeed', 'chat'),
|
||||
model('MiniMax-M3', 'MiniMax M3', 'reasoning'),
|
||||
model('MiniMax-M3', 'MiniMax-M3', 'MiniMax M3', 'reasoning'),
|
||||
model('MiniMax-M2.7', 'MiniMax-M2.7', 'MiniMax M2.7', 'reasoning'),
|
||||
model('MiniMax-M2.7-highspeed', 'MiniMax-M2.7-highspeed', 'MiniMax M2.7 Highspeed'),
|
||||
],
|
||||
defaultModelId: 'MiniMax-M2.7',
|
||||
},
|
||||
{
|
||||
id: 'kimi',
|
||||
name: 'Kimi / Moonshot',
|
||||
description: 'Moonshot 官方 OpenAI 兼容接口',
|
||||
presetId: 'kimi',
|
||||
protocol: 'openai-chat',
|
||||
authStyle: 'bearer',
|
||||
baseUrl: 'https://api.moonshot.cn/v1',
|
||||
allowInsecureHttp: false,
|
||||
models: [model('kimi-k2.5', 'Kimi K2.5', 'reasoning'), model('moonshot-v1-128k', 'Moonshot 128K')],
|
||||
defaultModelId: 'kimi-k2.5',
|
||||
},
|
||||
{
|
||||
id: 'qwen',
|
||||
name: 'Qwen / DashScope',
|
||||
description: '阿里云百炼 OpenAI 兼容接口',
|
||||
presetId: 'qwen',
|
||||
protocol: 'openai-chat',
|
||||
authStyle: 'bearer',
|
||||
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
allowInsecureHttp: false,
|
||||
models: [model('qwen3.5-plus', 'Qwen 3.5 Plus', 'reasoning'), model('qwen-plus', 'Qwen Plus')],
|
||||
defaultModelId: 'qwen3.5-plus',
|
||||
},
|
||||
{
|
||||
id: 'openai',
|
||||
name: 'OpenAI',
|
||||
description: '官方 Responses API',
|
||||
presetId: 'openai',
|
||||
protocol: 'openai-responses',
|
||||
authStyle: 'bearer',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
allowInsecureHttp: false,
|
||||
models: [model('gpt-5', 'GPT-5', 'reasoning'), model('gpt-4.1', 'GPT-4.1')],
|
||||
defaultModelId: 'gpt-5',
|
||||
defaultModelId: 'MiniMax-M3',
|
||||
},
|
||||
|
||||
// ——— Anthropic / Google Gemini ———
|
||||
{
|
||||
id: 'anthropic',
|
||||
name: 'Anthropic',
|
||||
description: '官方 Messages API',
|
||||
description: 'Claude',
|
||||
family: 'standalone',
|
||||
presetId: 'anthropic',
|
||||
protocol: 'anthropic',
|
||||
authStyle: 'x-api-key',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
allowInsecureHttp: false,
|
||||
models: [model('claude-sonnet-4-5', 'Claude Sonnet 4.5', 'reasoning')],
|
||||
defaultModelId: 'claude-sonnet-4-5',
|
||||
models: [
|
||||
model('claude-opus-5', 'claude-opus-5', 'Claude Opus 5', 'reasoning'),
|
||||
model('claude-sonnet-5', 'claude-sonnet-5', 'Claude Sonnet 5', 'reasoning'),
|
||||
model('claude-haiku-4-5', 'claude-haiku-4-5', 'Claude Haiku 4.5'),
|
||||
],
|
||||
defaultModelId: 'claude-opus-5',
|
||||
},
|
||||
{
|
||||
id: 'openrouter',
|
||||
name: 'OpenRouter',
|
||||
description: '多供应商 OpenAI 兼容聚合',
|
||||
presetId: 'openrouter',
|
||||
id: 'google',
|
||||
name: 'Google Gemini',
|
||||
description: 'Gemini',
|
||||
family: 'standalone',
|
||||
presetId: 'google',
|
||||
protocol: 'openai-chat',
|
||||
authStyle: 'bearer',
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
||||
allowInsecureHttp: false,
|
||||
models: [],
|
||||
defaultModelId: '',
|
||||
models: [
|
||||
model('gemini-3.1-pro-preview', 'gemini-3.1-pro-preview', 'Gemini 3.1 Pro', 'reasoning'),
|
||||
model('gemini-3-flash-preview', 'gemini-3-flash-preview', 'Gemini 3 Flash'),
|
||||
model('gemini-3.1-flash-lite', 'gemini-3.1-flash-lite', 'Gemini 3.1 Flash-Lite'),
|
||||
],
|
||||
defaultModelId: 'gemini-3.1-pro-preview',
|
||||
},
|
||||
|
||||
// ——— Plan 计划 ———
|
||||
{
|
||||
id: 'glm',
|
||||
name: 'GLM',
|
||||
description: '智谱',
|
||||
family: 'plan',
|
||||
presetId: 'glm',
|
||||
protocol: 'openai-chat',
|
||||
authStyle: 'bearer',
|
||||
baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
|
||||
allowInsecureHttp: false,
|
||||
models: [
|
||||
model('glm-5.2-plan', 'glm-5.2', 'GLM-5.2', 'plan', 'plan', {
|
||||
baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
|
||||
}),
|
||||
model('glm-5.1-plan', 'glm-5.1', 'GLM-5.1', 'plan', 'plan', {
|
||||
baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
|
||||
}),
|
||||
model('glm-5.2-payg', 'glm-5.2', 'GLM-5.2(按量)', 'reasoning', 'payg', {
|
||||
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
|
||||
}),
|
||||
],
|
||||
defaultModelId: 'glm-5.2-plan',
|
||||
},
|
||||
{
|
||||
id: 'minimax-plan',
|
||||
name: 'MiniMax',
|
||||
description: 'Token Plan',
|
||||
family: 'plan',
|
||||
presetId: 'minimax-plan',
|
||||
protocol: 'openai-chat',
|
||||
authStyle: 'bearer',
|
||||
baseUrl: 'https://api.minimax.io/v1',
|
||||
allowInsecureHttp: false,
|
||||
models: [
|
||||
model('MiniMax-M3-plan', 'MiniMax-M3', 'MiniMax M3', 'plan', 'plan'),
|
||||
model('MiniMax-M2.7-plan', 'MiniMax-M2.7', 'MiniMax M2.7', 'plan', 'plan'),
|
||||
model('MiniMax-M2.7-highspeed-plan', 'MiniMax-M2.7-highspeed', 'MiniMax M2.7 Highspeed', 'plan', 'plan'),
|
||||
],
|
||||
defaultModelId: 'MiniMax-M3-plan',
|
||||
},
|
||||
{
|
||||
id: 'kimi-plan',
|
||||
name: 'Kimi',
|
||||
description: 'Code Plan',
|
||||
family: 'plan',
|
||||
presetId: 'kimi-plan',
|
||||
protocol: 'openai-chat',
|
||||
authStyle: 'bearer',
|
||||
baseUrl: 'https://api.kimi.com/coding/v1',
|
||||
allowInsecureHttp: false,
|
||||
models: [
|
||||
model('kimi-k3-plan', 'kimi-k3', 'Kimi K3', 'plan', 'plan'),
|
||||
model('kimi-k2.7-code-plan', 'kimi-k2.7-code', 'Kimi K2.7 Code', 'plan', 'plan'),
|
||||
model('kimi-k2.6-plan', 'kimi-k2.6', 'Kimi K2.6', 'plan', 'plan'),
|
||||
],
|
||||
defaultModelId: 'kimi-k3-plan',
|
||||
},
|
||||
|
||||
// ——— 私有化 ———
|
||||
{
|
||||
id: 'ollama',
|
||||
name: 'Ollama',
|
||||
description: '本机模型,不需要 API Key',
|
||||
description: '本机',
|
||||
family: 'local',
|
||||
presetId: 'ollama',
|
||||
protocol: 'openai-chat',
|
||||
authStyle: 'none',
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
allowInsecureHttp: true,
|
||||
models: [
|
||||
model('qwen3:8b', 'Qwen3 8B · 本地', 'chat', 'local'),
|
||||
model('deepseek-r1:8b', 'DeepSeek R1 8B · 本地', 'reasoning', 'local'),
|
||||
model('qwen3:latest', 'qwen3:latest', 'Qwen3', 'chat', 'local'),
|
||||
model('deepseek-r1:latest', 'deepseek-r1:latest', 'DeepSeek R1', 'reasoning', 'local'),
|
||||
],
|
||||
defaultModelId: 'qwen3:8b',
|
||||
defaultModelId: 'qwen3:latest',
|
||||
},
|
||||
{
|
||||
id: 'new-api',
|
||||
name: 'New API',
|
||||
description: '自建 new-api / One API 聚合网关',
|
||||
presetId: 'new-api',
|
||||
id: 'vllm',
|
||||
name: 'VLLM',
|
||||
description: '内网',
|
||||
family: 'local',
|
||||
presetId: 'vllm',
|
||||
protocol: 'openai-chat',
|
||||
authStyle: 'bearer',
|
||||
baseUrl: 'https://new-api.example.com/v1',
|
||||
baseUrl: 'http://127.0.0.1:8000/v1',
|
||||
allowInsecureHttp: true,
|
||||
models: [],
|
||||
defaultModelId: '',
|
||||
},
|
||||
|
||||
// ——— Custom ———
|
||||
{
|
||||
id: 'custom',
|
||||
name: 'Custom自定义',
|
||||
description: '自定义',
|
||||
family: 'custom',
|
||||
presetId: 'custom',
|
||||
protocol: 'openai-chat',
|
||||
authStyle: 'bearer',
|
||||
baseUrl: '',
|
||||
allowInsecureHttp: false,
|
||||
models: [],
|
||||
defaultModelId: '',
|
||||
},
|
||||
]
|
||||
|
||||
export const AI_PROVIDER_FAMILY_ORDER: AiProviderFamily[] = [
|
||||
'openai_compatible',
|
||||
'standalone',
|
||||
'plan',
|
||||
'local',
|
||||
'custom',
|
||||
]
|
||||
|
||||
export function presetsByFamily(family: AiProviderFamily): AiProviderPreset[] {
|
||||
return AI_PROVIDER_PRESETS.filter((preset) => preset.family === family)
|
||||
}
|
||||
|
||||
function instanceId(base: string): string {
|
||||
return `${base}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
|
||||
}
|
||||
|
||||
const PRESET_ALIASES: Record<string, string> = {
|
||||
zai: 'glm',
|
||||
zhipu: 'glm',
|
||||
}
|
||||
|
||||
export function createProviderFromPreset(presetId: string): AiProviderConfig {
|
||||
const preset = AI_PROVIDER_PRESETS.find((item) => item.id === presetId)
|
||||
if (presetId === 'custom') return createCustomProvider()
|
||||
const resolvedId = PRESET_ALIASES[presetId] || presetId
|
||||
const preset = AI_PROVIDER_PRESETS.find((item) => item.id === resolvedId)
|
||||
if (!preset) throw new Error(`未知 Provider 预设: ${presetId}`)
|
||||
return {
|
||||
...preset,
|
||||
@@ -173,10 +343,11 @@ export function createProviderFromPreset(presetId: string): AiProviderConfig {
|
||||
export function createCustomProvider(): AiProviderConfig {
|
||||
return {
|
||||
id: instanceId('custom'),
|
||||
name: '自定义 Provider',
|
||||
name: 'Custom',
|
||||
family: 'custom',
|
||||
protocol: 'openai-chat',
|
||||
authStyle: 'bearer',
|
||||
baseUrl: 'https://example.com/v1',
|
||||
baseUrl: '',
|
||||
allowInsecureHttp: false,
|
||||
models: [],
|
||||
defaultModelId: '',
|
||||
@@ -184,3 +355,19 @@ export function createCustomProvider(): AiProviderConfig {
|
||||
hasApiKey: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveProviderFamily(
|
||||
provider: Pick<AiProviderConfig, 'presetId' | 'family' | 'protocol' | 'authStyle' | 'baseUrl'>
|
||||
): AiProviderFamily {
|
||||
if ((provider.family as string) === 'zhipu') return 'plan'
|
||||
if (provider.family) return provider.family
|
||||
const presetId = provider.presetId || ''
|
||||
if (presetId === 'zai' || presetId === 'zhipu' || presetId === 'glm' || presetId.endsWith('-plan')) return 'plan'
|
||||
if (presetId === 'custom') return 'custom'
|
||||
if (presetId === 'anthropic' || presetId === 'google') return 'standalone'
|
||||
if (presetId === 'ollama' || presetId === 'vllm') return 'local'
|
||||
if (provider.authStyle === 'none' || /127\.0\.0\.1|localhost/i.test(provider.baseUrl)) return 'local'
|
||||
if (provider.protocol === 'anthropic') return 'standalone'
|
||||
const fromPreset = AI_PROVIDER_PRESETS.find((item) => item.id === presetId)
|
||||
return fromPreset?.family || 'openai_compatible'
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback, lazy, Suspense } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppShell from './AppShell'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useSessionStore, useCommandPaletteStore, useEntitlementStore, type Session } from '../stores'
|
||||
@@ -25,6 +26,13 @@ import HostKeyDialog from '../components/terminal/HostKeyDialog'
|
||||
import { parseSshConfig } from '../utils/sshConfig'
|
||||
import { formatSshLinkTarget } from '../utils/sshLink'
|
||||
import { suppressWebViewContextMenu } from '../utils/nativeContextMenu'
|
||||
import { encodeTextToBase64, decodeBase64ToText } from '../utils/textBase64'
|
||||
import {
|
||||
decryptSessionBackup,
|
||||
encryptSessionBackup,
|
||||
isEncryptedSessionBackup,
|
||||
} from '../utils/sessionBackupCrypto'
|
||||
import { usePromptDialog } from '../components/common/PromptDialog'
|
||||
import { licenseServerBaseUrl } from '../stores/entitlementStore'
|
||||
|
||||
const ConnectionPanel = lazy(() => import('../components/connection/ConnectionPanel'))
|
||||
@@ -32,6 +40,7 @@ const SessionDialog = lazy(() => import('../components/sessions/SessionDialog'))
|
||||
const QuickConnectDialog = lazy(() => import('../components/sessions/QuickConnectDialog'))
|
||||
const QuickLauncher = lazy(() => import('../components/sessions/QuickLauncher'))
|
||||
const SerialConnectDialog = lazy(() => import('../components/serial/SerialConnectDialog'))
|
||||
const DesktopConnectDialog = lazy(() => import('../components/desktop/DesktopConnectDialog'))
|
||||
const UpgradeWall = lazy(() => import('../components/pro/UpgradeWall'))
|
||||
const CommandPalette = lazy(() =>
|
||||
import('../components/common/CommandPalette').then((m) => ({ default: m.CommandPalette }))
|
||||
@@ -40,16 +49,20 @@ const CommandPalette = lazy(() =>
|
||||
const logger = createLogger('App')
|
||||
|
||||
function App() {
|
||||
const { t } = useTranslation(['sessions', 'common'])
|
||||
const [isSessionDialogOpen, setIsSessionDialogOpen] = useState(false)
|
||||
const [sessionDialogGroup, setSessionDialogGroup] = useState('default')
|
||||
const [isQuickConnectOpen, setIsQuickConnectOpen] = useState(false)
|
||||
const [quickConnectInitialTarget, setQuickConnectInitialTarget] = useState('')
|
||||
const [isLauncherOpen, setIsLauncherOpen] = useState(false)
|
||||
const [isSerialConnectOpen, setIsSerialConnectOpen] = useState(false)
|
||||
const [desktopConnectProtocol, setDesktopConnectProtocol] = useState<'rdp' | 'vnc' | null>(null)
|
||||
const [editSession, setEditSession] = useState<Session | null>(null)
|
||||
const { activeTabId } = useTabStore()
|
||||
const { sessions } = useSessionStore()
|
||||
const { close: closeCommandPalette } = useCommandPaletteStore()
|
||||
const { toasts, toast, removeToast } = useToast()
|
||||
const { prompt, PromptDialogComponent } = usePromptDialog()
|
||||
const { prompt: hostKeyPrompt, respond: respondHostKey } = useHostKeyPrompt()
|
||||
const settings = useSettingsStore((state) => state.settings)
|
||||
const settingsLoaded = useSettingsStore((state) => state.isLoaded)
|
||||
@@ -71,6 +84,11 @@ function App() {
|
||||
void hydrateEntitlement()
|
||||
}, [loadSettings, hydrateEntitlement])
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded) return
|
||||
void import('../i18n').then(({ setAppLanguage }) => setAppLanguage(settings.language))
|
||||
}, [settingsLoaded, settings.language])
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded) return
|
||||
getConnectionStore().setConfig({
|
||||
@@ -147,11 +165,12 @@ function App() {
|
||||
}, [])
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
useTabStore.getState().addTab({ title: '设置', type: 'settings' })
|
||||
}, [])
|
||||
useTabStore.getState().addTab({ title: t('common:tab.settings'), type: 'settings' })
|
||||
}, [t])
|
||||
|
||||
const openNewSession = useCallback(() => {
|
||||
const openNewSession = useCallback((group?: string) => {
|
||||
setEditSession(null)
|
||||
setSessionDialogGroup(group?.trim() || 'default')
|
||||
setIsSessionDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
@@ -169,52 +188,94 @@ function App() {
|
||||
if (!requirePro('batchImportExport')) return
|
||||
try {
|
||||
const result = await window.opsxApi?.showSaveDialog({
|
||||
title: '导出会话',
|
||||
defaultPath: 'opsxshell-sessions.json',
|
||||
filters: [{ name: 'JSON', extensions: ['json'] }],
|
||||
title: t('sessions:importExport.exportTitle'),
|
||||
defaultPath: t('sessions:importExport.exportDefaultPath'),
|
||||
filters: [
|
||||
{ name: t('sessions:importExport.exportFilterBak'), extensions: ['bak'] },
|
||||
{ name: t('sessions:importExport.exportFilterJson'), extensions: ['json'] },
|
||||
],
|
||||
})
|
||||
|
||||
if (result?.canceled || !result?.filePath) return
|
||||
|
||||
const password = await prompt({
|
||||
title: t('sessions:importExport.exportPasswordTitle'),
|
||||
message: t('sessions:importExport.exportPasswordMessage'),
|
||||
placeholder: t('sessions:importExport.exportPasswordPlaceholder'),
|
||||
inputType: 'password',
|
||||
confirmText: t('common:action.confirm'),
|
||||
})
|
||||
if (password == null) return
|
||||
if (!password) {
|
||||
toast.warning(t('sessions:importExport.exportPasswordRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = await prompt({
|
||||
title: t('sessions:importExport.exportPasswordConfirmTitle'),
|
||||
message: t('sessions:importExport.exportPasswordConfirmMessage'),
|
||||
placeholder: t('sessions:importExport.exportPasswordConfirmPlaceholder'),
|
||||
inputType: 'password',
|
||||
confirmText: t('common:action.confirm'),
|
||||
})
|
||||
if (confirmed == null) return
|
||||
if (confirmed !== password) {
|
||||
toast.error(t('sessions:importExport.exportPasswordMismatch'))
|
||||
return
|
||||
}
|
||||
|
||||
const exportData = sessions.map((s) => ({
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
port: s.port,
|
||||
username: s.username,
|
||||
authType: s.authType,
|
||||
password: s.password,
|
||||
privateKey: s.privateKey,
|
||||
passphrase: s.passphrase,
|
||||
group: s.group,
|
||||
tags: s.tags,
|
||||
proxy: s.proxy
|
||||
? { type: s.proxy.type, host: s.proxy.host, port: s.proxy.port, username: s.proxy.username }
|
||||
? {
|
||||
type: s.proxy.type,
|
||||
host: s.proxy.host,
|
||||
port: s.proxy.port,
|
||||
username: s.proxy.username,
|
||||
password: s.proxy.password,
|
||||
}
|
||||
: undefined,
|
||||
proxyJumpAlias: s.jumpSessionId
|
||||
? sessions.find((candidate) => candidate.id === s.jumpSessionId)?.name
|
||||
: undefined,
|
||||
}))
|
||||
|
||||
const writeResult = await window.opsxApi?.writeFile(
|
||||
result.filePath,
|
||||
JSON.stringify({ version: 1, sessions: exportData }, null, 2)
|
||||
const plaintext = JSON.stringify(
|
||||
{ version: 1, exportedAt: new Date().toISOString(), sessions: exportData },
|
||||
null,
|
||||
2
|
||||
)
|
||||
const encrypted = await encryptSessionBackup(plaintext, password)
|
||||
const writeResult = await window.opsxApi?.writeFile(result.filePath, encodeTextToBase64(encrypted))
|
||||
if (!writeResult?.success) {
|
||||
throw new Error(writeResult?.error || '写入导出文件失败')
|
||||
throw new Error(writeResult?.error || t('sessions:importExport.exportWriteFailed'))
|
||||
}
|
||||
toast.success(`已导出 ${exportData.length} 个会话到:\n${result.filePath}`)
|
||||
toast.success(t('sessions:importExport.exportSuccess', { count: exportData.length, path: result.filePath }))
|
||||
} catch (error) {
|
||||
logger.error('Export failed:', error)
|
||||
toast.error('导出会话失败')
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
toast.error(t('sessions:importExport.exportFailedDetail', { message }))
|
||||
}
|
||||
}, [requirePro, sessions, toast])
|
||||
}, [prompt, requirePro, sessions, toast, t])
|
||||
|
||||
const handleImportSessions = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.opsxApi?.showOpenDialog({
|
||||
title: '导入会话',
|
||||
title: t('sessions:importExport.importTitle'),
|
||||
filters: [
|
||||
{ name: 'JSON', extensions: ['json'] },
|
||||
{ name: 'SSH Config', extensions: ['conf', 'config'] },
|
||||
{ name: '所有文件', extensions: ['*'] },
|
||||
{ name: t('sessions:importExport.importFilterBak'), extensions: ['bak'] },
|
||||
{ name: t('sessions:importExport.importFilterJson'), extensions: ['json'] },
|
||||
{ name: t('sessions:importExport.importFilterSshConfig'), extensions: ['conf', 'config'] },
|
||||
{ name: t('sessions:importExport.importFilterAll'), extensions: ['*'] },
|
||||
],
|
||||
properties: ['openFile'],
|
||||
})
|
||||
@@ -225,32 +286,44 @@ function App() {
|
||||
const fileResult = await window.opsxApi?.readFile(filePath)
|
||||
|
||||
if (!fileResult?.success || !fileResult.data) {
|
||||
toast.error('读取文件失败')
|
||||
toast.error(
|
||||
t('sessions:importExport.readFileFailedDetail', {
|
||||
message: fileResult?.error || t('sessions:importExport.readFileFailed'),
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const fileText = decodeBase64ToText(fileResult.data)
|
||||
|
||||
type ImportCandidate = Partial<Session> & { proxyJumpAlias?: string }
|
||||
let importSessions: ImportCandidate[] = []
|
||||
let isJsonMigration = false
|
||||
try {
|
||||
const data = JSON.parse(fileResult.data)
|
||||
isJsonMigration = true
|
||||
|
||||
const parseSessionPayload = (payloadText: string) => {
|
||||
const data = JSON.parse(payloadText) as unknown
|
||||
if (Array.isArray(data)) {
|
||||
importSessions = data
|
||||
} else if (data.sessions && Array.isArray(data.sessions)) {
|
||||
importSessions = data.sessions
|
||||
} else if (data.hosts && Array.isArray(data.hosts)) {
|
||||
importSessions = data.hosts.map(
|
||||
(h: {
|
||||
return data as ImportCandidate[]
|
||||
}
|
||||
if (data && typeof data === 'object') {
|
||||
const record = data as {
|
||||
sessions?: ImportCandidate[]
|
||||
hosts?: Array<{
|
||||
name?: string
|
||||
host?: string
|
||||
port?: number
|
||||
user?: string
|
||||
password?: string
|
||||
privateKey?: string
|
||||
passphrase?: string
|
||||
group?: string
|
||||
}) => ({
|
||||
}>
|
||||
}
|
||||
if (Array.isArray(record.sessions)) {
|
||||
return record.sessions
|
||||
}
|
||||
if (Array.isArray(record.hosts)) {
|
||||
return record.hosts.map((h) => ({
|
||||
name: h.name || h.host,
|
||||
host: h.host,
|
||||
port: h.port || 22,
|
||||
@@ -258,12 +331,48 @@ function App() {
|
||||
authType: h.password ? 'password' : h.privateKey ? 'key' : 'agent',
|
||||
password: h.password,
|
||||
privateKey: h.privateKey,
|
||||
passphrase: h.passphrase,
|
||||
group: h.group,
|
||||
})
|
||||
)
|
||||
})) as ImportCandidate[]
|
||||
}
|
||||
}
|
||||
return [] as ImportCandidate[]
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(fileText) as unknown
|
||||
if (isEncryptedSessionBackup(parsed)) {
|
||||
const password = await prompt({
|
||||
title: t('sessions:importExport.importPasswordTitle'),
|
||||
message: t('sessions:importExport.importPasswordMessage'),
|
||||
placeholder: t('sessions:importExport.importPasswordPlaceholder'),
|
||||
inputType: 'password',
|
||||
confirmText: t('common:action.confirm'),
|
||||
})
|
||||
if (password == null) return
|
||||
if (!password) {
|
||||
toast.warning(t('sessions:importExport.importPasswordRequired'))
|
||||
return
|
||||
}
|
||||
let plaintext: string
|
||||
try {
|
||||
plaintext = await decryptSessionBackup(parsed, password)
|
||||
} catch (error) {
|
||||
const code = error instanceof Error ? error.message : String(error)
|
||||
if (code === 'BACKUP_PASSWORD_INVALID' || code === 'BACKUP_PASSWORD_REQUIRED') {
|
||||
toast.error(t('sessions:importExport.importPasswordInvalid'))
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
importSessions = parseSessionPayload(plaintext)
|
||||
isJsonMigration = true
|
||||
} else {
|
||||
importSessions = parseSessionPayload(fileText)
|
||||
isJsonMigration = true
|
||||
}
|
||||
} catch {
|
||||
importSessions = parseSshConfig(fileResult.data)
|
||||
importSessions = parseSshConfig(fileText)
|
||||
}
|
||||
|
||||
if (isJsonMigration && importSessions.length > 0 && !requirePro('batchImportExport')) {
|
||||
@@ -271,13 +380,13 @@ function App() {
|
||||
}
|
||||
|
||||
if (importSessions.length === 0) {
|
||||
toast.warning('未找到可导入的会话')
|
||||
toast.warning(t('sessions:importExport.noSessionsFound'))
|
||||
return
|
||||
}
|
||||
|
||||
const validSessions = importSessions.filter((s) => s.host && s.username)
|
||||
if (validSessions.length === 0) {
|
||||
toast.warning('文件中没有有效的会话配置')
|
||||
toast.warning(t('sessions:importExport.noValidSessions'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -291,9 +400,10 @@ function App() {
|
||||
host: session.host!,
|
||||
port: session.port || 22,
|
||||
username: session.username!,
|
||||
authType: session.authType || (session.privateKey ? 'key' : 'agent'),
|
||||
authType: session.authType || (session.privateKey ? 'key' : session.password ? 'password' : 'agent'),
|
||||
password: session.password,
|
||||
privateKey: session.privateKey,
|
||||
passphrase: session.passphrase,
|
||||
group: session.group,
|
||||
tags: session.tags,
|
||||
proxy: session.proxy,
|
||||
@@ -324,7 +434,7 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(`成功导入 ${created}/${validSessions.length} 个会话`)
|
||||
toast.success(t('sessions:importExport.importSuccess', { created, total: validSessions.length }))
|
||||
|
||||
const refreshResult = await window.opsxApi?.sessionGetAll()
|
||||
if (refreshResult?.success && refreshResult.sessions) {
|
||||
@@ -332,9 +442,10 @@ function App() {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Import failed:', error)
|
||||
toast.error('导入会话失败')
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
toast.error(t('sessions:importExport.importFailedDetail', { message }))
|
||||
}
|
||||
}, [requirePro, sessions, toast])
|
||||
}, [prompt, requirePro, sessions, toast, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.opsxApi) return
|
||||
@@ -363,7 +474,7 @@ function App() {
|
||||
if (tokenFromQuery) {
|
||||
const result = await useEntitlementStore.getState().activate(tokenFromQuery)
|
||||
if (result.success) {
|
||||
toast.success('Pro 许可证已激活')
|
||||
toast.success(t('common:pro.licenseActivated'))
|
||||
useEntitlementStore.getState().openUpgradeTab()
|
||||
return
|
||||
}
|
||||
@@ -381,12 +492,12 @@ function App() {
|
||||
if (data.token) {
|
||||
const result = await useEntitlementStore.getState().activate(data.token)
|
||||
if (result.success) {
|
||||
toast.success('Pro 许可证已激活')
|
||||
toast.success(t('common:pro.licenseActivated'))
|
||||
useEntitlementStore.getState().openUpgradeTab()
|
||||
return
|
||||
}
|
||||
}
|
||||
toast.info('支付完成后请粘贴许可证,或稍后再试激活')
|
||||
toast.info(t('common:pro.paymentPending'))
|
||||
useEntitlementStore.getState().openUpgradeTab()
|
||||
} catch (error) {
|
||||
logger.warn('处理 Pro 深链接失败', error)
|
||||
@@ -441,6 +552,14 @@ function App() {
|
||||
onCloseMobileSidebar={closeMobileSidebar}
|
||||
onNewSession={openNewSession}
|
||||
onQuickConnect={openQuickConnect}
|
||||
onRdpConnect={() => {
|
||||
if (!requirePro('rdp')) return
|
||||
setDesktopConnectProtocol('rdp')
|
||||
}}
|
||||
onVncConnect={() => {
|
||||
if (!requirePro('vnc')) return
|
||||
setDesktopConnectProtocol('vnc')
|
||||
}}
|
||||
onOpenLauncher={() => setIsLauncherOpen(true)}
|
||||
onEditSession={openEditSession}
|
||||
>
|
||||
@@ -454,8 +573,10 @@ function App() {
|
||||
onClose={() => {
|
||||
setIsSessionDialogOpen(false)
|
||||
setEditSession(null)
|
||||
setSessionDialogGroup('default')
|
||||
}}
|
||||
editSession={editSession}
|
||||
defaultGroup={sessionDialogGroup}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -497,6 +618,33 @@ function App() {
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
{desktopConnectProtocol && (
|
||||
<DesktopConnectDialog
|
||||
protocol={desktopConnectProtocol}
|
||||
isOpen={!!desktopConnectProtocol}
|
||||
onClose={() => setDesktopConnectProtocol(null)}
|
||||
onConnect={(protocol, config) => {
|
||||
if (protocol === 'rdp') {
|
||||
const rdpConfig = config as import('../../shared/types/desktop.types').RdpConfig
|
||||
useTabStore.getState().addTab({
|
||||
title: `RDP ${rdpConfig.host}`,
|
||||
type: 'rdp',
|
||||
rdpConfig,
|
||||
})
|
||||
} else {
|
||||
const vncConfig = config as import('../../shared/types/desktop.types').VncConfig
|
||||
useTabStore.getState().addTab({
|
||||
title: `VNC ${vncConfig.host}`,
|
||||
type: 'vnc',
|
||||
vncConfig,
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<UpgradeWall />
|
||||
</Suspense>
|
||||
@@ -523,6 +671,7 @@ function App() {
|
||||
</Suspense>
|
||||
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
{PromptDialogComponent}
|
||||
|
||||
<HostKeyDialog
|
||||
open={!!hostKeyPrompt}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { PanelLeft } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChevronsRight } from 'lucide-react'
|
||||
import Sidebar from '../components/layout/Sidebar'
|
||||
import TabBar from '../components/layout/TabBar'
|
||||
import TabContent from '../components/layout/TabContent'
|
||||
@@ -24,8 +25,10 @@ export interface AppShellProps {
|
||||
onToggleSidebar: () => void
|
||||
onToggleMobileSidebar: () => void
|
||||
onCloseMobileSidebar: () => void
|
||||
onNewSession: () => void
|
||||
onNewSession: (group?: string) => void
|
||||
onQuickConnect: () => void
|
||||
onRdpConnect?: () => void
|
||||
onVncConnect?: () => void
|
||||
onOpenLauncher: () => void
|
||||
onEditSession: (session: Session) => void
|
||||
children?: ReactNode
|
||||
@@ -42,21 +45,20 @@ function TitleBar() {
|
||||
className="bg-dark-900/95 flex h-10 flex-shrink-0 select-none items-center border-b border-dark-800"
|
||||
>
|
||||
<div data-tauri-drag-region className="h-full w-[78px] flex-shrink-0" aria-hidden />
|
||||
<div data-tauri-drag-region className="flex h-full min-w-0 flex-1 items-center justify-center px-2">
|
||||
<span className="pointer-events-none truncate text-[11px] tracking-wide text-txt-faint">OpsXShell</span>
|
||||
</div>
|
||||
<div data-tauri-drag-region className="flex h-full min-w-0 flex-1 items-center justify-center px-2" />
|
||||
<div data-tauri-drag-region className="h-full w-[78px] flex-shrink-0" aria-hidden />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileMenuButton({ onClick }: { onClick: () => void }) {
|
||||
const { t } = useTranslation('common')
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="titlebar-no-drag flex h-10 w-10 items-center justify-center text-txt-muted transition-colors hover:bg-dark-800 hover:text-content-primary"
|
||||
aria-label="切换侧边栏"
|
||||
aria-label={t('sidebar.toggle')}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
@@ -66,15 +68,16 @@ function MobileMenuButton({ onClick }: { onClick: () => void }) {
|
||||
}
|
||||
|
||||
function ExpandSidebarButton({ onClick }: { onClick: () => void }) {
|
||||
const { t } = useTranslation('common')
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="titlebar-no-drag flex h-10 w-10 flex-shrink-0 items-center justify-center border-r border-dark-700/40 text-txt-muted transition-colors hover:bg-dark-700/60 hover:text-content-primary"
|
||||
title="展开侧栏 (Ctrl/Cmd+B)"
|
||||
aria-label="展开侧栏"
|
||||
className="titlebar-no-drag group flex h-10 w-9 flex-shrink-0 items-center justify-center border-r border-dark-700/30 text-txt-faint transition-colors hover:bg-dark-700/50 hover:text-txt-muted"
|
||||
title={t('sidebar.expand')}
|
||||
aria-label={t('sidebar.expandAria')}
|
||||
>
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
<ChevronsRight className="h-3.5 w-3.5 opacity-70 transition-opacity group-hover:opacity-100" strokeWidth={1.75} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -89,12 +92,14 @@ export default function AppShell({
|
||||
onCloseMobileSidebar,
|
||||
onNewSession,
|
||||
onQuickConnect,
|
||||
onRdpConnect,
|
||||
onVncConnect,
|
||||
onOpenLauncher,
|
||||
onEditSession,
|
||||
children,
|
||||
}: AppShellProps) {
|
||||
const handleSidebarNewSession = () => {
|
||||
onNewSession()
|
||||
const handleSidebarNewSession = (group?: string) => {
|
||||
onNewSession(group)
|
||||
if (isMobile) onCloseMobileSidebar()
|
||||
}
|
||||
|
||||
@@ -151,7 +156,13 @@ export default function AppShell({
|
||||
</div>
|
||||
|
||||
<div className="titlebar-no-drag min-h-0 flex-1 overflow-hidden">
|
||||
<TabContent activeTabId={activeTabId} onNewSession={onNewSession} onQuickConnect={onQuickConnect} />
|
||||
<TabContent
|
||||
activeTabId={activeTabId}
|
||||
onNewSession={onNewSession}
|
||||
onQuickConnect={onQuickConnect}
|
||||
onRdpConnect={onRdpConnect}
|
||||
onVncConnect={onVncConnect}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill="#FFF" d="m115.679 69.288-15.591-8.94-2.689-1.163-63.781.436v32.381h82.061z"/><path fill="#F38020" d="M87.295 89.022c.763-2.617.472-5.015-.8-6.796-1.163-1.635-3.125-2.58-5.488-2.689l-44.737-.581c-.291 0-.545-.145-.691-.363s-.182-.509-.109-.8c.145-.436.581-.763 1.054-.8l45.137-.581c5.342-.254 11.157-4.579 13.192-9.885l2.58-6.723c.109-.291.145-.581.073-.872-2.906-13.158-14.644-22.97-28.672-22.97-12.938 0-23.913 8.359-27.838 19.952a13.35 13.35 0 0 0-9.267-2.58c-6.215.618-11.193 5.597-11.811 11.811-.145 1.599-.036 3.162.327 4.615C10.104 70.051 2 78.337 2 88.549c0 .909.073 1.817.182 2.726a.895.895 0 0 0 .872.763h82.57c.472 0 .909-.327 1.054-.8l.617-2.216z"/><path fill="#FAAE40" d="M101.542 60.275c-.4 0-.836 0-1.236.036-.291 0-.545.218-.654.509l-1.744 6.069c-.763 2.617-.472 5.015.8 6.796 1.163 1.635 3.125 2.58 5.488 2.689l9.522.581c.291 0 .545.145.691.363.145.218.182.545.109.8-.145.436-.581.763-1.054.8l-9.924.582c-5.379.254-11.157 4.579-13.192 9.885l-.727 1.853c-.145.363.109.727.509.727h34.089c.4 0 .763-.254.872-.654.581-2.108.909-4.325.909-6.614 0-13.447-10.975-24.422-24.458-24.422"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google Cloud</title><path fill="#4285F4" d="M12.19 2.38a9.344 9.344 0 0 0-9.234 6.893c.053-.02-.055.013 0 0-3.875 2.551-3.922 8.11-.247 10.941l.006-.007-.007.03a6.717 6.717 0 0 0 4.077 1.356h5.173l.03.03h5.192c6.687.053 9.376-8.605 3.835-12.35a9.365 9.365 0 0 0-2.821-4.552l-.043.043.006-.05A9.344 9.344 0 0 0 12.19 2.38zm-.358 4.146c1.244-.04 2.518.368 3.486 1.15a5.186 5.186 0 0 1 1.862 4.078v.518c3.53-.07 3.53 5.262 0 5.193h-5.193l-.008.009v-.04H6.785a2.59 2.59 0 0 1-1.067-.23h.001a2.597 2.597 0 1 1 3.437-3.437l3.013-3.012A6.747 6.747 0 0 0 8.11 8.24c.018-.01.04-.026.054-.023a5.186 5.186 0 0 1 3.67-1.69z"/></svg>
|
||||
|
After Width: | Height: | Size: 699 B |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Alibaba Cloud</title><path d="M3.996 4.517h5.291L8.01 6.324 4.153 7.506a1.668 1.668 0 0 0-1.165 1.601v5.786a1.668 1.668 0 0 0 1.165 1.6l3.857 1.183 1.277 1.807H3.996A3.996 3.996 0 0 1 0 15.487V8.513a3.996 3.996 0 0 1 3.996-3.996m16.008 0h-5.291l1.277 1.807 3.857 1.182c.715.227 1.17.889 1.165 1.601v5.786a1.668 1.668 0 0 1-1.165 1.6l-3.857 1.183-1.277 1.807h5.291A3.996 3.996 0 0 0 24 15.487V8.513a3.996 3.996 0 0 0-3.996-3.996m-4.007 8.345H8.002v-1.804h7.995Z"/></svg>
|
||||
|
After Width: | Height: | Size: 547 B |
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg"><path d="M95.965 47.4c-8.687-.15-17.27 3.253-23.996 9.49-.63.426-.84 1.067-.418 1.708 4.195-1.282 8.597-2.563 13.21-2.774 4.825-.426 9.86-.851 14.684 1.278 6.082 2.559 7.133 8.316 2.516 12.796-2.098 2.133-4.824 3.625-7.758 4.477-1.68.426-1.89 1.281-1.262 2.984 1.891 4.692 3.149 9.598 2.52 14.926-.211.856-.422 1.496.418 2.133 1.886 1.281 9.02 0 10.488-1.492 2.727-2.559 1.676-5.543 1.047-8.317-.836-3.41-.418-6.609 1.469-9.382 1.887-2.77 4.613-4.051 7.55-5.329 1.258-.425 2.31-1.066 3.149-2.558 1.887-3.625-.422-9.809-3.988-12.797-6.055-4.758-12.873-7.025-19.63-7.143zm22.734 24.809c-.332-.005-.669.007-1.01.033-5.66.641-9.437 4.906-9.02 10.664.422 5.328 5.036 9.38 10.278 8.953 5.453-.426 9.442-5.117 9.02-10.875-.392-5.196-4.289-8.707-9.268-8.775z" fill="#85d82e"/><path d="M69.662 63.74c-.262-.027-.523.08-.838.4 2.516 3.836 4.824 7.891 6.711 12.153 1.68 4.692 3.567 9.383 2.938 14.715-.63 6.398-5.875 9.168-11.328 5.969-2.727-1.493-4.825-3.625-6.5-6.184-1.051-1.492-2.098-1.492-3.356-.426-3.988 3.414-8.18 5.973-13.426 6.824-.836 0-1.675 0-2.097.852-.63 2.133 2.52 8.742 4.406 9.809 3.144 1.921 5.664 0 8.18-1.493 2.937-1.703 5.87-2.343 9.226-1.277 3.149 1.067 5.246 3.41 7.133 5.969.84 1.066 1.68 1.918 3.355 2.347 3.985 1.067 9.438-3.199 11.118-7.464 6.293-16.418-.84-33.266-14.684-41.793-.314-.213-.576-.374-.838-.4zm-9.017 44.112c-5.036 0-9.23 4.265-9.23 9.808 0 6.183 3.983 10.024 10.07 10.024 4.82-.215 9.015-4.692 9.015-9.809 0-5.332-4.824-10.023-9.855-10.023z" fill="#22c1ff"/><path d="M99.096 4.623a8.923 8.923 0 00-.49.025c-5.454.426-9.227 5.329-8.81 10.66.419 5.333 5.243 9.383 10.696 8.958 5.035-.426 9.23-5.332 8.813-10.45-.41-5.165-5.144-9.325-10.21-9.193zM80.789 7.607c-2.765-.032-5.766.906-7.77 2.584-13.632 11.09-15.94 29.641-8.18 44.352.208.64.63 1.281 1.466.855v-.002c-.207-4.69-.418-9.38.422-13.86.84-5.118 1.675-10.235 4.824-14.497 3.773-5.332 9.648-5.121 12.793.637 1.68 2.773 2.308 5.758 2.515 8.957 0 1.918.84 2.344 2.52 2.133 5.032-.852 10.066-.852 14.89 1.066.63.211 1.258.64 2.098 0 1.676-1.492 2.098-8.957 1.047-10.875-1.887-3.414-4.824-2.984-7.758-2.984-3.355.21-6.293-.856-8.601-3.625-2.098-2.559-2.727-5.758-3.145-8.957-.21-1.492-.422-2.774-1.68-3.84-1.283-1.306-3.29-1.918-5.44-1.944z" fill="#ffc911"/><path d="M21.68 47.53c-.39.014-.833.163-1.305.404-.84.425-1.68.851-2.309 1.28-5.453 3.41-6.082 7.677-1.468 12.368 3.355 3.41 5.035 7.25 3.777 11.941-.629 2.344-1.89 4.051-3.355 5.97-1.051 1.28-1.47 2.558-1.262 4.265.422 4.05 4.406 8.101 9.23 8.527 13.422 1.281 24.328-3.41 31.88-14.926 2.519-3.624 4.405-7.675 5.245-11.941-.84-.211-1.05.215-1.68.43-4.823 5.117-9.859 10.02-16.359 13.433-2.515 1.278-5.242 2.344-8.18 2.344-4.613 0-7.34-2.984-6.921-7.676.21-3.84 1.68-7.25 4.195-10.023 1.258-1.492 1.258-2.344-.211-3.625-4.195-2.985-7.969-6.61-9.855-11.516-.264-.932-.771-1.281-1.422-1.256zM9.646 60.872c-.336.006-.676.028-1.017.068-4.824.426-9.02 5.543-8.602 10.66C.45 76.935 5.48 81.2 10.516 80.56c6.082-.426 9.23-6.399 9.02-11.301-.396-4.798-4.842-8.472-9.89-8.385z" fill="#0068d8"/><path d="M33.938.316a9.087 9.087 0 00-.981.067c-5.66.64-9.02 5.117-8.387 11.3.629 4.692 5.66 8.743 10.485 8.106 5.664-.856 9.437-5.547 8.808-11.09-.59-4.6-5.048-8.447-9.925-8.383zm17.26 6.938c-2.666-.061-4.711 1.697-5.866 5.07-2.516 7.465-7.34 10.235-15.102 8.742-1.046-.214-2.097-.425-2.515-.425-3.985.64-6.293 2.984-6.922 6.183-.84 9.81 4.195 16.844 11.117 22.602 7.34 6.398 16.36 7.89 25.797 7.465.84 0 1.68 0 1.887-.641.21-1.066-.836-1.066-1.469-1.277-4.402-1.922-9.016-3.414-13.004-6.188-3.984-2.769-8.598-5.117-10.277-10.445-1.258-4.051.422-7.676 4.406-8.957 3.566-1.281 7.133-1.281 10.488 0 2.098.851 2.934.426 3.356-1.918.629-4.051 1.886-7.89 4.402-11.3 2.938-4.267 2.938-4.692-1.887-7.466-.21 0-.21-.215-.418-.215-1.442-.8-2.782-1.202-3.994-1.23z" fill="#ff4546"/></svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Alpine Linux</title><path d="M5.998 1.607L0 12l5.998 10.393h12.004L24 12 18.002 1.607H5.998zM9.965 7.12L12.66 9.9l1.598 1.595.002-.002 2.41 2.363c-.2.14-.386.252-.563.344a3.756 3.756 0 01-.496.217 2.702 2.702 0 01-.425.111c-.131.023-.25.034-.358.034-.13 0-.242-.014-.338-.034a1.317 1.317 0 01-.24-.072.95.95 0 01-.2-.113l-1.062-1.092-3.039-3.041-1.1 1.053-3.07 3.072a.974.974 0 01-.2.111 1.274 1.274 0 01-.237.073c-.096.02-.209.033-.338.033-.108 0-.227-.009-.358-.031a2.7 2.7 0 01-.425-.114 3.748 3.748 0 01-.496-.217 5.228 5.228 0 01-.563-.343l6.803-6.727zm4.72.785l4.579 4.598 1.382 1.353a5.24 5.24 0 01-.564.344 3.73 3.73 0 01-.494.217 2.697 2.697 0 01-.426.111c-.13.023-.251.034-.36.034-.129 0-.241-.014-.337-.034a1.285 1.285 0 01-.385-.146c-.033-.02-.05-.036-.053-.04l-1.232-1.218-2.111-2.111-.334.334L12.79 9.8l1.896-1.897zm-5.966 4.12v2.529a2.128 2.128 0 01-.356-.035 2.765 2.765 0 01-.422-.116 3.708 3.708 0 01-.488-.214 5.217 5.217 0 01-.555-.34l1.82-1.825Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="256px" height="153px" viewBox="0 0 256 153" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
|
||||
<g>
|
||||
<path d="M72.392053,55.4384106 C72.392053,58.5748344 72.7311258,61.1178808 73.3245033,62.9827815 C74.002649,64.8476821 74.8503311,66.8821192 76.0370861,69.0860927 C76.4609272,69.7642384 76.6304636,70.4423841 76.6304636,71.0357616 C76.6304636,71.8834437 76.1218543,72.7311258 75.0198675,73.5788079 L69.6794702,77.1390728 C68.9165563,77.6476821 68.1536424,77.9019868 67.4754967,77.9019868 C66.6278146,77.9019868 65.7801325,77.4781457 64.9324503,76.7152318 C63.7456954,75.4437086 62.7284768,74.0874172 61.8807947,72.7311258 C61.0331126,71.2900662 60.1854305,69.6794702 59.2529801,67.7298013 C52.6410596,75.5284768 44.3337748,79.4278146 34.3311258,79.4278146 C27.210596,79.4278146 21.5311258,77.3933775 17.3774834,73.3245033 C13.2238411,69.2556291 11.1046358,63.8304636 11.1046358,57.0490066 C11.1046358,49.8437086 13.6476821,43.994702 18.818543,39.586755 C23.989404,35.1788079 30.8556291,32.9748344 39.586755,32.9748344 C42.4688742,32.9748344 45.4357616,33.2291391 48.5721854,33.6529801 C51.7086093,34.0768212 54.9298013,34.7549669 58.3205298,35.5178808 L58.3205298,29.3298013 C58.3205298,22.8874172 56.9642384,18.394702 54.3364238,15.7668874 C51.6238411,13.1390728 47.0463576,11.8675497 40.5192053,11.8675497 C37.5523179,11.8675497 34.5006623,12.2066225 31.3642384,12.9695364 C28.2278146,13.7324503 25.1761589,14.6649007 22.2092715,15.8516556 C20.8529801,16.4450331 19.8357616,16.784106 19.2423841,16.9536424 C18.6490066,17.1231788 18.2251656,17.207947 17.8860927,17.207947 C16.6993377,17.207947 16.1059603,16.3602649 16.1059603,14.5801325 L16.1059603,10.4264901 C16.1059603,9.07019868 16.2754967,8.05298013 16.6993377,7.45960265 C17.1231788,6.86622517 17.8860927,6.27284768 19.0728477,5.6794702 C22.0397351,4.15364238 25.6,2.88211921 29.7536424,1.86490066 C33.9072848,0.762913907 38.3152318,0.254304636 42.9774834,0.254304636 C53.0649007,0.254304636 60.4397351,2.54304636 65.186755,7.1205298 C69.8490066,11.6980132 72.2225166,18.6490066 72.2225166,27.9735099 L72.2225166,55.4384106 L72.392053,55.4384106 Z M37.9761589,68.3231788 C40.7735099,68.3231788 43.6556291,67.8145695 46.7072848,66.797351 C49.7589404,65.7801325 52.4715232,63.9152318 54.7602649,61.3721854 C56.1165563,59.7615894 57.1337748,57.981457 57.6423841,55.9470199 C58.1509934,53.9125828 58.4900662,51.4543046 58.4900662,48.5721854 L58.4900662,45.0119205 C56.0317881,44.418543 53.4039735,43.9099338 50.6913907,43.5708609 C47.9788079,43.2317881 45.3509934,43.0622517 42.7231788,43.0622517 C37.0437086,43.0622517 32.8900662,44.1642384 30.0927152,46.4529801 C27.2953642,48.7417219 25.9390728,51.9629139 25.9390728,56.2013245 C25.9390728,60.1854305 26.9562914,63.1523179 29.0754967,65.186755 C31.1099338,67.3059603 34.0768212,68.3231788 37.9761589,68.3231788 Z M106.045033,77.4781457 C104.519205,77.4781457 103.501987,77.2238411 102.823841,76.6304636 C102.145695,76.1218543 101.552318,74.9350993 101.043709,73.3245033 L81.1231788,7.7986755 C80.6145695,6.10331126 80.3602649,5.0013245 80.3602649,4.40794702 C80.3602649,3.05165563 81.0384106,2.28874172 82.394702,2.28874172 L90.7019868,2.28874172 C92.3125828,2.28874172 93.4145695,2.54304636 94.007947,3.13642384 C94.6860927,3.64503311 95.194702,4.83178808 95.7033113,6.44238411 L109.944371,62.5589404 L123.168212,6.44238411 C123.592053,4.74701987 124.100662,3.64503311 124.778808,3.13642384 C125.456954,2.62781457 126.643709,2.28874172 128.169536,2.28874172 L134.950993,2.28874172 C136.561589,2.28874172 137.663576,2.54304636 138.341722,3.13642384 C139.019868,3.64503311 139.613245,4.83178808 139.952318,6.44238411 L153.345695,63.2370861 L168.010596,6.44238411 C168.519205,4.74701987 169.112583,3.64503311 169.70596,3.13642384 C170.384106,2.62781457 171.486093,2.28874172 173.011921,2.28874172 L180.895364,2.28874172 C182.251656,2.28874172 183.01457,2.96688742 183.01457,4.40794702 C183.01457,4.83178808 182.929801,5.25562914 182.845033,5.76423841 C182.760265,6.27284768 182.590728,6.95099338 182.251656,7.88344371 L161.822517,73.4092715 C161.313907,75.1046358 160.72053,76.2066225 160.042384,76.7152318 C159.364238,77.2238411 158.262252,77.5629139 156.821192,77.5629139 L149.531126,77.5629139 C147.92053,77.5629139 146.818543,77.3086093 146.140397,76.7152318 C145.462252,76.1218543 144.868874,75.0198675 144.529801,73.3245033 L131.390728,18.6490066 L118.336424,73.2397351 C117.912583,74.9350993 117.403974,76.0370861 116.725828,76.6304636 C116.047682,77.2238411 114.860927,77.4781457 113.335099,77.4781457 L106.045033,77.4781457 Z M214.972185,79.7668874 C210.564238,79.7668874 206.156291,79.2582781 201.917881,78.2410596 C197.67947,77.2238411 194.37351,76.1218543 192.169536,74.8503311 C190.813245,74.0874172 189.880795,73.2397351 189.541722,72.4768212 C189.202649,71.7139073 189.033113,70.8662252 189.033113,70.1033113 L189.033113,65.7801325 C189.033113,64 189.711258,63.1523179 190.982781,63.1523179 C191.491391,63.1523179 192,63.2370861 192.508609,63.4066225 C193.017219,63.5761589 193.780132,63.9152318 194.627815,64.2543046 C197.509934,65.5258278 200.646358,66.5430464 203.952318,67Line truncated
|
||||
<path d="M230.993377,120.964238 C203.104636,141.562914 162.58543,152.498013 127.745695,152.498013 C78.9192053,152.498013 34.9245033,134.442384 1.69536424,104.434437 C-0.932450331,102.060927 1.4410596,98.8397351 4.57748344,100.704636 C40.5192053,121.557616 84.8529801,134.188079 130.712583,134.188079 C161.65298,134.188079 195.645033,127.745695 226.924503,114.521854 C231.586755,112.402649 235.570861,117.57351 230.993377,120.964238 Z M242.606623,107.740397 C239.046358,103.162914 219.04106,105.536424 209.970861,106.638411 C207.258278,106.977483 206.834437,104.603974 209.292715,102.823841 C225.229139,91.6344371 251.422517,94.8556291 254.474172,98.5854305 C257.525828,102.4 253.62649,128.593377 238.707285,141.139073 C236.418543,143.088742 234.21457,142.071523 235.231788,139.528477 C238.622517,131.136424 246.166887,112.233113 242.606623,107.740397 Z" fill="#FF9900"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M513.2288 51.2c-122.368 0-239.7696 48.9472-326.2976 136.2944a467.8144 467.8144 0 0 0-135.168 329.2672c0 4.352 0.512 8.6528 0.5632 12.9024l-1.024 319.744c-0.8192 20.7872 3.9424 41.472 13.7728 59.904 9.728 18.3808 21.504 32.256 41.984 44.8512 20.2752 12.544 37.7856 16.9472 58.5216 17.3568a115.712 115.712 0 0 0 59.2384-14.848 118.1696 118.1696 0 0 0 60.0064-102.2976v-35.4816l1.4336-168.192V519.168a98.5088 98.5088 0 0 0-24.5248-66.4064 73.8816 73.8816 0 0 1 5.3248-102.144 72.192 72.192 0 0 1 101.376 2.56 73.8816 73.8816 0 0 1 0.3072 102.2464 93.9008 93.9008 0 0 0-24.5248 64.7168v27.7504s12.2368 103.0656 85.0944 10.3424l46.08-75.8784c12.6464-20.8384 17.2544-45.7728 12.8-69.7856a73.216 73.216 0 0 1 56.32-85.3504 72.704 72.704 0 0 1 85.8624 54.9376 73.3696 73.3696 0 0 1-52.3264 87.9104c-22.7328 6.656-41.984 21.76-54.016 42.1376l-48.7936 80.0768s-24.9344 47.5136 32.512 47.5136l66.048-2.0992c23.7056-0.3072 46.3872-9.728 63.4368-26.2656a72.0384 72.0384 0 0 1 101.376-2.1504c28.7744 27.136 30.7712 72.4992 4.608 102.1952a71.9872 71.9872 0 0 1-101.2224 7.168 96.9216 96.9216 0 0 0-66.3552-23.8592s-66.6624-1.024-111.0528 49.9712a139.3664 139.3664 0 0 0-22.1696 149.248c1.1776 2.56 2.56 5.0688 3.7888 7.936l1.536 2.304c0.9728 1.7408 2.048 3.3792 3.072 5.0176l1.8432 2.816c1.024 1.536 2.048 2.8672 3.2256 4.3008l2.304 2.9184c1.1264 1.4336 2.2528 2.7648 3.4304 3.9936l2.7136 2.9696 3.6864 3.7376 3.072 2.7648 4.096 3.584 3.4304 2.56 4.5568 3.3792a48.896 48.896 0 0 0 3.584 2.4064 48.9984 48.9984 0 0 0 5.12 3.072l3.8912 2.3552c1.8432 1.024 3.7376 1.9968 5.5808 2.8672l4.1984 2.048 6.5536 2.6624 4.1472 1.7408 7.936 2.7136 3.4304 1.1264c4.0448 1.28 7.8848 2.304 12.4416 3.328l2.4576 0.512c3.6352 0.768 7.3216 1.536 10.9568 2.1504l5.12 0.768c3.0208 0.4608 6.0416 0.9216 9.2672 1.2288l6.144 0.6144 8.3456 0.6144c8.8576 0.5632 17.8176 0.3072 26.624-0.5632C839.168 921.6 994.816 704.512 970.24 468.224 945.7152 231.8848 748.7488 52.224 513.28 51.2h-0.0512z" fill="#59AB2D" /></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><g fill="#1791cf"><path fill-rule="evenodd" d="M61.113 4.886C55.82 17.788 52.629 26.23 46.738 38.749c3.613 3.805 8.047 8.242 15.246 13.25-7.742-3.168-13.02-6.347-16.969-9.648-7.539 15.644-19.351 37.933-43.324 80.77 18.844-10.817 33.45-17.485 47.059-20.032a33.989 33.989 0 0 1-.895-8.023l.024-.602c.296-12.004 6.578-21.238 14.015-20.61 7.438.626 13.223 10.872 12.922 22.876-.055 2.261-.312 4.433-.762 6.449 13.465 2.621 27.914 9.273 46.5 19.941-3.664-6.707-6.933-12.757-10.058-18.52-4.922-3.792-10.055-8.726-20.524-14.073 7.196 1.863 12.348 4.007 16.364 6.406C74.578 38.12 72.004 30.308 61.113 4.886Zm0 0"/><path d="M121.14 112.57v-3.243h-1.215v-.433h2.93v.433h-1.223v3.243h-.492M123.363 112.57v-3.676h.734l.875 2.601c.082.243.14.426.176.543.043-.132.11-.328.2-.586l.882-2.558h.66v3.676h-.472v-3.079l-1.075 3.079h-.441l-1.066-3.13v3.13h-.473"/></g></svg>
|
||||
|
After Width: | Height: | Size: 916 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<path d="M431.44 474.564l36.916 36.708-36.916 36.7H171.604v122.284L13.144 511.272l158.46-156.32v119.612h259.84z m114.216-312.272h315.716v315.72h-315.716v-315.72z" fill="#932279"/>
|
||||
<path d="M549.1 431.44l-36.7 36.916-36.7-36.916V171.604H353.408L512.4 13.144l156.308 158.46h-119.608v259.84z m-3.44 114.216h315.712v315.72h-315.716v-315.72z" fill="#EFA724"/>
|
||||
<path d="M592.228 549.1l-36.92-36.7 36.92-36.7h259.832V353.408l158.468 158.992-158.468 156.308v-119.608h-259.832z m-429.936-3.44h315.716v315.72H162.292v-315.72z" fill="#262577"/>
|
||||
<path d="M474.564 592.228l36.7-36.92 36.708 36.92v259.84h122.284l-158.992 158.46-156.304-158.46h119.604v-259.84zM162.292 162.292h315.716v315.72H162.292v-315.72z" fill="#9CCD2A"/>
|
||||
<path d="M151.016 151.016h338.268v338.268H151.016V151.016z m22.548 315.72h293.164V173.572H173.564v293.164z m360.824-315.72h338.268v338.268h-338.268V151.016z m22.548 315.72h293.164V173.572h-293.164v293.164z m-22.548 67.652h338.268v338.276h-338.268v-338.28z m22.548 315.712h293.164v-293.164h-293.164v293.164z m-405.92-315.712h338.268v338.276H151.016v-338.28z m22.548 315.712h293.164v-293.164H173.564v293.164z" fill="#FFFFFF"/>
|
||||
<path d="M240.752 751.032l-239.2-239.2L240.748 272.64l239.2 239.192-239.192 239.2z m-207.304-239.2l207.304 207.304 207.296-207.304-207.304-207.296-207.296 207.296z m478.384-31.888L272.64 240.752l239.192-239.2 239.192 239.2-239.192 239.192zM304.536 240.752l207.296 207.296 207.304-207.296-207.304-207.304-207.296 207.304z m478.384 510.28l-239.192-239.2L782.92 272.64l239.192 239.192-239.192 239.2z m-207.304-239.2l207.304 207.304 207.296-207.304-207.296-207.296-207.304 207.296z m-63.784 510.28L272.64 782.92l239.192-239.192 239.192 239.192-239.192 239.192zM304.536 782.92l207.296 207.296 207.304-207.296-207.304-207.296-207.296 207.296z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48" /></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M676.16 59.84c-68.96-25.184-139.584-32-208.192-25.6-79.552 6.304-118.336 30.912-116.032 26.464-130.048 46.4-240 146.24-290.88 286.272-90.624 249.344 37.76 525.12 286.72 615.904 249.12 90.784 524.448-37.792 615.136-287.168 90.688-249.28-37.728-525.056-286.784-615.808l0.032-0.032zM364.192 917.76a426.88 426.88 0 0 1-76.544-37.216l1.856 1.056c116.032 8.896 267.456-17.792 369.6-113.344 0 0 194.624-155.52 53.76-410.752 0 0 22.72 102.848-6.208 187.456 0 0-27.616 115.072-150.272 148.48-180.704 49.216-386.56-77.216-472.64-138.528-6.496-62.944-0.736-128 22.4-191.488 34.176-94.08 98.496-167.52 177.088-216.8-19.616 137.408-4.096 264 18.24 316.992 29.952 71.04 81.984 153.984 183.584 164.608 101.6 10.656 157.6-84.416 157.6-84.416 52.16-79.168 60.32-192.896 59.52-195.68-0.8-2.784-13.888-10.272-13.888-10.272C653.216 479.808 595.36 527.2 595.36 527.2c-91.2 88.064-155.776 27.008-155.776 27.008-69.44-74.624-20.8-195.808-20.8-195.808 27.2-82.56 106.432-203.36 196.224-264.8 14.944 3.68 29.984 6.016 44.8 11.36 55.008 20.48 102.24 49.792 142.432 86.688l-0.288-0.288c-65.632 24-171.616 74.624-171.616 74.624-168.032 70.784-179.424 177.824-179.424 177.824-17.376 110.304 70.176 63.584 70.176 63.584 90.112-43.872 134.4-180.608 134.4-180.608a100.096 100.096 0 0 0-50.304 3.2c-35.968 88.8-109.312 125.28-109.312 125.28-28.896 15.264-35.616-11.712-35.616-11.712-4.896-20.256 20.864-23.68 20.864-23.68 40-15.584 65.568-57.568 71.36-74.72 5.76-17.184 16.48-18.56 16.48-18.56 30.336-9.92 65.92-17.504 102.592-21.216l2.176-0.192c68.032-8.352 172.128 24.096 172.128 24.096a647.606 647.606 0 0 0 65.632 22.048c38.144 95.36 44.16 203.904 6.4 308-81.6 224.384-329.44 340.16-553.664 258.464h-0.032z" fill="#007CFF" /></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path d="M127.82 64.004C127.82 28.754 99.246.18 64 .18 28.766.18.203 28.73.18 63.957v49.39c.02 7.997 6.504 14.473 14.508 14.473h49.335c35.239-.015 63.797-28.578 63.797-63.816" fill="#294172"/><path d="M36.973 68.12H59.91V91.06c0 12.66-10.273 22.937-22.937 22.937-12.66 0-22.934-10.277-22.934-22.937 0-12.66 10.274-22.938 22.934-22.938zm0 0" fill="none" stroke-width="14.003274" stroke="#3c6eb4"/><path d="M82.738 68.164H59.801V45.231c0-12.66 10.277-22.938 22.937-22.938 12.66 0 22.938 10.274 22.938 22.938 0 12.66-10.278 22.933-22.938 22.933zm0 0" fill="none" stroke-width="14.003274" stroke="#3c6eb4"/><path d="M66.926 61.137v29.89c0 16.54-13.41 29.953-29.95 29.953-2.511 0-4.296-.285-6.617-.89-3.39-.887-6.156-3.664-6.16-6.895 0-3.906 2.836-6.746 7.074-6.746 2.016 0 2.747.387 5.704.387 8.718 0 15.793-7.063 15.808-15.785V77.312c0-1.23-1-2.23-2.234-2.226l-10.387-.004c-3.867 0-6.996-3.086-6.996-6.965 0-3.906 3.16-6.98 7.07-6.98" fill="#fff"/><path d="M52.785 75.148V45.262c0-16.543 13.41-29.953 29.953-29.953 2.508 0 4.293.28 6.617.89 3.387.887 6.157 3.664 6.157 6.895 0 3.906-2.836 6.746-7.07 6.746-2.02 0-2.75-.387-5.704-.387-8.722 0-15.797 7.063-15.812 15.781v13.743a2.235 2.235 0 002.234 2.226l10.387.004c3.871 0 6.996 3.086 6.996 6.965.004 3.906-3.16 6.98-7.07 6.98" fill="#fff"/><path d="M116.809 116.773v-2.652l-1.211 2.781-1.18-2.78v2.651h-.68v-4.187h.711l1.168 2.676 1.149-2.676h.722v4.187zm-4.954-3.484v3.484h-.71v-3.484h-1.192v-.703h3.09v.703" fill="#3c6eb4"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>FreeBSD</title><path fill="#AB2B28" d="M23.682 2.406c-.001-.149-.097-.187-.24-.189h-.25v.659h.108v-.282h.102l.17.282h.122l-.184-.29c.102-.012.175-.065.172-.18zm-.382.096v-.193h.13c.06-.002.145.011.143.089.005.09-.08.107-.153.103h-.12zM21.851 1.49c1.172 1.171-2.077 6.319-2.626 6.869-.549.548-1.944.044-3.115-1.128-1.172-1.171-1.676-2.566-1.127-3.115.549-.55 5.697-3.798 6.868-2.626zM1.652 6.61C.626 4.818-.544 2.215.276 1.395c.81-.81 3.355.319 5.144 1.334A11.003 11.003 0 0 0 1.652 6.61zm18.95.418a10.584 10.584 0 0 1 1.368 5.218c0 5.874-4.762 10.636-10.637 10.636C5.459 22.882.697 18.12.697 12.246.697 6.371 5.459 1.61 11.333 1.61c1.771 0 3.441.433 4.909 1.199-.361.201-.69.398-.969.574-.428-.077-.778-.017-.998.202-.402.402-.269 1.245.263 2.2.273.539.701 1.124 1.25 1.674.103.104.208.202.315.297 1.519 1.446 3.205 2.111 3.829 1.486.267-.267.297-.728.132-1.287.167-.27.35-.584.538-.927zm2.814-5.088c-.322 0-.584.266-.584.595s.261.595.584.595c.323 0 .584-.266.584-.595s-.261-.595-.584-.595zm0 1.087c-.252 0-.457-.22-.457-.492s.204-.492.457-.492c.252 0 .457.22.457.492s-.204.492-.457.492z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill="#2777ff" d="M68.15 31.699s-10.512-.7-28.411 4.901c-18.242 5.708-28.587 13.798-28.587 13.798s27.188-15.18 57.876-16.043zm39.206 16.505 1.367-.09s-7.83-9.499-22.815-14.126c8.424 3.424 15.756 7.961 21.448 14.215zm2.237 3.946c.21-.364.888 1.158 1.403 1.797.022.128.058.205-.24.144-.026-.131-.069-.17-.069-.17s-.72-.428-.942-.732c-.22-.305-.26-.837-.152-1.039m18.39 45.218s1.662-19.081-28.317-23.48a97.5 97.5 0 0 0-13.465-1c-24.029.318-24.905-27.713-6.798-29.128 7.503-.62 16.463 3.429 25.223 7.508-.033 1.088.012 2.055.727 2.948s3.46 1.867 4.336 2.37 3.687 2.29 5.409 4.531c.373-.698 3.491-2.728 3.491-2.728s-.747.016-2.485-.633c-1.738-.65-3.8-2.615-3.849-2.728-.048-.114-.08-.292.325-.374.309-.26-.39-1.104-.698-1.412-.309-.309-2.371-3.816-2.42-3.898-.049-.08-.065-.162-.211-.26-.455-.146-2.453.212-2.453.212s-3.072-1.51-4.13-4.762c.015.57-.527 1.192 0 2.5-1.603-.677-2.978-1.833-4.064-4.689-.646 1.625 0 2.658 0 2.658s-3.772-1.054-4.375-4.533c-.662 1.562 0 2.5 0 2.5s-6.15-3.208-16.367-3.254c-6.841-.628-8.265-12.662-7.631-14.688 0 0-9.867-5.2-29.29-7.497-19.424-2.298-35.353-.346-35.353-.346s34.4-1.651 61.96 9.506c.936 4.19 3.754 11.169 5.272 14.523-4.343 3.004-9.241 5.827-10.004 15.843s7.848 18.824 18.526 19.095c10.139.54 17.144.617 25.635 5.02 8.105 4.48 14.75 18.136 15.408 30.417.71-9.112-2.71-28.707-18.666-34.656 22.302 3.902 24.264 20.436 24.264 20.436zM67.624 30.202l-.802-2.588s-13.241-2.351-31.044-1.086C17.975 27.794 0 34.025 0 34.025s36.78-9.254 67.624-3.823"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M525.2 198.3c-8.6 5.6-15.2 13.8-18.9 23.4-3.8 12.4-3.2 25.6 1.5 37.7 3.9 12.7 11.7 23.8 22.2 31.8 5.4 3.8 11.6 6.2 18.2 7 6.6 0.8 13.2-0.3 19.1-3.3 7-3.9 12.6-10 15.9-17.3 3.2-7.4 5-15.3 5.2-23.3 0.7-10.2-0.6-20.4-3.8-30.1-3.5-10.6-10.3-19.7-19.5-25.9-4.7-3-9.9-5-15.4-5.8-5.5-0.8-11.1-0.2-16.3 1.8-2.9 1.2-5.7 2.7-8.3 4.5" fill="#FFFFFF" /><path d="M810.2 606.5c-5.1-28.3-13.1-56-23.8-82.6-7.3-19.8-17.2-38.6-29.5-55.8-12.4-16.5-28.1-30.4-40.2-47.1-6.4-8.7-11.8-18.4-18.5-26.9-2.7-5.6-5.3-11.2-7.9-16.8-8-17.5-15.3-35.4-24.8-52-1.5-2.6-3.1-5.2-4.6-7.7-1.2-16-2.9-32-3.8-48 0.7-32.1-2-64.3-8.1-95.9-4.2-15.1-10.6-29.6-19-42.8-9.8-15.6-22.4-29.2-37.2-40.1-24.1-17.1-52.9-26.3-82.4-26.4-21.7-0.5-43.2 4.4-62.5 14.4-20.3 11.1-36.7 28.2-47 48.9-9.6 20.9-14.7 43.5-15 66.5-0.8 22.6 1.3 45 2.2 67.6 0.9 23.4 0.4 46.9 2.3 70.3 0.6 7.5 1.5 15 1.5 22.6 0 3.8-0.2 7.6-0.3 11.3l-0.3 0.8c-10.2 17.3-21.5 34-33.8 49.9-8.6 10.9-17.2 21.7-25.9 32.4-11.3 12.7-20.9 26.8-28.5 42-5.1 13.2-9.2 26.8-12.4 40.6l-0.3 1.1c-4.8 15.9-10.8 31.3-18 46.2-0.7 1.4-1.4 2.9-2 4.2-4.3 8.9-8.8 17.8-13.5 26.5l-5.4 10.1c-3.4 6.1-6.4 12.4-9 18.8-1.5 3.9-2.7 7.9-3.4 12-1.3 8.7-0.7 17.5 1.6 25.9 0.5 2.1 1.2 4.2 1.9 6.3 2.2 6.2 4.8 12.3 7.9 18.1 1.4 2.7 2.9 5.3 4.3 8l1.3 1.9c1.4 2.5 2.9 5 4.4 7.4l0.2 0.3c1.7 2.8 3.6 5.5 5.4 8.2l0.3 0.4c1.9 2.6 3.8 5.3 5.8 7.9 7.4 28.9 21 55.8 39.7 79-2.9 5.1-5.5 10.1-8.4 15.1-10.2 14.8-18.6 30.7-25.1 47.4-2.7 8.6-3.4 17.7-1.9 26.6 1.4 9 6 17.1 13 23 4.7 3.6 10.1 6.1 15.8 7.3 5.7 1.2 11.6 1.8 17.5 1.5 22.2-1.7 44.2-6.1 65.4-12.9 12.8-3.4 25.6-6.4 38.6-9 13.5-3.1 27.2-5 41-5.6 3.4 0.1 6.8-0.1 10.1-0.3 9.4 1 18.8 1.4 28.3 1l3.5-0.2c2.4 0.3 4.9 0.4 7.4 0.6 16.6 0.9 33.1 2.6 49.5 5.1 14.4 2.2 28.8 5 43 8.5 21.9 6.6 44.4 11 67.3 12.9 6 0.3 12-0.2 18-1.4 5.9-1.2 11.5-3.8 16.3-7.4 7-5.8 11.6-13.9 13.1-22.9 1.5-8.9 0.8-18-1.9-26.6-6.6-16.7-15.1-32.6-25.5-47.3-3.6-6.1-7-12.4-10.6-18.5 15.5-17.3 29.2-36.3 40.7-56.5 7 0.4 13.9-0.4 20.6-2.6 17.5-5.9 32.7-17.3 43.3-32.5 3.2-4.5 5.7-9.5 7.2-14.8 6.9-10.7 11.6-22.7 13.8-35.3 3.2-20.8 2.7-42.1-1.5-62.7h-0.2z m0 0" fill="#020204" /><path d="M425.6 323.2c-3.1 4-5.3 8.7-6.4 13.6-1.1 4.9-1.8 10-1.9 15 0.3 10.1-0.5 20.2-2.5 30.1-3.5 10.3-8.8 19.8-15.6 28.3-11.7 14.7-20.9 31.2-27.2 48.8-3.2 10.9-4.3 22.3-3.1 33.7-12.1 17.9-22.6 36.9-31.3 56.7-13.4 29.9-22 61.8-25.5 94.4-4.3 40.1 1.6 80.6 17 117.8 11.3 26.8 28.5 50.8 50.3 70.1 11.2 9.7 23.5 17.9 36.7 24.4 46.7 22.8 101.4 22.3 147.6-1.4 23.1-13.5 44.2-30.2 62.6-49.5 11.9-10.8 22.5-22.9 31.8-36.1 15.5-26.9 24.6-57.1 26.5-88.1 9.6-53.6 3.7-108.8-16.9-159.2-8.1-16.8-18.8-32.2-31.8-45.6a252.5 252.5 0 0 0-20.2-68c-7.2-15.5-15.9-30.3-22.6-46.2-2.7-6.5-5.1-13.1-8.1-19.4-2.9-6.4-6.9-12.3-11.8-17.3-5.3-4.9-11.6-8.6-18.5-10.7-6.9-2.2-14-3.4-21.2-3.6-14.4-0.7-28.9 1.1-43.1 0.6-11.5-0.5-22.8-2.5-34.3-1.8-5.7 0.3-11.4 1.4-16.7 3.5-5.4 2.1-10.1 5.5-13.8 10m4.6-125.1c-5.4 0.4-10.5 2.7-14.4 6.4-3.9 3.7-6.8 8.4-8.4 13.5-2.7 10.4-3.4 21.3-1.9 32 0.2 9.7 1.9 19.4 5.1 28.6 1.8 4.5 4.4 8.7 7.8 12.2 3.4 3.5 7.7 6.1 12.4 7.3 4.5 1.1 9.2 0.9 13.5-0.5 4.3-1.4 8.3-3.8 11.5-7 4.7-4.8 8.1-10.7 9.8-17.1 1.7-6.4 2.5-13.1 2.3-19.8 0-8.3-1.3-16.6-3.8-24.6s-6.8-15.3-12.6-21.4c-2.8-2.9-6-5.4-9.6-7.2-3.7-1.7-7.7-2.6-11.7-2.4m95 0c-8.6 5.6-15.2 13.8-18.9 23.4-3.8 12.4-3.2 25.6 1.5 37.7 3.9 12.7 11.7 23.8 22.2 31.8 5.4 3.8 11.6 6.2 18.2 7 6.6 0.8 13.2-0.3 19.1-3.3 7-3.9 12.6-10 15.9-17.3 3.2-7.4 5-15.3 5.2-23.3 0.7-10.2-0.6-20.4-3.8-30.1-3.5-10.6-10.3-19.7-19.5-25.9-4.7-3-9.9-5-15.4-5.8-5.5-0.8-11.1-0.2-16.3 1.8-2.9 1.2-5.7 2.7-8.3 4.5" fill="#FFFFFF" /><path d="M544.5 223.6c-3.2 0.2-6.2 1.2-8.9 2.9s-5 4-6.8 6.6c-3.4 5.3-5.3 11.5-5.4 17.9-0.3 4.7 0.4 9.5 1.9 14s4.3 8.5 7.9 11.5c3.8 3.1 8.4 4.9 13.3 5.2 4.9 0.2 9.7-1.1 13.7-3.9 3.2-2.3 5.8-5.2 7.6-8.7 1.8-3.4 2.9-7.2 3.4-11 1-6.8-0.2-13.8-3.2-19.9-3.1-6.2-8.4-10.9-14.8-13.4-2.8-1.1-5.7-1.5-8.7-1.4" fill="#020204" /><path d="M430.2 198.3c-5.4 0.4-10.5 2.7-14.4 6.4-3.9 3.7-6.8 8.4-8.4 13.5-2.7 10.4-3.4 21.3-1.9 32 0.2 9.7 1.9 19.4 5.1 28.6 1.8 4.6 4.4 8.7 7.8 12.2 3.4 3.5 7.7 6.1 12.4 7.3 4.5 1.1 9.2 0.9 13.5-0.5 4.3-1.4 8.3-3.8 11.5-7 4.7-4.8 8.1-10.7 9.8-17.1 1.7-6.4 2.5-13.1 2.3-19.8 0-8.3-1.3-16.6-3.8-24.6s-6.8-15.3-12.6-21.4c-2.8-2.9-6-5.4-9.6-7.2-3.7-1.7-7.7-2.6-11.7-2.4" fill="#FFFFFF" /><path d="M417.3 242.8c-1.3 6.7-1 13.7 1.1 20.2 1.6 4.3 4 8.2 7.2 11.5 2 2.2 4.3 4.1 7 5.4 2.7 1.4 5.7 1.8 8.7 1.1 2.7-0.7 5-2.3 6.7-4.5 1.7-2.2 2.9-4.7 3.7-7.3 2.3-7.8 2.1-16.1-0.4-23.9-1.6-5.7-4.7-10.9-9.1-14.8-2.1-1.8-4.7-3.2-7.4-3.9-2.8-0.7-5.7-0.5-8.4 0.7-2.8 1.4-5.1 3.7-6.5 6.5-1.4 2.8-2.3 5.8-2.7 8.9" fill="#020204" /><path d="M404.6 326.9c0.2 0.9 0.5 1.8 1 2.5 0.9 1.4 2 2.5 3.4 3.4 1.3 0.9 2.6 1.7 3.9 2.5 6.9 4.7 13 10.5 17.9 17.3 6 9.4 13.5 17.8 22 25 6.5 4.5 14.1 7.2 22 7.9 9.2 0.7 18.5-0.4 27.4-3.2 8.2-2.4 16.1-5.8 23.5-10.3 12.7-10.2 26.3-19.2 40.7-26.7 3.4-1.2 6.8-2.1 10-3.6 3.3-1.4 6.1-3.8 7.8-7 1.1-3.2 1.8-6.6 1.9-10 0.5-3.6 1.7-7.1 2.3-10.7 0.8-3.6 0.5-7.3-0.8-10.8Line truncated
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>macOS</title><path fill="#000000" d="M0 14.727h.941v-2.453c0-.484.318-.835.771-.835.439 0 .71.276.71.722v2.566h.915V12.25c0-.48.31-.812.764-.812.46 0 .718.28.718.77v2.518h.94v-2.748c0-.801-.517-1.334-1.307-1.334-.578 0-1.054.31-1.247.805h-.023c-.147-.514-.552-.805-1.118-.805-.545 0-.968.306-1.142.771H.903v-.695H0v4.006zm7.82-.646c-.408 0-.68-.208-.68-.537 0-.318.26-.522.714-.552l.926-.057v.307c0 .483-.427.839-.96.839zm-.284.71c.514 0 1.017-.268 1.248-.703h.018v.639h.908v-2.76c0-.804-.647-1.33-1.64-1.33-1.021 0-1.66.537-1.701 1.285h.873c.06-.332.344-.548.79-.548.464 0 .748.242.748.662v.287l-1.058.06c-.976.061-1.524.488-1.524 1.199 0 .721.564 1.209 1.338 1.209zm6.305-2.642c-.065-.843-.719-1.512-1.777-1.512-1.164 0-1.92.805-1.92 2.087 0 1.3.756 2.082 1.928 2.082 1.005 0 1.697-.59 1.772-1.485h-.888c-.087.453-.397.725-.873.725-.597 0-.982-.483-.982-1.322 0-.824.381-1.323.975-1.323.502 0 .8.321.876.748h.889zm2.906-2.967c-1.591 0-2.589 1.085-2.589 2.82 0 1.735.998 2.816 2.59 2.816 1.586 0 2.584-1.081 2.584-2.816 0-1.735-.997-2.82-2.585-2.82zm0 .832c.971 0 1.591.77 1.591 1.988 0 1.213-.62 1.984-1.59 1.984-.976 0-1.592-.77-1.592-1.984 0-1.217.616-1.988 1.591-1.988zm2.982 3.178c.042 1.006.866 1.626 2.12 1.626 1.32 0 2.151-.65 2.151-1.686 0-.813-.469-1.27-1.576-1.523l-.627-.144c-.67-.158-.945-.37-.945-.733 0-.453.415-.756 1.032-.756.623 0 1.05.306 1.096.817h.93c-.023-.96-.817-1.61-2.019-1.61-1.187 0-2.03.653-2.03 1.62 0 .78.477 1.263 1.482 1.494l.707.166c.688.163.967.39.967.782 0 .454-.457.779-1.115.779-.665 0-1.167-.329-1.228-.832h-.945z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M849.3056 410.5216c-18.7392 22.6816-66.8672 31.8976-105.1136 20.224-36.5056-11.1616-48.2816-36.7616-28.5184-56.9344 19.0464-19.4048 61.696-27.8016 97.2288-18.9952 37.12 9.1648 54.3744 33.9456 36.352 55.7056z m-168.8064-46.336c-19.968 19.5072-62.8224 27.904-93.7984 18.432-17.2032-5.2224-26.7264-16.2304-27.2384-25.3952-0.3584-7.0144-11.4688-13.5168-23.7568-16.9984-12.288-3.5328-26.5728-3.1744-40.8064-1.536-3.6352 0.4096-11.264 2.048-16.1792 3.584-13.1584 4.1984-24.5248 8.9088-33.9968 16.4352a45.4144 45.4144 0 0 0-15.4624 32.256c0.4096 8.6528 8.7552 16.0768 21.504 20.6336 12.4928 4.5056 28.0576 5.3248 43.5712 2.9696 18.944-4.864 39.68-5.0688 56.6784 0.6656 31.1296 10.496 39.0144 37.376 15.4624 60.3648-24.576 23.9616-71.7824 32.9728-103.1168 19.7632a37.376 37.376 0 0 1-24.4736-33.024c-0.7168-9.6768-9.728-17.408-22.016-22.2208-12.3904-4.864-26.8288-4.6592-43.008-3.328-2.1504 0.1536-11.776 1.9968-17.664 3.4816-8.0896 2.048-24.7296 6.5024-38.7072 19.712a82.7392 82.7392 0 0 0-18.7904 26.112c-1.6384 3.3792-3.584 11.0592-3.6864 13.7216-0.3584 10.8544 6.0928 20.6848 18.6368 26.9824 12.3904 6.2464 28.7232 7.8848 45.6704 5.5296 20.992-5.4784 43.1104-4.864 60.16 3.072 31.3856 14.6944 35.072 49.5104 5.632 78.336-30.8736 30.1056-83.456 39.7824-114.5344 21.1968-16.9984-10.1888-21.9648-25.4976-19.5584-43.4176v-0.1024a36.352 36.352 0 0 0-0.6656-6.5024s-0.4608-3.584-2.816-8.0896a36.864 36.864 0 0 0-15.4624-15.0528c-12.1344-6.656-28.16-8.448-44.8512-6.0416-20.9408 5.4272-41.3696 4.6592-54.8352-3.3792-22.9376-13.7216-16.0768-42.752 13.312-64.7168 15.2064-11.3664 33.7408-18.7904 51.2-21.7088 16.6912-3.2768 33.792-10.4448 47.2064-20.736 13.1584-10.1376 19.968-21.0944 20.6336-30.6176a48.2816 48.2816 0 0 0-0.6656-10.3424c-0.6144-8.192-0.1536-16.384 7.68-25.1904 4.9152-5.632 15.7696-11.1616 27.5968-13.312 8.0896-1.4336 18.5344-1.1264 24.4224-1.4336 5.12-0.256 14.0288-1.2288 17.2032-2.1504a74.752 74.752 0 0 0 27.904-14.0288c9.0112-6.912 14.592-14.1824 15.616-20.8384 0.2048-1.3824 0.6656-7.8848 0.512-8.704-3.0208-15.5136 19.6608-32.5632 50.5856-38.0928 15.6672-2.7648 30.464-2.048 41.6768 1.3824l1.1776 0.2048c11.264 2.816 18.5344 8.0384 21.248 14.3872h0.1024l0.1024 0.512a14.9504 14.9504 0 0 1 0.9216 3.584c2.6624 6.8096 10.4448 12.6464 23.0912 15.9744 12.4416 3.2768 27.1872 3.5328 41.472 1.2288 17.2544-4.4032 36.7104-5.12 53.4016-1.024 30.464 7.5264 40.96 28.672 21.7088 47.4624zM608.256 710.144c-30.5152 37.2224-91.4944 47.9744-132.2496 23.552-38.4-22.9376-41.8304-67.3792-10.9568-98.9696 29.44-30.1568 81.8176-39.8336 119.9104-22.016 40.2944 18.7904 52.224 62.1568 23.296 97.4336zM907.4688 261.12l-366.1312-202.3424a60.928 60.928 0 0 0-58.6752 0L116.5312 261.0688a55.7568 55.7568 0 0 0-29.3376 48.64v404.6336c0 20.0704 11.1616 38.5536 29.3376 48.64l366.1312 202.2912a60.928 60.928 0 0 0 58.6752 0l366.1312-202.3424c18.176-10.0352 29.3376-28.5184 29.3376-48.64V309.7088c0-20.0192-11.1616-38.5536-29.3376-48.64z" fill="#002FA7" /></svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M457.557333 0a512 512 0 0 0-403.114666 197.248c62.464 16.682667 106.666667 31.018667 119.893333 35.498667 0.213333-8.106667 1.578667-80.768 1.578667-80.768s0.170667-1.706667 1.066666-2.56c1.109333-1.109333 2.773333-0.768 2.773334-0.768 16.426667 2.389333 367.018667 54.357333 514.816 140.458666 18.218667 10.666667 27.221333 22.058667 38.485333 33.536 40.874667 42.24 94.848 217.941333 100.650667 254.165334 0.213333 1.408-1.536 2.986667-2.304 3.541333a220.885333 220.885333 0 0 1-13.354667 9.728c-34.986667 23.466667-115.541333 79.872-218.88 70.656-92.842667-8.234667-214.101333-61.44-360.32-157.824 14.336 33.706667 28.501333 67.413333 42.666667 101.162667 21.205333 11.008 225.578667 115.2 326.442666 113.109333 81.237333-1.706667 168.149333-41.301333 202.922667-62.208 0 0 7.637333-4.608 10.965333-2.048 3.626667 2.816 2.602667 7.125333 1.749334 11.52-2.133333 9.984-6.997333 28.16-10.325334 36.821333l-2.773333 7.04c-3.968 10.666667-7.808 20.565333-15.189333 26.666667-20.48 18.602667-53.162667 33.450667-104.362667 55.68-79.146667 34.645333-207.573333 56.661333-326.826667 55.893333-42.709333-0.938667-83.968-5.674667-120.192-9.898666-74.368-8.405333-134.869333-15.232-171.776 11.477333A512 512 0 0 0 457.557333 1024a512 512 0 0 0 512-512 512 512 0 0 0-512-512zM571.733333 297.088a149.461333 149.461333 0 0 0-107.562666 40.192 149.248 149.248 0 0 0-47.530667 104.490667 150.528 150.528 0 0 0 144.64 155.306666 148.48 148.48 0 0 0 107.690667-40.362666 149.504 149.504 0 0 0 47.530666-104.362667 150.485333 150.485333 0 0 0-144.768-155.306667z m-1.28 44.16a104.874667 104.874667 0 0 1 101.034667 108.330667 103.68 103.68 0 0 1-33.024 72.789333 104.789333 104.789333 0 0 1-75.178667 28.117333 105.002667 105.002667 0 0 1-100.864-108.458666c0.853333-27.946667 12.8-53.76 33.152-72.832a103.210667 103.210667 0 0 1 74.922667-27.946667z m17.152 52.48c-25.685333 0-46.378667 13.866667-46.378666 31.018667 0 17.066667 20.693333 30.933333 46.378666 30.933333 25.6 0 46.421333-13.909333 46.421334-30.933333 0-17.152-20.778667-30.976-46.421334-30.976z" fill="#73BA25" /></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="512px" height="67px" viewBox="0 0 512 67" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
|
||||
<g>
|
||||
<path d="M221.034224,43.3028585 L254.866139,43.3028585 L236.977029,14.5219565 L204.144344,66.5590894 L189.201618,66.5590894 L229.137107,4.05139996 C230.873472,1.52582675 233.767268,0 236.977029,0 C240.081163,0 242.975008,1.47323786 244.658785,3.94617225 L284.75209,66.5590894 L269.809323,66.5590894 L262.758717,54.9309781 L228.505591,54.9309781 L221.034224,43.3028585 L221.034224,43.3028585 Z M376.251178,54.9309781 L376.251178,0.631391222 L363.570757,0.631391222 L363.570757,60.2452021 C363.570757,61.8762982 364.202256,63.4547554 365.412325,64.6649074 C366.62231,65.8751009 368.25349,66.5590894 370.042461,66.5590894 L427.867434,66.5590894 L435.338818,54.9309781 L376.251178,54.9309781 L376.251178,54.9309781 Z M166.471559,45.1970405 C178.783867,45.1970405 188.780891,35.2526558 188.780891,22.9405145 C188.780891,10.6284147 178.783867,0.631391222 166.471559,0.631391222 L110.99843,0.631391222 L110.99843,66.5590894 L123.673825,66.5590894 L123.673825,12.2595108 L165.629671,12.2595108 C171.522872,12.2595108 176.258211,17.0475214 176.258211,22.9405145 C176.258211,28.8334992 171.522872,33.6215597 165.629671,33.6215597 L129.882576,33.5689209 L167.734175,66.5590894 L186.150048,66.5590894 L160.683969,45.1970405 L166.471559,45.1970405 L166.471559,45.1970405 Z M32.9694209,66.5590894 C14.7693798,66.5590894 9.23705556e-14,51.8266442 9.23705556e-14,33.6215597 C9.23705556e-14,15.4164336 14.7693798,0.631391222 32.9694209,0.631391222 L71.2892929,0.631391222 C89.4943774,0.631391222 104.253246,15.4164336 104.253246,33.6215597 C104.253246,51.8266442 89.4943774,66.5590894 71.2892929,66.5590894 L32.9694209,66.5590894 L32.9694209,66.5590894 Z M70.4368936,54.9309781 C82.2283308,54.9309781 91.7781116,45.4074876 91.7781116,33.6215597 C91.7781116,21.8355819 82.2283308,12.2595108 70.4368936,12.2595108 L33.8163525,12.2595108 C22.030383,12.2595108 12.4751261,21.8355819 12.4751261,33.6215597 C12.4751261,45.4074876 22.030383,54.9309781 33.8163525,54.9309781 L70.4368936,54.9309781 L70.4368936,54.9309781 Z M311.217808,66.5590894 C293.012682,66.5590894 278.227789,51.8266442 278.227789,33.6215597 C278.227789,15.4164336 293.012682,0.631391222 311.217808,0.631391222 L356.73054,0.631391222 L349.311753,12.2595108 L312.059696,12.2595108 C300.273676,12.2595108 290.697822,21.8355819 290.697822,33.6215597 C290.697822,45.4074876 300.273676,54.9309781 312.059696,54.9309781 L357.782733,54.9309781 L350.311433,66.5590894 L311.217808,66.5590894 L311.217808,66.5590894 Z M466.276962,54.9309781 C456.5429,54.9309781 448.282225,48.4065938 445.75656,39.4092751 L499.951078,39.4092751 L507.422461,27.7811639 L445.75656,27.7811639 C448.282225,18.8364757 456.5429,12.2595108 466.276962,12.2595108 L503.476422,12.2595108 L511.000403,0.631391222 L465.435074,0.631391222 C447.229948,0.631391222 432.445056,15.4164336 432.445056,33.6215597 C432.445056,51.8266442 447.229948,66.5590894 465.435074,66.5590894 L504.528699,66.5590894 L512,54.9309781 L466.276962,54.9309781 L466.276962,54.9309781 Z" fill="#EA1B22"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M512 512m-512 0a512 512 0 1 0 1024 0 512 512 0 1 0-1024 0Z" fill="#040000" /><path d="M928.768 454.848c-21.632-42.048-95.616-56.448-123.84-70.016-9.344 18.368-26.304 49.856-31.744 47.168-3.712-1.856 5.056-21.376 9.536-43.456a304.64 304.64 0 0 0 4.736-38.784c-16.128-63.04-60.736-217.856-119.488-231.808-84.864-20.096-111.872 7.168-159.488 8.192-47.552 0.896-77.44-25.024-129.728-13.184C312.128 128 298.944 277.952 297.216 302.784c8.896 12.288 38.4 45.568 104.64 60.224 52.544 11.712 64.576 7.36 97.792 23.232 38.4 18.368 32.128 38.208 35.712 59.648 3.52 21.376 27.072 25.664 21.312 33.856-5.696 8.256-113.408-19.264-168.448-49.92-54.976-30.72-107.072-47.104-112.768-82.432-3.456-21.12 3.904-34.432 10.24-41.856-34.496-3.008-141.184-7.168-174.08 52.928-36.096 66.112 163.84 209.024 310.912 261.568 126.016 44.992 344.96 84.416 458.944-12.8 56.896-48.576 64.256-119.36 47.296-152.384zM488.896 242.432s-34.176-1.664-57.856 4.992-21.376 16.512-38.464 16.448c-7.68-0.064-27.136-4.736-31.424-29.888-4.288-25.088 3.52-34.368 3.52-34.368s17.856 23.552 36.416 26.368c18.56 2.88 90.688-6.4 95.68 3.648s-7.872 12.8-7.872 12.8z m173.12-77.056c-6.144 13.184-63.552 20.288-63.552 20.288s27.84-11.776 26.048-26.752-45.696-19.584-45.696-19.584 95.936-1.728 83.2 26.048z" fill="#DD2A2A" /><path d="M770.112 809.344l3.84 20.032s-37.376 19.392-60.928 33.28c-23.488 13.888-65.152 62.656-76.48 51.968-12.736-11.968 50.496-73.024 73.728-109.44 22.4-35.072 25.6-112.896 25.6-112.896s-23.488 9.792-44.992 10.752c-24.256 1.088-32.96-5.888-46.464-7.232s-18.688 5.184-33.536 5.184c-14.208 0-20.416-4.48-48.832-5.184-28.416-0.704-71.808 8-105.472 2.944s-61.76-78.72-94.208-78.72c-11.904 0-9.664 9.28-21.248 6.912s-36.992-40.512-61.376-52.16c-26.368-12.608-57.088 0.448-56.192 38.336 0.448 17.856 14.72 38.4 30.976 54.272 18.176 17.792 37.952 30.592 25.28 41.856-21.12 18.944-81.344-52.288-81.344-52.288l-131.584-0.96v34.624l-18.944 2.304-2.368 1.088C116.032 882.688 298.752 1012.992 512 1012.992c166.4 0 312.832-77.952 402.944-203.712h-144.832z m-176.64-14.016c-27.456 0-46.592-17.408-48.768-21.696a8.512 8.512 0 0 1 6.72-12.032c6.72-1.088 26.176 13.184 42.688 12.16 17.408-1.088 19.968-12.224 26.048-12.224 12.224 0 1.856 33.792-26.688 33.792z" fill="#FFFFFF" /></svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M294.4 975.36C360.96 1006.08 435.2 1024 512 1024c166.4 0 312.32-79.36 407.04-202.24l-235.52-235.52-389.12 389.12z" fill="#04BA7F" /><path d="M151.04 875.52l529.92-529.92L998.4 663.04c17.92-48.64 25.6-97.28 25.6-151.04C1024 230.4 793.6 0 512 0S0 230.4 0 512c0 143.36 58.88 271.36 151.04 363.52z" fill="#04BA7F" /></svg>
|
||||
|
After Width: | Height: | Size: 392 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M512 512m-512 0a512 512 0 1 0 1024 0 512 512 0 1 0-1024 0Z" fill="#DD4814" /><path d="M251.716267 512c0 36.932267-29.5936 66.833067-66.116267 66.833067S119.466667 548.9152 119.466667 512c0-36.9152 29.610667-66.833067 66.133333-66.833067 36.5056 0 66.116267 29.917867 66.116267 66.833067zM627.882667 836.983467a65.7408 65.7408 0 0 0 90.333866 24.439466c31.573333-18.432 42.4448-59.306667 24.1664-91.2896a65.706667 65.706667 0 0 0-90.282666-24.439466c-31.624533 18.466133-42.461867 59.323733-24.2176 91.2896z m114.517333-583.133867c18.261333-31.9488 7.441067-72.823467-24.183467-91.2896a65.757867 65.757867 0 0 0-90.333866 24.456533c-18.261333 31.965867-7.406933 72.8576 24.200533 91.306667a65.6896 65.6896 0 0 0 90.299733-24.4736z m-223.7952 67.549867c98.542933 0 179.370667 76.356267 187.835733 173.704533L802.133333 493.602133a287.607467 287.607467 0 0 0-83.4048-185.429333c-25.361067 9.898667-54.7328 8.533333-80.0768-6.263467a93.917867 93.917867 0 0 1-45.431466-67.037866 281.770667 281.770667 0 0 0-74.581334-10.001067c-45.226667 0-88.0128 10.683733-126.0032 29.730133l46.557867 84.514134a186.026667 186.026667 0 0 1 79.428267-17.7152zM330.069333 512c0-64.494933 31.6928-121.480533 80.1792-155.989333l-49.134933-82.9952a287.505067 287.505067 0 0 0-117.1968 165.717333A94.242133 94.242133 0 0 1 278.6304 512a94.242133 94.242133 0 0 1-34.7136 73.2672 287.573333 287.573333 0 0 0 117.1968 165.717333l49.134933-82.9952A190.976 190.976 0 0 1 330.069333 512zM518.621867 702.634667a186.368 186.368 0 0 1-79.428267-17.698134l-46.557867 84.514134a280.405333 280.405333 0 0 0 125.986134 29.713066 281.258667 281.258667 0 0 0 74.5984-10.001066 93.9008 93.9008 0 0 1 45.431466-67.037867 91.989333 91.989333 0 0 1 80.0768-6.263467A287.744 287.744 0 0 0 802.133333 530.414933l-95.675733-1.536c-8.448 97.3824-89.2928 173.738667-187.835733 173.738667z" fill="#FFFFFF" /></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M456 484V160.1l-335.9 72V484H456zM512 484h391.8V64.2l-391.8 84V484zM456 540H120.2v251.9l335.9 72V540zM512 540v335.9l391.8 84V540H512z" fill="#00adef" /></svg>
|
||||
|
After Width: | Height: | Size: 232 B |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Alibaba Cloud</title><path fill="#FF6A00" d="M3.996 4.517h5.291L8.01 6.324 4.153 7.506a1.668 1.668 0 0 0-1.165 1.601v5.786a1.668 1.668 0 0 0 1.165 1.6l3.857 1.183 1.277 1.807H3.996A3.996 3.996 0 0 1 0 15.487V8.513a3.996 3.996 0 0 1 3.996-3.996m16.008 0h-5.291l1.277 1.807 3.857 1.182c.715.227 1.17.889 1.165 1.601v5.786a1.668 1.668 0 0 1-1.165 1.6l-3.857 1.183-1.277 1.807h5.291A3.996 3.996 0 0 0 24 15.487V8.513a3.996 3.996 0 0 0-3.996-3.996m-4.007 8.345H8.002v-1.804h7.995Z"/></svg>
|
||||
|
After Width: | Height: | Size: 563 B |
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg"><path d="M95.965 47.4c-8.687-.15-17.27 3.253-23.996 9.49-.63.426-.84 1.067-.418 1.708 4.195-1.282 8.597-2.563 13.21-2.774 4.825-.426 9.86-.851 14.684 1.278 6.082 2.559 7.133 8.316 2.516 12.796-2.098 2.133-4.824 3.625-7.758 4.477-1.68.426-1.89 1.281-1.262 2.984 1.891 4.692 3.149 9.598 2.52 14.926-.211.856-.422 1.496.418 2.133 1.886 1.281 9.02 0 10.488-1.492 2.727-2.559 1.676-5.543 1.047-8.317-.836-3.41-.418-6.609 1.469-9.382 1.887-2.77 4.613-4.051 7.55-5.329 1.258-.425 2.31-1.066 3.149-2.558 1.887-3.625-.422-9.809-3.988-12.797-6.055-4.758-12.873-7.025-19.63-7.143zm22.734 24.809c-.332-.005-.669.007-1.01.033-5.66.641-9.437 4.906-9.02 10.664.422 5.328 5.036 9.38 10.278 8.953 5.453-.426 9.442-5.117 9.02-10.875-.392-5.196-4.289-8.707-9.268-8.775z" fill="#85d82e"/><path d="M69.662 63.74c-.262-.027-.523.08-.838.4 2.516 3.836 4.824 7.891 6.711 12.153 1.68 4.692 3.567 9.383 2.938 14.715-.63 6.398-5.875 9.168-11.328 5.969-2.727-1.493-4.825-3.625-6.5-6.184-1.051-1.492-2.098-1.492-3.356-.426-3.988 3.414-8.18 5.973-13.426 6.824-.836 0-1.675 0-2.097.852-.63 2.133 2.52 8.742 4.406 9.809 3.144 1.921 5.664 0 8.18-1.493 2.937-1.703 5.87-2.343 9.226-1.277 3.149 1.067 5.246 3.41 7.133 5.969.84 1.066 1.68 1.918 3.355 2.347 3.985 1.067 9.438-3.199 11.118-7.464 6.293-16.418-.84-33.266-14.684-41.793-.314-.213-.576-.374-.838-.4zm-9.017 44.112c-5.036 0-9.23 4.265-9.23 9.808 0 6.183 3.983 10.024 10.07 10.024 4.82-.215 9.015-4.692 9.015-9.809 0-5.332-4.824-10.023-9.855-10.023z" fill="#22c1ff"/><path d="M99.096 4.623a8.923 8.923 0 00-.49.025c-5.454.426-9.227 5.329-8.81 10.66.419 5.333 5.243 9.383 10.696 8.958 5.035-.426 9.23-5.332 8.813-10.45-.41-5.165-5.144-9.325-10.21-9.193zM80.789 7.607c-2.765-.032-5.766.906-7.77 2.584-13.632 11.09-15.94 29.641-8.18 44.352.208.64.63 1.281 1.466.855v-.002c-.207-4.69-.418-9.38.422-13.86.84-5.118 1.675-10.235 4.824-14.497 3.773-5.332 9.648-5.121 12.793.637 1.68 2.773 2.308 5.758 2.515 8.957 0 1.918.84 2.344 2.52 2.133 5.032-.852 10.066-.852 14.89 1.066.63.211 1.258.64 2.098 0 1.676-1.492 2.098-8.957 1.047-10.875-1.887-3.414-4.824-2.984-7.758-2.984-3.355.21-6.293-.856-8.601-3.625-2.098-2.559-2.727-5.758-3.145-8.957-.21-1.492-.422-2.774-1.68-3.84-1.283-1.306-3.29-1.918-5.44-1.944z" fill="#ffc911"/><path d="M21.68 47.53c-.39.014-.833.163-1.305.404-.84.425-1.68.851-2.309 1.28-5.453 3.41-6.082 7.677-1.468 12.368 3.355 3.41 5.035 7.25 3.777 11.941-.629 2.344-1.89 4.051-3.355 5.97-1.051 1.28-1.47 2.558-1.262 4.265.422 4.05 4.406 8.101 9.23 8.527 13.422 1.281 24.328-3.41 31.88-14.926 2.519-3.624 4.405-7.675 5.245-11.941-.84-.211-1.05.215-1.68.43-4.823 5.117-9.859 10.02-16.359 13.433-2.515 1.278-5.242 2.344-8.18 2.344-4.613 0-7.34-2.984-6.921-7.676.21-3.84 1.68-7.25 4.195-10.023 1.258-1.492 1.258-2.344-.211-3.625-4.195-2.985-7.969-6.61-9.855-11.516-.264-.932-.771-1.281-1.422-1.256zM9.646 60.872c-.336.006-.676.028-1.017.068-4.824.426-9.02 5.543-8.602 10.66C.45 76.935 5.48 81.2 10.516 80.56c6.082-.426 9.23-6.399 9.02-11.301-.396-4.798-4.842-8.472-9.89-8.385z" fill="#0068d8"/><path d="M33.938.316a9.087 9.087 0 00-.981.067c-5.66.64-9.02 5.117-8.387 11.3.629 4.692 5.66 8.743 10.485 8.106 5.664-.856 9.437-5.547 8.808-11.09-.59-4.6-5.048-8.447-9.925-8.383zm17.26 6.938c-2.666-.061-4.711 1.697-5.866 5.07-2.516 7.465-7.34 10.235-15.102 8.742-1.046-.214-2.097-.425-2.515-.425-3.985.64-6.293 2.984-6.922 6.183-.84 9.81 4.195 16.844 11.117 22.602 7.34 6.398 16.36 7.89 25.797 7.465.84 0 1.68 0 1.887-.641.21-1.066-.836-1.066-1.469-1.277-4.402-1.922-9.016-3.414-13.004-6.188-3.984-2.769-8.598-5.117-10.277-10.445-1.258-4.051.422-7.676 4.406-8.957 3.566-1.281 7.133-1.281 10.488 0 2.098.851 2.934.426 3.356-1.918.629-4.051 1.886-7.89 4.402-11.3 2.938-4.267 2.938-4.692-1.887-7.466-.21 0-.21-.215-.418-.215-1.442-.8-2.782-1.202-3.994-1.23z" fill="#ff4546"/></svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#252f3e" d="M36.379 53.64c0 1.56.168 2.825.465 3.75.336.926.758 1.938 1.347 3.032.207.336.293.672.293.969 0 .418-.254.84-.8 1.261l-2.653 1.77c-.379.25-.758.379-1.093.379-.422 0-.844-.211-1.266-.59a13.28 13.28 0 0 1-1.516-1.98 34.153 34.153 0 0 1-1.304-2.485c-3.282 3.875-7.41 5.813-12.38 5.813-3.535 0-6.355-1.012-8.421-3.032-2.063-2.023-3.114-4.718-3.114-8.086 0-3.578 1.262-6.484 3.833-8.671 2.566-2.192 5.976-3.286 10.316-3.286 1.43 0 2.902.125 4.46.336 1.56.211 3.161.547 4.845.926v-3.074c0-3.2-.676-5.43-1.98-6.734C26.061 32.633 23.788 32 20.546 32c-1.473 0-2.988.168-4.547.547a33.416 33.416 0 0 0-4.547 1.433c-.676.293-1.18.461-1.473.547-.296.082-.507.125-.675.125-.59 0-.883-.422-.883-1.304v-2.063c0-.676.082-1.18.293-1.476.21-.293.59-.586 1.18-.883 1.472-.758 3.242-1.39 5.304-1.895 2.063-.547 4.254-.8 6.57-.8 5.008 0 8.672 1.136 11.032 3.41 2.316 2.273 3.492 5.726 3.492 10.359v13.64Zm-17.094 6.403c1.387 0 2.82-.254 4.336-.758 1.516-.508 2.863-1.433 4-2.695.672-.8 1.18-1.684 1.43-2.695.254-1.012.422-2.23.422-3.665v-1.765a34.401 34.401 0 0 0-3.871-.719 31.816 31.816 0 0 0-3.961-.25c-2.82 0-4.883.547-6.274 1.684-1.387 1.136-2.062 2.734-2.062 4.84 0 1.98.504 3.453 1.558 4.464 1.012 1.051 2.485 1.559 4.422 1.559Zm33.809 4.547c-.758 0-1.262-.125-1.598-.422-.34-.254-.633-.84-.887-1.64L40.715 29.98c-.25-.843-.38-1.39-.38-1.687 0-.672.337-1.05 1.013-1.05h4.125c.8 0 1.347.124 1.644.421.336.25.59.84.84 1.64l7.074 27.876 6.57-27.875c.208-.84.462-1.39.797-1.64.34-.255.93-.423 1.688-.423h3.367c.8 0 1.348.125 1.684.422.336.25.633.84.8 1.64l6.653 28.212 7.285-28.211c.25-.84.547-1.39.84-1.64.336-.255.887-.423 1.644-.423h3.914c.676 0 1.055.336 1.055 1.051 0 .21-.043.422-.086.676-.043.254-.125.59-.293 1.05L80.801 62.57c-.254.84-.547 1.387-.887 1.64-.336.255-.883.423-1.598.423h-3.62c-.801 0-1.348-.13-1.684-.422-.34-.297-.633-.844-.801-1.684l-6.527-27.16-6.485 27.117c-.21.844-.46 1.391-.8 1.684-.337.297-.926.422-1.684.422Zm54.105 1.137c-2.187 0-4.379-.254-6.484-.758-2.106-.504-3.746-1.055-4.84-1.684-.676-.379-1.137-.8-1.305-1.18a2.919 2.919 0 0 1-.254-1.18v-2.148c0-.882.336-1.304.97-1.304.25 0 .503.043.757.129.25.082.629.25 1.05.418a23.102 23.102 0 0 0 4.634 1.476c1.683.336 3.324.504 5.011.504 2.653 0 4.715-.465 6.145-1.39 1.433-.926 2.191-2.274 2.191-4 0-1.18-.379-2.145-1.136-2.946-.758-.8-2.192-1.516-4.254-2.191l-6.106-1.895c-3.074-.969-5.348-2.398-6.734-4.293-1.39-1.855-2.106-3.918-2.106-6.105 0-1.77.38-3.328 1.137-4.676a10.829 10.829 0 0 1 3.031-3.453c1.262-.965 2.696-1.684 4.38-2.188 1.683-.504 3.452-.715 5.304-.715.926 0 1.894.043 2.82.168.969.125 1.852.293 2.738.461.84.211 1.641.422 2.399.676.758.254 1.348.504 1.77.758.59.336 1.011.672 1.261 1.05.254.34.379.802.379 1.391v1.98c0 .884-.336 1.348-.969 1.348-.336 0-.883-.171-1.597-.507-2.403-1.094-5.098-1.641-8.086-1.641-2.399 0-4.293.379-5.598 1.18-1.309.797-1.98 2.02-1.98 3.746 0 1.18.421 2.191 1.261 2.988.844.8 2.403 1.602 4.633 2.316l5.98 1.895c3.032.969 5.22 2.316 6.524 4.043 1.305 1.727 1.938 3.707 1.938 5.895 0 1.812-.38 3.453-1.094 4.882-.758 1.434-1.77 2.696-3.074 3.707-1.305 1.051-2.864 1.809-4.672 2.36-1.895.586-3.875.883-6.024.883Zm0 0"/>
|
||||
<path fill="#f90" d="M118 73.348c-4.432.063-9.664 1.052-13.621 3.832-1.223.883-1.012 2.062.336 1.894 4.508-.547 14.44-1.726 16.21.547 1.77 2.23-1.976 11.62-3.663 15.79-.504 1.26.59 1.769 1.726.8 7.41-6.231 9.348-19.242 7.832-21.137-.757-.925-4.388-1.79-8.82-1.726zM1.63 75.859c-.927.116-1.347 1.236-.368 2.121 16.508 14.902 38.359 23.872 62.613 23.872 17.305 0 37.43-5.43 51.281-15.66 2.273-1.688.297-4.254-2.02-3.204-15.534 6.57-32.421 9.77-47.788 9.77-22.778 0-44.8-6.273-62.653-16.633-.39-.231-.755-.304-1.064-.266z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M513.2288 51.2c-122.368 0-239.7696 48.9472-326.2976 136.2944a467.8144 467.8144 0 0 0-135.168 329.2672c0 4.352 0.512 8.6528 0.5632 12.9024l-1.024 319.744c-0.8192 20.7872 3.9424 41.472 13.7728 59.904 9.728 18.3808 21.504 32.256 41.984 44.8512 20.2752 12.544 37.7856 16.9472 58.5216 17.3568a115.712 115.712 0 0 0 59.2384-14.848 118.1696 118.1696 0 0 0 60.0064-102.2976v-35.4816l1.4336-168.192V519.168a98.5088 98.5088 0 0 0-24.5248-66.4064 73.8816 73.8816 0 0 1 5.3248-102.144 72.192 72.192 0 0 1 101.376 2.56 73.8816 73.8816 0 0 1 0.3072 102.2464 93.9008 93.9008 0 0 0-24.5248 64.7168v27.7504s12.2368 103.0656 85.0944 10.3424l46.08-75.8784c12.6464-20.8384 17.2544-45.7728 12.8-69.7856a73.216 73.216 0 0 1 56.32-85.3504 72.704 72.704 0 0 1 85.8624 54.9376 73.3696 73.3696 0 0 1-52.3264 87.9104c-22.7328 6.656-41.984 21.76-54.016 42.1376l-48.7936 80.0768s-24.9344 47.5136 32.512 47.5136l66.048-2.0992c23.7056-0.3072 46.3872-9.728 63.4368-26.2656a72.0384 72.0384 0 0 1 101.376-2.1504c28.7744 27.136 30.7712 72.4992 4.608 102.1952a71.9872 71.9872 0 0 1-101.2224 7.168 96.9216 96.9216 0 0 0-66.3552-23.8592s-66.6624-1.024-111.0528 49.9712a139.3664 139.3664 0 0 0-22.1696 149.248c1.1776 2.56 2.56 5.0688 3.7888 7.936l1.536 2.304c0.9728 1.7408 2.048 3.3792 3.072 5.0176l1.8432 2.816c1.024 1.536 2.048 2.8672 3.2256 4.3008l2.304 2.9184c1.1264 1.4336 2.2528 2.7648 3.4304 3.9936l2.7136 2.9696 3.6864 3.7376 3.072 2.7648 4.096 3.584 3.4304 2.56 4.5568 3.3792a48.896 48.896 0 0 0 3.584 2.4064 48.9984 48.9984 0 0 0 5.12 3.072l3.8912 2.3552c1.8432 1.024 3.7376 1.9968 5.5808 2.8672l4.1984 2.048 6.5536 2.6624 4.1472 1.7408 7.936 2.7136 3.4304 1.1264c4.0448 1.28 7.8848 2.304 12.4416 3.328l2.4576 0.512c3.6352 0.768 7.3216 1.536 10.9568 2.1504l5.12 0.768c3.0208 0.4608 6.0416 0.9216 9.2672 1.2288l6.144 0.6144 8.3456 0.6144c8.8576 0.5632 17.8176 0.3072 26.624-0.5632C839.168 921.6 994.816 704.512 970.24 468.224 945.7152 231.8848 748.7488 52.224 513.28 51.2h-0.0512z" fill="#59AB2D" /></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><g fill="#1791cf"><path fill-rule="evenodd" d="M61.113 4.886C55.82 17.788 52.629 26.23 46.738 38.749c3.613 3.805 8.047 8.242 15.246 13.25-7.742-3.168-13.02-6.347-16.969-9.648-7.539 15.644-19.351 37.933-43.324 80.77 18.844-10.817 33.45-17.485 47.059-20.032a33.989 33.989 0 0 1-.895-8.023l.024-.602c.296-12.004 6.578-21.238 14.015-20.61 7.438.626 13.223 10.872 12.922 22.876-.055 2.261-.312 4.433-.762 6.449 13.465 2.621 27.914 9.273 46.5 19.941-3.664-6.707-6.933-12.757-10.058-18.52-4.922-3.792-10.055-8.726-20.524-14.073 7.196 1.863 12.348 4.007 16.364 6.406C74.578 38.12 72.004 30.308 61.113 4.886Zm0 0"/><path d="M121.14 112.57v-3.243h-1.215v-.433h2.93v.433h-1.223v3.243h-.492M123.363 112.57v-3.676h.734l.875 2.601c.082.243.14.426.176.543.043-.132.11-.328.2-.586l.882-2.558h.66v3.676h-.472v-3.079l-1.075 3.079h-.441l-1.066-3.13v3.13h-.473"/></g></svg>
|
||||
|
After Width: | Height: | Size: 916 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M431.44 474.564l36.916 36.708-36.916 36.7H171.604v122.284L13.144 511.272l158.46-156.32v119.612h259.84z m114.216-312.272h315.716v315.72h-315.716v-315.72z" fill="#932279" /><path d="M549.1 431.44l-36.7 36.916-36.7-36.916V171.604H353.408L512.4 13.144l156.308 158.46h-119.608v259.84z m-3.44 114.216h315.712v315.72h-315.716v-315.72z" fill="#EFA724" /><path d="M592.228 549.1l-36.92-36.7 36.92-36.7h259.832V353.408l158.468 158.992-158.468 156.308v-119.608h-259.832z m-429.936-3.44h315.716v315.72H162.292v-315.72z" fill="#262577" /><path d="M474.564 592.228l36.7-36.92 36.708 36.92v259.84h122.284l-158.992 158.46-156.304-158.46h119.604v-259.84zM162.292 162.292h315.716v315.72H162.292v-315.72z" fill="#9CCD2A" /><path d="M151.016 151.016h338.268v338.268H151.016V151.016z m22.548 315.72h293.164V173.572H173.564v293.164z m360.824-315.72h338.268v338.268h-338.268V151.016z m22.548 315.72h293.164V173.572h-293.164v293.164z m-22.548 67.652h338.268v338.276h-338.268v-338.28z m22.548 315.712h293.164v-293.164h-293.164v293.164z m-405.92-315.712h338.268v338.276H151.016v-338.28z m22.548 315.712h293.164v-293.164H173.564v293.164z" fill="#FFFFFF" /><path d="M240.752 751.032l-239.2-239.2L240.748 272.64l239.2 239.192-239.192 239.2z m-207.304-239.2l207.304 207.304 207.296-207.304-207.304-207.296-207.296 207.296z m478.384-31.888L272.64 240.752l239.192-239.2 239.192 239.2-239.192 239.192zM304.536 240.752l207.296 207.296 207.304-207.296-207.304-207.304-207.296 207.304z m478.384 510.28l-239.192-239.2L782.92 272.64l239.192 239.192-239.192 239.2z m-207.304-239.2l207.304 207.304 207.296-207.304-207.296-207.296-207.304 207.296z m-63.784 510.28L272.64 782.92l239.192-239.192 239.192 239.192-239.192 239.192zM304.536 782.92l207.296 207.296 207.304-207.296-207.304-207.296-207.296 207.296z" fill="#FFFFFF" /></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48" /></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M676.16 59.84c-68.96-25.184-139.584-32-208.192-25.6-79.552 6.304-118.336 30.912-116.032 26.464-130.048 46.4-240 146.24-290.88 286.272-90.624 249.344 37.76 525.12 286.72 615.904 249.12 90.784 524.448-37.792 615.136-287.168 90.688-249.28-37.728-525.056-286.784-615.808l0.032-0.032zM364.192 917.76a426.88 426.88 0 0 1-76.544-37.216l1.856 1.056c116.032 8.896 267.456-17.792 369.6-113.344 0 0 194.624-155.52 53.76-410.752 0 0 22.72 102.848-6.208 187.456 0 0-27.616 115.072-150.272 148.48-180.704 49.216-386.56-77.216-472.64-138.528-6.496-62.944-0.736-128 22.4-191.488 34.176-94.08 98.496-167.52 177.088-216.8-19.616 137.408-4.096 264 18.24 316.992 29.952 71.04 81.984 153.984 183.584 164.608 101.6 10.656 157.6-84.416 157.6-84.416 52.16-79.168 60.32-192.896 59.52-195.68-0.8-2.784-13.888-10.272-13.888-10.272C653.216 479.808 595.36 527.2 595.36 527.2c-91.2 88.064-155.776 27.008-155.776 27.008-69.44-74.624-20.8-195.808-20.8-195.808 27.2-82.56 106.432-203.36 196.224-264.8 14.944 3.68 29.984 6.016 44.8 11.36 55.008 20.48 102.24 49.792 142.432 86.688l-0.288-0.288c-65.632 24-171.616 74.624-171.616 74.624-168.032 70.784-179.424 177.824-179.424 177.824-17.376 110.304 70.176 63.584 70.176 63.584 90.112-43.872 134.4-180.608 134.4-180.608a100.096 100.096 0 0 0-50.304 3.2c-35.968 88.8-109.312 125.28-109.312 125.28-28.896 15.264-35.616-11.712-35.616-11.712-4.896-20.256 20.864-23.68 20.864-23.68 40-15.584 65.568-57.568 71.36-74.72 5.76-17.184 16.48-18.56 16.48-18.56 30.336-9.92 65.92-17.504 102.592-21.216l2.176-0.192c68.032-8.352 172.128 24.096 172.128 24.096a647.606 647.606 0 0 0 65.632 22.048c38.144 95.36 44.16 203.904 6.4 308-81.6 224.384-329.44 340.16-553.664 258.464h-0.032z" fill="#007CFF" /></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path d="M127.82 64.004C127.82 28.754 99.246.18 64 .18 28.766.18.203 28.73.18 63.957v49.39c.02 7.997 6.504 14.473 14.508 14.473h49.335c35.239-.015 63.797-28.578 63.797-63.816" fill="#294172"/><path d="M36.973 68.12H59.91V91.06c0 12.66-10.273 22.937-22.937 22.937-12.66 0-22.934-10.277-22.934-22.937 0-12.66 10.274-22.938 22.934-22.938zm0 0" fill="none" stroke-width="14.003274" stroke="#3c6eb4"/><path d="M82.738 68.164H59.801V45.231c0-12.66 10.277-22.938 22.937-22.938 12.66 0 22.938 10.274 22.938 22.938 0 12.66-10.278 22.933-22.938 22.933zm0 0" fill="none" stroke-width="14.003274" stroke="#3c6eb4"/><path d="M66.926 61.137v29.89c0 16.54-13.41 29.953-29.95 29.953-2.511 0-4.296-.285-6.617-.89-3.39-.887-6.156-3.664-6.16-6.895 0-3.906 2.836-6.746 7.074-6.746 2.016 0 2.747.387 5.704.387 8.718 0 15.793-7.063 15.808-15.785V77.312c0-1.23-1-2.23-2.234-2.226l-10.387-.004c-3.867 0-6.996-3.086-6.996-6.965 0-3.906 3.16-6.98 7.07-6.98" fill="#fff"/><path d="M52.785 75.148V45.262c0-16.543 13.41-29.953 29.953-29.953 2.508 0 4.293.28 6.617.89 3.387.887 6.157 3.664 6.157 6.895 0 3.906-2.836 6.746-7.07 6.746-2.02 0-2.75-.387-5.704-.387-8.722 0-15.797 7.063-15.812 15.781v13.743a2.235 2.235 0 002.234 2.226l10.387.004c3.871 0 6.996 3.086 6.996 6.965.004 3.906-3.16 6.98-7.07 6.98" fill="#fff"/><path d="M116.809 116.773v-2.652l-1.211 2.781-1.18-2.78v2.651h-.68v-4.187h.711l1.168 2.676 1.149-2.676h.722v4.187zm-4.954-3.484v3.484h-.71v-3.484h-1.192v-.703h3.09v.703" fill="#3c6eb4"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>FreeBSD</title><path fill="#AB2B28" d="M23.682 2.406c-.001-.149-.097-.187-.24-.189h-.25v.659h.108v-.282h.102l.17.282h.122l-.184-.29c.102-.012.175-.065.172-.18zm-.382.096v-.193h.13c.06-.002.145.011.143.089.005.09-.08.107-.153.103h-.12zM21.851 1.49c1.172 1.171-2.077 6.319-2.626 6.869-.549.548-1.944.044-3.115-1.128-1.172-1.171-1.676-2.566-1.127-3.115.549-.55 5.697-3.798 6.868-2.626zM1.652 6.61C.626 4.818-.544 2.215.276 1.395c.81-.81 3.355.319 5.144 1.334A11.003 11.003 0 0 0 1.652 6.61zm18.95.418a10.584 10.584 0 0 1 1.368 5.218c0 5.874-4.762 10.636-10.637 10.636C5.459 22.882.697 18.12.697 12.246.697 6.371 5.459 1.61 11.333 1.61c1.771 0 3.441.433 4.909 1.199-.361.201-.69.398-.969.574-.428-.077-.778-.017-.998.202-.402.402-.269 1.245.263 2.2.273.539.701 1.124 1.25 1.674.103.104.208.202.315.297 1.519 1.446 3.205 2.111 3.829 1.486.267-.267.297-.728.132-1.287.167-.27.35-.584.538-.927zm2.814-5.088c-.322 0-.584.266-.584.595s.261.595.584.595c.323 0 .584-.266.584-.595s-.261-.595-.584-.595zm0 1.087c-.252 0-.457-.22-.457-.492s.204-.492.457-.492c.252 0 .457.22.457.492s-.204.492-.457.492z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill="#2777ff" d="M68.15 31.699s-10.512-.7-28.411 4.901c-18.242 5.708-28.587 13.798-28.587 13.798s27.188-15.18 57.876-16.043zm39.206 16.505 1.367-.09s-7.83-9.499-22.815-14.126c8.424 3.424 15.756 7.961 21.448 14.215zm2.237 3.946c.21-.364.888 1.158 1.403 1.797.022.128.058.205-.24.144-.026-.131-.069-.17-.069-.17s-.72-.428-.942-.732c-.22-.305-.26-.837-.152-1.039m18.39 45.218s1.662-19.081-28.317-23.48a97.5 97.5 0 0 0-13.465-1c-24.029.318-24.905-27.713-6.798-29.128 7.503-.62 16.463 3.429 25.223 7.508-.033 1.088.012 2.055.727 2.948s3.46 1.867 4.336 2.37 3.687 2.29 5.409 4.531c.373-.698 3.491-2.728 3.491-2.728s-.747.016-2.485-.633c-1.738-.65-3.8-2.615-3.849-2.728-.048-.114-.08-.292.325-.374.309-.26-.39-1.104-.698-1.412-.309-.309-2.371-3.816-2.42-3.898-.049-.08-.065-.162-.211-.26-.455-.146-2.453.212-2.453.212s-3.072-1.51-4.13-4.762c.015.57-.527 1.192 0 2.5-1.603-.677-2.978-1.833-4.064-4.689-.646 1.625 0 2.658 0 2.658s-3.772-1.054-4.375-4.533c-.662 1.562 0 2.5 0 2.5s-6.15-3.208-16.367-3.254c-6.841-.628-8.265-12.662-7.631-14.688 0 0-9.867-5.2-29.29-7.497-19.424-2.298-35.353-.346-35.353-.346s34.4-1.651 61.96 9.506c.936 4.19 3.754 11.169 5.272 14.523-4.343 3.004-9.241 5.827-10.004 15.843s7.848 18.824 18.526 19.095c10.139.54 17.144.617 25.635 5.02 8.105 4.48 14.75 18.136 15.408 30.417.71-9.112-2.71-28.707-18.666-34.656 22.302 3.902 24.264 20.436 24.264 20.436zM67.624 30.202l-.802-2.588s-13.241-2.351-31.044-1.086C17.975 27.794 0 34.025 0 34.025s36.78-9.254 67.624-3.823"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M525.2 198.3c-8.6 5.6-15.2 13.8-18.9 23.4-3.8 12.4-3.2 25.6 1.5 37.7 3.9 12.7 11.7 23.8 22.2 31.8 5.4 3.8 11.6 6.2 18.2 7 6.6 0.8 13.2-0.3 19.1-3.3 7-3.9 12.6-10 15.9-17.3 3.2-7.4 5-15.3 5.2-23.3 0.7-10.2-0.6-20.4-3.8-30.1-3.5-10.6-10.3-19.7-19.5-25.9-4.7-3-9.9-5-15.4-5.8-5.5-0.8-11.1-0.2-16.3 1.8-2.9 1.2-5.7 2.7-8.3 4.5" fill="#FFFFFF" /><path d="M810.2 606.5c-5.1-28.3-13.1-56-23.8-82.6-7.3-19.8-17.2-38.6-29.5-55.8-12.4-16.5-28.1-30.4-40.2-47.1-6.4-8.7-11.8-18.4-18.5-26.9-2.7-5.6-5.3-11.2-7.9-16.8-8-17.5-15.3-35.4-24.8-52-1.5-2.6-3.1-5.2-4.6-7.7-1.2-16-2.9-32-3.8-48 0.7-32.1-2-64.3-8.1-95.9-4.2-15.1-10.6-29.6-19-42.8-9.8-15.6-22.4-29.2-37.2-40.1-24.1-17.1-52.9-26.3-82.4-26.4-21.7-0.5-43.2 4.4-62.5 14.4-20.3 11.1-36.7 28.2-47 48.9-9.6 20.9-14.7 43.5-15 66.5-0.8 22.6 1.3 45 2.2 67.6 0.9 23.4 0.4 46.9 2.3 70.3 0.6 7.5 1.5 15 1.5 22.6 0 3.8-0.2 7.6-0.3 11.3l-0.3 0.8c-10.2 17.3-21.5 34-33.8 49.9-8.6 10.9-17.2 21.7-25.9 32.4-11.3 12.7-20.9 26.8-28.5 42-5.1 13.2-9.2 26.8-12.4 40.6l-0.3 1.1c-4.8 15.9-10.8 31.3-18 46.2-0.7 1.4-1.4 2.9-2 4.2-4.3 8.9-8.8 17.8-13.5 26.5l-5.4 10.1c-3.4 6.1-6.4 12.4-9 18.8-1.5 3.9-2.7 7.9-3.4 12-1.3 8.7-0.7 17.5 1.6 25.9 0.5 2.1 1.2 4.2 1.9 6.3 2.2 6.2 4.8 12.3 7.9 18.1 1.4 2.7 2.9 5.3 4.3 8l1.3 1.9c1.4 2.5 2.9 5 4.4 7.4l0.2 0.3c1.7 2.8 3.6 5.5 5.4 8.2l0.3 0.4c1.9 2.6 3.8 5.3 5.8 7.9 7.4 28.9 21 55.8 39.7 79-2.9 5.1-5.5 10.1-8.4 15.1-10.2 14.8-18.6 30.7-25.1 47.4-2.7 8.6-3.4 17.7-1.9 26.6 1.4 9 6 17.1 13 23 4.7 3.6 10.1 6.1 15.8 7.3 5.7 1.2 11.6 1.8 17.5 1.5 22.2-1.7 44.2-6.1 65.4-12.9 12.8-3.4 25.6-6.4 38.6-9 13.5-3.1 27.2-5 41-5.6 3.4 0.1 6.8-0.1 10.1-0.3 9.4 1 18.8 1.4 28.3 1l3.5-0.2c2.4 0.3 4.9 0.4 7.4 0.6 16.6 0.9 33.1 2.6 49.5 5.1 14.4 2.2 28.8 5 43 8.5 21.9 6.6 44.4 11 67.3 12.9 6 0.3 12-0.2 18-1.4 5.9-1.2 11.5-3.8 16.3-7.4 7-5.8 11.6-13.9 13.1-22.9 1.5-8.9 0.8-18-1.9-26.6-6.6-16.7-15.1-32.6-25.5-47.3-3.6-6.1-7-12.4-10.6-18.5 15.5-17.3 29.2-36.3 40.7-56.5 7 0.4 13.9-0.4 20.6-2.6 17.5-5.9 32.7-17.3 43.3-32.5 3.2-4.5 5.7-9.5 7.2-14.8 6.9-10.7 11.6-22.7 13.8-35.3 3.2-20.8 2.7-42.1-1.5-62.7h-0.2z m0 0" fill="#020204" /><path d="M425.6 323.2c-3.1 4-5.3 8.7-6.4 13.6-1.1 4.9-1.8 10-1.9 15 0.3 10.1-0.5 20.2-2.5 30.1-3.5 10.3-8.8 19.8-15.6 28.3-11.7 14.7-20.9 31.2-27.2 48.8-3.2 10.9-4.3 22.3-3.1 33.7-12.1 17.9-22.6 36.9-31.3 56.7-13.4 29.9-22 61.8-25.5 94.4-4.3 40.1 1.6 80.6 17 117.8 11.3 26.8 28.5 50.8 50.3 70.1 11.2 9.7 23.5 17.9 36.7 24.4 46.7 22.8 101.4 22.3 147.6-1.4 23.1-13.5 44.2-30.2 62.6-49.5 11.9-10.8 22.5-22.9 31.8-36.1 15.5-26.9 24.6-57.1 26.5-88.1 9.6-53.6 3.7-108.8-16.9-159.2-8.1-16.8-18.8-32.2-31.8-45.6a252.5 252.5 0 0 0-20.2-68c-7.2-15.5-15.9-30.3-22.6-46.2-2.7-6.5-5.1-13.1-8.1-19.4-2.9-6.4-6.9-12.3-11.8-17.3-5.3-4.9-11.6-8.6-18.5-10.7-6.9-2.2-14-3.4-21.2-3.6-14.4-0.7-28.9 1.1-43.1 0.6-11.5-0.5-22.8-2.5-34.3-1.8-5.7 0.3-11.4 1.4-16.7 3.5-5.4 2.1-10.1 5.5-13.8 10m4.6-125.1c-5.4 0.4-10.5 2.7-14.4 6.4-3.9 3.7-6.8 8.4-8.4 13.5-2.7 10.4-3.4 21.3-1.9 32 0.2 9.7 1.9 19.4 5.1 28.6 1.8 4.5 4.4 8.7 7.8 12.2 3.4 3.5 7.7 6.1 12.4 7.3 4.5 1.1 9.2 0.9 13.5-0.5 4.3-1.4 8.3-3.8 11.5-7 4.7-4.8 8.1-10.7 9.8-17.1 1.7-6.4 2.5-13.1 2.3-19.8 0-8.3-1.3-16.6-3.8-24.6s-6.8-15.3-12.6-21.4c-2.8-2.9-6-5.4-9.6-7.2-3.7-1.7-7.7-2.6-11.7-2.4m95 0c-8.6 5.6-15.2 13.8-18.9 23.4-3.8 12.4-3.2 25.6 1.5 37.7 3.9 12.7 11.7 23.8 22.2 31.8 5.4 3.8 11.6 6.2 18.2 7 6.6 0.8 13.2-0.3 19.1-3.3 7-3.9 12.6-10 15.9-17.3 3.2-7.4 5-15.3 5.2-23.3 0.7-10.2-0.6-20.4-3.8-30.1-3.5-10.6-10.3-19.7-19.5-25.9-4.7-3-9.9-5-15.4-5.8-5.5-0.8-11.1-0.2-16.3 1.8-2.9 1.2-5.7 2.7-8.3 4.5" fill="#FFFFFF" /><path d="M544.5 223.6c-3.2 0.2-6.2 1.2-8.9 2.9s-5 4-6.8 6.6c-3.4 5.3-5.3 11.5-5.4 17.9-0.3 4.7 0.4 9.5 1.9 14s4.3 8.5 7.9 11.5c3.8 3.1 8.4 4.9 13.3 5.2 4.9 0.2 9.7-1.1 13.7-3.9 3.2-2.3 5.8-5.2 7.6-8.7 1.8-3.4 2.9-7.2 3.4-11 1-6.8-0.2-13.8-3.2-19.9-3.1-6.2-8.4-10.9-14.8-13.4-2.8-1.1-5.7-1.5-8.7-1.4" fill="#020204" /><path d="M430.2 198.3c-5.4 0.4-10.5 2.7-14.4 6.4-3.9 3.7-6.8 8.4-8.4 13.5-2.7 10.4-3.4 21.3-1.9 32 0.2 9.7 1.9 19.4 5.1 28.6 1.8 4.6 4.4 8.7 7.8 12.2 3.4 3.5 7.7 6.1 12.4 7.3 4.5 1.1 9.2 0.9 13.5-0.5 4.3-1.4 8.3-3.8 11.5-7 4.7-4.8 8.1-10.7 9.8-17.1 1.7-6.4 2.5-13.1 2.3-19.8 0-8.3-1.3-16.6-3.8-24.6s-6.8-15.3-12.6-21.4c-2.8-2.9-6-5.4-9.6-7.2-3.7-1.7-7.7-2.6-11.7-2.4" fill="#FFFFFF" /><path d="M417.3 242.8c-1.3 6.7-1 13.7 1.1 20.2 1.6 4.3 4 8.2 7.2 11.5 2 2.2 4.3 4.1 7 5.4 2.7 1.4 5.7 1.8 8.7 1.1 2.7-0.7 5-2.3 6.7-4.5 1.7-2.2 2.9-4.7 3.7-7.3 2.3-7.8 2.1-16.1-0.4-23.9-1.6-5.7-4.7-10.9-9.1-14.8-2.1-1.8-4.7-3.2-7.4-3.9-2.8-0.7-5.7-0.5-8.4 0.7-2.8 1.4-5.1 3.7-6.5 6.5-1.4 2.8-2.3 5.8-2.7 8.9" fill="#020204" /><path d="M404.6 326.9c0.2 0.9 0.5 1.8 1 2.5 0.9 1.4 2 2.5 3.4 3.4 1.3 0.9 2.6 1.7 3.9 2.5 6.9 4.7 13 10.5 17.9 17.3 6 9.4 13.5 17.8 22 25 6.5 4.5 14.1 7.2 22 7.9 9.2 0.7 18.5-0.4 27.4-3.2 8.2-2.4 16.1-5.8 23.5-10.3 12.7-10.2 26.3-19.2 40.7-26.7 3.4-1.2 6.8-2.1 10-3.6 3.3-1.4 6.1-3.8 7.8-7 1.1-3.2 1.8-6.6 1.9-10 0.5-3.6 1.7-7.1 2.3-10.7 0.8-3.6 0.5-7.3-0.8-10.8Line truncated
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>macOS</title><path fill="#000000" d="M0 14.727h.941v-2.453c0-.484.318-.835.771-.835.439 0 .71.276.71.722v2.566h.915V12.25c0-.48.31-.812.764-.812.46 0 .718.28.718.77v2.518h.94v-2.748c0-.801-.517-1.334-1.307-1.334-.578 0-1.054.31-1.247.805h-.023c-.147-.514-.552-.805-1.118-.805-.545 0-.968.306-1.142.771H.903v-.695H0v4.006zm7.82-.646c-.408 0-.68-.208-.68-.537 0-.318.26-.522.714-.552l.926-.057v.307c0 .483-.427.839-.96.839zm-.284.71c.514 0 1.017-.268 1.248-.703h.018v.639h.908v-2.76c0-.804-.647-1.33-1.64-1.33-1.021 0-1.66.537-1.701 1.285h.873c.06-.332.344-.548.79-.548.464 0 .748.242.748.662v.287l-1.058.06c-.976.061-1.524.488-1.524 1.199 0 .721.564 1.209 1.338 1.209zm6.305-2.642c-.065-.843-.719-1.512-1.777-1.512-1.164 0-1.92.805-1.92 2.087 0 1.3.756 2.082 1.928 2.082 1.005 0 1.697-.59 1.772-1.485h-.888c-.087.453-.397.725-.873.725-.597 0-.982-.483-.982-1.322 0-.824.381-1.323.975-1.323.502 0 .8.321.876.748h.889zm2.906-2.967c-1.591 0-2.589 1.085-2.589 2.82 0 1.735.998 2.816 2.59 2.816 1.586 0 2.584-1.081 2.584-2.816 0-1.735-.997-2.82-2.585-2.82zm0 .832c.971 0 1.591.77 1.591 1.988 0 1.213-.62 1.984-1.59 1.984-.976 0-1.592-.77-1.592-1.984 0-1.217.616-1.988 1.591-1.988zm2.982 3.178c.042 1.006.866 1.626 2.12 1.626 1.32 0 2.151-.65 2.151-1.686 0-.813-.469-1.27-1.576-1.523l-.627-.144c-.67-.158-.945-.37-.945-.733 0-.453.415-.756 1.032-.756.623 0 1.05.306 1.096.817h.93c-.023-.96-.817-1.61-2.019-1.61-1.187 0-2.03.653-2.03 1.62 0 .78.477 1.263 1.482 1.494l.707.166c.688.163.967.39.967.782 0 .454-.457.779-1.115.779-.665 0-1.167-.329-1.228-.832h-.945z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M849.3056 410.5216c-18.7392 22.6816-66.8672 31.8976-105.1136 20.224-36.5056-11.1616-48.2816-36.7616-28.5184-56.9344 19.0464-19.4048 61.696-27.8016 97.2288-18.9952 37.12 9.1648 54.3744 33.9456 36.352 55.7056z m-168.8064-46.336c-19.968 19.5072-62.8224 27.904-93.7984 18.432-17.2032-5.2224-26.7264-16.2304-27.2384-25.3952-0.3584-7.0144-11.4688-13.5168-23.7568-16.9984-12.288-3.5328-26.5728-3.1744-40.8064-1.536-3.6352 0.4096-11.264 2.048-16.1792 3.584-13.1584 4.1984-24.5248 8.9088-33.9968 16.4352a45.4144 45.4144 0 0 0-15.4624 32.256c0.4096 8.6528 8.7552 16.0768 21.504 20.6336 12.4928 4.5056 28.0576 5.3248 43.5712 2.9696 18.944-4.864 39.68-5.0688 56.6784 0.6656 31.1296 10.496 39.0144 37.376 15.4624 60.3648-24.576 23.9616-71.7824 32.9728-103.1168 19.7632a37.376 37.376 0 0 1-24.4736-33.024c-0.7168-9.6768-9.728-17.408-22.016-22.2208-12.3904-4.864-26.8288-4.6592-43.008-3.328-2.1504 0.1536-11.776 1.9968-17.664 3.4816-8.0896 2.048-24.7296 6.5024-38.7072 19.712a82.7392 82.7392 0 0 0-18.7904 26.112c-1.6384 3.3792-3.584 11.0592-3.6864 13.7216-0.3584 10.8544 6.0928 20.6848 18.6368 26.9824 12.3904 6.2464 28.7232 7.8848 45.6704 5.5296 20.992-5.4784 43.1104-4.864 60.16 3.072 31.3856 14.6944 35.072 49.5104 5.632 78.336-30.8736 30.1056-83.456 39.7824-114.5344 21.1968-16.9984-10.1888-21.9648-25.4976-19.5584-43.4176v-0.1024a36.352 36.352 0 0 0-0.6656-6.5024s-0.4608-3.584-2.816-8.0896a36.864 36.864 0 0 0-15.4624-15.0528c-12.1344-6.656-28.16-8.448-44.8512-6.0416-20.9408 5.4272-41.3696 4.6592-54.8352-3.3792-22.9376-13.7216-16.0768-42.752 13.312-64.7168 15.2064-11.3664 33.7408-18.7904 51.2-21.7088 16.6912-3.2768 33.792-10.4448 47.2064-20.736 13.1584-10.1376 19.968-21.0944 20.6336-30.6176a48.2816 48.2816 0 0 0-0.6656-10.3424c-0.6144-8.192-0.1536-16.384 7.68-25.1904 4.9152-5.632 15.7696-11.1616 27.5968-13.312 8.0896-1.4336 18.5344-1.1264 24.4224-1.4336 5.12-0.256 14.0288-1.2288 17.2032-2.1504a74.752 74.752 0 0 0 27.904-14.0288c9.0112-6.912 14.592-14.1824 15.616-20.8384 0.2048-1.3824 0.6656-7.8848 0.512-8.704-3.0208-15.5136 19.6608-32.5632 50.5856-38.0928 15.6672-2.7648 30.464-2.048 41.6768 1.3824l1.1776 0.2048c11.264 2.816 18.5344 8.0384 21.248 14.3872h0.1024l0.1024 0.512a14.9504 14.9504 0 0 1 0.9216 3.584c2.6624 6.8096 10.4448 12.6464 23.0912 15.9744 12.4416 3.2768 27.1872 3.5328 41.472 1.2288 17.2544-4.4032 36.7104-5.12 53.4016-1.024 30.464 7.5264 40.96 28.672 21.7088 47.4624zM608.256 710.144c-30.5152 37.2224-91.4944 47.9744-132.2496 23.552-38.4-22.9376-41.8304-67.3792-10.9568-98.9696 29.44-30.1568 81.8176-39.8336 119.9104-22.016 40.2944 18.7904 52.224 62.1568 23.296 97.4336zM907.4688 261.12l-366.1312-202.3424a60.928 60.928 0 0 0-58.6752 0L116.5312 261.0688a55.7568 55.7568 0 0 0-29.3376 48.64v404.6336c0 20.0704 11.1616 38.5536 29.3376 48.64l366.1312 202.2912a60.928 60.928 0 0 0 58.6752 0l366.1312-202.3424c18.176-10.0352 29.3376-28.5184 29.3376-48.64V309.7088c0-20.0192-11.1616-38.5536-29.3376-48.64z" fill="#002FA7" /></svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M457.557333 0a512 512 0 0 0-403.114666 197.248c62.464 16.682667 106.666667 31.018667 119.893333 35.498667 0.213333-8.106667 1.578667-80.768 1.578667-80.768s0.170667-1.706667 1.066666-2.56c1.109333-1.109333 2.773333-0.768 2.773334-0.768 16.426667 2.389333 367.018667 54.357333 514.816 140.458666 18.218667 10.666667 27.221333 22.058667 38.485333 33.536 40.874667 42.24 94.848 217.941333 100.650667 254.165334 0.213333 1.408-1.536 2.986667-2.304 3.541333a220.885333 220.885333 0 0 1-13.354667 9.728c-34.986667 23.466667-115.541333 79.872-218.88 70.656-92.842667-8.234667-214.101333-61.44-360.32-157.824 14.336 33.706667 28.501333 67.413333 42.666667 101.162667 21.205333 11.008 225.578667 115.2 326.442666 113.109333 81.237333-1.706667 168.149333-41.301333 202.922667-62.208 0 0 7.637333-4.608 10.965333-2.048 3.626667 2.816 2.602667 7.125333 1.749334 11.52-2.133333 9.984-6.997333 28.16-10.325334 36.821333l-2.773333 7.04c-3.968 10.666667-7.808 20.565333-15.189333 26.666667-20.48 18.602667-53.162667 33.450667-104.362667 55.68-79.146667 34.645333-207.573333 56.661333-326.826667 55.893333-42.709333-0.938667-83.968-5.674667-120.192-9.898666-74.368-8.405333-134.869333-15.232-171.776 11.477333A512 512 0 0 0 457.557333 1024a512 512 0 0 0 512-512 512 512 0 0 0-512-512zM571.733333 297.088a149.461333 149.461333 0 0 0-107.562666 40.192 149.248 149.248 0 0 0-47.530667 104.490667 150.528 150.528 0 0 0 144.64 155.306666 148.48 148.48 0 0 0 107.690667-40.362666 149.504 149.504 0 0 0 47.530666-104.362667 150.485333 150.485333 0 0 0-144.768-155.306667z m-1.28 44.16a104.874667 104.874667 0 0 1 101.034667 108.330667 103.68 103.68 0 0 1-33.024 72.789333 104.789333 104.789333 0 0 1-75.178667 28.117333 105.002667 105.002667 0 0 1-100.864-108.458666c0.853333-27.946667 12.8-53.76 33.152-72.832a103.210667 103.210667 0 0 1 74.922667-27.946667z m17.152 52.48c-25.685333 0-46.378667 13.866667-46.378666 31.018667 0 17.066667 20.693333 30.933333 46.378666 30.933333 25.6 0 46.421333-13.909333 46.421334-30.933333 0-17.152-20.778667-30.976-46.421334-30.976z" fill="#73BA25" /></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M512 512m-512 0a512 512 0 1 0 1024 0 512 512 0 1 0-1024 0Z" fill="#040000" /><path d="M928.768 454.848c-21.632-42.048-95.616-56.448-123.84-70.016-9.344 18.368-26.304 49.856-31.744 47.168-3.712-1.856 5.056-21.376 9.536-43.456a304.64 304.64 0 0 0 4.736-38.784c-16.128-63.04-60.736-217.856-119.488-231.808-84.864-20.096-111.872 7.168-159.488 8.192-47.552 0.896-77.44-25.024-129.728-13.184C312.128 128 298.944 277.952 297.216 302.784c8.896 12.288 38.4 45.568 104.64 60.224 52.544 11.712 64.576 7.36 97.792 23.232 38.4 18.368 32.128 38.208 35.712 59.648 3.52 21.376 27.072 25.664 21.312 33.856-5.696 8.256-113.408-19.264-168.448-49.92-54.976-30.72-107.072-47.104-112.768-82.432-3.456-21.12 3.904-34.432 10.24-41.856-34.496-3.008-141.184-7.168-174.08 52.928-36.096 66.112 163.84 209.024 310.912 261.568 126.016 44.992 344.96 84.416 458.944-12.8 56.896-48.576 64.256-119.36 47.296-152.384zM488.896 242.432s-34.176-1.664-57.856 4.992-21.376 16.512-38.464 16.448c-7.68-0.064-27.136-4.736-31.424-29.888-4.288-25.088 3.52-34.368 3.52-34.368s17.856 23.552 36.416 26.368c18.56 2.88 90.688-6.4 95.68 3.648s-7.872 12.8-7.872 12.8z m173.12-77.056c-6.144 13.184-63.552 20.288-63.552 20.288s27.84-11.776 26.048-26.752-45.696-19.584-45.696-19.584 95.936-1.728 83.2 26.048z" fill="#DD2A2A" /><path d="M770.112 809.344l3.84 20.032s-37.376 19.392-60.928 33.28c-23.488 13.888-65.152 62.656-76.48 51.968-12.736-11.968 50.496-73.024 73.728-109.44 22.4-35.072 25.6-112.896 25.6-112.896s-23.488 9.792-44.992 10.752c-24.256 1.088-32.96-5.888-46.464-7.232s-18.688 5.184-33.536 5.184c-14.208 0-20.416-4.48-48.832-5.184-28.416-0.704-71.808 8-105.472 2.944s-61.76-78.72-94.208-78.72c-11.904 0-9.664 9.28-21.248 6.912s-36.992-40.512-61.376-52.16c-26.368-12.608-57.088 0.448-56.192 38.336 0.448 17.856 14.72 38.4 30.976 54.272 18.176 17.792 37.952 30.592 25.28 41.856-21.12 18.944-81.344-52.288-81.344-52.288l-131.584-0.96v34.624l-18.944 2.304-2.368 1.088C116.032 882.688 298.752 1012.992 512 1012.992c166.4 0 312.832-77.952 402.944-203.712h-144.832z m-176.64-14.016c-27.456 0-46.592-17.408-48.768-21.696a8.512 8.512 0 0 1 6.72-12.032c6.72-1.088 26.176 13.184 42.688 12.16 17.408-1.088 19.968-12.224 26.048-12.224 12.224 0 1.856 33.792-26.688 33.792z" fill="#FFFFFF" /></svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M294.4 975.36C360.96 1006.08 435.2 1024 512 1024c166.4 0 312.32-79.36 407.04-202.24l-235.52-235.52-389.12 389.12z" fill="#04BA7F" /><path d="M151.04 875.52l529.92-529.92L998.4 663.04c17.92-48.64 25.6-97.28 25.6-151.04C1024 230.4 793.6 0 512 0S0 230.4 0 512c0 143.36 58.88 271.36 151.04 363.52z" fill="#04BA7F" /></svg>
|
||||
|
After Width: | Height: | Size: 392 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M512 512m-512 0a512 512 0 1 0 1024 0 512 512 0 1 0-1024 0Z" fill="#DD4814" /><path d="M251.716267 512c0 36.932267-29.5936 66.833067-66.116267 66.833067S119.466667 548.9152 119.466667 512c0-36.9152 29.610667-66.833067 66.133333-66.833067 36.5056 0 66.116267 29.917867 66.116267 66.833067zM627.882667 836.983467a65.7408 65.7408 0 0 0 90.333866 24.439466c31.573333-18.432 42.4448-59.306667 24.1664-91.2896a65.706667 65.706667 0 0 0-90.282666-24.439466c-31.624533 18.466133-42.461867 59.323733-24.2176 91.2896z m114.517333-583.133867c18.261333-31.9488 7.441067-72.823467-24.183467-91.2896a65.757867 65.757867 0 0 0-90.333866 24.456533c-18.261333 31.965867-7.406933 72.8576 24.200533 91.306667a65.6896 65.6896 0 0 0 90.299733-24.4736z m-223.7952 67.549867c98.542933 0 179.370667 76.356267 187.835733 173.704533L802.133333 493.602133a287.607467 287.607467 0 0 0-83.4048-185.429333c-25.361067 9.898667-54.7328 8.533333-80.0768-6.263467a93.917867 93.917867 0 0 1-45.431466-67.037866 281.770667 281.770667 0 0 0-74.581334-10.001067c-45.226667 0-88.0128 10.683733-126.0032 29.730133l46.557867 84.514134a186.026667 186.026667 0 0 1 79.428267-17.7152zM330.069333 512c0-64.494933 31.6928-121.480533 80.1792-155.989333l-49.134933-82.9952a287.505067 287.505067 0 0 0-117.1968 165.717333A94.242133 94.242133 0 0 1 278.6304 512a94.242133 94.242133 0 0 1-34.7136 73.2672 287.573333 287.573333 0 0 0 117.1968 165.717333l49.134933-82.9952A190.976 190.976 0 0 1 330.069333 512zM518.621867 702.634667a186.368 186.368 0 0 1-79.428267-17.698134l-46.557867 84.514134a280.405333 280.405333 0 0 0 125.986134 29.713066 281.258667 281.258667 0 0 0 74.5984-10.001066 93.9008 93.9008 0 0 1 45.431466-67.037867 91.989333 91.989333 0 0 1 80.0768-6.263467A287.744 287.744 0 0 0 802.133333 530.414933l-95.675733-1.536c-8.448 97.3824-89.2928 173.738667-187.835733 173.738667z" fill="#FFFFFF" /></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M456 484V160.1l-335.9 72V484H456zM512 484h391.8V64.2l-391.8 84V484zM456 540H120.2v251.9l335.9 72V540zM512 540v335.9l391.8 84V540H512z" fill="#00adef" /></svg>
|
||||
|
After Width: | Height: | Size: 232 B |
@@ -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'
|
||||
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||