feat: multi-language interface (zh-CN/zh-TW/en/ja), multilingual offline changelog, settings robustness

i18n
- shared/i18n: dependency-free t() with flat per-namespace dictionaries
  (common/settings/workspace/terminal/ssh/panels/main), zh-CN fallback
- language picker in Settings -> System; antd ConfigProvider locale follows it
- main process tracks the language too: tray menu, close prompt, ssh/sftp/
  zmodem errors and the log TUI marker are translated; tray rebuilds on change

changelog
- CHANGELOG.md (zh-CN canonical) + .zh-TW/.en/.ja, bundled via ?raw and read
  per interface language with per-version fallback to zh-CN (no network)
- sync-changelog.cjs merges RELEASE_NOTES[.<lang>].md per release; release.cjs
  refuses to publish without a zh-CN entry for the version

settings robustness
- closeAction 'ask' survives the sanitizer (was silently coerced to 'tray',
  which made the 'ask every time' option dead)
- highlight rules are repaired instead of dropped: string priority, 0/1
  enabled, missing fg colour; unknown fields preserved
- load-time warnings are written to settings-warnings.log (deduped, capped)

terminal/UI
- configurable terminal toolbar: open working directory (default on), session
  log recording (off), open logs folder (off)
- global shortcut field records key combos (modifier or F-key required)
- input suggestions + command history default to off, with a one-time reset
  migration for existing installs
- settings dialog scrolling fixed (antd v6 renamed the tabs container), theme
  gallery nested scrollbar removed, joined segmented pickers with readable
  selected-state text

tests: settings-store.mjs (15 checks) added; commands-store.mjs updated for
the new off-by-default history setting
This commit is contained in:
Bill committed 2026-09-20 14:22:29 +08:00
1 parent 34094d607b
commit 4c6a29ef28
82 files changed
+3342 -608

No files matched your search

+28 -6
View File
@@ -1,12 +1,26 @@
import { useEffect, useState } from 'react'
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import zhTW from 'antd/locale/zh_TW'
import enUS from 'antd/locale/en_US'
import jaJP from 'antd/locale/ja_JP'
import Workspace from '@renderer/workspace/Workspace'
import { SettingsDialog } from '@renderer/settings/SettingsDialog'
import { TransferPanel } from '@renderer/sftp/TransferPanel'
import { useSettingsStore } from '@renderer/settings/store'
import { getThemeById } from '@shared/theme'
import { DEFAULT_LANGUAGE, setLanguage, type Language } from '@shared/i18n'
import { applyChromeTheme, applyTabAccent } from '@renderer/theme/chrome'
import appIconUrl from '../../../build/icon.png'
/** antd's own component texts (empty states, pagination, date pickers). */
const ANTD_LOCALES: Record<Language, typeof zhCN> = {
'zh-CN': zhCN,
'zh-TW': zhTW,
en: enUS,
ja: jaJP
}
/** Custom window title bar: draggable strip themed with the active terminal
* theme; native min/max/close buttons come from BrowserWindow titleBarOverlay. */
function TitleBar(): React.JSX.Element {
@@ -28,12 +42,18 @@ export default function App(): React.JSX.Element {
const themeId = useSettingsStore((s) => s.settings.terminal.themeId)
const customThemes = useSettingsStore((s) => s.settings.customThemes)
const tabAccentColor = useSettingsStore((s) => s.settings.terminal.tabAccentColor)
const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE)
const [settingsOpen, setSettingsOpen] = useState(false)
useEffect(() => {
void hydrate()
}, [hydrate])
// Interface language: drives t() and antd's built-in component texts.
useEffect(() => {
setLanguage(language)
}, [language])
// Sidebar / tab bars / dividers follow the active terminal theme.
useEffect(() => {
applyChromeTheme(getThemeById(themeId, customThemes).colors)
@@ -45,11 +65,13 @@ export default function App(): React.JSX.Element {
}, [tabAccentColor])
return (
<div className="app-root">
<TitleBar />
<Workspace onOpenSettings={() => setSettingsOpen(true)} />
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<TransferPanel />
</div>
<ConfigProvider locale={ANTD_LOCALES[language]}>
<div className="app-root">
<TitleBar />
<Workspace onOpenSettings={() => setSettingsOpen(true)} />
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<TransferPanel />
</div>
</ConfigProvider>
)
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { Component } from 'react'
import type { ErrorInfo, ReactNode } from 'react'
import { t } from '@shared/i18n'
interface ErrorBoundaryProps {
children: ReactNode
@@ -38,7 +39,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
color: '#cccccc'
}}
>
<div>界面出现异常</div>
<div>{t('workspace.error.title')}</div>
<pre
style={{
maxWidth: 720,
@@ -56,7 +57,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
onClick={() => window.location.reload()}
style={{ padding: '6px 18px', cursor: 'pointer' }}
>
重新加载
{t('workspace.error.reload')}
</button>
</div>
)
+34 -27
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { Button, Modal, Popconfirm, Tabs } from 'antd'
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'
import type { CommandItem } from '@shared/commands'
import { t } from '@shared/i18n'
import { useWorkspaceModeStore } from '../workspace/workspaceModeStore'
import './commands.css'
@@ -88,8 +89,10 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
return (
<div className="commands-panel">
<div className={`commands-mode-badge commands-mode-badge--${workspaceMode}`}>
<span className="commands-mode-badge-text">发送目标</span>
<span className="commands-mode-badge-tag">{workspaceMode === 'ssh' ? 'SSH 服务器' : '终端'}</span>
<span className="commands-mode-badge-text">{t('panels.commands.target')}</span>
<span className="commands-mode-badge-tag">
{workspaceMode === 'ssh' ? t('panels.commands.targetSsh') : t('panels.commands.targetTerminal')}
</span>
</div>
<Tabs
size="small"
@@ -97,27 +100,29 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
items={[
{
key: 'history',
label: '历史',
label: t('panels.commands.tabHistory'),
children: (
<div className="commands-section">
<div className="commands-section-head">
<span className="commands-section-title">历史命令</span>
<span className="commands-section-title">{t('panels.commands.historyTitle')}</span>
{history.length > 0 && (
<Popconfirm
title="清空全部命令历史?"
okText="清空"
cancelText="取消"
title={t('panels.commands.clearTitle')}
okText={t('panels.commands.clear')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
onConfirm={() => void handleClearHistory()}
>
<Button type="text" size="small" danger className="commands-clear-btn">
清空
{t('panels.commands.clear')}
</Button>
</Popconfirm>
)}
</div>
{history.length === 0 ? (
<div className="commands-empty">{loaded ? '暂无历史命令' : '加载中…'}</div>
<div className="commands-empty">
{loaded ? t('panels.commands.noHistory') : t('panels.commands.loading')}
</div>
) : (
<ul className="commands-list">
{history.map((item) => (
@@ -130,22 +135,24 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
},
{
key: 'library',
label: '命令库',
label: t('panels.commands.tabLibrary'),
children: (
<div className="commands-section">
<div className="commands-section-head">
<span className="commands-section-title">命令库</span>
<span className="commands-section-title">{t('panels.commands.libraryTitle')}</span>
<Button
type="text"
size="small"
icon={<PlusOutlined />}
onClick={handleAdd}
aria-label="新增命令"
title="新增命令"
aria-label={t('panels.commands.add')}
title={t('panels.commands.add')}
/>
</div>
{library.length === 0 ? (
<div className="commands-empty">{loaded ? '命令库为空' : '加载中…'}</div>
<div className="commands-empty">
{loaded ? t('panels.commands.libraryEmpty') : t('panels.commands.loading')}
</div>
) : (
<ul className="commands-list">
{library.map((item) => (
@@ -166,9 +173,9 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
<Modal
open={addOpen}
title="新增命令"
okText="保存"
cancelText="取消"
title={t('panels.commands.add')}
okText={t('common.save')}
cancelText={t('common.cancel')}
okButtonProps={{ disabled: command.trim().length === 0 }}
onOk={() => void handleSave()}
onCancel={() => setAddOpen(false)}
@@ -176,23 +183,23 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
>
<div className="commands-form">
<label className="commands-field">
<span className="commands-field-label">名称</span>
<span className="commands-field-label">{t('panels.commands.name')}</span>
<input
className="commands-input"
type="text"
value={name}
placeholder="(可选)"
placeholder={t('panels.commands.optional')}
spellCheck={false}
onChange={(e) => setName(e.target.value)}
/>
</label>
<label className="commands-field">
<span className="commands-field-label">命令</span>
<span className="commands-field-label">{t('panels.commands.command')}</span>
<input
className="commands-input"
type="text"
value={command}
placeholder="如 ls -la"
placeholder={t('panels.commands.commandPlaceholder')}
spellCheck={false}
autoFocus
onChange={(e) => setCommand(e.target.value)}
@@ -203,12 +210,12 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
/>
</label>
<label className="commands-field">
<span className="commands-field-label">备注</span>
<span className="commands-field-label">{t('panels.commands.note')}</span>
<input
className="commands-input"
type="text"
value={note}
placeholder="(可选)"
placeholder={t('panels.commands.optional')}
spellCheck={false}
onChange={(e) => setNote(e.target.value)}
/>
@@ -240,9 +247,9 @@ function Row({
</div>
{item.name && onDelete && (
<Popconfirm
title="删除该命令?"
okText="删除"
cancelText="取消"
title={t('panels.commands.deleteTitle')}
okText={t('common.delete')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
onConfirm={onDelete}
>
@@ -252,7 +259,7 @@ function Row({
danger
icon={<DeleteOutlined />}
className="commands-row-del"
aria-label="删除命令"
aria-label={t('panels.commands.deleteAria')}
/>
</Popconfirm>
)}
+6 -1
View File
@@ -1,4 +1,5 @@
import type { SshAuthMethod, SshConnection, SshConnectionInput, SshSavedAuthFlags } from '@shared/connections'
import { t } from '@shared/i18n'
/**
* Shared helpers for the connection manager UI (connections/**, renderer).
@@ -22,7 +23,11 @@ export function hasSavedKind(kind: SecretKind, savedAuth: SshSavedAuthFlags | un
}
export function authLabel(kind: SshAuthMethod): string {
return kind === 'privateKey' ? '私钥' : kind === 'agent' ? 'Agent' : '密码'
return kind === 'privateKey'
? t('ssh.auth.privateKey')
: kind === 'agent'
? t('ssh.auth.agent')
: t('ssh.auth.password')
}
export function AuthTags({ auth }: { auth: SshAuthMethod }): React.JSX.Element {
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { Alert, Button, Form, Input, InputNumber, Modal, Radio, Select } from 'antd'
import type { SshAuthMethod, SshConnection, SshConnectionInput } from '@shared/connections'
import { getLanguage, t } from '@shared/i18n'
import { AuthFieldsRenderer } from './Fields'
import { buildInput, num, hasSavedKind, type SecretKind } from './Common'
@@ -87,7 +88,7 @@ export function ConnectionEditDialog({
onSaved()
onClose()
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : '保存失败,请检查输入后重试')
setErrorMsg(err instanceof Error ? err.message : t('ssh.dialog.saveFailed'))
} finally {
setSaving(false)
}
@@ -97,21 +98,24 @@ export function ConnectionEditDialog({
if (!saving) onClose()
}
/** Active language; a dep of the memo below so translated labels refresh. */
const language = getLanguage()
const savedNotes = useMemo(() => {
if (!editing) return []
const kinds: Array<{ kind: SecretKind; label: string }> = [
{ kind: 'password', label: '密码' },
{ kind: 'keyContent', label: '私钥' },
{ kind: 'passphrase', label: '口令' }
{ kind: 'password', label: t('ssh.auth.password') },
{ kind: 'keyContent', label: t('ssh.auth.privateKey') },
{ kind: 'passphrase', label: t('ssh.secret.passphrase') }
]
const active = auth === 'password' ? ['password'] : auth === 'privateKey' ? ['keyContent', 'passphrase'] : []
return kinds.filter((k) => active.includes(k.kind) && hasSavedKind(k.kind, editing.savedAuth))
}, [editing, auth])
}, [editing, auth, language])
return (
<Modal
open={open}
title={editing ? '编辑连接' : '新建连接'}
title={editing ? t('ssh.dialog.titleEdit') : t('ssh.dialog.titleNew')}
width={520}
onCancel={handleCancel}
destroyOnHidden
@@ -119,10 +123,10 @@ export function ConnectionEditDialog({
<div className="connections-dialog-footer">
<span className="connections-dialog-error">{errorMsg}</span>
<Button onClick={onClose} disabled={saving} style={{ marginRight: 8 }}>
取消
{t('common.cancel')}
</Button>
<Button type="primary" loading={saving} onClick={() => void handleSave()}>
保存
{t('common.save')}
</Button>
</div>
}
@@ -136,17 +140,17 @@ export function ConnectionEditDialog({
>
<Form.Item
name="name"
label="名称"
rules={[{ required: true, whitespace: true, message: '请输入连接名称' }]}
label={t('ssh.dialog.name')}
rules={[{ required: true, whitespace: true, message: t('ssh.dialog.nameRequired') }]}
>
<Input placeholder="例如:生产服务器" autoComplete="off" />
<Input placeholder={t('ssh.dialog.namePlaceholder')} autoComplete="off" />
</Form.Item>
<Form.Item name="group" label="分组">
<Form.Item name="group" label={t('ssh.dialog.group')}>
<Select
allowClear
mode="tags"
placeholder="选择或输入新分组,留空归入“默认”"
placeholder={t('ssh.dialog.groupPlaceholder')}
options={groupOptions.map((g) => ({ value: g, label: g }))}
maxCount={1}
/>
@@ -154,33 +158,33 @@ export function ConnectionEditDialog({
<Form.Item
name="host"
label="主机"
rules={[{ required: true, whitespace: true, message: '请输入主机地址' }]}
label={t('ssh.dialog.host')}
rules={[{ required: true, whitespace: true, message: t('ssh.dialog.hostRequired') }]}
>
<Input placeholder="192.168.1.10 或 example.com" autoComplete="off" />
<Input placeholder={t('ssh.dialog.hostPlaceholder')} autoComplete="off" />
</Form.Item>
<Form.Item
name="port"
label="端口"
rules={[{ type: 'number', min: 1, max: 65535, message: '端口需在 1–65535 之间' }]}
label={t('ssh.dialog.port')}
rules={[{ type: 'number', min: 1, max: 65535, message: t('ssh.dialog.portRange') }]}
>
<InputNumber className="connections-port-input" min={1} max={65535} placeholder="22" />
</Form.Item>
<Form.Item
name="username"
label="用户名"
rules={[{ required: true, whitespace: true, message: '请输入用户名' }]}
label={t('ssh.dialog.username')}
rules={[{ required: true, whitespace: true, message: t('ssh.dialog.usernameRequired') }]}
>
<Input placeholder="root" autoComplete="off" />
</Form.Item>
<Form.Item name="auth" label="认证方式">
<Form.Item name="auth" label={t('ssh.dialog.authMethod')}>
<Radio.Group onChange={(e) => setAuth(e.target.value as SshAuthMethod)}>
<Radio value="password">密码</Radio>
<Radio value="privateKey">私钥</Radio>
<Radio value="agent">Agent</Radio>
<Radio value="password">{t('ssh.auth.password')}</Radio>
<Radio value="privateKey">{t('ssh.auth.privateKey')}</Radio>
<Radio value="agent">{t('ssh.auth.agent')}</Radio>
</Radio.Group>
</Form.Item>
@@ -191,7 +195,7 @@ export function ConnectionEditDialog({
type="info"
showIcon
className="connections-secret-note"
message={`已保存${n.label},留空将保留原值`}
message={t('ssh.dialog.savedSecret', { kind: n.label })}
/>
))}
@@ -199,9 +203,9 @@ export function ConnectionEditDialog({
<Form.Item
name="keepaliveIntervalSec"
label="Keepalive 间隔(秒)"
tooltip="0 表示关闭 keepalive"
rules={[{ type: 'number', min: 0, message: '不能为负数' }]}
label={t('ssh.dialog.keepalive')}
tooltip={t('ssh.dialog.keepaliveTooltip')}
rules={[{ type: 'number', min: 0, message: t('ssh.dialog.keepaliveMin') }]}
>
<InputNumber className="connections-keepalive-input" min={0} placeholder="30" />
</Form.Item>
@@ -2,6 +2,7 @@ import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'r
import { Button, Collapse, Popconfirm, Tooltip } from 'antd'
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
import type { SshConnection } from '@shared/connections'
import { t } from '@shared/i18n'
import { AuthTags } from './Common'
import { ConnectionEditDialog } from './ConnectionEditDialog'
import { CommandsPanel } from '../commands/CommandsPanel'
@@ -28,18 +29,16 @@ export interface ConnectionSidebarHandle {
refresh: () => Promise<void>
}
const DEFAULT_GROUP = '默认'
/** Short human "time ago" for the recent-session rows. */
function formatRelativeTime(ts: number): string {
const diffSec = Math.max(0, Math.floor((Date.now() - ts) / 1000))
if (diffSec < 60) return '刚刚'
if (diffSec < 60) return t('ssh.sidebar.timeJustNow')
const mins = Math.floor(diffSec / 60)
if (mins < 60) return `${mins} 分钟前`
if (mins < 60) return t('ssh.sidebar.timeMinutesAgo', { n: mins })
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours} 小时前`
if (hours < 24) return t('ssh.sidebar.timeHoursAgo', { n: hours })
const days = Math.floor(hours / 24)
if (days < 30) return `${days} 天前`
if (days < 30) return t('ssh.sidebar.timeDaysAgo', { n: days })
return new Date(ts).toLocaleDateString()
}
@@ -87,27 +86,28 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
}, [])
// Fold every group open whenever the dataset changes.
const defaultGroup = t('ssh.sidebar.defaultGroup')
const activeGroups = useMemo(() => {
const groups = new Set<string>()
for (const c of connections) groups.add(c.group ?? DEFAULT_GROUP)
for (const c of connections) groups.add(c.group ?? defaultGroup)
return [...groups]
}, [connections])
}, [connections, defaultGroup])
const groups = useMemo(() => {
const map = new Map<string, SshConnection[]>()
for (const c of connections) {
const key = c.group ?? DEFAULT_GROUP
const key = c.group ?? defaultGroup
const list = map.get(key)
if (list) list.push(c)
else map.set(key, [c])
}
const entries = [...map.entries()].sort((a, b) => {
if (a[0] === DEFAULT_GROUP) return -1
if (b[0] === DEFAULT_GROUP) return 1
if (a[0] === defaultGroup) return -1
if (b[0] === defaultGroup) return 1
return a[0].localeCompare(b[0])
})
return entries.map(([group, list]) => ({ group, list }))
}, [connections])
}, [connections, defaultGroup])
const openCreate = (): void => {
setEditing(null)
@@ -163,16 +163,16 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
return (
<aside className={className ?? 'connections-sidebar'}>
<div className="connections-mode-title">
{isTerminal ? '终端工作区' : 'SSH 工作区'}
{isTerminal ? t('ssh.sidebar.modeTerminal') : t('ssh.sidebar.modeSsh')}
</div>
<div className="connections-header">
<span className="connections-title">连接</span>
<span className="connections-title">{t('ssh.sidebar.title')}</span>
<Button
type="text"
size="small"
icon={<PlusOutlined />}
onClick={openCreate}
aria-label="新建连接"
aria-label={t('ssh.sidebar.newConnection')}
/>
</div>
@@ -180,11 +180,11 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
{isTerminal && (
<section className="connections-section">
<div className="connections-section-head">
<span className="connections-section-title">终端</span>
<span className="connections-section-title">{t('ssh.sidebar.sectionTerminals')}</span>
<span className="connections-section-count">{localPanels.length}</span>
</div>
{localPanels.length === 0 ? (
<div className="connections-section-empty">无打开的本地终端</div>
<div className="connections-section-empty">{t('ssh.sidebar.noLocalTerminals')}</div>
) : (
<ul className="connections-local-list">
{localPanels.map((panel) => (
@@ -195,7 +195,7 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
title={panel.title}
>
<span className="connections-local-name">{panel.title}</span>
<span className="connections-local-tag">本地</span>
<span className="connections-local-tag">{t('ssh.sidebar.local')}</span>
</li>
))}
</ul>
@@ -206,16 +206,16 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
{!isTerminal && (
<section className="connections-section">
<div className="connections-section-head">
<span className="connections-section-title">SSH 服务器</span>
<span className="connections-section-title">{t('ssh.sidebar.sectionServers')}</span>
</div>
{connections.length === 0 ? (
<div className="connections-empty">
{loaded ? (
<button type="button" className="connections-empty-create" onClick={openCreate}>
暂无连接,点击+新建
{t('ssh.sidebar.empty')}
</button>
) : (
<span>加载中…</span>
<span>{t('ssh.sidebar.loading')}</span>
)}
</div>
) : (
@@ -231,7 +231,7 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
{recent.length > 0 && (
<div className="connections-recent">
<div className="connections-recent-head">
<span className="connections-recent-title">最近会话</span>
<span className="connections-recent-title">{t('ssh.sidebar.recent')}</span>
</div>
{recent.map((conn) => (
<button
@@ -255,7 +255,7 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
{isTerminal && (
<section className="connections-section commands-sidebar-section">
<div className="connections-section-head">
<span className="connections-section-title">命令</span>
<span className="connections-section-title">{t('panels.commands.sectionTitle')}</span>
</div>
<CommandsPanel onRun={onRunCommand ?? ((): void => undefined)} />
</section>
@@ -290,7 +290,7 @@ function Row({
<div className="connections-row-main">
<div className="connections-row-name">
<span className="connections-row-label">{conn.name}</span>
<Tooltip title={conn.host === conn.host ? `双击连接 ${conn.host}` : undefined}>
<Tooltip title={conn.host === conn.host ? t('ssh.sidebar.dblClickConnect', { host: conn.host }) : undefined}>
<AuthTags auth={conn.auth} />
</Tooltip>
</div>
@@ -300,19 +300,19 @@ function Row({
</div>
</div>
<div className="connections-row-actions">
<Button type="text" size="small" icon={<EditOutlined />} onClick={onEdit} aria-label="编辑" />
<Button type="text" size="small" icon={<EditOutlined />} onClick={onEdit} aria-label={t('common.edit')} />
<Popconfirm
title="删除该连接?"
description="连接配置删除后不可恢复。已保存的密钥数据会同步删除。"
okText="删除"
cancelText="取消"
title={t('ssh.sidebar.deleteTitle')}
description={t('ssh.sidebar.deleteDesc')}
okText={t('common.delete')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
open={confirming}
onConfirm={onDelete}
onOpenChange={(visible) => setConfirming(visible)}
onCancel={() => setConfirming(false)}
>
<Button size="small" type="text" danger icon={<DeleteOutlined />} aria-label="删除" />
<Button size="small" type="text" danger icon={<DeleteOutlined />} aria-label={t('common.delete')} />
</Popconfirm>
</div>
</div>
+13 -12
View File
@@ -2,6 +2,7 @@ import { Form, Input, Switch } from 'antd'
import useFormInstance from 'antd/es/form/hooks/useFormInstance'
import { useWatch } from 'antd/es/form/Form'
import type { SshAuthMethod } from '@shared/connections'
import { t } from '@shared/i18n'
/**
* Auth-method dependent fields rendered inside the edit dialog's <Form>.
@@ -16,7 +17,7 @@ export function AuthFieldsRenderer({ auth }: { auth: SshAuthMethod }): React.JSX
const askPassphraseAtConnect = useWatch('askPassphraseAtConnect', form) ?? false
if (auth === 'agent') {
return <div className="connections-auth-desc">使用本机 SSH Agent 中已加载的密钥进行认证,无需在本应用内保存任何密钥。</div>
return <div className="connections-auth-desc">{t('ssh.fields.agentDesc')}</div>
}
return (
@@ -25,36 +26,36 @@ export function AuthFieldsRenderer({ auth }: { auth: SshAuthMethod }): React.JSX
<>
<SwitchRow
name="askPasswordAtConnect"
label="连接时询问密码"
hint="开启后每次连接弹出对话框输入密码,不再使用已保存的密码"
label={t('ssh.fields.askPasswordLabel')}
hint={t('ssh.fields.askPasswordHint')}
/>
{!askPasswordAtConnect && (
<Form.Item name="password" label="密码" className="connections-secret-item">
<Input.Password placeholder="编辑时留空表示保留已保存的密码" autoComplete="off" />
<Form.Item name="password" label={t('ssh.auth.password')} className="connections-secret-item">
<Input.Password placeholder={t('ssh.fields.passwordPlaceholder')} autoComplete="off" />
</Form.Item>
)}
</>
) : (
<>
<Form.Item name="keyPath" label="私钥路径(服务器上的本地私钥路径,可留空)">
<Form.Item name="keyPath" label={t('ssh.fields.keyPath')}>
<Input placeholder="/home/me/.ssh/id_rsa" autoComplete="off" />
</Form.Item>
<Form.Item name="keyContent" label="私钥内容">
<Form.Item name="keyContent" label={t('ssh.fields.keyContent')}>
<Input.TextArea
rows={4}
placeholder="粘贴私钥内容;留空则使用上面的路径(编辑时留空表示保留已保存的私钥)"
placeholder={t('ssh.fields.keyContentPlaceholder')}
autoComplete="off"
spellCheck={false}
/>
</Form.Item>
<SwitchRow
name="askPassphraseAtConnect"
label="连接时询问口令"
hint="开启后每次连接提示输入私钥口令"
label={t('ssh.fields.askPassphraseLabel')}
hint={t('ssh.fields.askPassphraseHint')}
/>
{!askPassphraseAtConnect && (
<Form.Item name="passphrase" label="私钥口令" className="connections-secret-item">
<Input.Password placeholder="编辑时留空表示保留已保存的口令" autoComplete="off" />
<Form.Item name="passphrase" label={t('ssh.fields.passphrase')} className="connections-secret-item">
<Input.Password placeholder={t('ssh.fields.passphrasePlaceholder')} autoComplete="off" />
</Form.Item>
)}
</>
+18 -15
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import type { SysinfoMeta, SysinfoSample } from '@shared/sysinfo'
import { t } from '@shared/i18n'
import './monitor.css'
export interface MonitorPanelProps {
@@ -106,12 +107,12 @@ function formatSize(mb: number): string {
/** human-readable uptime */
function humanizeUptime(sec: number): string {
const s = Math.floor(sec)
if (s < 60) return `${s}秒`
if (s < 60) return t('ssh.monitor.uptimeSeconds', { n: s })
const mins = Math.floor(s / 60)
if (s < 3600) return `${mins}分钟`
if (s < 3600) return t('ssh.monitor.uptimeMinutes', { n: mins })
const hours = Math.floor(s / 3600)
if (s < 86400) return `${hours}小时${Math.floor((s % 3600) / 60)}分`
return `${Math.floor(s / 86400)}天`
if (s < 86400) return t('ssh.monitor.uptimeHours', { h: hours, m: Math.floor((s % 3600) / 60) })
return t('ssh.monitor.uptimeDays', { n: Math.floor(s / 86400) })
}
function Bar({ pct, color }: { pct: number; color: string }) {
@@ -166,7 +167,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
return (
<div className="mm-root mm-empty">
<span className="mm-dots"><span /> <span /> <span /></span>
采集中…
{t('ssh.monitor.collecting')}
</div>
)
}
@@ -180,7 +181,9 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
return (
<div className={`mm-root${failed ? ' mm-failed' : ''}`}>
{failed && <div className="mm-error">采集中断:{sample.error}</div>}
{failed && (
<div className="mm-error">{t('ssh.monitor.interrupted', { error: sample.error ?? '' })}</div>
)}
<header className="mm-header">
<span className="mm-host">{meta.hostname || 'unknown'}</span>
@@ -191,19 +194,19 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
<div className="mm-block-row">
<span className="mm-label">CPU</span>
<span className="mm-value" style={{ color: cpuColor(cpu.usage) }}>
{cpu.usage.toFixed(0)}%<span className="mm-value-sub"> · {cpu.cores} 核</span>
{cpu.usage.toFixed(0)}%<span className="mm-value-sub"> · {t('ssh.monitor.cores', { n: cpu.cores })}</span>
</span>
</div>
<Bar pct={cpu.usage} color={cpuColor(cpu.usage)} />
<div className="mm-sub mm-load">
负载 {cpu.loadavg.map((v) => v.toFixed(1)).join(' / ')}
{t('ssh.monitor.loadavg', { value: cpu.loadavg.map((v) => v.toFixed(1)).join(' / ') })}
</div>
<HistoryChart data={cpuHist} lineColor="#3fb950" fillColor="rgba(63,185,80,0.15)" height={48} maxY={100} />
</section>
<section className="mm-block" aria-label="内存">
<section className="mm-block" aria-label={t('ssh.monitor.mem')}>
<div className="mm-block-row">
<span className="mm-label">内存</span>
<span className="mm-label">{t('ssh.monitor.mem')}</span>
<span className="mm-mem-size">
{formatSize(mem.usedMb)} / {formatSize(mem.totalMb)}
</span>
@@ -217,9 +220,9 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
</section>
{disks.length > 0 && (
<section className="mm-block" aria-label="磁盘">
<section className="mm-block" aria-label={t('ssh.monitor.disks')}>
<div className="mm-block-row">
<span className="mm-label">磁盘</span>
<span className="mm-label">{t('ssh.monitor.disks')}</span>
</div>
<div className="mm-disks">
{disks.slice(0, 6).map((d) => {
@@ -242,9 +245,9 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
</section>
)}
<section className="mm-block" aria-label="网络">
<section className="mm-block" aria-label={t('ssh.monitor.net')}>
<div className="mm-block-row">
<span className="mm-label">网络</span>
<span className="mm-label">{t('ssh.monitor.net')}</span>
<div className="mm-net">
<span className="mm-net-item mm-rx">
<span className="mm-arrow mm-up">↓</span>
@@ -265,7 +268,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
</section>
<footer className="mm-footer">
运行时间 <span className="mm-uptime">{humanizeUptime(sample.uptimeSec)}</span>
{t('ssh.monitor.uptime')} <span className="mm-uptime">{humanizeUptime(sample.uptimeSec)}</span>
</footer>
</div>
)
+62 -30
View File
@@ -1,55 +1,84 @@
import { useEffect, useState } from 'react'
import { Button, Progress, Select, Switch, Tag } from 'antd'
import { DEFAULT_LANGUAGE, t, type Language } from '@shared/i18n'
import type { AppInfo, ReleaseNote, UpdateState, UpdateStatus } from '@shared/ipc'
import { useSettingsStore } from './store'
import appIconUrl from '../../../../build/icon.png'
import changelogMd from '../../../../CHANGELOG.md?raw'
import changelogZhCN from '../../../../CHANGELOG.md?raw'
import changelogZhTW from '../../../../CHANGELOG.zh-TW.md?raw'
import changelogEn from '../../../../CHANGELOG.en.md?raw'
import changelogJa from '../../../../CHANGELOG.ja.md?raw'
const STATUS_TEXT: Record<UpdateStatus, string> = {
idle: '点击检查更新',
checking: '正在检查更新…',
available: '发现新版本',
latest: '已经是最新版本',
downloading: '正在下载…',
downloaded: '下载完成,可重启安装',
error: '检查失败',
dev: '开发模式不支持更新检查'
/** Bundled changelog per language; zh-CN is the canonical source. */
const CHANGELOGS: Record<Language, string> = {
'zh-CN': changelogZhCN,
'zh-TW': changelogZhTW,
en: changelogEn,
ja: changelogJa
}
/** Parse the bundled CHANGELOG.md into release notes. Sections look like
* `## v1.0.12 - 2026-09-20`; the body below the heading is kept verbatim. */
function parseChangelog(md: string): ReleaseNote[] {
const notes: ReleaseNote[] = []
const STATUS_TEXT: Record<UpdateStatus, string> = {
idle: 'settings.about.statusIdle',
checking: 'settings.about.statusChecking',
available: 'settings.about.statusAvailable',
latest: 'settings.about.statusLatest',
downloading: 'settings.about.statusDownloading',
downloaded: 'settings.about.statusDownloaded',
error: 'settings.about.statusError',
dev: 'settings.about.statusDev'
}
/** Parse a bundled changelog into release notes, keyed by version. Sections
* look like `## v1.0.12 - 2026-09-20`; the body below is kept verbatim. */
function parseChangelog(md: string): Map<string, ReleaseNote> {
const notes = new Map<string, ReleaseNote>()
for (const section of md.split(/^## /m).slice(1)) {
const lineEnd = section.indexOf('\n')
if (lineEnd < 0) continue
const head = section.slice(0, lineEnd).trim()
const m = /^(v[\d.]+)\s*-\s*(\d{4}-\d{2}-\d{2})/.exec(head)
const body = section.slice(lineEnd + 1).trim()
if (m && body) notes.push({ version: m[1], date: m[2], body })
if (m && body) notes.set(m[1], { version: m[1], date: m[2], body })
}
return notes
}
/** 关于页:版本信息 + 更新检查/下载/安装 + 更新日志(内置 CHANGELOG 优先,网络兜底)。 */
/** Notes for `language`, newest first, falling back to zh-CN per version so a
* missing translation shows the canonical text instead of a gap. */
function notesFor(language: Language): ReleaseNote[] {
const primary = parseChangelog(CHANGELOGS[language])
const fallback = language === DEFAULT_LANGUAGE ? primary : parseChangelog(CHANGELOGS[DEFAULT_LANGUAGE])
const versions = new Set([...primary.keys(), ...fallback.keys()])
return [...versions]
.map((v) => primary.get(v) ?? fallback.get(v))
.filter((n): n is ReleaseNote => n !== undefined)
.sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true }))
}
/** 关于页:版本信息 + 更新检查/下载/安装 + 更新日志(内置多语言 CHANGELOG 优先,网络兜底)。 */
export function AboutTab(): React.JSX.Element {
const [version, setVersion] = useState('')
const [state, setState] = useState<UpdateState | null>(null)
const [notes, setNotes] = useState<ReleaseNote[]>([])
const updateSystem = useSettingsStore((s) => s.updateSystem)
const autoCheck = useSettingsStore((s) => s.settings.system.autoCheckUpdate !== false)
const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE)
useEffect(() => {
// Embedded changelog first (works offline, follows the UI language);
// network sources as fallback.
const local = notesFor(language)
if (local.length > 0) setNotes(local)
else void window.api.updateChangelog().then(setNotes).catch(() => undefined)
}, [language])
useEffect(() => {
void window.api.appInfo().then((i: AppInfo) => setVersion(i.appVersion))
// Embedded changelog first (works offline); network sources as fallback.
const local = parseChangelog(changelogMd)
if (local.length > 0) setNotes(local)
else void window.api.updateChangelog().then(setNotes).catch(() => undefined)
return window.api.onUpdateState(setState)
}, [])
const status = state?.status ?? 'idle'
const statusText = STATUS_TEXT[status] + (state?.version ? ` v${state.version}` : '')
const statusText = t(STATUS_TEXT[status]) + (state?.version ? ` v${state.version}` : '')
return (
<div className="about-tab">
@@ -58,7 +87,7 @@ export function AboutTab(): React.JSX.Element {
<div>
<div className="about-name">OpenTerminal</div>
<div className="about-version">
当前版本: {version || '…'}{' '}
{t('settings.about.currentVersion')}: {version || '…'}{' '}
<span className={'about-status' + (status === 'available' ? ' is-new' : '')}>
{statusText}
</span>
@@ -70,7 +99,7 @@ export function AboutTab(): React.JSX.Element {
<div className="about-controls">
<Select
value="stable"
options={[{ value: 'stable', label: '稳定更新通道' }]}
options={[{ value: 'stable', label: t('settings.about.channelStable') }]}
style={{ width: 150 }}
/>
<label className="about-autocheck">
@@ -79,26 +108,27 @@ export function AboutTab(): React.JSX.Element {
checked={autoCheck}
onChange={(checked) => void updateSystem({ autoCheckUpdate: checked })}
/>
启动时自动检查
{t('settings.about.autoCheck')}
</label>
{status === 'available' && (
<Button type="primary" onClick={() => void window.api.updateDownload()}>
下载更新{state?.feed === 'github' ? '(GitHub 源)' : ''}
{t('settings.about.download')}
{state?.feed === 'github' ? t('settings.about.downloadGithub') : ''}
</Button>
)}
{status === 'downloaded' && (
<Button type="primary" onClick={() => void window.api.updateInstall()}>
重启安装
{t('settings.about.install')}
</Button>
)}
{(status === 'idle' || status === 'latest' || status === 'error') && (
<Button type="primary" onClick={() => void window.api.updateCheck()}>
检查更新
{t('settings.about.check')}
</Button>
)}
{status === 'checking' && (
<Button type="primary" loading disabled>
检查中
{t('settings.about.checkingButton')}
</Button>
)}
</div>
@@ -108,8 +138,10 @@ export function AboutTab(): React.JSX.Element {
)}
<div className="about-changelog">
<div className="about-changelog-title">更新日志</div>
{notes.length === 0 && <div className="about-changelog-empty">暂无更新日志</div>}
<div className="about-changelog-title">{t('settings.about.changelog')}</div>
{notes.length === 0 && (
<div className="about-changelog-empty">{t('settings.about.changelogEmpty')}</div>
)}
{notes.map((n) => (
<div key={n.version} className="about-release">
<div className="about-release-head">
+39 -38
View File
@@ -11,6 +11,7 @@ import {
Tooltip
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { t } from '@shared/i18n'
import { DEFAULT_HIGHLIGHT_RULES } from '@shared/settings'
import type { HighlightRule } from '@shared/settings'
import { useSettingsStore } from './store'
@@ -31,7 +32,7 @@ function tryCompile(pattern: string): { ok: boolean; message?: string } {
new RegExp(pattern)
return { ok: true }
} catch (err) {
return { ok: false, message: err instanceof Error ? err.message : '正则表达式无效' }
return { ok: false, message: err instanceof Error ? err.message : t('settings.highlight.invalidRegex') }
}
}
@@ -83,7 +84,7 @@ export function HighlightTab(): React.JSX.Element {
const columns: ColumnsType<HighlightRule> = [
{
title: '启用',
title: t('settings.highlight.enabled'),
dataIndex: 'enabled',
width: 64,
align: 'center',
@@ -92,13 +93,13 @@ export function HighlightTab(): React.JSX.Element {
)
},
{
title: '正则',
title: t('settings.highlight.pattern'),
dataIndex: 'pattern',
ellipsis: true,
render: (pattern: string) => <PatternCell pattern={pattern} />
},
{
title: '优先级',
title: t('settings.highlight.priority'),
dataIndex: 'priority',
width: 76,
align: 'center',
@@ -107,7 +108,7 @@ export function HighlightTab(): React.JSX.Element {
render: (priority: number) => <span className="hl-cell-priority">{priority}</span>
},
{
title: '预览',
title: t('settings.preview'),
dataIndex: 'color',
width: 120,
align: 'center',
@@ -124,32 +125,32 @@ export function HighlightTab(): React.JSX.Element {
)
},
{
title: '备注',
title: t('settings.highlight.note'),
dataIndex: 'note',
ellipsis: true,
render: (note: string | undefined) =>
note ? <span className="hl-cell-note">{note}</span> : <span className="hl-cell-empty">—</span>
},
{
title: '操作',
title: t('settings.highlight.actions'),
key: 'action',
width: 120,
align: 'center',
render: (_, record) => (
<div className="hl-actions">
<Button size="small" onClick={() => openEdit(record)}>
编辑
{t('common.edit')}
</Button>
<Popconfirm
title="删除高亮规则"
description="删除后无法恢复,确定继续?"
okText="删除"
cancelText="取消"
title={t('settings.highlight.deleteTitle')}
description={t('settings.deleteConfirmDesc')}
okText={t('common.delete')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
onConfirm={() => void handleDelete(record.id)}
>
<Button size="small" danger>
删除
{t('common.delete')}
</Button>
</Popconfirm>
</div>
@@ -163,20 +164,20 @@ export function HighlightTab(): React.JSX.Element {
type="info"
showIcon
closable
message="自定义高亮可增强显示效果,但会略微增加性能消耗。规则按优先级顺序应用。PS: 高亮在写入终端时注入颜色,对已有回滚内容不生效"
message={t('settings.highlight.alert')}
/>
<div className="hl-toolbar">
<Popconfirm
title="恢复默认高亮规则"
description="将替换当前全部规则为内置预设,不可恢复。"
okText="恢复"
cancelText="取消"
title={t('settings.highlight.resetTitle')}
description={t('settings.highlight.resetDesc')}
okText={t('settings.highlight.resetOk')}
cancelText={t('common.cancel')}
onConfirm={() => void handleReset()}
>
<Button>恢复默认</Button>
<Button>{t('common.reset')}</Button>
</Popconfirm>
<Button type="primary" onClick={openCreate}>
+ 新增规则
{t('settings.highlight.addRule')}
</Button>
</div>
<div className="hl-table">
@@ -186,7 +187,7 @@ export function HighlightTab(): React.JSX.Element {
columns={columns}
dataSource={sorted}
pagination={false}
locale={{ emptyText: '暂无高亮规则,点击「+ 新增规则」创建' }}
locale={{ emptyText: t('settings.highlight.empty') }}
/>
</div>
<HighlightEditor
@@ -307,22 +308,22 @@ function HighlightEditor({
open={open}
onCancel={onClose}
onOk={() => void handleSave()}
okText="保存"
cancelText="取消"
okText={t('common.save')}
cancelText={t('common.cancel')}
confirmLoading={saving}
destroyOnHidden
width={560}
title={isCreate ? '新建高亮规则' : '编辑高亮规则'}
title={isCreate ? t('settings.highlight.createTitle') : t('settings.highlight.editTitle')}
okButtonProps={{ disabled: !valid }}
>
<div className="hl-editor">
<div className="hl-editor-row">
<span className="hl-editor-label">正则</span>
<span className="hl-editor-label">{t('settings.highlight.pattern')}</span>
<div className="hl-editor-control">
<Input.TextArea
value={draft.pattern}
onChange={(e) => patch({ pattern: e.target.value })}
placeholder="例如 \b(ERROR|FAILED)\b"
placeholder={t('settings.highlight.patternPlaceholder')}
autoSize={{ minRows: 1, maxRows: 4 }}
className={compile.ok ? undefined : 'hl-editor-input-bad'}
/>
@@ -333,7 +334,7 @@ function HighlightEditor({
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">优先级</span>
<span className="hl-editor-label">{t('settings.highlight.priority')}</span>
<InputNumber
min={1}
max={100}
@@ -342,19 +343,19 @@ function HighlightEditor({
if (v !== null) patch({ priority: v })
}}
/>
<span className="hl-editor-hint">值越小越先应用</span>
<span className="hl-editor-hint">{t('settings.highlight.priorityHint')}</span>
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">颜色</span>
<span className="hl-editor-label">{t('settings.highlight.color')}</span>
<div className="hl-editor-control">
<ColorField
label="前景"
label={t('settings.color.foreground')}
value={draft.fg}
onChange={(v) => patch({ fg: v })}
/>
<div className="hl-editor-inline">
<span className="hl-editor-sub-label">背景</span>
<span className="hl-editor-sub-label">{t('settings.color.background')}</span>
{showBg ? (
<>
<ColorField
@@ -362,12 +363,12 @@ function HighlightEditor({
onChange={(v) => patch({ bg: v })}
/>
<Button size="small" onClick={() => patch({ bg: undefined })}>
清空
{t('settings.highlight.clearBg')}
</Button>
</>
) : (
<Button size="small" onClick={() => setShowBg(true)}>
添加
{t('settings.highlight.addBg')}
</Button>
)}
</div>
@@ -375,9 +376,9 @@ function HighlightEditor({
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">色板</span>
<span className="hl-editor-label">{t('settings.highlight.palette')}</span>
<div className="hl-palette">
{showBg && <span className="hl-palette-tag">背景</span>}
{showBg && <span className="hl-palette-tag">{t('settings.color.background')}</span>}
{PRESET_COLORS.map((c) => (
<PaletteSwatch
key={c}
@@ -390,17 +391,17 @@ function HighlightEditor({
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">备注</span>
<span className="hl-editor-label">{t('settings.highlight.note')}</span>
<Input
value={draft.note ?? ''}
onChange={(e) => patch({ note: e.target.value })}
placeholder="规则说明(可选)"
placeholder={t('settings.highlight.notePlaceholder')}
maxLength={60}
/>
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">启用</span>
<span className="hl-editor-label">{t('settings.highlight.enabled')}</span>
<Switch checked={draft.enabled} onChange={(c) => patch({ enabled: c })} />
</div>
</div>
+13 -12
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { Button, Modal, Tabs } from 'antd'
import type { TabsProps } from 'antd'
import { t } from '@shared/i18n'
import { DEFAULT_SETTINGS } from '@shared/settings'
import { useSettingsStore } from './store'
import { CursorSettingsTab, FontSettingsTab, RenderSettingsTab, SystemSettingsTab } from './SettingsTabs'
@@ -69,13 +70,13 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
}
const tabItems: TabsProps['items'] = [
{ key: 'font', label: '字体', children: <FontSettingsTab /> },
{ key: 'cursor', label: '光标与滚动', children: <CursorSettingsTab /> },
{ key: 'render', label: '渲染与行为', children: <RenderSettingsTab /> },
{ key: 'highlight', label: '高亮', children: <HighlightTab /> },
{ key: 'font', label: t('settings.tabs.font'), children: <FontSettingsTab /> },
{ key: 'cursor', label: t('settings.tabs.cursor'), children: <CursorSettingsTab /> },
{ key: 'render', label: t('settings.tabs.render'), children: <RenderSettingsTab /> },
{ key: 'highlight', label: t('settings.tabs.highlight'), children: <HighlightTab /> },
{
key: 'theme',
label: '主题',
label: t('settings.tabs.theme'),
children: (
<ThemeSettingsTab
onEditTheme={(themeId) => setEditorState({ open: true, themeId })}
@@ -83,8 +84,8 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
/>
)
},
{ key: 'system', label: '系统', children: <SystemSettingsTab /> },
{ key: 'about', label: '关于', children: <AboutTab /> }
{ key: 'system', label: t('settings.tabs.system'), children: <SystemSettingsTab /> },
{ key: 'about', label: t('settings.tabs.about'), children: <AboutTab /> }
]
const closeEditor = (): void => setEditorState((prev) => (prev.open ? { ...prev, open: false } : prev))
@@ -99,12 +100,12 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
onCancel={onClose}
centered
width={dialogSize.w}
title="设置"
title={t('settings.title')}
footer={
<div className="settings-footer">
<Button onClick={handleResetDefaults}>恢复默认</Button>
<Button onClick={handleResetDefaults}>{t('common.reset')}</Button>
<Button type="primary" onClick={onClose}>
关闭
{t('common.close')}
</Button>
</div>
}
@@ -115,8 +116,8 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
{/* Drag corner: resizes the dialog until it is closed. */}
<span
className="settings-resize"
title="拖拽调整大小"
aria-label="拖拽调整大小"
title={t('settings.resizeHandle')}
aria-label={t('settings.resizeHandle')}
onMouseDown={startResize}
/>
<ThemeEditor
+81 -65
View File
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
import { useMemo, useState } from 'react'
import { Input, InputNumber, Radio, Select, Switch } from 'antd'
import type { ThemeColors } from '@shared/theme'
import { DEFAULT_LANGUAGE, LANGUAGES, t, type Language } from '@shared/i18n'
import { useSettingsStore, useResolvedTheme } from './store'
import {
DEFAULT_FONT_STACK,
@@ -57,17 +58,20 @@ export function FontSettingsTab(): React.JSX.Element {
return (
<div className="settings-pane">
<SettingRow
label="字体"
desc="内置默认字体或系统等宽字体"
label={t('settings.font.label')}
desc={t('settings.font.desc')}
control={
<Select
className="settings-select"
showSearch
placeholder="选择字体"
placeholder={t('settings.font.placeholder')}
value={selectedFont()}
onChange={handleFontChange}
options={[
{ value: 'default', label: <span style={{ fontFamily: DEFAULT_FONT_STACK }}>默认</span> },
{
value: 'default',
label: <span style={{ fontFamily: DEFAULT_FONT_STACK }}>{t('settings.font.default')}</span>
},
...fontOptions
]}
filterOption={(input, option) =>
@@ -78,8 +82,8 @@ export function FontSettingsTab(): React.JSX.Element {
}
/>
<SettingRow
label="字号"
desc="终端文字的像素大小"
label={t('settings.font.size')}
desc={t('settings.font.sizeDesc')}
control={
<InputNumber
className="settings-input-number"
@@ -93,8 +97,8 @@ export function FontSettingsTab(): React.JSX.Element {
}
/>
<SettingRow
label="字重"
desc="400 为常规,700 为粗体"
label={t('settings.font.weight')}
desc={t('settings.font.weightDesc')}
control={
<Select
className="settings-select"
@@ -107,8 +111,8 @@ export function FontSettingsTab(): React.JSX.Element {
}
/>
<SettingRow
label="粗体字重"
desc="粗体文字所用的字重"
label={t('settings.font.boldWeight')}
desc={t('settings.font.boldWeightDesc')}
control={
<Select
className="settings-select"
@@ -121,8 +125,8 @@ export function FontSettingsTab(): React.JSX.Element {
}
/>
<SettingRow
label="字间距"
desc="字符之间的水平间距"
label={t('settings.font.letterSpacing')}
desc={t('settings.font.letterSpacingDesc')}
control={
<InputNumber
className="settings-input-number"
@@ -137,8 +141,8 @@ export function FontSettingsTab(): React.JSX.Element {
}
/>
<SettingRow
label="行高"
desc="相邻两行之间的垂直距离"
label={t('settings.font.lineHeight')}
desc={t('settings.font.lineHeightDesc')}
control={
<InputNumber
className="settings-input-number"
@@ -171,8 +175,8 @@ export function CursorSettingsTab(): React.JSX.Element {
return (
<div className="settings-pane">
<SettingRow
label="光标闪烁"
desc="光标是否周期性闪烁"
label={t('settings.cursor.blink')}
desc={t('settings.cursor.blinkDesc')}
control={
<Switch
checked={settings.terminal.cursorBlink}
@@ -181,8 +185,8 @@ export function CursorSettingsTab(): React.JSX.Element {
}
/>
<SettingRow
label="光标样式"
desc="光标的显示形状"
label={t('settings.cursor.style')}
desc={t('settings.cursor.styleDesc')}
control={
<Select
className="settings-select"
@@ -190,13 +194,13 @@ export function CursorSettingsTab(): React.JSX.Element {
onChange={(v) => {
if (v !== null) void updateTerminal({ cursorStyle: v })
}}
options={cursorStyleOptions}
options={cursorStyleOptions()}
/>
}
/>
<SettingRow
label="非活动光标样式"
desc="窗口失焦时光标样式"
label={t('settings.cursor.inactiveStyle')}
desc={t('settings.cursor.inactiveStyleDesc')}
control={
<Select
className="settings-select"
@@ -204,13 +208,13 @@ export function CursorSettingsTab(): React.JSX.Element {
onChange={(v) => {
if (v !== null) void updateTerminal({ cursorInactiveStyle: v })
}}
options={inactiveCursorStyleOptions}
options={inactiveCursorStyleOptions()}
/>
}
/>
<SettingRow
label="滚动缓冲区"
desc="屏幕上可回滚的行数"
label={t('settings.cursor.scrollback')}
desc={t('settings.cursor.scrollbackDesc')}
control={
<InputNumber
className="settings-input-number"
@@ -231,26 +235,27 @@ export function CursorSettingsTab(): React.JSX.Element {
export function RenderSettingsTab(): React.JSX.Element {
const settings = useSettingsStore((s) => s.settings)
const updateTerminal = useSettingsStore((s) => s.updateTerminal)
const rendererModes = rendererModeOptions()
return (
<div className="settings-pane">
<div className="settings-block-label">
渲染模式
<span className="settings-block-hint">终端单元渲染加速方式,改动将应用到所有终端</span>
{t('settings.render.modeLabel')}
<span className="settings-block-hint">{t('settings.render.modeHint')}</span>
</div>
<Radio.Group
className="settings-radio-group"
value={settings.terminal.rendererMode}
onChange={(e) => void updateTerminal({ rendererMode: e.target.value })}
options={rendererModeOptions}
options={rendererModes}
optionType="button"
buttonStyle="solid"
/>
<div className="settings-block-hint settings-render-hint">
{rendererModeOptions.find((o) => o.value === settings.terminal.rendererMode)?.hint}
{rendererModes.find((o) => o.value === settings.terminal.rendererMode)?.hint}
</div>
<SettingRow
label="自动换行"
desc="超出宽度时自动折行"
label={t('settings.render.autoWrap')}
desc={t('settings.render.autoWrapDesc')}
control={
<Switch
checked={settings.terminal.autoWrap}
@@ -259,8 +264,8 @@ export function RenderSettingsTab(): React.JSX.Element {
}
/>
<SettingRow
label="选中即复制"
desc="鼠标选中文字时自动复制"
label={t('settings.render.copyOnSelect')}
desc={t('settings.render.copyOnSelectDesc')}
control={
<Switch
checked={settings.terminal.copyOnSelect}
@@ -269,8 +274,8 @@ export function RenderSettingsTab(): React.JSX.Element {
}
/>
<SettingRow
label="粘贴风险确认"
desc="多行文本(或超长单行)粘贴前弹出确认;单行直接粘贴"
label={t('settings.render.pasteRiskConfirm')}
desc={t('settings.render.pasteRiskConfirmDesc')}
control={
<Switch
checked={settings.terminal.pasteRiskConfirm}
@@ -279,8 +284,8 @@ export function RenderSettingsTab(): React.JSX.Element {
}
/>
<SettingRow
label="输入建议"
desc="输入时弹出命令建议;Tab 接受,回车始终直接执行"
label={t('settings.render.suggest')}
desc={t('settings.render.suggestDesc')}
control={
<Switch
checked={settings.terminal.suggestEnabled}
@@ -289,8 +294,8 @@ export function RenderSettingsTab(): React.JSX.Element {
}
/>
<SettingRow
label="记录命令历史"
desc="关闭后不再记录新命令;已保存的历史仍然保留"
label={t('settings.render.history')}
desc={t('settings.render.historyDesc')}
control={
<Switch
checked={settings.terminal.historyEnabled}
@@ -299,8 +304,8 @@ export function RenderSettingsTab(): React.JSX.Element {
}
/>
<SettingRow
label="历史条数上限"
desc="命令历史最多保留多少条,重复命令只记一次"
label={t('settings.render.historyLimit')}
desc={t('settings.render.historyLimitDesc')}
control={
<InputNumber
min={1}
@@ -316,8 +321,8 @@ export function RenderSettingsTab(): React.JSX.Element {
}
/>
<SettingRow
label="终端工具栏 · 录制"
desc="右上角显示「记录会话日志」按钮"
label={t('settings.render.recButton')}
desc={t('settings.render.recButtonDesc')}
control={
<Switch
checked={settings.terminal.showRecButton}
@@ -326,8 +331,8 @@ export function RenderSettingsTab(): React.JSX.Element {
}
/>
<SettingRow
label="终端工具栏 · 打开日志目录"
desc="右上角显示「打开日志目录」按钮"
label={t('settings.render.openLogsButton')}
desc={t('settings.render.openLogsButtonDesc')}
control={
<Switch
checked={settings.terminal.showOpenLogsButton}
@@ -336,8 +341,8 @@ export function RenderSettingsTab(): React.JSX.Element {
}
/>
<SettingRow
label="终端工具栏 · 打开工作区目录"
desc="右上角显示「打开当前目录」按钮(SSH 会话不显示;目录随 cd 实时跟踪,需开启 shell 集成或使用 cd 命令)"
label={t('settings.render.openCwdButton')}
desc={t('settings.render.openCwdButtonDesc')}
control={
<Switch
checked={settings.terminal.showOpenCwdButton}
@@ -354,9 +359,20 @@ export function SystemSettingsTab(): React.JSX.Element {
const updateSystem = useSettingsStore((s) => s.updateSystem)
return (
<div className="settings-pane">
{/* Interface language — first row: the whole dialog re-renders on change. */}
<div className="settings-block-label">{t('common.language')}</div>
<Radio.Group
className="settings-language-group"
value={settings.system.language ?? DEFAULT_LANGUAGE}
onChange={(e) => void updateSystem({ language: e.target.value as Language })}
options={LANGUAGES.map((l) => ({ value: l.value, label: l.label }))}
optionType="button"
buttonStyle="solid"
/>
<div className="settings-block-hint settings-language-hint">{t('common.languageDesc')}</div>
<SettingRow
label="开机时自启动"
desc="开机时自动启动软件(开发模式下注册的是开发版程序)"
label={t('settings.system.launchAtLogin')}
desc={t('settings.system.launchAtLoginDesc')}
control={
<Switch
checked={settings.system.launchAtLogin}
@@ -365,8 +381,8 @@ export function SystemSettingsTab(): React.JSX.Element {
}
/>
<SettingRow
label="阻止系统休眠"
desc="开启后 OpenTerminal 将阻止系统自动休眠;关闭则允许系统按系统设置休眠"
label={t('settings.system.preventSleep')}
desc={t('settings.system.preventSleepDesc')}
control={
<Switch
checked={settings.system.preventSleep}
@@ -375,8 +391,8 @@ export function SystemSettingsTab(): React.JSX.Element {
}
/>
<SettingRow
label="启动时恢复上次会话"
desc="下次启动恢复分屏布局,并让每个终端回到它上次所在的目录;关闭则每次都以单个终端启动"
label={t('settings.system.restoreSession')}
desc={t('settings.system.restoreSessionDesc')}
control={
<Switch
checked={settings.system.restoreSession !== false}
@@ -385,8 +401,8 @@ export function SystemSettingsTab(): React.JSX.Element {
}
/>
<SettingRow
label="Shell 集成"
desc="让 shell 主动上报工作目录(OSC 7),路径记忆更精确(含脚本内的 cd);会包装你的命令提示符,遇到提示符主题异常时关掉即可"
label={t('settings.system.shellIntegration')}
desc={t('settings.system.shellIntegrationDesc')}
control={
<Switch
checked={settings.system.shellIntegration === true}
@@ -395,24 +411,24 @@ export function SystemSettingsTab(): React.JSX.Element {
}
/>
<SettingRow
label="关闭按钮行为"
desc="点击窗口关闭按钮时的动作;最小化到托盘后可在托盘图标右键菜单中退出"
label={t('settings.system.closeAction')}
desc={t('settings.system.closeActionDesc')}
control={
<Select
className="settings-select"
value={settings.system.closeAction ?? 'tray'}
onChange={(value) => void updateSystem({ closeAction: value })}
options={[
{ value: 'tray', label: '最小化到托盘' },
{ value: 'exit', label: '直接退出' },
{ value: 'ask', label: '每次询问' }
{ value: 'tray', label: t('settings.system.closeActionTray') },
{ value: 'exit', label: t('settings.system.closeActionExit') },
{ value: 'ask', label: t('settings.system.closeActionAsk') }
]}
/>
}
/>
<SettingRow
label="全局唤起快捷键"
desc="点击输入框后直接按下组合键;Esc 取消,退格清空禁用"
label={t('settings.system.globalShortcut')}
desc={t('settings.system.globalShortcutDesc')}
control={
<ShortcutInput
value={settings.system.globalShowHide ?? ''}
@@ -421,7 +437,7 @@ export function SystemSettingsTab(): React.JSX.Element {
}
/>
<div className="settings-block-hint settings-shortcut-hint">
配合全局键可在任何界面唤起/隐藏窗口;若注册失败(与其他软件冲突)则不生效,应用不会报错。
{t('settings.system.globalShortcutHint')}
</div>
</div>
)
@@ -481,8 +497,8 @@ function ShortcutInput({ value, onChange }: { value: string; onChange: (v: strin
<Input
className={`settings-select shortcut-input${recording ? ' is-recording' : ''}`}
readOnly
value={recording ? '按下组合键…' : value}
placeholder="点击后按下快捷键,留空禁用"
value={recording ? t('settings.system.shortcutRecording') : value}
placeholder={t('settings.system.shortcutPlaceholder')}
onFocus={() => setRecording(true)}
onBlur={() => setRecording(false)}
onKeyDown={(e) => {
@@ -557,7 +573,7 @@ function TerminalPreview({
}
return (
<div className="settings-preview" style={{ background: colors.background }}>
<span className="settings-preview-title">预览</span>
<span className="settings-preview-title">{t('settings.preview')}</span>
<div className="settings-preview-grid" style={gridStyle}>
{lines.map((l, i) => (
<div key={i} className="settings-preview-line">
+24 -23
View File
@@ -1,5 +1,6 @@
import { useMemo } from 'react'
import { Button, ColorPicker, Popconfirm } from 'antd'
import { t } from '@shared/i18n'
import type { TerminalSettings } from '@shared/settings'
import { DEFAULT_SETTINGS } from '@shared/settings'
import type { TerminalTheme, ThemeColors } from '@shared/theme'
@@ -49,8 +50,8 @@ export function ThemeSettingsTab({
<div className="theme-pane">
<div className="theme-accent-row">
<div>
<div className="settings-block-label">标签强调色</div>
<div className="settings-block-hint">活动标签边框、侧栏指示条与 SSH 徽章的强调颜色</div>
<div className="settings-block-label">{t('settings.theme.accent')}</div>
<div className="settings-block-hint">{t('settings.theme.accentDesc')}</div>
</div>
<div className="theme-accent-controls">
<ColorPicker
@@ -61,33 +62,33 @@ export function ThemeSettingsTab({
size="small"
onClick={() => void updateTerminal({ tabAccentColor: DEFAULT_SETTINGS.terminal.tabAccentColor })}
>
恢复默认
{t('common.reset')}
</Button>
</div>
</div>
<div className="theme-gallery">
{themeCards.map((t) => {
const selected = t.id === activeTheme.id
const isCustom = t.builtin === false
{themeCards.map((theme) => {
const selected = theme.id === activeTheme.id
const isCustom = theme.builtin === false
return (
<div
key={t.id}
key={theme.id}
className={selected ? 'theme-card theme-card-selected' : 'theme-card'}
onClick={() => void updateTerminal({ themeId: t.id })}
title={t.name}
onClick={() => void updateTerminal({ themeId: theme.id })}
title={theme.name}
>
<div className="theme-card-bg" style={{ background: t.colors.background }} />
<div className="theme-card-bg" style={{ background: theme.colors.background }} />
<div className="theme-card-body">
<div className="theme-card-name-row">
<span className="theme-card-name">{t.name}</span>
{isCustom && <span className="theme-card-badge">自定义</span>}
<span className="theme-card-name">{theme.name}</span>
{isCustom && <span className="theme-card-badge">{t('settings.theme.customBadge')}</span>}
</div>
<div className="theme-card-dots">
{ANSI_KEYS.map((k) => (
<span
key={k}
className="theme-card-dot"
style={{ background: t.colors[k] }}
style={{ background: theme.colors[k] }}
/>
))}
</div>
@@ -98,29 +99,29 @@ export function ThemeSettingsTab({
size="small"
onClick={(e) => {
e.stopPropagation()
onEditTheme(t.id)
onEditTheme(theme.id)
}}
>
编辑
{t('common.edit')}
</Button>
<Popconfirm
title="删除自定义主题"
description="删除后无法恢复,确定继续?"
okText="删除"
cancelText="取消"
title={t('settings.theme.deleteTitle')}
description={t('settings.deleteConfirmDesc')}
okText={t('common.delete')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
onConfirm={(e) => {
e?.stopPropagation()
void handleDelete(t.id)
void handleDelete(theme.id)
}}
>
<Button
size="small"
danger
onClick={(e) => e.stopPropagation()}
title="删除"
title={t('common.delete')}
>
删除
{t('common.delete')}
</Button>
</Popconfirm>
</>
@@ -133,7 +134,7 @@ export function ThemeSettingsTab({
<div className="theme-card theme-card-new" onClick={onCreateTheme}>
<div className="theme-card-new-icon">+</div>
<div className="theme-card-new-text">新建主题</div>
<div className="theme-card-new-text">{t('settings.theme.create')}</div>
</div>
</div>
</div>
+21 -16
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react'
import { t } from '@shared/i18n'
import type { TerminalSettings } from '@shared/settings'
/** font stack shown as the persistent "默认" option */
@@ -8,31 +9,35 @@ export const WEIGHT_OPTIONS = [100, 200, 300, 400, 500, 600, 700, 800, 900]
export const weightOptions = WEIGHT_OPTIONS.map((w) => ({ value: w, label: String(w) }))
export const cursorStyleOptions: Array<{ value: TerminalSettings['cursorStyle']; label: string }> = [
{ value: 'bar', label: '竖线 (bar)' },
{ value: 'block', label: '方块 (block)' },
{ value: 'underline', label: '下划线 (underline)' }
export const cursorStyleOptions = (): Array<{ value: TerminalSettings['cursorStyle']; label: string }> => [
{ value: 'bar', label: t('settings.cursorStyle.bar') },
{ value: 'block', label: t('settings.cursorStyle.block') },
{ value: 'underline', label: t('settings.cursorStyle.underline') }
]
export const inactiveCursorStyleOptions: Array<{
export const inactiveCursorStyleOptions = (): Array<{
value: TerminalSettings['cursorInactiveStyle']
label: string
}> = [
{ value: 'outline', label: '轮廓 (outline)' },
{ value: 'block', label: '方块 (block)' },
{ value: 'bar', label: '竖线 (bar)' },
{ value: 'underline', label: '下划线 (underline)' },
{ value: 'none', label: '无 (none)' }
}> => [
{ value: 'outline', label: t('settings.cursorStyle.outline') },
{ value: 'block', label: t('settings.cursorStyle.block') },
{ value: 'bar', label: t('settings.cursorStyle.bar') },
{ value: 'underline', label: t('settings.cursorStyle.underline') },
{ value: 'none', label: t('settings.cursorStyle.none') }
]
export const rendererModeOptions: Array<{
export const rendererModeOptions = (): Array<{
value: TerminalSettings['rendererMode']
label: string
hint: string
}> = [
{ value: 'auto', label: '自动', hint: '优先使用 WebGL 加速,失败时自动回退到 DOM 渲染' },
{ value: 'webgl', label: '高性能 WebGL', hint: '强制使用 GPU 加速渲染,适合高刷新率与大行数滚动' },
{ value: 'dom', label: '兼容 DOM', hint: '纯 CPU 渲染,兼容性最好,适合无 GPU 或远程桌面环境' }
}> => [
{ value: 'auto', label: t('settings.rendererMode.auto'), hint: t('settings.rendererMode.autoHint') },
{
value: 'webgl',
label: t('settings.rendererMode.webgl'),
hint: t('settings.rendererMode.webglHint')
},
{ value: 'dom', label: t('settings.rendererMode.dom'), hint: t('settings.rendererMode.domHint') }
]
/** one label + control row inside a settings pane */
+33 -3
View File
@@ -163,10 +163,10 @@
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent);
}
/* Button-style radio groups render as one joined strip (no gaps, shared
borders) — matches the language picker and the reference UI. */
.settings-radio-group {
display: flex;
flex-wrap: wrap;
gap: 12px;
display: inline-flex;
margin-bottom: 16px;
}
@@ -175,6 +175,36 @@
text-align: center;
}
/* Language picker: one joined strip (no gaps, shared borders) instead of the
spread-out radio buttons used elsewhere. */
.settings-language-group {
display: inline-flex;
margin-bottom: 16px;
}
.settings-language-group .ant-radio-button-wrapper {
min-width: 104px;
text-align: center;
}
/* Selected segment: the filled (primary) background needs a light label —
antd's own checked colour loses to our text overrides, so pin it here. */
.settings-radio-group .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled),
.settings-language-group .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) {
color: #fff;
}
.settings-radio-group .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):hover,
.settings-language-group .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):hover {
color: #fff;
}
/* Own positive spacing: the shared `.settings-render-hint` uses a negative
top margin that lands the text on top of the button strip here. */
.settings-language-hint {
margin: 6px 0 18px;
}
.settings-render-hint {
margin: -8px 0 16px;
}
+73 -48
View File
@@ -17,6 +17,7 @@ import {
import { App, Button, Checkbox, Dropdown, Input, Modal, Popconfirm, Tooltip } from 'antd'
import type { MenuProps } from 'antd'
import type { SftpEntry, TransferProgressEvent } from '@shared/sftp'
import { getLanguage, t } from '@shared/i18n'
import './sftp.css'
/** Remote file browser for one SSH session (M4). */
@@ -70,8 +71,14 @@ function fmtTime(ms: number): string {
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
const PERM_ROW_LABELS = ['所有者', '组', '其他'] as const
const PERM_COL_LABELS = ['读', '写', '执行'] as const
/** Permission-grid labels are resolved per render so they follow the language. */
function permRowLabels(): string[] {
return [t('ssh.file.permRowOwner'), t('ssh.file.permRowGroup'), t('ssh.file.permRowOther')]
}
function permColLabels(): string[] {
return [t('ssh.file.permColRead'), t('ssh.file.permColWrite'), t('ssh.file.permColExec')]
}
interface PermissionModalProps {
open: boolean
@@ -89,6 +96,8 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
const [gid, setGid] = useState('')
const [saving, setSaving] = useState(false)
const hasUidGid = entry != null && (entry.uid != null || entry.gid != null)
const rowLabels = permRowLabels()
const colLabels = permColLabels()
const modeStr = useMemo(() => {
if (!entry) return { type: '-', str: '---------' }
@@ -164,7 +173,7 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
await window.api.chownRemote(sessionId, entry.path, u, g)
}
if (octalStr !== curOctal || ownerChanged) {
message.success('已应用权限')
message.success(t('ssh.file.permApplied'))
}
onClose()
onSaved()
@@ -178,13 +187,13 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
return (
<Modal
title={`权限 · ${entry?.name ?? ''}`}
title={t('ssh.file.permTitle', { name: entry?.name ?? '' })}
open={open}
onOk={onSave}
onCancel={onClose}
confirmLoading={saving}
okText="保存"
cancelText="取消"
okText={t('common.save')}
cancelText={t('common.cancel')}
width={380}
>
<div className="sftp-perm">
@@ -198,14 +207,14 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
<div className="sftp-perm-grid">
<div className="sftp-perm-grid-head">
<span />
{PERM_COL_LABELS.map((l) => (
{colLabels.map((l) => (
<span key={l}>{l}</span>
))}
</div>
{PERM_ROW_LABELS.map((row, r) => (
{rowLabels.map((row, r) => (
<div className="sftp-perm-grid-row" key={row}>
<span className="sftp-perm-grid-label">{row}</span>
{PERM_COL_LABELS.map((_, c) => {
{colLabels.map((_, c) => {
const idx = r * 3 + c
return (
<Checkbox key={idx} checked={bits[idx]} onChange={() => toggleBit(idx)} />
@@ -216,11 +225,11 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
</div>
<div className="sftp-perm-octal-input">
<span className="sftp-perm-label">八进制</span>
<span className="sftp-perm-label">{t('ssh.file.octal')}</span>
<Input
value={octal}
onChange={(e) => applyOctal(e.target.value)}
placeholder="如 755"
placeholder={t('ssh.file.octalPlaceholder')}
addonAfter={bitsToModeStr(modeStr.type, bits)}
/>
</div>
@@ -228,14 +237,22 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
<div className="sftp-perm-own">
<div className="sftp-perm-own-field">
<span className="sftp-perm-label">UID</span>
<Input value={uid} onChange={(e) => setUid(e.target.value)} placeholder={hasUidGid ? '不改则留空' : '0'} />
<Input
value={uid}
onChange={(e) => setUid(e.target.value)}
placeholder={hasUidGid ? t('ssh.file.keepEmpty') : '0'}
/>
</div>
<div className="sftp-perm-own-field">
<span className="sftp-perm-label">GID</span>
<Input value={gid} onChange={(e) => setGid(e.target.value)} placeholder={hasUidGid ? '不改则留空' : '0'} />
<Input
value={gid}
onChange={(e) => setGid(e.target.value)}
placeholder={hasUidGid ? t('ssh.file.keepEmpty') : '0'}
/>
</div>
</div>
<div className="sftp-perm-hint">UID/GID 留空表示不修改属主</div>
<div className="sftp-perm-hint">{t('ssh.file.ownerHint')}</div>
</div>
</Modal>
)
@@ -363,13 +380,16 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
void (async () => {
try {
await navigator.clipboard.writeText(entry.path)
message.success('已复制路径')
message.success(t('ssh.file.pathCopied'))
} catch {
message.error('复制路径失败')
message.error(t('ssh.file.copyPathFailed'))
}
})()
}
/** Active language; a dep of the menu below so translated labels refresh. */
const language = getLanguage()
/** opens the mkdir modal in create-a-file mode (no-op: new files unsupported) */
const menu: MenuProps = useMemo(() => {
const target = menuTarget
@@ -377,51 +397,51 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
const canAction = target != null
return {
items: [
{ key: 'refresh', label: '刷新', icon: <ReloadOutlined /> },
{ key: 'refresh', label: t('ssh.file.refresh'), icon: <ReloadOutlined /> },
{ type: 'divider' },
{
key: 'open',
label: isDir ? '打开' : '下载',
label: isDir ? t('ssh.file.open') : t('ssh.file.download'),
icon: isDir ? <FolderOpenOutlined /> : <DownloadOutlined />,
disabled: !canAction
},
{
key: 'download',
label: '下载',
label: t('ssh.file.download'),
icon: <DownloadOutlined />,
disabled: !canAction || Boolean(isDir)
},
{ key: 'upload', label: '上传…', icon: <UploadOutlined /> },
{ key: 'upload', label: t('ssh.file.upload'), icon: <UploadOutlined /> },
{
key: 'rename',
label: '重命名',
label: t('ssh.file.rename'),
icon: <FormOutlined />,
disabled: !canAction
},
{
type: 'submenu',
key: 'new',
label: '新建',
label: t('ssh.file.new'),
icon: <FileAddOutlined />,
children: [
{ key: 'mkdir', label: '新建文件夹', icon: <FolderAddOutlined /> }
{ key: 'mkdir', label: t('ssh.file.newFolder'), icon: <FolderAddOutlined /> }
// 新建文件:无现成 SFTP 创建文件 API(main 仅 mkdir/rename/chmod/chown),不做
]
},
{ type: 'divider' },
{
key: 'copyPath',
label: '复制路径',
label: t('ssh.file.copyPath'),
icon: <CopyOutlined />,
disabled: !canAction
},
{
key: 'permission',
label: '文件权限…',
label: t('ssh.file.permission'),
icon: <SafetyOutlined />,
disabled: !canAction
},
{ key: 'delete', label: '删除', icon: <DeleteOutlined />, danger: true, disabled: !canAction }
{ key: 'delete', label: t('common.delete'), icon: <DeleteOutlined />, danger: true, disabled: !canAction }
],
onClick: ({ key }) => {
const entry = menuTarget
@@ -447,11 +467,11 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
setPermOpen(true)
} else if (key === 'delete') {
modal.confirm({
title: '确认删除?',
title: t('ssh.file.deleteTitle'),
content: entry ? `${entry.name}(${entry.path})` : undefined,
okText: '删除',
okText: t('common.delete'),
okButtonProps: { danger: true },
cancelText: '取消',
cancelText: t('common.cancel'),
onOk: () =>
new Promise<void>((resolve, reject) => {
if (!entry) {
@@ -471,12 +491,12 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [menuTarget, dir, refresh])
}, [menuTarget, dir, refresh, language])
return (
<div className="sftp-panel">
<div className="sftp-toolbar">
<Tooltip title="上一级">
<Tooltip title={t('ssh.file.up')}>
<Button
type="text"
size="small"
@@ -485,10 +505,10 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
onClick={() => void refresh(parent)}
/>
</Tooltip>
<Tooltip title="刷新">
<Tooltip title={t('ssh.file.refresh')}>
<Button type="text" size="small" icon={<ReloadOutlined />} onClick={() => void refresh(dir)} />
</Tooltip>
<Tooltip title="新建文件夹">
<Tooltip title={t('ssh.file.newFolder')}>
<Button
type="text"
size="small"
@@ -499,10 +519,10 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
}}
/>
</Tooltip>
<Tooltip title="上传文件">
<Tooltip title={t('ssh.file.uploadFile')}>
<Button type="text" size="small" icon={<UploadOutlined />} onClick={doUpload} />
</Tooltip>
<Tooltip title="下载选中项">
<Tooltip title={t('ssh.file.downloadSelected')}>
<Button
type="text"
size="small"
@@ -511,7 +531,7 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
onClick={() => selected && doDownload(selected)}
/>
</Tooltip>
<Tooltip title="重命名">
<Tooltip title={t('ssh.file.rename')}>
<Button
type="text"
size="small"
@@ -523,7 +543,7 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
}}
/>
</Tooltip>
<Tooltip title="权限 / 属主">
<Tooltip title={t('ssh.file.permOwner')}>
<Button
type="text"
size="small"
@@ -532,7 +552,12 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
onClick={() => setPermOpen(true)}
/>
</Tooltip>
<Popconfirm title="确认删除?" okText="删除" cancelText="取消" onConfirm={doDelete}>
<Popconfirm
title={t('ssh.file.deleteTitle')}
okText={t('common.delete')}
cancelText={t('common.cancel')}
onConfirm={doDelete}
>
<Button type="text" size="small" icon={<DeleteOutlined />} disabled={!selected} danger />
</Popconfirm>
</div>
@@ -552,8 +577,8 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
<div className="sftp-error">{error}</div>
) : (
<div className="sftp-list">
{loading && entries.length === 0 && <div className="sftp-empty">加载中…</div>}
{!loading && entries.length === 0 && <div className="sftp-empty">空目录</div>}
{loading && entries.length === 0 && <div className="sftp-empty">{t('ssh.file.loading')}</div>}
{!loading && entries.length === 0 && <div className="sftp-empty">{t('ssh.file.empty')}</div>}
{entries.map((e) => (
<Dropdown
key={e.path}
@@ -577,7 +602,7 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
<span className="sftp-name" title={e.name}>
{e.name}
</span>
<span className="sftp-mode" title={e.mode ?? '权限不可用'}>
<span className="sftp-mode" title={e.mode ?? t('ssh.file.modeUnavailable')}>
{e.mode ?? '----------'}
</span>
<span className="sftp-owner" title={`UID ${e.uid ?? '—'} / GID ${e.gid ?? '—'}`}>
@@ -601,29 +626,29 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
onSaved={() => void refresh(dir)}
/>
<Modal
title="新建文件夹"
title={t('ssh.file.newFolder')}
open={mkdirOpen}
onOk={doMkdir}
onCancel={() => setMkdirOpen(false)}
okText="创建"
cancelText="取消"
okText={t('ssh.file.create')}
cancelText={t('common.cancel')}
width={360}
>
<Input
autoFocus
placeholder="文件夹名称"
placeholder={t('ssh.file.folderName')}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onPressEnter={doMkdir}
/>
</Modal>
<Modal
title={`重命名 ${selected?.name ?? ''}`}
title={t('ssh.file.renameTitle', { name: selected?.name ?? '' })}
open={renameOpen}
onOk={doRename}
onCancel={() => setRenameOpen(false)}
okText="确定"
cancelText="取消"
okText={t('common.ok')}
cancelText={t('common.cancel')}
width={360}
>
<Input
+21 -20
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { CloseOutlined } from '@ant-design/icons'
import type { TransferKind, TransferProgressEvent } from '@shared/sftp'
import { t } from '@shared/i18n'
interface TransferState {
kind: TransferKind
@@ -75,27 +76,27 @@ export function TransferPanel(): React.JSX.Element | null {
return (
<div className="sftp-transfer-panel">
{list.map(([id, t]) => {
const pct = t.totalBytes > 0 ? Math.min(100, Math.round((t.bytes / t.totalBytes) * 100)) : t.state === 'done' ? 100 : 0
{list.map(([id, tr]) => {
const pct = tr.totalBytes > 0 ? Math.min(100, Math.round((tr.bytes / tr.totalBytes) * 100)) : tr.state === 'done' ? 100 : 0
return (
<div key={id} className={`sftp-transfer sftp-transfer-${t.state}`}>
<div key={id} className={`sftp-transfer sftp-transfer-${tr.state}`}>
<div className="sftp-transfer-head">
<span className="sftp-transfer-title">
{t.state === 'error'
? '传输失败'
: t.state === 'cancelled'
? '已取消'
: t.state === 'done'
? '传输完成'
: t.kind === 'upload'
? '上传中'
: t.kind === 'download'
? '下载中'
: t.kind === 'zmodem-upload'
? 'ZMODEM 上传中'
: 'ZMODEM 下载中'}
{tr.state === 'error'
? t('panels.transfer.failed')
: tr.state === 'cancelled'
? t('panels.transfer.cancelled')
: tr.state === 'done'
? t('panels.transfer.done')
: tr.kind === 'upload'
? t('panels.transfer.uploading')
: tr.kind === 'download'
? t('panels.transfer.downloading')
: tr.kind === 'zmodem-upload'
? t('panels.transfer.zmodemUploading')
: t('panels.transfer.zmodemDownloading')}
</span>
{(t.state === 'error' || t.state === 'done') && (
{(tr.state === 'error' || tr.state === 'done') && (
<button
type="button"
className="sftp-transfer-close"
@@ -111,13 +112,13 @@ export function TransferPanel(): React.JSX.Element | null {
</button>
)}
</div>
<div className="sftp-transfer-file">{t.file || t.error || ''}</div>
{t.state === 'running' && t.totalBytes > 0 && (
<div className="sftp-transfer-file">{tr.file || tr.error || ''}</div>
{tr.state === 'running' && tr.totalBytes > 0 && (
<div className="sftp-transfer-bar">
<div className="sftp-transfer-fill" style={{ width: `${pct}%` }} />
</div>
)}
{t.error && <div className="sftp-transfer-err">{t.error}</div>}
{tr.error && <div className="sftp-transfer-err">{tr.error}</div>}
</div>
)
})}
+27 -26
View File
@@ -8,6 +8,7 @@ import { SearchAddon } from '@xterm/addon-search'
import '@xterm/xterm/css/xterm.css'
import { ClearOutlined, CopyOutlined, ExportOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons'
import { Checkbox, Modal } from 'antd'
import { t } from '@shared/i18n'
import { getThemeById } from '@shared/theme'
import type { CommandItem } from '@shared/commands'
import type { TerminalSettings } from '@shared/settings'
@@ -113,7 +114,7 @@ function SearchBar({ value, onChange, onPrev, onNext, onClose, enabled, result }
className="term-searchbar-input"
type="text"
value={value}
placeholder="搜索"
placeholder={t('terminal.search.placeholder')}
spellCheck={false}
autoFocus
onChange={(e) => onChange(e.target.value)}
@@ -123,10 +124,10 @@ function SearchBar({ value, onChange, onPrev, onNext, onClose, enabled, result }
else if (e.key === 'Escape') onClose()
}}
/>
<button type="button" className="term-searchbar-btn" disabled={!enabled} onClick={onPrev}>上一个</button>
<button type="button" className="term-searchbar-btn" disabled={!enabled} onClick={onNext}>下一个</button>
<button type="button" className="term-searchbar-btn" disabled={!enabled} onClick={onPrev}>{t('terminal.search.prev')}</button>
<button type="button" className="term-searchbar-btn" disabled={!enabled} onClick={onNext}>{t('terminal.search.next')}</button>
<span className="term-searchbar-count">{result}</span>
<button type="button" className="term-searchbar-btn" onClick={onClose}>关闭</button>
<button type="button" className="term-searchbar-btn" onClick={onClose}>{t('common.close')}</button>
</div>
)
}
@@ -454,7 +455,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
dir === 1
? addon.findNext(q, { decorations: SEARCH_DECORATIONS })
: addon.findPrevious(q, { decorations: SEARCH_DECORATIONS })
setResultInfo(found ? '已找到' : '未找到')
setResultInfo(found ? t('terminal.search.found') : t('terminal.search.notFound'))
}, [])
/** Record a submitted command line; cd-style lines also update cwd memory. */
@@ -1122,7 +1123,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
onClick={() => runMenuAction(() => void copySelection())}
>
<CopyOutlined />
<span className="term-menu-label">复制</span>
<span className="term-menu-label">{t('common.copy')}</span>
<kbd>Ctrl+Shift+C</kbd>
</button>
<button
@@ -1132,23 +1133,23 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
onClick={() => runMenuAction(() => void pasteFromClipboard())}
>
<SnippetsOutlined />
<span className="term-menu-label">粘贴</span>
<span className="term-menu-label">{t('common.paste')}</span>
<kbd>Ctrl+Shift+V</kbd>
</button>
<div className="term-menu-sep" />
<button type="button" className="term-menu-item" onClick={() => runMenuAction(openSearch)}>
<SearchOutlined />
<span className="term-menu-label">查找</span>
<span className="term-menu-label">{t('terminal.menu.find')}</span>
<kbd>Ctrl+F</kbd>
</button>
<button type="button" className="term-menu-item" onClick={() => runMenuAction(() => termRef.current?.selectAll())}>
<SelectOutlined />
<span className="term-menu-label">全选</span>
<span className="term-menu-label">{t('terminal.menu.selectAll')}</span>
<kbd />
</button>
<button type="button" className="term-menu-item" onClick={() => runMenuAction(() => termRef.current?.clear())}>
<ClearOutlined />
<span className="term-menu-label">清屏</span>
<span className="term-menu-label">{t('terminal.menu.clear')}</span>
<kbd />
</button>
</div>
@@ -1170,8 +1171,8 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
<button
type="button"
className={`term-rec-btn${recording ? ' is-recording' : ''}`}
title={recording ? '停止记录会话日志' : '开始记录会话日志'}
aria-label={recording ? '停止记录' : '开始记录'}
title={recording ? t('terminal.rec.stopLog') : t('terminal.rec.startLog')}
aria-label={recording ? t('terminal.rec.stop') : t('terminal.rec.start')}
onClick={() => {
if (recording) void handleLogStop()
else void handleLogStart()
@@ -1184,8 +1185,8 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
<button
type="button"
className="term-rec-btn"
title="打开日志目录"
aria-label="打开日志目录"
title={t('terminal.rec.openLogs')}
aria-label={t('terminal.rec.openLogs')}
onClick={() => window.api.openLogsDir()}
>
<FolderOpenOutlined />
@@ -1195,8 +1196,8 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
<button
type="button"
className="term-rec-btn"
title="打开工作区目录"
aria-label="打开工作区目录"
title={t('terminal.rec.openCwd')}
aria-label={t('terminal.rec.openCwd')}
onClick={() => {
const dir = getSessionCwd(sessionId)
if (dir) void window.api.openDirectory(dir)
@@ -1208,29 +1209,29 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
</div>
<Modal
open={paste !== null}
title="确认粘贴"
okText="粘贴"
cancelText="取消"
title={t('terminal.paste.title')}
okText={t('common.paste')}
cancelText={t('common.cancel')}
width={420}
maskClosable={false}
onOk={acceptPaste}
onCancel={cancelPaste}
>
<div className="term-paste-confirm">
<p>检测到多行或潜在危险命令,确定要粘贴到终端吗?</p>
<p>{t('terminal.paste.message')}</p>
<Checkbox
checked={paste?.noPrompt ?? false}
onChange={(e) => setPaste((prev) => (prev ? { ...prev, noPrompt: e.target.checked } : prev))}
>
本次会话不再提示
{t('terminal.paste.noPrompt')}
</Checkbox>
<Checkbox
checked={paste?.disableDetection ?? false}
onChange={(e) => setPaste((prev) => (prev ? { ...prev, disableDetection: e.target.checked } : prev))}
>
关闭后续粘贴检测
{t('terminal.paste.disableDetection')}
</Checkbox>
<p className="term-paste-confirm-hint">可在通用终端设置中重新开启或切换检测策略。</p>
<p className="term-paste-confirm-hint">{t('terminal.paste.hint')}</p>
</div>
</Modal>
{/* Relative-positioned wrapper around the xterm surface: the suggestion
@@ -1258,14 +1259,14 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
</button>
))}
</div>
<div className="term-suggest-hint">↑/↓ 选择 · Tab 接受 · Esc 关闭</div>
<div className="term-suggest-hint">{t('terminal.suggest.hint')}</div>
</div>
)}
<div className="terminal-view-dock" ref={hostRef}>
{dead && (
<div className="term-dead-mask">
<div>进程已退出 (代码 {exitCode})</div>
<button type="button" className="term-dead-btn" onClick={() => onCloseRef.current()}>关闭</button>
<div>{t('terminal.dead.message', { code: exitCode })}</div>
<button type="button" className="term-dead-btn" onClick={() => onCloseRef.current()}>{t('common.close')}</button>
</div>
)}
</div>
+14 -13
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { Input, Modal } from 'antd'
import { t } from '@shared/i18n'
import type { ThemeColors, TerminalTheme } from '@shared/theme'
import { DEFAULT_DARK, getThemeById } from '@shared/theme'
import { useSettingsStore } from '../../settings/store'
@@ -22,11 +23,11 @@ interface FieldSpec {
label: string
}
const HEADER_KEYS: FieldSpec[] = [
{ key: 'foreground', label: '前景' },
{ key: 'background', label: '背景' },
{ key: 'cursor', label: '光标' },
{ key: 'selectionBackground', label: '选区' }
const headerKeys = (): FieldSpec[] => [
{ key: 'foreground', label: t('settings.color.foreground') },
{ key: 'background', label: t('settings.color.background') },
{ key: 'cursor', label: t('settings.color.cursor') },
{ key: 'selectionBackground', label: t('settings.color.selection') }
]
const ANSI_KEYS: FieldSpec[] = [
@@ -78,7 +79,7 @@ export function ThemeEditor({ open, onClose, themeId }: ThemeEditorProps): React
} else {
/** create mode: seed a fresh copy based on the currently active theme */
const active = getThemeById(settings.terminal.themeId, settings.customThemes)
setName(active.name + '(副本)')
setName(active.name + t('settings.themeEditor.copySuffix'))
setColors({ ...active.colors })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -122,27 +123,27 @@ export function ThemeEditor({ open, onClose, themeId }: ThemeEditorProps): React
open={open}
onCancel={onClose}
onOk={handleSave}
okText="保存"
cancelText="取消"
okText={t('common.save')}
cancelText={t('common.cancel')}
confirmLoading={saving}
destroyOnHidden
width={560}
title={isCreate ? '新建主题' : '编辑主题'}
title={isCreate ? t('settings.theme.create') : t('settings.theme.editTitle')}
okButtonProps={{ disabled: name.trim() === '' }}
>
<div className="theme-editor">
<div className="theme-editor-row">
<span className="theme-editor-label">名称</span>
<span className="theme-editor-label">{t('settings.themeEditor.name')}</span>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="主题名称"
placeholder={t('settings.themeEditor.namePlaceholder')}
maxLength={60}
/>
</div>
<div className="theme-editor-colors">
{HEADER_KEYS.map((spec) => (
{headerKeys().map((spec) => (
<ColorField key={spec.key} spec={spec} value={colors[spec.key]} onChange={setColor} />
))}
<div className="theme-editor-divider" />
@@ -152,7 +153,7 @@ export function ThemeEditor({ open, onClose, themeId }: ThemeEditorProps): React
</div>
<div className="theme-editor-preview">
<span className="theme-editor-preview-label">预览</span>
<span className="theme-editor-preview-label">{t('settings.preview')}</span>
<ThemePreviewSnippet colors={colors} />
</div>
</div>
+10 -9
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { Button, Input, Modal } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import type { SshConnection, SshSecretOverride } from '@shared/connections'
import { t } from '@shared/i18n'
/**
* Connect-time gateways for one SSH connection attempt.
@@ -54,9 +55,9 @@ export function ConnectFlow({ conn, phase, onConfirmed, onCancel }: ConnectFlowP
return (
<SecretModal
key={key}
title="输入连接密码"
title={t('workspace.connect.passwordTitle')}
description={`${conn.username}@${conn.host}:${conn.port}`}
placeholder="密码"
placeholder={t('workspace.connect.passwordPlaceholder')}
value={password}
onChange={(v) => setPassword(v)}
onOk={() => onConfirmed({ password })}
@@ -69,9 +70,9 @@ export function ConnectFlow({ conn, phase, onConfirmed, onCancel }: ConnectFlowP
return (
<SecretModal
key={key}
title="输入私钥口令"
title={t('workspace.connect.passphraseTitle')}
description={`${conn.username}@${conn.host}:${conn.port}`}
placeholder="私钥口令"
placeholder={t('workspace.connect.passphrasePlaceholder')}
value={passphrase}
onChange={(v) => setPassphrase(v)}
onOk={() => onConfirmed({ passphrase })}
@@ -85,7 +86,7 @@ export function ConnectFlow({ conn, phase, onConfirmed, onCancel }: ConnectFlowP
<Modal
key={key}
open
title="连接中…"
title={t('workspace.connect.connecting')}
closable={false}
maskClosable={false}
keyboard={false}
@@ -95,7 +96,7 @@ export function ConnectFlow({ conn, phase, onConfirmed, onCancel }: ConnectFlowP
<div className="connect-flow-connecting">
<LoadingOutlined spin style={{ fontSize: 26 }} />
<span className="connect-flow-connecting-text">
正在连接 {conn?.name != null ? conn.name : '…'}
{t('workspace.connect.connectingNamed', { name: conn?.name != null ? conn.name : '…' })}
</span>
</div>
</Modal>
@@ -137,8 +138,8 @@ function SecretModal({
key={key}
open
title={title}
okText="连接"
cancelText="取消"
okText={t('workspace.connect.action')}
cancelText={t('common.cancel')}
onOk={handleOk}
onCancel={onCancel}
confirmLoading={submitting}
@@ -169,7 +170,7 @@ export function ConnectSpinnerButton({ busy }: { busy: boolean }): React.JSX.Ele
return busy ? (
<Button size="small" type="text" disabled className="workspace-connect-busy-button">
<LoadingOutlined spin />
连接中…
{t('workspace.connect.connecting')}
</Button>
) : null
}
+12 -9
View File
@@ -1,5 +1,6 @@
import { Alert, Button, Modal } from 'antd'
import type { HostKeyPromptEvent } from '@shared/connections'
import { t } from '@shared/i18n'
/**
* Host key verification dialog. Shown one at a time (Workspace keeps a FIFO
@@ -27,13 +28,13 @@ export function HostKeyModal({ event, onDecision }: HostKeyModalProps): React.JS
open
title={
isChanged ? (
<div className="hostkey-changed-title">主机指纹已变更!</div>
<div className="hostkey-changed-title">{t('ssh.hostKey.changedTitle')}</div>
) : (
'未知主机'
t('ssh.hostKey.unknownHost')
)
}
okText="接受并连接"
cancelText="拒绝"
okText={t('ssh.hostKey.accept')}
cancelText={t('ssh.hostKey.reject')}
okButtonProps={isChanged ? { danger: true } : { type: 'primary' }}
cancelButtonProps={{ danger: !isChanged }}
onOk={() => onDecision('accept')}
@@ -45,15 +46,17 @@ export function HostKeyModal({ event, onDecision }: HostKeyModalProps): React.JS
<Alert
type="warning"
showIcon
message="可能遭受中间人攻击"
description="远程主机的密钥指纹与之前记录的不一致。如果这不是你本人更换了服务器密钥,可能有人正在冒充该主机。"
message={t('ssh.hostKey.mitmTitle')}
description={t('ssh.hostKey.mitmDesc')}
/>
) : (
<div className="hostkey-desc">首次连接该主机,尚未记录其指纹。</div>
<div className="hostkey-desc">{t('ssh.hostKey.firstConnect')}</div>
)}
<div className="hostkey-target">主机:{target}</div>
<div className="hostkey-fingerprint">指纹:{event.fingerprint}</div>
<div className="hostkey-target">{t('ssh.hostKey.target', { target })}</div>
<div className="hostkey-fingerprint">
{t('ssh.hostKey.fingerprint', { fingerprint: event.fingerprint })}
</div>
</div>
</Modal>
)
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { t } from '@shared/i18n'
import { FilePanel } from '../sftp/FilePanel'
export interface SshBottomPanelProps {
@@ -55,7 +56,7 @@ export function SshBottomPanel({ sessionId, terminal }: SshBottomPanelProps): Re
className="ssh-bottom-divider"
role="separator"
aria-orientation="horizontal"
aria-label="调整文件面板高度"
aria-label={t('ssh.bottomPanel.resize')}
onMouseDown={onDividerMouseDown}
/>
<div className="ssh-bottom-files" style={{ height: filesHeight }}>
+3 -1
View File
@@ -1,3 +1,5 @@
import { t } from '@shared/i18n'
/**
* Small capsule badge showing the SSH host label (e.g. `root@36.151.147.123`)
* on an SSH terminal tab. Host labels are often long, so the display string is
@@ -9,7 +11,7 @@ export function SshHostBadge({ label }: { label: string }): React.JSX.Element {
<span
className="ssh-host-badge"
title={label}
aria-label={`SSH 主机 ${label}`}
aria-label={t('ssh.hostBadge.aria', { label })}
>
{label}
</span>
+3 -2
View File
@@ -3,6 +3,7 @@ import type { IDockviewPanelProps } from 'dockview-react'
import { Button } from 'antd'
import { AreaChartOutlined } from '@ant-design/icons'
import { t } from '@shared/i18n'
import { TerminalView } from '../terminal/TerminalView'
import { MonitorPanel } from '../monitor/MonitorPanel'
import { SshBottomPanel } from './SshBottomPanel'
@@ -78,8 +79,8 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps
size="small"
className="workspace-terminal-monitor-toggle"
icon={<AreaChartOutlined />}
aria-label={sideOpen ? '收起侧边栏' : '展开侧边栏'}
title={sideOpen ? '收起侧边栏' : '展开侧边栏'}
aria-label={sideOpen ? t('workspace.panel.hideSidebar') : t('workspace.panel.showSidebar')}
title={sideOpen ? t('workspace.panel.hideSidebar') : t('workspace.panel.showSidebar')}
onClick={() => setSideOpen((prev) => !prev)}
/>
)}
+20 -19
View File
@@ -6,6 +6,7 @@ import type { IDockviewPanelHeaderProps } from 'dockview-react'
import { useBroadcastStore } from './broadcastStore'
import { SshHostBadge } from './SshHostBadge'
import { t } from '@shared/i18n'
import type { LayoutMeta } from '@shared/ipc'
export type TerminalTabProps = IDockviewPanelHeaderProps<{
@@ -35,7 +36,7 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
? hostLabel && hostLabel !== title
? `${title} — ${hostLabel}`
: (hostLabel || title)
: '本地终端'
: t('workspace.tab.localTerminal')
// M6 broadcast: show a badge on tabs that are current broadcast targets.
const isBroadcastTarget = useBroadcastStore((state) => state.targets.has(params?.sessionId ?? ''))
@@ -54,9 +55,9 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
const menu: MenuProps = {
items: [
{ key: 'close', label: '关闭' },
{ key: 'closeOthers', label: '关闭其他', disabled: panel.panels.length <= 1 },
{ key: 'closeRight', label: '关闭右侧', disabled: selfIndex === -1 || selfIndex === panel.panels.length - 1 }
{ key: 'close', label: t('common.close') },
{ key: 'closeOthers', label: t('workspace.tab.closeOthers'), disabled: panel.panels.length <= 1 },
{ key: 'closeRight', label: t('workspace.tab.closeRight'), disabled: selfIndex === -1 || selfIndex === panel.panels.length - 1 }
],
onClick: ({ key }) => {
if (key === 'close') {
@@ -85,16 +86,16 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
{isBroadcastTarget && (
<span
className="workspace-terminal-tab-bcast"
title="广播目标"
aria-label="广播目标"
title={t('workspace.tab.broadcastTarget')}
aria-label={t('workspace.tab.broadcastTarget')}
>
播
{t('workspace.tab.broadcastBadge')}
</span>
)}
<button
type="button"
className="workspace-terminal-tab-close"
aria-label="关闭"
aria-label={t('common.close')}
onPointerDown={(event) => event.preventDefault()}
onClick={(event) => {
event.preventDefault()
@@ -144,17 +145,17 @@ export function SaveTemplateModal({
return (
<Modal
title="保存布局模板"
title={t('workspace.template.saveTitle')}
open={open}
onCancel={onCancel}
onOk={handleOk}
okText="保存"
cancelText="取消"
okText={t('common.save')}
cancelText={t('common.cancel')}
confirmLoading={saving}
okButtonProps={{ disabled: !name.trim() }}
>
<Input
placeholder="模板名称"
placeholder={t('workspace.template.namePlaceholder')}
value={name}
autoFocus
onChange={(event) => setName(event.target.value)}
@@ -210,9 +211,9 @@ export function ApplyTemplateModal({
}
return (
<Modal title="应用布局模板" open={open} onCancel={onClose} footer={null} width={420}>
<Modal title={t('workspace.template.applyTitle')} open={open} onCancel={onClose} footer={null} width={420}>
{items.length === 0 ? (
<div className="workspace-template-empty">暂无已保存的模板</div>
<div className="workspace-template-empty">{t('workspace.template.empty')}</div>
) : (
<ul className="workspace-template-list">
{items.map((meta) => (
@@ -229,16 +230,16 @@ export function ApplyTemplateModal({
loading={busyId === meta.id}
onClick={() => void handleApply(meta)}
>
应用
{t('workspace.template.apply')}
</Button>
<Popconfirm
title="删除该模板?"
okText="删除"
cancelText="取消"
title={t('workspace.template.deleteConfirm')}
okText={t('common.delete')}
cancelText={t('common.cancel')}
onConfirm={() => void handleDelete(meta)}
>
<Button size="small" danger disabled={busyId === meta.id}>
删除
{t('common.delete')}
</Button>
</Popconfirm>
</Space>
+48 -33
View File
@@ -25,6 +25,7 @@ import type {
import 'dockview-react/dist/styles/dockview.css'
import type { HostKeyPromptEvent, SshConnection, SshSecretOverride } from '@shared/connections'
import { t } from '@shared/i18n'
import { ConnectionSidebar } from '../connections/ConnectionSidebar'
import type { ConnectionSidebarHandle } from '../connections/ConnectionSidebar'
import { ConnectFlow } from './ConnectFlow'
@@ -68,13 +69,21 @@ const TERMINAL_TAB_COMPONENT = 'terminal-tab'
*/
function nextTerminalTitle(existing: Iterable<string | undefined>): string {
const used = new Set<number>()
// The pattern is translated, so the number is read back through that same
// pattern (the `{n}` placeholder marks the digits) instead of a fixed one.
const [head, tail] = t('workspace.tab.terminalTitle', { n: '\u0000' }).split('\u0000')
const matcher = new RegExp(`^${escapeRegExp(head)}(\\d+)${escapeRegExp(tail ?? '')}$`)
for (const title of existing) {
const n = title?.match(/^终端 (\d+)$/)?.[1]
const n = title?.match(matcher)?.[1]
if (n) used.add(Number(n))
}
let n = 1
while (used.has(n)) n++
return `终端 ${n}`
return t('workspace.tab.terminalTitle', { n })
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function sessionIdOf(panel: IDockviewPanel | undefined): string | undefined {
@@ -184,7 +193,7 @@ function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsPr
const { message } = App.useApp()
const handleAdd = (): void => {
if (mode === 'ssh') {
message.info('SSH 会话请从服务器列表连接')
message.info(t('workspace.hint.sshFromList'))
return
}
void (async (): Promise<void> => {
@@ -665,7 +674,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
// B's sidebar refreshes lastConnectedAt on connect.
void sidebarRef.current?.refresh()
} catch (error) {
message.error(typeof error === 'string' ? error : 'SSH 连接失败')
message.error(typeof error === 'string' ? error : t('workspace.connect.failed'))
} finally {
setConnecting(false)
setRequestedConn(null)
@@ -714,7 +723,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
const handleRailNewTerminal = useCallback((): void => {
const mode = useWorkspaceModeStore.getState().mode
if (mode === 'ssh') {
message.info('SSH 会话请从服务器列表连接')
message.info(t('workspace.hint.sshFromList'))
return
}
void addTerminal()
@@ -732,7 +741,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
const current = sshApiRef.current
const hasSession = current ? current.panels.some((p) => sessionIdOf(p)) : false
if (!hasSession) {
message.info('请先连接 SSH 服务器')
message.info(t('workspace.hint.connectSshFirst'))
return
}
const reference = activeSshPanelRef.current
@@ -947,12 +956,12 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
(cmd: string): void => {
const panel = activePanelRef.current
if (!panel) {
message.info('没有激活的终端:请至少打开一个本地终端或 SSH 会话')
message.info(t('workspace.hint.noActiveTerminal'))
return
}
const sid = sessionIdOf(panel)
if (!sid) {
message.info('当前激活面板没有可用的会话')
message.info(t('workspace.hint.noSessionInPanel'))
return
}
writeBroadcast(sid, `${cmd}\r`)
@@ -963,11 +972,11 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
const layoutMenu: MenuProps = {
items: [
{ key: 'split-right', label: '水平分屏' },
{ key: 'split-below', label: '垂直分屏' },
{ key: 'split-right', label: t('workspace.layout.splitRight') },
{ key: 'split-below', label: t('workspace.layout.splitBelow') },
{ type: 'divider' },
{ key: 'save', label: '保存为模板' },
{ key: 'apply', label: '应用模板' }
{ key: 'save', label: t('workspace.layout.saveTemplate') },
{ key: 'apply', label: t('workspace.layout.applyTemplate') }
],
onClick: ({ key }) => {
if (key === 'split-right') handleSplit('right')
@@ -1022,14 +1031,14 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
{terminalCount === 0 && (
<div className="workspace-empty-overlay">
<div className="workspace-empty-inner">
<div className="workspace-empty-title">暂无终端</div>
<div className="workspace-empty-title">{t('workspace.empty.terminalTitle')}</div>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={handleNewTerminal}
>
新建终端
{t('workspace.newTerminal')}
</Button>
</div>
</div>
@@ -1056,8 +1065,8 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
{sshCount === 0 && (
<div className="workspace-empty-overlay">
<div className="workspace-empty-inner">
<div className="workspace-empty-title">暂无 SSH 会话</div>
<div className="workspace-empty-hint">请从左侧服务器列表连接</div>
<div className="workspace-empty-title">{t('workspace.empty.sshTitle')}</div>
<div className="workspace-empty-hint">{t('workspace.empty.sshHint')}</div>
</div>
</div>
)}
@@ -1102,8 +1111,8 @@ function AddTerminalGroupButton({ onClick }: { onClick: () => void }): React.JSX
<button
type="button"
className="workspace-tabbar-add"
title="在此分屏中新建终端"
aria-label="在此分屏中新建终端"
title={t('workspace.tab.addInSplit')}
aria-label={t('workspace.tab.addInSplit')}
onClick={onClick}
>
<PlusOutlined />
@@ -1144,8 +1153,8 @@ function BroadcastToggleButton(): React.JSX.Element {
<button
type="button"
className={cls}
title={enabled ? `广播中 (${targets.size} 个目标)` : '广播输入'}
aria-label="广播输入"
title={enabled ? t('workspace.broadcast.activeTitle', { count: targets.size }) : t('workspace.broadcast.toggle')}
aria-label={t('workspace.broadcast.toggle')}
aria-pressed={enabled}
>
<ShareAltOutlined />
@@ -1179,21 +1188,27 @@ function WorkspaceBroadcastPopover({
`${s.title}${s.isSsh ? ' (SSH)' : ''}`
return (
<div className="workspace-bcast-pop">
<div className="workspace-bcast-pop-mode">广播输入({mode === 'ssh' ? 'SSH' : '终端'})</div>
<div className="workspace-bcast-pop-mode">
{mode === 'ssh' ? t('workspace.broadcast.popoverSsh') : t('workspace.broadcast.popoverTerminal')}
</div>
<div className="workspace-bcast-pop-switch">
<span className="workspace-bcast-pop-label">广播开关</span>
<span className="workspace-bcast-pop-label">{t('workspace.broadcast.switch')}</span>
<Checkbox
checked={enabled}
disabled={targets.size < 2}
onChange={(e) => onSetEnabled(e.target.checked)}
>
{enabled ? `已启用 (${targets.size})` : targets.size < 2 ? '需至少选择 2 个目标' : '未启用'}
{enabled
? t('workspace.broadcast.enabled', { count: targets.size })
: targets.size < 2
? t('workspace.broadcast.needTwo')
: t('workspace.broadcast.disabled')}
</Checkbox>
</div>
<div className="workspace-bcast-pop-list">
{groupSessions.length === 0 ? (
<div className="workspace-bcast-pop-empty">
{mode === 'ssh' ? '暂无 SSH 服务器会话' : '暂无终端'}
{mode === 'ssh' ? t('workspace.broadcast.emptySsh') : t('workspace.broadcast.emptyTerminal')}
</div>
) : (
groupSessions.map((s) => (
@@ -1205,7 +1220,7 @@ function WorkspaceBroadcastPopover({
))
)}
</div>
<div className="workspace-bcast-pop-hint">选择 ≥2 个终端后广播自动开启;关闭任意一个使目标少于 2 个时自动关闭。仅当前模式组的会话可被勾选。</div>
<div className="workspace-bcast-pop-hint">{t('workspace.broadcast.hint')}</div>
</div>
)
}
@@ -1234,22 +1249,22 @@ function IconRail({
return (
<div className="workspace-rail">
<RailButton
title={sidebarOpen ? '收起侧栏' : '展开侧栏'}
title={sidebarOpen ? t('workspace.rail.hideSidebar') : t('workspace.rail.showSidebar')}
icon={sidebarOpen ? <MenuFoldOutlined /> : <MenuUnfoldOutlined />}
onClick={onToggleSidebar}
/>
<div className="workspace-rail-divider" />
<RailButton title="新建终端" icon={<PlusOutlined />} onClick={onNewTerminal} />
<RailButton title={t('workspace.newTerminal')} icon={<PlusOutlined />} onClick={onNewTerminal} />
<LayoutFlyout menu={layoutMenu} />
<div className="workspace-rail-divider" />
<ModeRailButtons />
<div className="workspace-rail-spacer" />
{connecting && (
<Tooltip title="正在连接…" placement="right">
<Tooltip title={t('workspace.rail.connecting')} placement="right">
<span className="workspace-rail-busy" />
</Tooltip>
)}
<RailButton title="设置" icon={<SettingOutlined />} onClick={onOpenSettings} />
<RailButton title={t('workspace.rail.settings')} icon={<SettingOutlined />} onClick={onOpenSettings} />
</div>
)
}
@@ -1261,13 +1276,13 @@ function ModeRailButtons(): React.JSX.Element {
return (
<>
<RailButton
title="终端工作区"
title={t('workspace.rail.terminalWorkspace')}
icon={<CodeOutlined />}
active={mode === 'terminal'}
onClick={() => setMode('terminal')}
/>
<RailButton
title="SSH 工作区"
title={t('workspace.rail.sshWorkspace')}
icon={<CloudServerOutlined />}
active={mode === 'ssh'}
onClick={() => setMode('ssh')}
@@ -1310,11 +1325,11 @@ function LayoutFlyout({ menu }: { menu: MenuProps }): React.JSX.Element {
return (
<div className="workspace-flyout-anchor" ref={rootRef}>
<Tooltip title={open ? '' : '布局'} placement="right">
<Tooltip title={open ? '' : t('workspace.rail.layout')} placement="right">
<button
type="button"
className={'workspace-rail-btn' + (open ? ' is-active' : '')}
aria-label="布局"
aria-label={t('workspace.rail.layout')}
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
+10 -8
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { App as AntdApp, Button, Modal, Space, Typography } from 'antd'
import type { ZmodemOfferEvent, ZmodemResponse } from '@shared/ipc'
import { t } from '@shared/i18n'
/**
* M6 ZMODEM offer/done UX: renders one dialog per active zmodem offer.
@@ -82,8 +83,11 @@ export default function ZmodemOffers(): null | React.JSX.Element {
const offDone = useCallback(
(evt: { id: string; ok: boolean; message?: string }) => {
if (evt.ok) message.success('ZMODEM 传输完成')
else message.error(`ZMODEM 传输失败:${evt.message ?? '未知错误'}`)
if (evt.ok) message.success(t('ssh.zmodem.done'))
else
message.error(
t('ssh.zmodem.failed', { message: evt.message ?? t('ssh.zmodem.unknownError') })
)
},
[message]
)
@@ -103,23 +107,21 @@ export default function ZmodemOffers(): null | React.JSX.Element {
closable={false}
maskClosable={false}
keyboard={false}
title={isReceive ? '下载到本地' : '上传到服务器'}
title={isReceive ? t('ssh.zmodem.titleReceive') : t('ssh.zmodem.titleSend')}
footer={
<Space>
<Button
type="primary"
onClick={() => (isReceive ? void handlePickDir(first.id) : void handlePickFiles(first.id))}
>
{isReceive ? '选择保存目录' : '选择文件'}
{isReceive ? t('ssh.zmodem.pickDir') : t('ssh.zmodem.pickFiles')}
</Button>
<Button onClick={() => handleCancel(first.id)}>取消</Button>
<Button onClick={() => handleCancel(first.id)}>{t('common.cancel')}</Button>
</Space>
}
>
<Typography.Text>
{isReceive
? '检测到 sz 下载请求(远端正在发送文件),请选择保存在本地的目录。'
: '检测到 rz 上传请求(远端正在接收文件),请选择要上传的文件。'}
{isReceive ? t('ssh.zmodem.receiveDesc') : t('ssh.zmodem.sendDesc')}
</Typography.Text>
</Modal>
)