使用 i18n.t() 全局方式(避免 hook 定位问题): - 288 目标文件中 135 个成功转换 - 2228 处中文替换为 i18n.t() 调用 - 1006 个新翻译 key 添加到 zh-CN.json + en-US.json - 词典总量: 3554 keys (22 namespaces) - tsc 零错误 + Vite 构建成功 - 6 个含代码示例/数据字符串的文件安全回退
374 lines
15 KiB
TypeScript
374 lines
15 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { Search, Server, Network, Loader2, Check } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { serverApi, kubernetesApi } from '@/api';
|
|
import { getPublicApiBase } from '@/utils/request';
|
|
import i18n from '@/i18n';
|
|
|
|
export interface DeployTargetValue {
|
|
deployTarget: 'ssh' | 'k8s';
|
|
serverIds: number[];
|
|
deployRemotePath: string;
|
|
deployScript: string;
|
|
k8sClusterId: number | '';
|
|
k8sNamespace: string;
|
|
k8sDeployment: string;
|
|
k8sContainerName: string;
|
|
}
|
|
|
|
interface DeployTargetFieldsProps {
|
|
value: DeployTargetValue;
|
|
onChange: (patch: Partial<DeployTargetValue>) => void;
|
|
requireTarget?: boolean;
|
|
}
|
|
|
|
interface ServerItem {
|
|
id: number;
|
|
hostname: string;
|
|
ip?: string;
|
|
status?: string;
|
|
}
|
|
|
|
interface ClusterItem {
|
|
id: number;
|
|
name: string;
|
|
code: string;
|
|
}
|
|
|
|
interface DeploymentItem {
|
|
namespace: string;
|
|
name: string;
|
|
image?: string;
|
|
ready_replicas?: number;
|
|
replicas?: number;
|
|
}
|
|
|
|
// 发布目标选择:SSH 关联主机 / K8s 关联 Deployment // Logan.Li
|
|
export function DeployTargetFields({ value, onChange, requireTarget = true }: DeployTargetFieldsProps) {
|
|
const [servers, setServers] = useState<ServerItem[]>([]);
|
|
const [clusters, setClusters] = useState<ClusterItem[]>([]);
|
|
const [deployments, setDeployments] = useState<DeploymentItem[]>([]);
|
|
const [serverSearch, setServerSearch] = useState('');
|
|
const [depSearch, setDepSearch] = useState('');
|
|
const [loadingServers, setLoadingServers] = useState(false);
|
|
const [loadingDeps, setLoadingDeps] = useState(false);
|
|
|
|
const loadServers = useCallback(async () => {
|
|
setLoadingServers(true);
|
|
try {
|
|
const res = await serverApi.getServers({ page_size: 200 });
|
|
const list = (res?.list ?? []) as ServerItem[];
|
|
setServers(list);
|
|
} catch {
|
|
setServers([]);
|
|
} finally {
|
|
setLoadingServers(false);
|
|
}
|
|
}, []);
|
|
|
|
const loadClusters = useCallback(async () => {
|
|
try {
|
|
const res = await kubernetesApi.getClusters();
|
|
setClusters((res?.list ?? []) as ClusterItem[]);
|
|
} catch {
|
|
setClusters([]);
|
|
}
|
|
}, []);
|
|
|
|
const loadDeployments = useCallback(async (clusterId: number | '') => {
|
|
if (!clusterId) {
|
|
setDeployments([]);
|
|
return;
|
|
}
|
|
const cluster = clusters.find((c) => c.id === clusterId);
|
|
if (!cluster?.code) {
|
|
setDeployments([]);
|
|
return;
|
|
}
|
|
setLoadingDeps(true);
|
|
try {
|
|
const res = await kubernetesApi.getAllDeployments(cluster.code);
|
|
const list = Array.isArray(res) ? res : [];
|
|
setDeployments(list as DeploymentItem[]);
|
|
} catch {
|
|
setDeployments([]);
|
|
} finally {
|
|
setLoadingDeps(false);
|
|
}
|
|
}, [clusters]);
|
|
|
|
useEffect(() => {
|
|
loadServers();
|
|
loadClusters();
|
|
}, [loadServers, loadClusters]);
|
|
|
|
useEffect(() => {
|
|
if (value.deployTarget === 'k8s' && value.k8sClusterId) {
|
|
loadDeployments(value.k8sClusterId);
|
|
}
|
|
}, [value.deployTarget, value.k8sClusterId, loadDeployments]);
|
|
|
|
const filteredServers = useMemo(() => {
|
|
const q = serverSearch.trim().toLowerCase();
|
|
if (!q) return servers;
|
|
return servers.filter(
|
|
(s) => s.hostname?.toLowerCase().includes(q) || s.ip?.toLowerCase().includes(q),
|
|
);
|
|
}, [servers, serverSearch]);
|
|
|
|
const filteredDeployments = useMemo(() => {
|
|
const q = depSearch.trim().toLowerCase();
|
|
if (!q) return deployments;
|
|
return deployments.filter(
|
|
(d) => d.name.toLowerCase().includes(q) || d.namespace.toLowerCase().includes(q),
|
|
);
|
|
}, [deployments, depSearch]);
|
|
|
|
const toggleServer = (id: number) => {
|
|
const next = value.serverIds.includes(id)
|
|
? value.serverIds.filter((x) => x !== id)
|
|
: [...value.serverIds, id];
|
|
onChange({ serverIds: next });
|
|
};
|
|
|
|
const selectDeployment = (dep: DeploymentItem) => {
|
|
onChange({
|
|
k8sNamespace: dep.namespace,
|
|
k8sDeployment: dep.name,
|
|
});
|
|
};
|
|
|
|
const selectedCluster = clusters.find((c) => c.id === value.k8sClusterId);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex gap-1 p-1 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
|
|
{([
|
|
{ key: 'ssh' as const, label: i18n.t('pipeline.deploytargetfie.secf81967'), icon: Server },
|
|
{ key: 'k8s' as const, label: i18n.t('pipeline.deploytargetfie.s7ff062d5'), icon: Network },
|
|
]).map((item) => {
|
|
const active = value.deployTarget === item.key;
|
|
const Icon = item.icon;
|
|
return (
|
|
<button
|
|
key={item.key}
|
|
type="button"
|
|
onClick={() => onChange({
|
|
deployTarget: item.key,
|
|
serverIds: item.key === 'ssh' ? value.serverIds : [],
|
|
deployRemotePath: item.key === 'ssh' ? value.deployRemotePath : '',
|
|
deployScript: item.key === 'ssh' ? value.deployScript : '',
|
|
k8sClusterId: item.key === 'k8s' ? value.k8sClusterId : '',
|
|
k8sNamespace: item.key === 'k8s' ? value.k8sNamespace : '',
|
|
k8sDeployment: item.key === 'k8s' ? value.k8sDeployment : '',
|
|
k8sContainerName: item.key === 'k8s' ? value.k8sContainerName : '',
|
|
})}
|
|
className={cn(
|
|
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium transition-colors',
|
|
active
|
|
? 'bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] border border-[var(--dg-border)] shadow-sm'
|
|
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]',
|
|
)}
|
|
>
|
|
<Icon className="h-3.5 w-3.5" />
|
|
{item.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{value.deployTarget === 'ssh' ? (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">
|
|
关联主机
|
|
{requireTarget && <span className="text-[var(--dg-red)] ml-0.5">*</span>}
|
|
</Label>
|
|
{value.serverIds.length > 0 && (
|
|
<Badge variant="outline" className="text-[10px] border-[var(--dg-border)]">
|
|
已选 {value.serverIds.length} 台
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-[var(--dg-text-muted)]" />
|
|
<Input
|
|
value={serverSearch}
|
|
onChange={(e) => setServerSearch(e.target.value)}
|
|
placeholder={i18n.t('pipeline.deploytargetfie.sc7624940')}
|
|
className="pl-8 h-8 text-xs dg-input"
|
|
/>
|
|
</div>
|
|
<ScrollArea className="h-[140px] rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
|
|
{loadingServers ? (
|
|
<div className="flex items-center justify-center py-8 text-xs text-[var(--dg-text-muted)]">
|
|
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />加载中
|
|
</div>
|
|
) : filteredServers.length === 0 ? (
|
|
<p className="text-xs text-[var(--dg-text-muted)] text-center py-8"> {i18n.t('pipeline.deploytargetfie.sb4240171')} </p>
|
|
) : (
|
|
<div className="p-1.5 space-y-1">
|
|
{filteredServers.map((srv) => {
|
|
const selected = value.serverIds.includes(srv.id);
|
|
return (
|
|
<button
|
|
key={srv.id}
|
|
type="button"
|
|
onClick={() => toggleServer(srv.id)}
|
|
className={cn(
|
|
'w-full flex items-center gap-2 px-2.5 py-2 rounded-md text-left text-xs transition-colors',
|
|
selected
|
|
? 'bg-[var(--dg-bg-hover)] border border-[var(--dg-border)]'
|
|
: 'hover:bg-[var(--dg-bg-hover)]/60',
|
|
)}
|
|
>
|
|
<span className={cn(
|
|
'w-4 h-4 rounded border flex items-center justify-center shrink-0',
|
|
selected ? 'bg-[var(--dg-blue)] border-[var(--dg-blue)]' : 'border-[var(--dg-border)]',
|
|
)}>
|
|
{selected && <Check className="h-2.5 w-2.5 text-white" />}
|
|
</span>
|
|
<Server className="h-3.5 w-3.5 text-[var(--dg-text-muted)] shrink-0" />
|
|
<span className="font-medium text-[var(--dg-text-primary)] truncate">{srv.hostname}</span>
|
|
{srv.ip && <span className="text-[var(--dg-text-muted)] ml-auto shrink-0">{srv.ip}</span>}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</ScrollArea>
|
|
<p className="text-[10px] text-[var(--dg-text-muted)]"> {i18n.t('pipeline.deploytargetfie.sb2b6db98')} </p>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs"> {i18n.t('pipeline.deploytargetfie.s5ca05a9b')} </Label>
|
|
<Input
|
|
value={value.deployRemotePath}
|
|
onChange={(e) => onChange({ deployRemotePath: e.target.value })}
|
|
placeholder="/opt/apps/my-app"
|
|
className="h-8 text-xs font-mono dg-input"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs"> {i18n.t('pipeline.deploytargetfie.s65b20ba7')} </Label>
|
|
<Input
|
|
value={value.deployScript}
|
|
onChange={(e) => onChange({ deployScript: e.target.value })}
|
|
placeholder="./restart.sh"
|
|
className="h-8 text-xs font-mono dg-input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">
|
|
Kubernetes 集群
|
|
{requireTarget && <span className="text-[var(--dg-red)] ml-0.5">*</span>}
|
|
</Label>
|
|
<Select
|
|
value={value.k8sClusterId ? String(value.k8sClusterId) : '__none'}
|
|
onValueChange={(v) => onChange({
|
|
k8sClusterId: v === '__none' ? '' : Number(v),
|
|
k8sNamespace: '',
|
|
k8sDeployment: '',
|
|
})}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs dg-input">
|
|
<SelectValue placeholder={i18n.t('kubernetes.topology.selectCluster')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none"> {i18n.t('k8s.storage.pleaseSelect')} </SelectItem>
|
|
{clusters.map((c) => (
|
|
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs"> {i18n.t('pipeline.deploytargetfie.s806e4e01')} </Label>
|
|
<Input
|
|
readOnly
|
|
value={value.k8sDeployment ? `${value.k8sNamespace}/${value.k8sDeployment}` : ''}
|
|
placeholder={i18n.t('pipeline.deploytargetfie.sbb1671f3')}
|
|
className="h-8 text-xs dg-input font-mono"
|
|
/>
|
|
</div>
|
|
</div>
|
|
{selectedCluster && (
|
|
<>
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-[var(--dg-text-muted)]" />
|
|
<Input
|
|
value={depSearch}
|
|
onChange={(e) => setDepSearch(e.target.value)}
|
|
placeholder={i18n.t('pipeline.deploytargetfie.saa98b28a')}
|
|
className="pl-8 h-8 text-xs dg-input"
|
|
/>
|
|
</div>
|
|
<ScrollArea className="h-[140px] rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
|
|
{loadingDeps ? (
|
|
<div className="flex items-center justify-center py-8 text-xs text-[var(--dg-text-muted)]">
|
|
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />加载 Deployment
|
|
</div>
|
|
) : filteredDeployments.length === 0 ? (
|
|
<p className="text-xs text-[var(--dg-text-muted)] text-center py-8"> {i18n.t('k8s.workloads.noDeployment')} </p>
|
|
) : (
|
|
<div className="p-1.5 space-y-1">
|
|
{filteredDeployments.map((dep) => {
|
|
const selected = value.k8sDeployment === dep.name && value.k8sNamespace === dep.namespace;
|
|
return (
|
|
<button
|
|
key={`${dep.namespace}/${dep.name}`}
|
|
type="button"
|
|
onClick={() => selectDeployment(dep)}
|
|
className={cn(
|
|
'w-full flex items-center gap-2 px-2.5 py-2 rounded-md text-left text-xs transition-colors',
|
|
selected
|
|
? 'bg-[var(--dg-bg-hover)] border border-[var(--dg-border)]'
|
|
: 'hover:bg-[var(--dg-bg-hover)]/60',
|
|
)}
|
|
>
|
|
<Network className="h-3.5 w-3.5 text-[var(--dg-text-muted)] shrink-0" />
|
|
<span className="font-mono text-[var(--dg-text-primary)] truncate">
|
|
{dep.namespace}/{dep.name}
|
|
</span>
|
|
<span className="text-[var(--dg-text-muted)] ml-auto shrink-0">
|
|
{dep.ready_replicas ?? 0}/{dep.replicas ?? 0}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</ScrollArea>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs"> {i18n.t('kubernetes.container.containerName')} <span className="text-[var(--dg-text-muted)]">(多容器时填写)</span></Label>
|
|
<Input
|
|
value={value.k8sContainerName}
|
|
onChange={(e) => onChange({ k8sContainerName: e.target.value })}
|
|
placeholder={i18n.t('pipeline.deploytargetfie.s389076f8')}
|
|
className="h-8 text-xs font-mono dg-input"
|
|
/>
|
|
</div>
|
|
{value.k8sDeployment && selectedCluster && (
|
|
<p className="text-[10px] text-[var(--dg-text-muted)] leading-relaxed">
|
|
流水线部署将调用 CI 接口:
|
|
<code className="text-[var(--dg-text-secondary)] break-all">
|
|
POST {getPublicApiBase()}/kubernetes/{selectedCluster.code}/namespace/{value.k8sNamespace}/deployments/{value.k8sDeployment}/update-image
|
|
</code>
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |