20 文件 ~70 处 UI 硬编码中文清零:
- credential-manager/constants.ts: 8 凭证类型 label/desc → labelKey/descKey + i18n.t()
- pipeline-run-detail/types.ts: 6 阶段 label → i18n.t('deploy.stage.*') + labelKey
- pipeline-manager/constants.ts + pipeline-editor/types.ts: 分秒 duration → i18n.t('common.duration.minSec')
- LiveMonitorDialog: 6 终端状态消息 → t()
- RdpAuditViewer: 6 RDP审计状态 → i18n.t('monitor.rdp.*')
- WorkspaceTermDialog: 2 终端连接状态 → t()
- TerminalSessionsTab: 4 时长格式+状态标签 → i18n.t()/t()
- k8s-event-utils + QueryHistoryPanel: 6 相对时间 → i18n.t('common.duration.*Ago')
- export-audit.ts: 9 CSV导出表头 → i18n.t('system.auditExport.*')
- useAuditSettings: 4 审计保留策略消息 → i18n.t('system.auditSettings.*')
- event-handler.ts: 4 AgentCLI事件消息 → i18n.t('aiops.cli.*')
- 其他 9 文件: NotifyPolicyTab/RoleFormDialog/CronJobViewDialog/TerminalOutput/shared-components/prometheus-ds/helpers/CloudServerImport/AdvancedSettingsPanel/releaseManagerHelpers
新增 75 翻译键, zh/en 7987→8059 对齐
tsc -b=0, npm build✓ (17.13s), ESLint=0/43/any=0, go build=0, 17/17 测试通过
198 lines
6.8 KiB
TypeScript
198 lines
6.8 KiB
TypeScript
import { useRef, useEffect, useState } from 'react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Terminal as XTerm } from 'xterm';
|
|
import { FitAddon } from 'xterm-addon-fit';
|
|
import 'xterm/css/xterm.css';
|
|
import { Maximize2, Minimize2, X, FolderOpen } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { attachTerminalResize, mountXterm } from '@/lib/xterm-utils';
|
|
import { useUserStore } from '@/store';
|
|
import { pipelineApi } from '@/api';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { TerminalLoadingOverlay } from '@/components/common/TerminalLoadingOverlay';
|
|
import type { JsonData } from '@/types/common';
|
|
|
|
interface WorkspaceTermDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
runId: number | null;
|
|
buildNumber: number;
|
|
}
|
|
|
|
/**
|
|
* Workspace 终端 Dialog — 进入流水线构建目录查看文件
|
|
*/
|
|
export function WorkspaceTermDialog({
|
|
open,
|
|
onOpenChange,
|
|
runId,
|
|
buildNumber,
|
|
}: WorkspaceTermDialogProps) {
|
|
const { t } = useTranslation();
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const termRef = useRef<XTerm | null>(null);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const fitRef = useRef<FitAddon | null>(null);
|
|
const [connected, setConnected] = useState(false);
|
|
const [fullscreen, setFullscreen] = useState(false);
|
|
const [workspace, setWorkspace] = useState('');
|
|
const [hasOutput, setHasOutput] = useState(false);
|
|
|
|
// 获取 workspace 路径
|
|
useEffect(() => {
|
|
if (!open || !runId) return;
|
|
setHasOutput(false);
|
|
pipelineApi.getWorkspace(runId)
|
|
.then((data: JsonData) => setWorkspace(data?.workspace_path || ''))
|
|
.catch(() => {});
|
|
}, [open, runId]);
|
|
|
|
// 连接终端
|
|
useEffect(() => {
|
|
if (!open || !runId) return;
|
|
|
|
const term = new XTerm({
|
|
cursorBlink: true,
|
|
fontSize: 13,
|
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
allowProposedApi: true,
|
|
theme: {
|
|
background: '#0a0e14',
|
|
foreground: '#d6d6d6',
|
|
cursor: '#d6d6d6',
|
|
selectionBackground: 'rgba(255,255,255,0.2)',
|
|
},
|
|
});
|
|
const fit = new FitAddon();
|
|
term.loadAddon(fit);
|
|
termRef.current = term;
|
|
fitRef.current = fit;
|
|
|
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
const token = useUserStore.getState().token || '';
|
|
const wsUrl = `${proto}://${location.host}/ws/pipeline/${runId}/workspace-shell?token=${encodeURIComponent(token)}`;
|
|
const ws = new WebSocket(wsUrl);
|
|
wsRef.current = ws;
|
|
|
|
term.onData((data) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ status: 0, data }));
|
|
}
|
|
});
|
|
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const msg = JSON.parse(event.data);
|
|
if (msg.status === 0 && typeof msg.data === 'string') {
|
|
setHasOutput(true);
|
|
term.write(msg.data);
|
|
}
|
|
} catch {
|
|
setHasOutput(true);
|
|
term.write(typeof event.data === 'string' ? event.data : '');
|
|
}
|
|
};
|
|
|
|
ws.onopen = () => {
|
|
setConnected(true);
|
|
mountXterm(term, fit, () => containerRef.current, ws);
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
setConnected(false);
|
|
setHasOutput(true);
|
|
term.write(`\r\n\x1b[31m${t('deploy.terminal.connClosed')}\x1b[0m\r\n`);
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
setHasOutput(true);
|
|
term.write(`\r\n\x1b[31m${t('deploy.terminal.connError')}\x1b[0m\r\n`);
|
|
};
|
|
|
|
return () => {
|
|
try { ws.close(); term.dispose(); } catch { /* noop */ }
|
|
termRef.current = null;
|
|
wsRef.current = null;
|
|
setConnected(false);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open, runId]);
|
|
|
|
// 容器尺寸变化时 fit 并同步远端 PTY,避免缩放后黑边或铺不满。 // Logan.Li
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
let cleanup: (() => void) | undefined;
|
|
let retry = 0;
|
|
const attach = () => {
|
|
const container = containerRef.current;
|
|
const fit = fitRef.current;
|
|
const term = termRef.current;
|
|
if (!container || !fit || !term) {
|
|
if (retry++ < 30) requestAnimationFrame(attach);
|
|
return;
|
|
}
|
|
cleanup = attachTerminalResize(container, fit, term, wsRef.current);
|
|
};
|
|
attach();
|
|
return () => cleanup?.();
|
|
}, [open, fullscreen]);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
// 禁用 Radix Dialog 默认的 ESC 关闭:工作区终端会用到 ESC,关闭只能点头部 X 按钮 // Logan.Li
|
|
onEscapeKeyDown={(e) => e.preventDefault()}
|
|
className={cn(
|
|
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
|
|
'!duration-0 !transition-none',
|
|
fullscreen
|
|
? '!max-w-none !w-screen !h-screen !rounded-none'
|
|
: ''
|
|
)}
|
|
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '900px', width: '90vw' }}
|
|
>
|
|
<DialogTitle className="sr-only">{t("deploy.workspaceTerminalTitle", { num: buildNumber })}</DialogTitle>
|
|
<DialogDescription className="sr-only">{t("common.buildTerminal")}</DialogDescription>
|
|
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
|
|
<div className="flex items-center gap-2 text-sm min-w-0">
|
|
<div className={cn('w-2 h-2 rounded-full shrink-0', connected ? 'bg-emerald-400' : 'bg-red-400')} />
|
|
<FolderOpen className="h-4 w-4 text-[var(--dg-text-secondary)] shrink-0" />
|
|
<span className="text-[var(--dg-text-primary)] font-medium truncate">
|
|
#{buildNumber} 构建目录
|
|
</span>
|
|
{workspace && (
|
|
<span className="text-[var(--dg-text-muted)] text-xs truncate hidden sm:inline">
|
|
{workspace}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => setFullscreen(!fullscreen)}
|
|
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
|
>
|
|
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
|
</button>
|
|
<button
|
|
onClick={() => onOpenChange(false)}
|
|
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="relative bg-[#0a0e14]" style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}>
|
|
<div ref={containerRef} className="xterm-terminal-host h-full" />
|
|
<TerminalLoadingOverlay show={!hasOutput} label={t('common.enteringWorkspace')} />
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|