feat(i18n): add multi-language support (ja/ko/vi/ru/zh-TW) with system auto-detection

- Extend AppLanguage to 7 locales: zh-CN, zh-TW, en, ja, ko, vi, ru
- Auto-detect system language via navigator.languages; fallback to English
- Add BCP47 locale mapping for date formatting and sorting
- Create 50 translation JSON files (5 languages × 10 namespaces, 1137 keys each)
- Update language selector with native language names
- Extend locale key parity test to cover all 7 languages (70 assertions)
- Add VNC/RDP desktop, serial terminal, AI agent, port forwarding, and SFTP features
- Add credential encryption, host metrics, session backup crypto, and zmodem bridge
- Update Rust backend: desktop services, host metrics, SSH/SFTP enhancements
This commit is contained in:
Logan.Li committed 2026-08-12 00:07:48 +08:00
1 parent fea6d8db04
commit b0d0e3a5f3
301 files changed
+28002 -2705

No files matched your search

+50 -38
View File
@@ -1,4 +1,6 @@
import { Plus, Zap, Clock, Server, ChevronRight, Laptop, Terminal, Monitor, Cast } from 'lucide-react'
import { Plus, LogIn, Clock, Server, ChevronRight, Laptop, Terminal, Monitor, Cast } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import HostDistroIcon from '../common/HostDistroIcon'
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'
import { useSessionStore } from '../../stores'
import { useTabStore } from '../../stores/tabStore'
@@ -8,10 +10,13 @@ import { openLocalShellTab, type ShellOption } from '../layout/sidebar/LocalShel
import appIconUrl from '../../assets/opsxshell-app-icon.png'
import { shellDisplayFromPath } from '../../utils/shellName'
import { fallbackLocalShells, modKeyLabel } from '../../utils/platformShells'
import { buildOpenTabPayload } from '../../utils/sessionProtocol'
interface WelcomeViewProps {
onNewSession?: () => void
onQuickConnect?: () => void
onRdpConnect?: () => void
onVncConnect?: () => void
}
function Kbd({ children }: { children: string }) {
@@ -22,10 +27,15 @@ function Kbd({ children }: { children: string }) {
)
}
export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeViewProps) {
export default function WelcomeView({
onNewSession,
onQuickConnect,
onRdpConnect,
onVncConnect,
}: WelcomeViewProps) {
const { t } = useTranslation('sessions')
const { addTab } = useTabStore()
const requirePro = useEntitlementStore((s) => s.requirePro)
const openUpgradeTab = useEntitlementStore((s) => s.openUpgradeTab)
const sessions = useSessionStore((s) => s.sessions)
const recentSessions = sessions.slice(-8).reverse()
const modKey = useMemo(() => modKeyLabel(), [])
@@ -70,11 +80,9 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
}, [defaultShell])
const handleSessionClick = (session: (typeof sessions)[number]) => {
addTab({
title: session.name || session.host,
type: 'terminal',
sessionId: session.id,
})
const protocol = session.protocol ?? 'ssh'
if ((protocol === 'rdp' || protocol === 'vnc') && !requirePro(protocol)) return
addTab(buildOpenTabPayload(session))
}
return (
@@ -91,11 +99,11 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
/>
<div className="min-w-0">
<h1 className="text-lg font-semibold tracking-tight text-content-primary">OpsXShell</h1>
<p className="text-xs text-txt-muted">远程终端 · SFTP · 端口转发</p>
<p className="text-xs text-txt-muted">{t('welcome.tagline')}</p>
</div>
</header>
<section aria-label="开始" className="mb-12">
<section aria-label={t('welcome.sectionStart')} className="mb-12">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<button
type="button"
@@ -104,13 +112,13 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
>
<div className="flex items-center justify-between">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-blue/15 text-accent-blue">
<Zap className="h-4 w-4" strokeWidth={2} />
<LogIn className="h-4 w-4" strokeWidth={2} />
</span>
<Kbd>{`${modKey}K`}</Kbd>
</div>
<div>
<div className="text-sm font-medium text-content-primary">快速连接</div>
<div className="mt-1 text-xs leading-relaxed text-txt-muted">临时 SSH,不写入会话库</div>
<div className="text-sm font-medium text-content-primary">{t('welcome.quickConnect.title')}</div>
<div className="mt-1 text-xs leading-relaxed text-txt-muted">{t('welcome.quickConnect.desc')}</div>
</div>
</button>
@@ -126,8 +134,8 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
<Kbd>{`${modKey}N`}</Kbd>
</div>
<div>
<div className="text-sm font-medium text-content-primary">新建会话</div>
<div className="mt-1 text-xs leading-relaxed text-txt-muted">保存主机,侧栏双击连接</div>
<div className="text-sm font-medium text-content-primary">{t('welcome.newSession.title')}</div>
<div className="mt-1 text-xs leading-relaxed text-txt-muted">{t('welcome.newSession.desc')}</div>
</div>
</button>
@@ -145,7 +153,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
<Terminal className="h-3.5 w-3.5 text-txt-faint" />
</div>
<div>
<div className="text-sm font-medium text-content-primary">本地 Shell</div>
<div className="text-sm font-medium text-content-primary">{t('welcome.localShell.title')}</div>
<div className="mt-1 truncate text-xs leading-relaxed text-txt-muted">
{defaultShellLabel}
</div>
@@ -153,7 +161,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
</button>
<button
type="button"
aria-label="选择 Shell"
aria-label={t('welcome.localShell.selectShell')}
aria-expanded={shellMenuOpen}
onClick={() => setShellMenuOpen((v) => !v)}
className="flex w-9 flex-shrink-0 items-center justify-center border-l border-dark-700/60 text-txt-faint transition-colors hover:bg-dark-700/40 hover:text-content-secondary"
@@ -177,7 +185,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
<span className="w-10 font-mono text-[10px] uppercase text-txt-faint">{s.kind}</span>
<span className="flex-1 text-sm text-content-primary">{s.name}</span>
{s.isDefault && (
<span className="text-[10px] text-txt-faint">默认</span>
<span className="text-[10px] text-txt-faint">{t('welcome.localShell.default')}</span>
)}
</button>
))}
@@ -187,51 +195,51 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
</div>
</section>
<section aria-label="Pro 即将推出" className="mb-10">
<section aria-label={t('welcome.sectionDesktop')} className="mb-10">
<div className="mb-3 flex items-center gap-2">
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">Pro · 即将推出</h2>
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">{t('welcome.sectionDesktop')}</h2>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<button
type="button"
onClick={() => {
if (!requirePro('rdp', { comingSoon: true })) return
openUpgradeTab('rdp', true)
if (!requirePro('rdp')) return
onRdpConnect?.()
}}
className="flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/30 px-4 py-3 text-left transition-colors hover:border-dark-600"
className="flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/30 px-4 py-3 text-left transition-colors hover:border-accent-blue/40"
>
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-dark-700 text-txt-muted">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-blue/15 text-accent-blue">
<Monitor className="h-4 w-4" />
</span>
<div className="min-w-0">
<div className="text-sm font-medium text-content-secondary">RDP 远程桌面</div>
<div className="text-[11px] text-txt-faint">Pro · 即将推出</div>
<div className="text-sm font-medium text-content-primary">{t('welcome.rdp.title')}</div>
<div className="text-[11px] text-txt-faint">{t('welcome.rdp.desc')}</div>
</div>
</button>
<button
type="button"
onClick={() => {
if (!requirePro('vnc', { comingSoon: true })) return
openUpgradeTab('vnc', true)
if (!requirePro('vnc')) return
onVncConnect?.()
}}
className="flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/30 px-4 py-3 text-left transition-colors hover:border-dark-600"
className="flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/30 px-4 py-3 text-left transition-colors hover:border-accent-blue/40"
>
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-dark-700 text-txt-muted">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-blue/15 text-accent-blue">
<Cast className="h-4 w-4" />
</span>
<div className="min-w-0">
<div className="text-sm font-medium text-content-secondary">VNC 远程桌面</div>
<div className="text-[11px] text-txt-faint">Pro · 即将推出</div>
<div className="text-sm font-medium text-content-primary">{t('welcome.vnc.title')}</div>
<div className="text-[11px] text-txt-faint">{t('welcome.vnc.desc')}</div>
</div>
</button>
</div>
</section>
<section aria-label="最近会话" className="min-h-0 flex-1">
<section aria-label={t('welcome.sectionRecent')} className="min-h-0 flex-1">
<div className="mb-3 flex items-center gap-2">
<Clock className="h-3.5 w-3.5 text-txt-faint" />
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
最近会话
{t('welcome.sectionRecent')}
</h2>
{recentSessions.length > 0 && (
<span className="text-[11px] text-txt-dim">{recentSessions.length}</span>
@@ -242,8 +250,8 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
<div className="rounded-xl border border-dashed border-dark-700/70 px-4 py-10">
<EmptyState
icon={Server}
title="暂无已保存会话"
description="用快速连接试连,或新建会话以便复用"
title={t('welcome.recentEmpty.title')}
description={t('welcome.recentEmpty.desc')}
/>
</div>
) : (
@@ -255,8 +263,12 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
onClick={() => handleSessionClick(session)}
className="group flex w-full items-center gap-3 px-3.5 py-2.5 text-left transition-colors hover:bg-dark-800/80"
>
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-dark-800 text-txt-muted group-hover:text-accent-blue">
<Server className="h-3.5 w-3.5" />
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center">
<HostDistroIcon
distro={session.distro}
protocol={session.protocol}
size="md"
/>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-content-primary">{session.name}</span>