feat(i18n): 全量中英文国际化改造
- 翻译 en-US.json 中 1006 条未翻译的中文条目为英文 - 提取 30+ 组件文件的硬编码中文到 locale JSON - 新增 900+ 翻译键覆盖所有业务模块: - Layout (Sidebar, Header, Breadcrumb) - Server (ServerForm, CloudServerImport, WebShell, RdpTools) - Deploy (ReleaseManager, PipelineCreate, AppRelease) - Kubernetes (DeploymentEdit, PodExec, PodLogs, ResourceEditor) - Monitor (UnifiedQueryPanel, AlertCenter, Prometheus/Grafana Dashboard) - AIOps (predictUtils, PredictiveCostTab, AIPredictive) - System (useLdapManagement, AgentMonitor, AgentWizard) - tsc --noEmit 类型检查通过
This commit is contained in:
1 parent
76ec9387f0
commit
a9513c2056
44 files changed
+4342
-1792
No files matched your search
@@ -22,6 +22,7 @@ import {
|
||||
Server,
|
||||
RefreshCw, Check, MapPin, Search, AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cloudApi, credentialApi } from '@/api';
|
||||
import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon';
|
||||
import { ImportCredentialSettings } from './ImportCredentialSettings';
|
||||
@@ -43,6 +44,7 @@ const cloudProviders = [
|
||||
];
|
||||
|
||||
export function CloudServerImport({ open, onClose, initialAccountId }: CloudServerImportProps) {
|
||||
const { t } = useTranslation();
|
||||
const [cloudAccounts, setCloudAccounts] = useState<any[]>([]);
|
||||
const [step, setStep] = useState(1);
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>('');
|
||||
@@ -282,18 +284,18 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
||||
);
|
||||
|
||||
const steps = [
|
||||
{ id: 1, title: '云厂商', icon: Cloud },
|
||||
{ id: 2, title: '云账号', icon: Key },
|
||||
{ id: 3, title: '选择区域', icon: MapPin },
|
||||
{ id: 4, title: '导入确认', icon: CheckCircle },
|
||||
{ id: 1, title: t('server.cloudProvider'), icon: Cloud },
|
||||
{ id: 2, title: t('server.cloudAccount'), icon: Key },
|
||||
{ id: 3, title: t('server.selectRegion'), icon: MapPin },
|
||||
{ id: 4, title: t('server.importConfirm'), icon: CheckCircle },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-3xl bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">导入云主机</DialogTitle>
|
||||
<DialogDescription>从云账号批量导入主机资产,可选关联 Linux SSH 和 Windows RDP 凭证</DialogDescription>
|
||||
<DialogTitle className="text-xl">{t('server.importCloudServers')}</DialogTitle>
|
||||
<DialogDescription>{t('server.importCloudServersDesc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Step Indicator */}
|
||||
@@ -321,7 +323,7 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
||||
{/* Step 1: Select Cloud Provider */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-[var(--dg-text-secondary)] text-sm">请选择要导入云主机的云厂商</p>
|
||||
<p className="text-[var(--dg-text-secondary)] text-sm">{t('server.selectCloudProvider')}</p>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{cloudProviders.map((provider) => (
|
||||
<Card
|
||||
@@ -351,7 +353,7 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
||||
{/* Step 2: Select Account */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-[var(--dg-text-secondary)] text-sm">请选择云账号(AccessKey)</p>
|
||||
<p className="text-[var(--dg-text-secondary)] text-sm">{t('server.selectAccount')}</p>
|
||||
{filteredAccounts.length > 0 ? (
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
{filteredAccounts.map((account) => (
|
||||
@@ -381,9 +383,9 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Badge className="bg-emerald-500/20 dg-text-green border-0">
|
||||
{account.region_count ?? account.regionCount ?? 0} 区域
|
||||
{account.region_count ?? account.regionCount ?? 0} {t('server.regionCountLabel', { count: account.region_count ?? account.regionCount ?? 0 })}
|
||||
</Badge>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-1">{account.server_count ?? account.serverCount ?? 0} 服务器</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-1">{account.server_count ?? account.serverCount ?? 0} {t('server.serverCountLabel', { count: account.server_count ?? account.serverCount ?? 0 })}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -392,7 +394,7 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
||||
) : (
|
||||
<div className="text-center py-8 text-[var(--dg-text-muted)]">
|
||||
<Key className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p>该云厂商暂无有效的云账号</p>
|
||||
<p>{t('server.noValidAccount')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -403,9 +405,9 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[var(--dg-text-secondary)] text-sm">
|
||||
请选择要同步的区域
|
||||
{t('server.selectSyncRegion')}
|
||||
{regions.length > 0 && (
|
||||
<span className="ml-2 text-[var(--dg-text-muted)]">(共 {regions.length} 个区域,已选 {selectedRegions.length} 个)</span>
|
||||
<span className="ml-2 text-[var(--dg-text-muted)]">{t('server.regionStats', { total: regions.length, selected: selectedRegions.length })}</span>
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
@@ -416,19 +418,19 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
||||
disabled={isSyncingRegions}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 mr-1 ${isSyncingRegions ? 'animate-spin' : ''}`} />
|
||||
{isSyncingRegions ? '同步中...' : '刷新区域'}
|
||||
{isSyncingRegions ? t('server.syncing') : t('server.refreshRegions')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isSyncingRegions ? (
|
||||
<div className="text-center py-12">
|
||||
<RefreshCw className="h-8 w-8 mx-auto mb-3 animate-spin dg-text-blue" />
|
||||
<p className="text-[var(--dg-text-secondary)]">正在从云 API 同步区域列表...</p>
|
||||
<p className="text-[var(--dg-text-secondary)]">{t('server.syncingRegionList')}</p>
|
||||
</div>
|
||||
) : regions.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--dg-text-muted)]">
|
||||
<MapPin className="h-10 w-10 mx-auto mb-3 opacity-50" />
|
||||
<p>暂无区域数据</p>
|
||||
<p>{t('server.noRegionData')}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -436,7 +438,7 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
||||
onClick={() => selectedAccount && syncRegions(selectedAccount.id)}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
立即同步
|
||||
{t('server.syncNow')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -446,14 +448,14 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
||||
<div className="relative flex-1">
|
||||
<Search className="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)]" />
|
||||
<Input
|
||||
placeholder="搜索区域名称或编码..."
|
||||
placeholder="{t('server.searchRegion')}"
|
||||
value={regionSearch}
|
||||
onChange={(e) => setRegionSearch(e.target.value)}
|
||||
className="pl-9 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleSelectAllRegions} className="border-[var(--dg-border)]">
|
||||
{selectedRegions.length === regions.length && regions.length > 0 ? '取消全选' : '全选'}
|
||||
{selectedRegions.length === regions.length && regions.length > 0 ? t('common.deselectAll') : t('common.selectAll')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -468,8 +470,8 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
||||
onCheckedChange={handleSelectAllRegions}
|
||||
/>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">区域编码</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">区域名称</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('server.regionCode')}</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('server.regionName')}</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">Endpoint</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -510,14 +512,14 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
||||
/* 导入结果 */
|
||||
<div className="text-center py-12">
|
||||
<CheckCircle className="h-12 w-12 mx-auto mb-3 dg-text-green" />
|
||||
<p className="text-lg font-medium text-[var(--dg-text-primary)]">导入完成</p>
|
||||
<p className="text-lg font-medium text-[var(--dg-text-primary)]">{t('server.importComplete')}</p>
|
||||
<div className="flex items-center justify-center gap-6 mt-4">
|
||||
<Badge className="bg-emerald-500/20 dg-text-green border-0 text-base px-4 py-1">
|
||||
成功导入 {importResult.imported} 台
|
||||
{t('server.importedCount', { count: importResult.imported })}
|
||||
</Badge>
|
||||
{importResult.skipped > 0 && (
|
||||
<Badge className="bg-amber-500/20 dg-text-yellow border-0 text-base px-4 py-1">
|
||||
跳过 {importResult.skipped} 台(已存在)
|
||||
{t('server.skippedCount', { count: importResult.skipped })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -526,19 +528,19 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[var(--dg-text-secondary)] text-sm">
|
||||
已选 {selectedRegions.length} 个区域
|
||||
{t('server.selectedRegionsCount', { count: selectedRegions.length })}
|
||||
{cloudServers.length > 0 && (
|
||||
<span className="ml-2">· 发现 {cloudServers.length} 台主机</span>
|
||||
<span className="ml-2">· {t('server.discoveredHosts', { count: cloudServers.length })}</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{cloudServers.length > 0 && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={handleSelectAllServers} className="border-[var(--dg-border)]">
|
||||
{selectedServers.length === cloudServers.length && cloudServers.length > 0 ? '取消全选' : '全选'}
|
||||
{selectedServers.length === cloudServers.length && cloudServers.length > 0 ? t('common.deselectAll') : t('common.selectAll')}
|
||||
</Button>
|
||||
<Badge className="bg-blue-500/20 dg-text-blue border-0">
|
||||
已选 {selectedServers.length} 台
|
||||
{t('server.selectedServersCount', { count: selectedServers.length })}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
@@ -550,7 +552,7 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
||||
className="border-[var(--dg-border)] dg-text-blue"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 mr-1 ${isDiscovering ? 'animate-spin' : ''}`} />
|
||||
{isDiscovering ? '发现中...' : cloudServers.length > 0 ? '重新发现' : '发现主机'}
|
||||
{isDiscovering ? t('server.discovering') : cloudServers.length > 0 ? t('server.rediscover') : t('server.discoverHosts')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -573,7 +575,7 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 dg-text-yellow text-sm">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">部分区域发现失败:</p>
|
||||
<p className="font-medium">{t('server.partialRegionFailed')}</p>
|
||||
{discoverErrors.map((err, i) => (
|
||||
<p key={i} className="text-xs mt-1 opacity-80">{err}</p>
|
||||
))}
|
||||
@@ -585,13 +587,13 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
||||
{isDiscovering ? (
|
||||
<div className="text-center py-12">
|
||||
<RefreshCw className="h-8 w-8 mx-auto mb-3 animate-spin dg-text-blue" />
|
||||
<p className="text-[var(--dg-text-secondary)]">正在发现云主机,请稍候...</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-1">正在逐区域查询,可能需要数秒</p>
|
||||
<p className="text-[var(--dg-text-secondary)]">{t('server.discoveringCloudServers')}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-1">{t('server.scanningRegions')}</p>
|
||||
</div>
|
||||
) : cloudServers.length === 0 && !isDiscovering ? (
|
||||
<div className="text-center py-8 text-[var(--dg-text-muted)]">
|
||||
<Server className="h-10 w-10 mx-auto mb-3 opacity-50" />
|
||||
<p>点击"发现主机"按钮,开始扫描所选区域</p>
|
||||
<p>{t('server.clickDiscoverHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg border-[var(--dg-border)] overflow-hidden max-h-[300px] overflow-y-auto">
|
||||
@@ -604,8 +606,8 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
||||
onCheckedChange={handleSelectAllServers}
|
||||
/>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">主机名</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">IP地址</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('server.excelHostname')}</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('server.excelIpAddress')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--dg-border)]">
|
||||
@@ -641,19 +643,19 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
||||
{step > 1 && !importResult && (
|
||||
<Button variant="outline" onClick={handleBack} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
上一步
|
||||
{t('common.previous')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{importResult ? (
|
||||
<Button onClick={handleClose} className="bg-blue-600 hover:bg-blue-700">
|
||||
完成
|
||||
{t('common.finish')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleClose} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||||
取消
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
{step < 4 ? (
|
||||
<Button
|
||||
@@ -665,7 +667,7 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
||||
}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
下一步
|
||||
{t('common.next')}
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
) : (
|
||||
@@ -677,12 +679,12 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
||||
{isImporting ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-1 animate-spin" />
|
||||
导入中...
|
||||
{t('server.importing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
确认导入 ({selectedServers.length})
|
||||
{t('server.confirmImport', { count: selectedServers.length })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ChevronLeft,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { regionApi, serverApi, credentialApi } from '@/api';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { ImportCredentialSettings } from './ImportCredentialSettings';
|
||||
@@ -110,6 +111,7 @@ function downloadTemplate() {
|
||||
}
|
||||
|
||||
export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState(1);
|
||||
const [selectedRegion, setSelectedRegion] = useState<string>('');
|
||||
const [, _setConnectionMethod] = useState<string>('password');
|
||||
@@ -163,7 +165,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
const text = await selectedFile.text();
|
||||
const rows = parseCSV(text);
|
||||
if (rows.length === 0) {
|
||||
toast.error('文件无有效数据,请使用模板格式(首行为表头)');
|
||||
toast.error(t('server.fileNoData'));
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
@@ -188,14 +190,14 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
disk: Number(r.disk) || 0,
|
||||
tags: r.tags ?? '',
|
||||
status: 'valid',
|
||||
message: '有效',
|
||||
message: t('server.valid'),
|
||||
};
|
||||
const errs: string[] = [];
|
||||
if (!hostname) errs.push('主机名缺失');
|
||||
if (!ip) errs.push('IP缺失');
|
||||
else if (!isValidIP(ip)) errs.push('IP格式错误');
|
||||
if (!hostname) errs.push(t('server.excelHostname') + ' missing');
|
||||
if (!ip) errs.push(t('server.excelIpAddress') + ' missing');
|
||||
else if (!isValidIP(ip)) errs.push(t('server.excelIpAddress') + ' format error');
|
||||
const key = `${hostname}|${ip}`;
|
||||
if (hostname && ip && seen.has(key)) errs.push('重复行');
|
||||
if (hostname && ip && seen.has(key)) errs.push('Duplicate row');
|
||||
else seen.add(key);
|
||||
if (errs.length > 0) {
|
||||
row.status = 'error';
|
||||
@@ -206,7 +208,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
setPreviewData(preview);
|
||||
setStep(step + 1);
|
||||
} catch {
|
||||
toast.error('文件解析失败,请确认是 CSV 格式');
|
||||
toast.error(t('server.fileParseError'));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
@@ -267,11 +269,11 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
const success = r.success ?? 0;
|
||||
const skipped = r.skipped ?? 0;
|
||||
const failed = r.failed ?? 0;
|
||||
toast.success(`导入完成:成功 ${success},跳过 ${skipped},失败 ${failed}`);
|
||||
toast.success(t('server.csvImportComplete', { success, skipped, failed }));
|
||||
resetState();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
toast.error(e?.message || '导入失败,请重试');
|
||||
toast.error(e?.message || t('server.importFailed'));
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
@@ -287,17 +289,17 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
const errorCount = previewData.filter(d => d.status === 'error').length;
|
||||
|
||||
const steps = [
|
||||
{ id: 1, title: '配置' },
|
||||
{ id: 2, title: '上传' },
|
||||
{ id: 3, title: '确认' },
|
||||
{ id: 1, title: t('server.excelConfig') },
|
||||
{ id: 2, title: t('server.excelUpload') },
|
||||
{ id: 3, title: t('common.confirm') },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-3xl bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">CSV 批量导入服务器</DialogTitle>
|
||||
<DialogDescription className="sr-only">通过 CSV 文件批量导入服务器资产</DialogDescription>
|
||||
<DialogTitle className="text-xl">{t('server.csvBatchImport')}</DialogTitle>
|
||||
<DialogDescription className="sr-only">{t('server.csvBatchImportDesc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Step Indicator */}
|
||||
@@ -335,13 +337,13 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
<FileSpreadsheet className="h-5 w-5 dg-text-green" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-[var(--dg-text-primary)]">主机导入模板</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">下载 CSV 模板,Linux 默认 SSH,Windows 默认 RDP</p>
|
||||
<p className="font-medium text-[var(--dg-text-primary)]">{t('server.importTemplate')}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">{t('server.importTemplateDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={downloadTemplate}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
下载模板
|
||||
{t('server.downloadTemplate')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -349,13 +351,13 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
|
||||
{/* Region Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-2">选择区域</label>
|
||||
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-2">{t('server.selectRegion')}</label>
|
||||
<select
|
||||
value={selectedRegion}
|
||||
onChange={(e) => setSelectedRegion(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm"
|
||||
>
|
||||
<option value="">请选择区域</option>
|
||||
<option value="">{t('server.selectRegionPlaceholder')}</option>
|
||||
{regions.map((region) => (
|
||||
<option key={region.id} value={region.id}>
|
||||
{region.name} ({region.code})
|
||||
@@ -384,8 +386,8 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
className="border-2 border-dashed border-[var(--dg-border)] rounded-lg p-8 text-center hover:border-blue-500/50 transition-colors"
|
||||
>
|
||||
<Upload className="h-12 w-12 mx-auto mb-4 text-[var(--dg-text-muted)]" />
|
||||
<p className="text-[var(--dg-text-secondary)] mb-2">点击或拖拽文件到此处上传</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-4">支持 CSV 格式文件(可点击左侧"下载模板"获取)</p>
|
||||
<p className="text-[var(--dg-text-secondary)] mb-2">{t('server.uploadHint')}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-4">{t('server.uploadFormatHint')}</p>
|
||||
<Input
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
@@ -395,7 +397,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
/>
|
||||
<label htmlFor="excel-upload">
|
||||
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" asChild>
|
||||
<span>选择文件</span>
|
||||
<span>{t('server.selectFile')}</span>
|
||||
</Button>
|
||||
</label>
|
||||
</div>
|
||||
@@ -425,7 +427,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
{isUploading && (
|
||||
<div className="text-center py-4">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||||
<p className="text-[var(--dg-text-secondary)] text-sm">正在解析文件...</p>
|
||||
<p className="text-[var(--dg-text-secondary)] text-sm">{t('server.parsingFile')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -435,22 +437,22 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[var(--dg-text-secondary)] text-sm">数据预览</p>
|
||||
<p className="text-[var(--dg-text-secondary)] text-sm">{t('server.dataPreview')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-emerald-500/20 dg-text-green border-0">
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
{validCount} 有效
|
||||
{validCount} {t('server.valid')}
|
||||
</Badge>
|
||||
{warningCount > 0 && (
|
||||
<Badge className="bg-amber-500/20 dg-text-yellow border-0">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
{warningCount} 警告
|
||||
{warningCount} {t('server.warning')}
|
||||
</Badge>
|
||||
)}
|
||||
{errorCount > 0 && (
|
||||
<Badge className="bg-red-500/20 dg-text-red border-0">
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
{errorCount} 错误
|
||||
{errorCount} {t('server.error')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -460,9 +462,9 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
<table className="w-full">
|
||||
<thead className="bg-[var(--dg-bg-secondary)] sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">行号</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">主机名</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">IP地址</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('server.rowNumber')}</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('server.excelHostname')}</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('server.excelIpAddress')}</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">类型</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">状态</th>
|
||||
</tr>
|
||||
@@ -506,7 +508,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30">
|
||||
<div className="flex items-center gap-2 dg-text-red">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="text-sm">存在 {errorCount} 条错误数据,请修正后重新上传</span>
|
||||
<span className="text-sm">{t('server.errorDataHint', { count: errorCount })}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -519,13 +521,13 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
{step > 1 && (
|
||||
<Button variant="outline" onClick={handleBack} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
上一步
|
||||
{t('common.previous')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleClose} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||||
取消
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
{step < 3 ? (
|
||||
<Button
|
||||
@@ -533,7 +535,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
disabled={(step === 1 && !selectedRegion) || (step === 2 && !selectedFile)}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
下一步
|
||||
{t('common.next')}
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
) : (
|
||||
@@ -547,7 +549,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
) : (
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{isImporting ? '导入中...' : `确认导入 (${validCount})`}
|
||||
{isImporting ? t('server.importing') : t('server.confirmImport', { count: validCount })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { readFromClipboard } from '@/utils/clipboard';
|
||||
import { rdpFilesApi, type RdpFileItem } from '@/api/server/rdp-files';
|
||||
@@ -29,6 +30,7 @@ export function RdpToolsPanel({
|
||||
onSyncToRemote,
|
||||
onAutoExec,
|
||||
}: RdpToolsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<ToolsTab>('general');
|
||||
const [localClipDraft, setLocalClipDraft] = useState('');
|
||||
const [files, setFiles] = useState<RdpFileItem[]>([]);
|
||||
@@ -43,7 +45,7 @@ export function RdpToolsPanel({
|
||||
setFiles(data?.list ?? []);
|
||||
} catch (err) {
|
||||
setFiles([]);
|
||||
toast.error(err instanceof Error ? err.message : '无法列出远端文件(SMB 445 可能未开放)');
|
||||
toast.error(err instanceof Error ? err.message : t('server.listFileError'));
|
||||
} finally {
|
||||
setLoadingFiles(false);
|
||||
}
|
||||
@@ -59,17 +61,17 @@ export function RdpToolsPanel({
|
||||
setLocalClipDraft(text);
|
||||
return;
|
||||
}
|
||||
toast.info('无法自动读取剪贴板,请 Ctrl+V 粘贴(HTTPS 下可自动读取)');
|
||||
toast.info(t('server.clipboardHint'));
|
||||
};
|
||||
|
||||
const syncLocalClip = () => {
|
||||
const trimmed = localClipDraft.trim();
|
||||
if (!trimmed) {
|
||||
toast.info('内容为空');
|
||||
toast.info(t('server.emptyContent'));
|
||||
return;
|
||||
}
|
||||
onSyncToRemote(trimmed);
|
||||
toast.success('已同步到远端');
|
||||
toast.success(t('server.syncedToRemote'));
|
||||
};
|
||||
|
||||
const uploadWithFallback = async (file: File) => {
|
||||
@@ -77,7 +79,7 @@ export function RdpToolsPanel({
|
||||
try {
|
||||
try {
|
||||
await rdpFilesApi.upload(serverId, file);
|
||||
toast.success(`${file.name} 已上传到 Download`);
|
||||
toast.success(t('server.uploadedToDownload', { name: file.name }));
|
||||
void refreshFiles();
|
||||
return;
|
||||
} catch {
|
||||
@@ -85,9 +87,9 @@ export function RdpToolsPanel({
|
||||
}
|
||||
const staged = await rdpFilesApi.stage(serverId, file);
|
||||
onAutoExec(staged.deploy_command);
|
||||
toast.success(`${file.name} 已自动下发(SMB 不可用时的回退模式)`);
|
||||
toast.success(t('server.autoSent', { name: file.name }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '上传失败');
|
||||
toast.error(err instanceof Error ? err.message : t('server.rdpUploadFailed'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
@@ -102,9 +104,9 @@ export function RdpToolsPanel({
|
||||
const handleDownload = async (name: string) => {
|
||||
try {
|
||||
await rdpFilesApi.download(serverId, name);
|
||||
toast.success(`已开始下载 ${name}`);
|
||||
toast.success(t('server.downloadStarted', { name }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '下载失败');
|
||||
toast.error(err instanceof Error ? err.message : t('server.rdpDownloadFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -122,7 +124,7 @@ export function RdpToolsPanel({
|
||||
)}
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
通用
|
||||
{t('server.general')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -135,24 +137,24 @@ export function RdpToolsPanel({
|
||||
)}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
文件
|
||||
{t('server.rdpFiles')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'general' && (
|
||||
<div className="p-3 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-[var(--dg-text-muted)]">远端剪贴板</label>
|
||||
<label className="text-xs text-[var(--dg-text-muted)]">{t('server.remoteClipboard')}</label>
|
||||
<textarea
|
||||
readOnly
|
||||
value={remoteClipboard}
|
||||
rows={3}
|
||||
placeholder="远端复制内容将显示在此"
|
||||
placeholder={t('server.remoteClipPlaceholder')}
|
||||
className="w-full resize-none rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-2 py-1.5 text-xs text-[var(--dg-text-secondary)] outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-[var(--dg-text-muted)]">同步到远端</label>
|
||||
<label className="text-xs text-[var(--dg-text-muted)]">{t('server.syncToRemote')}</label>
|
||||
<textarea
|
||||
value={localClipDraft}
|
||||
onChange={(e) => setLocalClipDraft(e.target.value)}
|
||||
@@ -162,11 +164,11 @@ export function RdpToolsPanel({
|
||||
if (pasted) setLocalClipDraft(pasted);
|
||||
}}
|
||||
rows={3}
|
||||
placeholder="点击输入框自动粘贴本地剪贴板"
|
||||
placeholder={t('server.syncPlaceholder')}
|
||||
className="w-full resize-none rounded border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-2 py-1.5 text-xs text-[var(--dg-text-primary)] outline-none"
|
||||
/>
|
||||
<Button type="button" size="sm" className="w-full" onClick={syncLocalClip}>
|
||||
同步到远端
|
||||
{t('server.syncButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,12 +196,12 @@ export function RdpToolsPanel({
|
||||
/>
|
||||
<Button type="button" size="sm" className="w-full" disabled={uploading} onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload className="h-3.5 w-3.5 mr-1.5" />
|
||||
{uploading ? '上传中...' : '上传文件'}
|
||||
{uploading ? t('server.rdpUploading') : t('server.uploadFile')}
|
||||
</Button>
|
||||
<div className="max-h-40 overflow-y-auto rounded border border-[var(--dg-border)]">
|
||||
{files.length === 0 ? (
|
||||
<p className="px-2 py-3 text-[10px] text-[var(--dg-text-muted)] text-center">
|
||||
{loadingFiles ? '加载中...' : '暂无文件(需 SMB 445 可达才能列出)'}
|
||||
{loadingFiles ? t('server.rdpLoading') : t('server.noRdpFiles')}
|
||||
</p>
|
||||
) : (
|
||||
files.map((f) => (
|
||||
@@ -216,7 +218,7 @@ export function RdpToolsPanel({
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] leading-relaxed">
|
||||
优先 SMB 直传(一键);若 445 不通则自动暂存到 AIOps 并由 RDP 执行下载命令。
|
||||
{t('server.smbHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -107,6 +108,7 @@ export function ServerForm({
|
||||
onSave,
|
||||
formSaving,
|
||||
}: ServerFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
|
||||
@@ -171,10 +173,10 @@ export function ServerForm({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-[var(--dg-blue)]" />
|
||||
{editingId ? '编辑服务器' : '添加服务器'}
|
||||
{editingId ? t('server.editServer') : t('server.addServer')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">
|
||||
{editingId ? '修改服务器资产信息' : '录入新的服务器资产到 CMDB'}
|
||||
{editingId ? t('server.editServerDesc') : t('server.addServerDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -183,7 +185,7 @@ export function ServerForm({
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">
|
||||
主机名 <span className="dg-text-red">*</span>
|
||||
{t('server.formHostname')} <span className="dg-text-red">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="web-server-01"
|
||||
@@ -194,7 +196,7 @@ export function ServerForm({
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">
|
||||
IP地址 <span className="dg-text-red">*</span>
|
||||
{t('server.formIpAddress')} <span className="dg-text-red">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="10.0.0.1"
|
||||
@@ -208,7 +210,7 @@ export function ServerForm({
|
||||
{/* 连接协议 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">系统类型</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('server.osType')}</Label>
|
||||
<Select
|
||||
value={formData.os_type}
|
||||
onValueChange={(v) => setFormData({ ...formData, os_type: v, protocol: v === 'windows' ? 'rdp' : 'ssh' })}
|
||||
@@ -223,7 +225,7 @@ export function ServerForm({
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">连接协议</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('server.connectionProtocol')}</Label>
|
||||
<Select value={formData.protocol} onValueChange={handleProtocolChange}>
|
||||
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<SelectValue />
|
||||
@@ -235,7 +237,7 @@ export function ServerForm({
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">{formData.protocol === 'rdp' ? 'RDP端口' : 'SSH端口'}</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{formData.protocol === 'rdp' ? t('server.rdpPort') : t('server.sshPort')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={formData.protocol === 'rdp' ? '3389' : '22'}
|
||||
@@ -251,16 +253,16 @@ export function ServerForm({
|
||||
{/* 连接配置 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">公网IP</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('server.publicIp')}</Label>
|
||||
<Input
|
||||
placeholder="如 203.0.113.10"
|
||||
placeholder={t('server.publicIpPlaceholder')}
|
||||
value={formData.public_ip}
|
||||
onChange={(e) => setFormData({ ...formData, public_ip: e.target.value })}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">登录用户</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('server.loginUser')}</Label>
|
||||
<Input
|
||||
placeholder={formData.protocol === 'rdp' ? 'Administrator' : 'root'}
|
||||
value={formData.username}
|
||||
@@ -273,7 +275,7 @@ export function ServerForm({
|
||||
{/* 凭证关联 — SSH/RDP 按协议过滤 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)] flex items-center gap-1.5">
|
||||
<Key className="h-3.5 w-3.5" /> {formData.protocol === 'rdp' ? 'RDP 凭证' : 'SSH 凭证'}
|
||||
<Key className="h-3.5 w-3.5" /> {formData.protocol === 'rdp' ? t('server.rdpCredential') : t('server.sshCredential')}
|
||||
{!editingId && !formData.credential_id && <span className="dg-text-red ml-1">*</span>}
|
||||
</Label>
|
||||
<Select
|
||||
@@ -281,7 +283,7 @@ export function ServerForm({
|
||||
onValueChange={handleCredentialSelect}
|
||||
>
|
||||
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<SelectValue placeholder={formData.protocol === 'rdp' ? '选择 Windows RDP 密码...' : '选择 SSH 凭证...'} />
|
||||
<SelectValue placeholder={formData.protocol === 'rdp' ? t('server.selectWindowsRdp') : t('server.selectSshCred')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
{selectableCredentials.map((cred) => (
|
||||
@@ -297,7 +299,7 @@ export function ServerForm({
|
||||
|
||||
{selectableCredentials.length === 0 && (
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">
|
||||
暂无可用的 {formData.protocol === 'rdp' ? 'Windows RDP 密码' : 'SSH 凭证'}。
|
||||
{formData.protocol === 'rdp' ? t('server.noAvailableRdp') : t('server.noAvailableSsh')}。
|
||||
<span
|
||||
className="text-[var(--dg-blue)] hover:underline cursor-pointer ml-1"
|
||||
onClick={() => {
|
||||
@@ -305,13 +307,13 @@ export function ServerForm({
|
||||
navigate('/credentials');
|
||||
}}
|
||||
>
|
||||
去系统管理 - 凭证配置创建
|
||||
{t('server.goToCredential')}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{formData.credential_id && (
|
||||
<p className="text-[10px] dg-text-green/70">已关联凭证,登录信息从凭证自动获取(登录用户和认证方式由凭证决定)</p>
|
||||
<p className="text-[10px] dg-text-green/70">{t('server.credentialLinked')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -325,7 +327,7 @@ export function ServerForm({
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
更多详情(OS版本 / 运行状态 / CPU / 内存 / 磁盘 / 标签 / 描述)
|
||||
{t('server.moreDetails')}
|
||||
</span>
|
||||
<ChevronDown className={cn('h-4 w-4 transition-transform', detailsOpen && 'rotate-180')} />
|
||||
</Button>
|
||||
@@ -333,16 +335,16 @@ export function ServerForm({
|
||||
<CollapsibleContent className="space-y-4 pt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">操作系统</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('server.os')}</Label>
|
||||
<Input
|
||||
placeholder="如 CentOS 7.9"
|
||||
placeholder={t('server.osPlaceholder')}
|
||||
value={formData.os}
|
||||
onChange={(e) => setFormData({ ...formData, os: e.target.value })}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">运行状态</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('server.runningStatus')}</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(v) => setFormData({ ...formData, status: v })}
|
||||
@@ -351,17 +353,17 @@ export function ServerForm({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<SelectItem value="running">运行中</SelectItem>
|
||||
<SelectItem value="stopped">已停止</SelectItem>
|
||||
<SelectItem value="maintenance">维护中</SelectItem>
|
||||
<SelectItem value="offline">离线</SelectItem>
|
||||
<SelectItem value="running">{t('server.formRunning')}</SelectItem>
|
||||
<SelectItem value="stopped">{t('server.formStopped')}</SelectItem>
|
||||
<SelectItem value="maintenance">{t('server.formMaintenance')}</SelectItem>
|
||||
<SelectItem value="offline">{t('server.formOffline')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">CPU核数</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('server.cpuCores')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="4"
|
||||
@@ -371,7 +373,7 @@ export function ServerForm({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">内存 (GB)</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('server.formMemory')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="8"
|
||||
@@ -381,7 +383,7 @@ export function ServerForm({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">磁盘 (GB)</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('server.formDisk')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="100"
|
||||
@@ -392,7 +394,7 @@ export function ServerForm({
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">标签</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('server.formTags')}</Label>
|
||||
<Input
|
||||
placeholder='JSON 格式,如 {"project":"web","team":"ops"}'
|
||||
value={formData.tags}
|
||||
@@ -401,9 +403,9 @@ export function ServerForm({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">描述</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('server.formDescription')}</Label>
|
||||
<Textarea
|
||||
placeholder="补充说明..."
|
||||
placeholder={t('server.descPlaceholder')}
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] min-h-[60px]"
|
||||
@@ -419,7 +421,7 @@ export function ServerForm({
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]"
|
||||
>
|
||||
取消
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSave}
|
||||
@@ -431,7 +433,7 @@ export function ServerForm({
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{editingId ? '保存修改' : '创建'}
|
||||
{editingId ? t('server.saveEdit') : t('server.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -40,6 +41,7 @@ export function TerminalDialog({
|
||||
serverIp,
|
||||
hasAuth = true,
|
||||
}: TerminalDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
@@ -66,10 +68,10 @@ export function TerminalDialog({
|
||||
try {
|
||||
setAssigning(true);
|
||||
await serverCredentialApi.assign(serverId, Number(selectedCredId));
|
||||
toast.success('凭据已关联');
|
||||
toast.success(t('common.operationSuccess'));
|
||||
setAuthReady(true);
|
||||
} catch (e) {
|
||||
toast.error(e?.message || '关联失败');
|
||||
toast.error(e?.message || t('common.operationFailed'));
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
@@ -141,12 +143,12 @@ export function TerminalDialog({
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
setHasOutput(true);
|
||||
term.write('\r\n\x1b[31m[连接已关闭]\x1b[0m\r\n');
|
||||
term.write(`\r\n\x1b[31m[${t('server.connectionClosed')}][0m\r\n`);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setHasOutput(true);
|
||||
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
|
||||
term.write(`\r\n\x1b[31m[${t('server.connectionError')}][0m\r\n`);
|
||||
};
|
||||
|
||||
return () => {
|
||||
@@ -233,18 +235,18 @@ export function TerminalDialog({
|
||||
<div className="p-6 flex flex-col items-center justify-center gap-4" style={{ height: '480px' }}>
|
||||
<Shield className="h-12 w-12 dg-text-yellow" />
|
||||
<div className="text-center">
|
||||
<p className="text-[var(--dg-text-primary)] font-medium mb-1">服务器未配置认证凭据</p>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">请选择一个凭据用于 SSH 连接</p>
|
||||
<p className="text-[var(--dg-text-primary)] font-medium mb-1">{t('server.noAuthCred')}</p>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">{t('server.selectCredHint')}</p>
|
||||
</div>
|
||||
{credentials.length === 0 ? (
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">暂无可用凭据,请先去「凭证配置」添加</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">{t('server.noCredAvailable')}</p>
|
||||
) : (
|
||||
<select
|
||||
value={selectedCredId}
|
||||
onChange={(e) => setSelectedCredId(e.target.value)}
|
||||
className="w-64 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm"
|
||||
>
|
||||
<option value="">-- 选择凭据 --</option>
|
||||
<option value="">{t('server.selectCred')}</option>
|
||||
{credentials.map((c: JsonData) => (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{c.type === 'ssh_key' ? '🔑' : '🔒'} {c.name}
|
||||
@@ -257,7 +259,7 @@ export function TerminalDialog({
|
||||
disabled={!selectedCredId || assigning}
|
||||
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
|
||||
>
|
||||
{assigning ? '关联中...' : '确认连接'}
|
||||
{assigning ? t('server.linking') : t('server.confirmConnect')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -265,7 +267,7 @@ export function TerminalDialog({
|
||||
{authReady && (
|
||||
<div className="relative bg-[#0a0e14]" style={{ height: fullscreen ? 'calc(100vh - 49px)' : '520px' }}>
|
||||
<div ref={containerRef} className="xterm-terminal-host h-full" />
|
||||
<TerminalLoadingOverlay show={!hasOutput} label="正在登录..." />
|
||||
<TerminalLoadingOverlay show={!hasOutput} label={t('server.terminalLoggingIn')} />
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -53,6 +54,7 @@ interface TermEntry {
|
||||
}
|
||||
|
||||
export function WebShell() {
|
||||
const { t } = useTranslation();
|
||||
const [servers, setServers] = useState<JsonData[]>([]);
|
||||
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
||||
const [activeSession, setActiveSession] = useState<string | null>(null);
|
||||
@@ -70,7 +72,7 @@ export function WebShell() {
|
||||
const res: JsonData = await serverApi.getServers();
|
||||
setServers(res?.list || []);
|
||||
} catch (e) {
|
||||
console.error('加载服务器列表失败:', e);
|
||||
console.error('loadServers error:', e);
|
||||
setServers([]);
|
||||
}
|
||||
};
|
||||
@@ -150,11 +152,11 @@ export function WebShell() {
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
term.write(`\x1b[32m正在连接 ${server.hostname} (${server.ip})...\x1b[0m\r\n`);
|
||||
term.write(`\x1b[32m${t('server.connecting', { name: server.hostname, ip: server.ip })}\x1b[0m\r\n`);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
term.write('\r\n\x1b[31m[连接已关闭]\x1b[0m\r\n');
|
||||
term.write(`\r\n\x1b[31m[${t('server.connectionClosed')}][0m\r\n`);
|
||||
setSessions((prev) =>
|
||||
prev.map((s) => (s.id === sessionId ? { ...s, connected: false, ready: true } : s))
|
||||
);
|
||||
@@ -162,7 +164,7 @@ export function WebShell() {
|
||||
|
||||
ws.onerror = () => {
|
||||
setSessions((prev) => prev.map((s) => (s.id === sessionId ? { ...s, ready: true } : s)));
|
||||
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
|
||||
term.write(`\r\n\x1b[31m[${t('server.connectionError')}][0m\r\n`);
|
||||
};
|
||||
|
||||
termsRef.current.set(sessionId, { term, ws, fit, opened: false });
|
||||
@@ -256,7 +258,7 @@ export function WebShell() {
|
||||
entry.opened = true;
|
||||
entry.resizeCleanup = attachTerminalResize(el, entry.fit, entry.term, entry.ws);
|
||||
} catch (e) {
|
||||
console.error('打开终端失败:', e);
|
||||
console.error('openTerminal error:', e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -276,33 +278,33 @@ export function WebShell() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">WebShell</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">远程服务器终端管理</p>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">{t('server.remoteTerminal')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-[var(--dg-blue)] hover:opacity-90 text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建连接
|
||||
{t('server.newConnection')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">新建终端连接</DialogTitle>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">{t('server.newTerminal')}</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-secondary)]">
|
||||
选择要连接的服务器
|
||||
{t('server.selectServer')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--dg-text-secondary)]">选择服务器</label>
|
||||
<label className="text-sm font-medium text-[var(--dg-text-secondary)]">{t('server.selectServer')}</label>
|
||||
<Select value={selectedServer} onValueChange={setSelectedServer}>
|
||||
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<SelectValue placeholder="选择服务器" />
|
||||
<SelectValue placeholder={t('server.selectServer')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
{runningServers.length === 0 ? (
|
||||
<SelectItem value="__none" disabled>暂无运行中的服务器</SelectItem>
|
||||
<SelectItem value="__none" disabled>{t('server.noRunningServers')}</SelectItem>
|
||||
) : (
|
||||
runningServers.map((server) => (
|
||||
<SelectItem key={server.id} value={server.id}>
|
||||
@@ -325,7 +327,7 @@ export function WebShell() {
|
||||
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
|
||||
>
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
连接
|
||||
{t('server.webshellConnect')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -372,7 +374,7 @@ export function WebShell() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-[var(--dg-text-secondary)]">暂无连接</span>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)]">{t('server.noConnections')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -410,7 +412,7 @@ export function WebShell() {
|
||||
<TerminalLoadingOverlay
|
||||
key={`${session.id}-loading`}
|
||||
show={activeSession === session.id && !session.ready}
|
||||
label="正在登录..."
|
||||
label={t('server.webshellLoggingIn')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -420,8 +422,8 @@ export function WebShell() {
|
||||
style={{ height: terminalHeight }}
|
||||
>
|
||||
<Terminal className="h-16 w-16 mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium">暂无活动连接</p>
|
||||
<p className="text-sm mt-1">点击“新建连接”开始远程会话</p>
|
||||
<p className="text-lg font-medium">{t('server.noActiveConnections')}</p>
|
||||
<p className="text-sm mt-1">{t('server.newConnectionHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -431,7 +433,7 @@ export function WebShell() {
|
||||
{activeSessionData && (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-[var(--dg-text-secondary)]">快捷命令</CardTitle>
|
||||
<CardTitle className="text-sm text-[var(--dg-text-secondary)]">{t('server.quickCommands')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
Reference in new issue
Block a user