批量转换脚本引入的语法错误已回退,保留稳定的 i18n 转换: - 翻译词典: 22 namespaces, 3600+ keys - 已接入: 145+ 文件 (Dashboard/Login/K8s 主页面/Monitor/System/Deploy) - 语言切换器在 Header 中可用 (🇨🇳/🇺🇸) - tsc 零错误, Vite 构建成功
880 lines
46 KiB
TypeScript
880 lines
46 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||
// ResourceVisualEditor — ConfigMap/Secret/Service/Ingress/PVC 可视化编辑 // Logan.Li
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
import type { ComponentType } from 'react';
|
||
import { X, Save, Loader2, Plus, Trash2, FileText, Network, Shield, Database, ChevronRight} from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { ModalOverlay } from '@/components/ui/modal-overlay';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Textarea } from '@/components/ui/textarea';
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||
import { useToast } from '@/hooks/use-toast';
|
||
import { kubernetesApi } from '@/api';
|
||
import { createEmptyManifest } from './resource-create-templates';
|
||
import { parseManifest, parseEditableManifest, prepareManifestForSave, prepareYamlForSave } from './k8s-manifest-utils';
|
||
import { YamlEditorToolbar } from './YamlEditorToolbar';
|
||
import { YamlCodeMirror } from './YamlCodeMirror';
|
||
import { getYamlResourceConfig, yamlDownloadFilename } from './k8s-yaml-resource-config';
|
||
import { downloadYamlFile } from '@/utils/export';
|
||
|
||
interface ResourceVisualEditorProps {
|
||
clusterCode: string;
|
||
namespace: string;
|
||
resource: string; // 'configmaps' | 'secrets' | 'services' | 'ingresses' | 'pvcs'
|
||
name: string;
|
||
onClose: () => void;
|
||
onSaved?: () => void;
|
||
/** 新建模式:不拉取远端 YAML,使用空模板 // Logan.Li */
|
||
isCreate?: boolean;
|
||
}
|
||
|
||
export function ResourceVisualEditor({ clusterCode, namespace, resource, name, onClose, onSaved, isCreate = false }: ResourceVisualEditorProps) {
|
||
const { toast } = useToast();
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [rawYaml, setRawYaml] = useState('');
|
||
const [manifest, setManifest] = useState<any>(null);
|
||
const [mode, setMode] = useState<'visual' | 'yaml'>('visual');
|
||
const [displayYaml, setDisplayYaml] = useState('');
|
||
const [editYaml, setEditYaml] = useState('');
|
||
const [yamlEditing, setYamlEditing] = useState(false);
|
||
const [showRawYaml, setShowRawYaml] = useState(false);
|
||
const [yamlCopied, setYamlCopied] = useState(false);
|
||
|
||
const loadData = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
if (isCreate) {
|
||
const empty = createEmptyManifest(resource, namespace, name || '');
|
||
const yamlStr = await prepareManifestForSave(empty);
|
||
setManifest(empty);
|
||
setDisplayYaml(yamlStr);
|
||
setEditYaml(yamlStr);
|
||
setRawYaml('');
|
||
setYamlEditing(true);
|
||
return;
|
||
}
|
||
const text = await kubernetesApi.getResourceYaml(clusterCode, namespace, resource, name);
|
||
setRawYaml(text);
|
||
// 先解析原始 YAML,再用对象级剥离;cleanYAML 会破坏 managedFields 导致解析失败 // Logan.Li
|
||
const { manifest: parsed, yaml: editableYaml } = await parseEditableManifest(text);
|
||
setDisplayYaml(editableYaml);
|
||
setEditYaml(editableYaml);
|
||
setYamlEditing(false);
|
||
setShowRawYaml(false);
|
||
setManifest(parsed);
|
||
if (!parsed) {
|
||
setMode('yaml');
|
||
toast({ title: '可视化编辑不可用', description: '无法解析资源结构,已切换到 YAML 编辑模式' });
|
||
}
|
||
} catch (e) {
|
||
toast({ title: '加载失败', description: e instanceof Error ? e.message : String(e), variant: 'destructive' });
|
||
} finally { setLoading(false); }
|
||
}, [clusterCode, namespace, resource, name, isCreate]);
|
||
|
||
useEffect(() => { loadData(); }, [loadData]);
|
||
|
||
const handleSave = async (updatedManifest: any) => {
|
||
setSaving(true);
|
||
try {
|
||
const yamlStr = await prepareManifestForSave(updatedManifest);
|
||
const resName = updatedManifest?.metadata?.name || name;
|
||
if (isCreate) {
|
||
if (!resName?.trim()) {
|
||
toast({ title: '请填写名称', variant: 'destructive' });
|
||
return;
|
||
}
|
||
await kubernetesApi.applyYAML(clusterCode, yamlStr);
|
||
toast({ title: '创建成功', description: `${resource}/${resName}` });
|
||
} else {
|
||
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, yamlStr);
|
||
toast({ title: '保存成功', description: `${resource}/${name} 已更新` });
|
||
}
|
||
onSaved?.();
|
||
onClose();
|
||
} catch (e) {
|
||
toast({ title: isCreate ? '创建失败' : '保存失败', description: e instanceof Error ? e.message : String(e), variant: 'destructive' });
|
||
} finally { setSaving(false); }
|
||
};
|
||
|
||
const handleSaveYaml = async () => {
|
||
setSaving(true);
|
||
try {
|
||
const yamlStr = await prepareYamlForSave(editYaml);
|
||
if (isCreate) {
|
||
await kubernetesApi.applyYAML(clusterCode, yamlStr);
|
||
toast({ title: '创建成功', description: 'YAML 已应用' });
|
||
} else {
|
||
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, yamlStr);
|
||
toast({ title: '保存成功', description: `${resource}/${name} 已更新` });
|
||
}
|
||
setYamlEditing(false);
|
||
setDisplayYaml(yamlStr);
|
||
setEditYaml(yamlStr);
|
||
if (!isCreate) setRawYaml(yamlStr);
|
||
onSaved?.();
|
||
onClose();
|
||
} catch (e) {
|
||
toast({ title: '保存失败', description: e instanceof Error ? e.message : String(e), variant: 'destructive' });
|
||
} finally { setSaving(false); }
|
||
};
|
||
|
||
// 可视化 ↔ YAML 双向同步 // Logan.Li
|
||
const switchToVisual = async () => {
|
||
const parsed = await parseManifest(editYaml);
|
||
if (!parsed) {
|
||
toast({ title: '无法解析 YAML', description: '请检查语法后继续用 YAML 模式', variant: 'destructive' });
|
||
return;
|
||
}
|
||
setManifest(parsed);
|
||
setMode('visual');
|
||
};
|
||
|
||
const switchToYaml = async () => {
|
||
if (manifest) {
|
||
const yamlStr = await prepareManifestForSave(manifest);
|
||
setDisplayYaml(yamlStr);
|
||
setEditYaml(yamlStr);
|
||
}
|
||
setYamlEditing(false);
|
||
setShowRawYaml(false);
|
||
setMode('yaml');
|
||
};
|
||
|
||
const currentYamlText = () => (yamlEditing ? editYaml : (showRawYaml ? rawYaml : displayYaml));
|
||
|
||
const copyYamlContent = async () => {
|
||
await navigator.clipboard.writeText(currentYamlText());
|
||
setYamlCopied(true);
|
||
setTimeout(() => setYamlCopied(false), 2000);
|
||
};
|
||
|
||
const downloadYamlContent = () => {
|
||
const fname = yamlDownloadFilename(resource, namespace, name || manifest?.metadata?.name || 'resource');
|
||
downloadYamlFile(fname, currentYamlText());
|
||
toast({ title: '已下载', description: fname });
|
||
};
|
||
|
||
const resCfg = getYamlResourceConfig(resource);
|
||
const resourceMeta: Record<string, { title: string; icon: ComponentType<{ className?: string }>; color: string }> = {
|
||
configmaps: { title: 'ConfigMap', icon: FileText, color: 'dg-text-blue' },
|
||
secrets: { title: 'Secret', icon: Shield, color: 'dg-text-yellow' },
|
||
services: { title: 'Service', icon: Network, color: 'dg-text-cyan' },
|
||
ingresses: { title: 'Ingress', icon: ChevronRight, color: 'dg-text-purple' },
|
||
pvcs: { title: 'PersistentVolumeClaim', icon: Database, color: 'dg-text-green' },
|
||
};
|
||
const meta = resourceMeta[resource] || resourceMeta.configmaps;
|
||
const MetaIcon = meta.icon;
|
||
|
||
return (
|
||
<ModalOverlay onClose={onClose}>
|
||
<div
|
||
className="bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl w-[90vw] max-w-3xl h-[85vh] flex flex-col shadow-2xl"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* Header:长名称/域名截断,避免挤压模式切换按钮 // Logan.Li */}
|
||
<div className="flex items-center justify-between p-4 border-b border-[var(--dg-border)] gap-3 shrink-0">
|
||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||
<MetaIcon className={`h-5 w-5 shrink-0 ${meta.color}`} />
|
||
<div className="min-w-0">
|
||
<h2 className="text-lg font-bold text-[var(--dg-text-primary)] truncate">
|
||
{isCreate ? `新建 ${meta.title}` : `${meta.title} 编辑`}
|
||
</h2>
|
||
<p
|
||
className="text-xs text-[var(--dg-text-muted)] truncate"
|
||
title={isCreate ? `${namespace} · ${clusterCode}` : `${name} · ${namespace} · ${clusterCode}`}
|
||
>
|
||
{isCreate ? `${namespace} · ${clusterCode}` : `${name} · ${namespace} · ${clusterCode}`}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
{/* Mode switcher */}
|
||
<div className="flex rounded-lg border border-[var(--dg-border)] overflow-hidden">
|
||
<button
|
||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${mode === 'visual' ? 'bg-blue-600 text-white' : 'bg-transparent text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]'}`}
|
||
onClick={() => { if (mode === 'yaml') switchToVisual(); else setMode('visual'); }}
|
||
disabled={!manifest && mode !== 'yaml'}
|
||
>可视化编辑</button>
|
||
<button
|
||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${mode === 'yaml' ? 'bg-blue-600 text-white' : 'bg-transparent text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]'}`}
|
||
onClick={() => { if (mode === 'visual') switchToYaml(); else setMode('yaml'); }}
|
||
>YAML 编辑</button>
|
||
</div>
|
||
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)]" onClick={onClose}><X className="h-4 w-4" /></Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Body */}
|
||
<div className={`flex-1 min-h-0 p-4 ${mode === 'yaml' ? 'flex flex-col overflow-hidden' : 'flex flex-col overflow-hidden'}`}>
|
||
{loading ? (
|
||
<div className="flex items-center justify-center h-full py-20"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-text-muted)]" /></div>
|
||
) : mode === 'yaml' ? (
|
||
<div className="flex flex-col flex-1 min-h-0 gap-3">
|
||
<div className="flex justify-end shrink-0">
|
||
<YamlEditorToolbar
|
||
editing={yamlEditing}
|
||
saving={saving}
|
||
copied={yamlCopied}
|
||
showRaw={showRawYaml}
|
||
compact
|
||
readOnly={resCfg.readOnly}
|
||
showRawToggle={!isCreate && !!rawYaml}
|
||
onCopy={copyYamlContent}
|
||
onDownload={downloadYamlContent}
|
||
onEdit={() => { setEditYaml(displayYaml); setYamlEditing(true); setShowRawYaml(false); }}
|
||
onCancel={async () => {
|
||
setYamlEditing(false);
|
||
if (isCreate) {
|
||
const empty = createEmptyManifest(resource, namespace, name || '');
|
||
const yamlStr = await prepareManifestForSave(empty);
|
||
setDisplayYaml(yamlStr);
|
||
setEditYaml(yamlStr);
|
||
return;
|
||
}
|
||
const { yaml: stripped } = await parseEditableManifest(rawYaml);
|
||
setDisplayYaml(stripped);
|
||
setEditYaml(stripped);
|
||
}}
|
||
onSave={handleSaveYaml}
|
||
onToggleRaw={() => setShowRawYaml((v) => !v)}
|
||
/>
|
||
</div>
|
||
<YamlCodeMirror
|
||
value={yamlEditing ? editYaml : (showRawYaml ? rawYaml : displayYaml)}
|
||
editing={yamlEditing}
|
||
fill
|
||
fontSize="12px"
|
||
onChange={(val) => yamlEditing && setEditYaml(val)}
|
||
/>
|
||
</div>
|
||
) : manifest ? (
|
||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||
{resource === 'configmaps' && <ConfigMapEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
|
||
{resource === 'secrets' && <SecretEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
|
||
{resource === 'services' && <ServiceEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
|
||
{resource === 'ingresses' && <IngressEditor manifest={manifest} clusterCode={clusterCode} onSave={handleSave} saving={saving} isCreate={isCreate} />}
|
||
{resource === 'pvcs' && <PvcEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
|
||
{!['configmaps', 'secrets', 'services', 'ingresses', 'pvcs'].includes(resource) && (
|
||
<div className="flex-1 flex items-center justify-center text-center text-[var(--dg-text-muted)]">
|
||
<div>
|
||
<p>该资源类型暂不支持可视化编辑</p>
|
||
<Button variant="outline" size="sm" className="mt-4" onClick={() => setMode('yaml')}>切换到 YAML 编辑</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="text-center py-12 text-[var(--dg-text-muted)]">
|
||
<p>无法解析资源结构</p>
|
||
<Button variant="outline" size="sm" className="mt-4" onClick={() => setMode('yaml')}>切换到 YAML 编辑</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</ModalOverlay>
|
||
);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════
|
||
// ConfigMap 编辑器 — 键值对表格
|
||
// ══════════════════════════════════════════════
|
||
function ConfigMapEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
|
||
const data = manifest?.data || {};
|
||
const [resName, setResName] = useState(manifest?.metadata?.name || '');
|
||
const [entries, setEntries] = useState<{ key: string; value: string }[]>(
|
||
Object.entries(data).map(([key, value]) => ({ key, value: String(value) }))
|
||
);
|
||
const [expandAll, setExpandAll] = useState(false);
|
||
|
||
const update = (i: number, field: 'key' | 'value', val: string) => {
|
||
const next = [...entries];
|
||
next[i] = { ...next[i], [field]: val };
|
||
setEntries(next);
|
||
};
|
||
|
||
const addRow = () => setEntries([...entries, { key: '', value: '' }]);
|
||
const removeRow = (i: number) => setEntries(entries.filter((_, idx) => idx !== i));
|
||
|
||
const handleSave = () => {
|
||
const newData: Record<string, string> = {};
|
||
entries.forEach(e => {
|
||
if (e.key.trim()) newData[e.key.trim()] = e.value;
|
||
});
|
||
onSave({ ...manifest, metadata: { ...manifest.metadata, name: resName.trim() }, data: newData });
|
||
};
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<div className="shrink-0">
|
||
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label>
|
||
<Input value={resName} onChange={(e) => setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
|
||
</div>
|
||
|
||
<div className="flex-1 min-h-0 flex flex-col mt-3">
|
||
<div className="flex items-center justify-between mb-2 shrink-0">
|
||
<Label className="text-[var(--dg-text-secondary)] text-sm">配置数据 (键值对)</Label>
|
||
<div className="flex gap-1">
|
||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setExpandAll(!expandAll)}>
|
||
{expandAll ? '折叠全部' : '展开全部'}
|
||
</Button>
|
||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRow}><Plus className="h-3 w-3 mr-1" />添加</Button>
|
||
</div>
|
||
</div>
|
||
<div className="flex-1 min-h-0 overflow-y-auto pr-1 space-y-2 border border-[var(--dg-border)] rounded-lg bg-[var(--dg-bg-secondary)] p-2">
|
||
{entries.map((entry, i) => (
|
||
<div key={i} className="flex items-start gap-2 p-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||
<Input
|
||
value={entry.key}
|
||
onChange={e => update(i, 'key', e.target.value)}
|
||
placeholder="键名 (如 LOG_LEVEL)"
|
||
className="flex-1 font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||
/>
|
||
<Textarea
|
||
value={entry.value}
|
||
onChange={e => update(i, 'value', e.target.value)}
|
||
placeholder="值"
|
||
rows={expandAll || entry.value.length > 300 ? 10 : (entry.value.length > 100 ? 6 : 3)}
|
||
className="flex-[2] font-mono text-xs min-h-[70px] max-h-[420px] resize-y bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] leading-relaxed"
|
||
/>
|
||
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-red hover:dg-text-red shrink-0" onClick={() => removeRow(i)}>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
{entries.length === 0 && (
|
||
<div className="text-center py-8 text-[var(--dg-text-muted)] text-xs">暂无配置数据,点击「添加」创建</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="shrink-0 flex justify-end gap-2 pt-3 mt-2 border-t border-[var(--dg-border)]">
|
||
<Button size="sm" className="bg-blue-600 hover:bg-blue-700" onClick={handleSave} disabled={saving}>
|
||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <Save className="h-3.5 w-3.5 mr-1" />}
|
||
保存配置
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════
|
||
// Secret 编辑器 — 键值对表格 (Base64 编码/解码)
|
||
// ══════════════════════════════════════════════
|
||
function SecretEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
|
||
const rawData = manifest?.data || {};
|
||
const [resName, setResName] = useState(manifest?.metadata?.name || '');
|
||
const [expandAll, setExpandAll] = useState(false);
|
||
// Decode base64 values for editing
|
||
const [entries, setEntries] = useState<{ key: string; value: string; encoded: boolean }[]>(
|
||
Object.entries(rawData).map(([key, value]) => {
|
||
const v = String(value);
|
||
try {
|
||
// Try to decode — if it's valid base64
|
||
const decoded = atob(v);
|
||
return { key, value: decoded, encoded: true };
|
||
} catch {
|
||
return { key, value: v, encoded: false };
|
||
}
|
||
})
|
||
);
|
||
|
||
const update = (i: number, field: 'key' | 'value', val: string) => {
|
||
const next = [...entries];
|
||
next[i] = { ...next[i], [field]: val };
|
||
setEntries(next);
|
||
};
|
||
|
||
const addRow = () => setEntries([...entries, { key: '', value: '', encoded: true }]);
|
||
const removeRow = (i: number) => setEntries(entries.filter((_, idx) => idx !== i));
|
||
|
||
const handleSave = () => {
|
||
const newData: Record<string, string> = {};
|
||
entries.forEach(e => {
|
||
if (e.key.trim()) {
|
||
// Re-encode if originally base64
|
||
newData[e.key.trim()] = e.encoded ? btoa(e.value) : e.value;
|
||
}
|
||
});
|
||
onSave({ ...manifest, metadata: { ...manifest.metadata, name: resName.trim() }, data: newData });
|
||
};
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<div className="shrink-0 grid grid-cols-2 gap-4">
|
||
<div>
|
||
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label>
|
||
<Input value={resName} onChange={(e) => setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
|
||
</div>
|
||
<div>
|
||
<Label className="text-[var(--dg-text-secondary)] text-xs">类型</Label>
|
||
<Input value={manifest?.type || 'Opaque'} readOnly className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 min-h-0 flex flex-col mt-3">
|
||
<div className="flex items-center justify-between mb-2 shrink-0">
|
||
<Label className="text-[var(--dg-text-secondary)] text-sm">密钥数据 (已自动解码 Base64)</Label>
|
||
<div className="flex gap-1">
|
||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setExpandAll(!expandAll)}>
|
||
{expandAll ? '折叠全部' : '展开全部'}
|
||
</Button>
|
||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRow}><Plus className="h-3 w-3 mr-1" />添加</Button>
|
||
</div>
|
||
</div>
|
||
<div className="flex-1 min-h-0 overflow-y-auto pr-1 space-y-2 border border-[var(--dg-border)] rounded-lg bg-[var(--dg-bg-secondary)] p-2">
|
||
{entries.map((entry, i) => (
|
||
<div key={i} className="flex items-start gap-2 p-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||
<Input
|
||
value={entry.key}
|
||
onChange={e => update(i, 'key', e.target.value)}
|
||
placeholder="键名 (如 password)"
|
||
className="flex-1 font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||
/>
|
||
<Textarea
|
||
value={entry.value}
|
||
onChange={e => update(i, 'value', e.target.value)}
|
||
placeholder="明文值"
|
||
rows={expandAll || entry.value.length > 300 ? 10 : (entry.value.length > 100 ? 6 : 3)}
|
||
className="flex-[2] font-mono text-xs min-h-[70px] max-h-[420px] resize-y bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] leading-relaxed"
|
||
/>
|
||
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-red hover:dg-text-red shrink-0" onClick={() => removeRow(i)}>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
{entries.length === 0 && (
|
||
<div className="text-center py-8 text-[var(--dg-text-muted)] text-xs">暂无密钥数据,点击「添加」创建</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="shrink-0 flex justify-end gap-2 pt-3 mt-2 border-t border-[var(--dg-border)]">
|
||
<Button size="sm" className="bg-blue-600 hover:bg-blue-700" onClick={handleSave} disabled={saving}>
|
||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <Save className="h-3.5 w-3.5 mr-1" />}
|
||
保存密钥
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════
|
||
// Service 编辑器 — 端口配置 + 类型 + 选择器
|
||
// ══════════════════════════════════════════════
|
||
function ServiceEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
|
||
const spec = manifest?.spec || {};
|
||
const [resName, setResName] = useState(manifest?.metadata?.name || '');
|
||
const [svcType, setSvcType] = useState(spec.type || 'ClusterIP');
|
||
const [ports, setPorts] = useState<any[]>(
|
||
(spec.ports || []).map(( p: any) => ({
|
||
name: p.name || '',
|
||
port: String(p.port || ''),
|
||
targetPort: String(p.targetPort || ''),
|
||
protocol: p.protocol || 'TCP',
|
||
nodePort: p.nodePort ? String(p.nodePort) : '',
|
||
}))
|
||
);
|
||
const [selector, setSelector] = useState<{ key: string; value: string }[]>(
|
||
Object.entries(spec.selector || {}).map(([key, value]) => ({ key, value: String(value) }))
|
||
);
|
||
|
||
const updatePort = (i: number, field: string, val: string) => {
|
||
const next = [...ports];
|
||
next[i] = { ...next[i], [field]: val };
|
||
setPorts(next);
|
||
};
|
||
const addPort = () => setPorts([...ports, { name: '', port: '', targetPort: '', protocol: 'TCP', nodePort: '' }]);
|
||
const removePort = (i: number) => setPorts(ports.filter((_, idx) => idx !== i));
|
||
|
||
const updateSelector = (i: number, field: 'key' | 'value', val: string) => {
|
||
const next = [...selector];
|
||
next[i] = { ...next[i], [field]: val };
|
||
setSelector(next);
|
||
};
|
||
const addSelector = () => setSelector([...selector, { key: '', value: '' }]);
|
||
const removeSelector = (i: number) => setSelector(selector.filter((_, idx) => idx !== i));
|
||
|
||
const handleSave = () => {
|
||
const newSpec: Record<string, any> = { ...spec };
|
||
newSpec.type = svcType;
|
||
newSpec.ports = ports.map(p => {
|
||
const port: Record<string, any> = {
|
||
port: parseInt(p.port) || 80,
|
||
targetPort: parseInt(p.targetPort) || parseInt(p.port) || 80,
|
||
protocol: p.protocol,
|
||
};
|
||
if (p.name) port.name = p.name;
|
||
if (p.nodePort && svcType === 'NodePort') port.nodePort = parseInt(p.nodePort);
|
||
return port;
|
||
});
|
||
const selObj: Record<string, string> = {};
|
||
selector.forEach(s => { if (s.key.trim()) selObj[s.key.trim()] = s.value; });
|
||
newSpec.selector = selObj;
|
||
onSave({ ...manifest, metadata: { ...manifest.metadata, name: resName.trim() }, spec: newSpec });
|
||
};
|
||
|
||
const svcTypes = [
|
||
{ value: 'ClusterIP', label: 'ClusterIP' },
|
||
{ value: 'NodePort', label: 'NodePort' },
|
||
{ value: 'LoadBalancer', label: 'LoadBalancer' },
|
||
];
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<div className="shrink-0">
|
||
<Label className="text-[var(--dg-text-secondary)] text-xs">服务名称</Label>
|
||
<Input value={resName} onChange={(e) => setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
|
||
</div>
|
||
<div className="shrink-0 mt-3">
|
||
<Label className="text-[var(--dg-text-secondary)] text-xs mb-2 block">服务类型</Label>
|
||
<div className="flex rounded-lg border border-[var(--dg-border)] overflow-hidden w-fit">
|
||
{svcTypes.map((t) => (
|
||
<button
|
||
key={t.value}
|
||
type="button"
|
||
className={`px-3 py-1.5 text-xs transition-colors ${svcType === t.value ? 'bg-blue-600 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]'}`}
|
||
onClick={() => setSvcType(t.value)}
|
||
>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 端口 + 选择器:统一滚动,避免条目过多时底部被裁切 // Logan.Li */}
|
||
<div className="flex-1 min-h-0 overflow-y-auto mt-3 pr-1 space-y-3">
|
||
<div>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<Label className="text-[var(--dg-text-secondary)] text-sm">端口映射</Label>
|
||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addPort}><Plus className="h-3 w-3 mr-1" />添加端口</Button>
|
||
</div>
|
||
<div className="space-y-2 border border-[var(--dg-border)] rounded-lg p-2 bg-[var(--dg-bg-secondary)]">
|
||
{ports.map((p, i) => (
|
||
<div key={i} className="overflow-x-auto">
|
||
<div className="grid grid-cols-[minmax(80px,1fr)_80px_80px_80px_80px_32px] gap-2 items-center min-w-[480px]">
|
||
<Input value={p.name} onChange={e => updatePort(i, 'name', e.target.value)} placeholder="名称" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
|
||
<Input value={p.port} onChange={e => updatePort(i, 'port', e.target.value)} placeholder="端口" type="number" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
|
||
<Input value={p.targetPort} onChange={e => updatePort(i, 'targetPort', e.target.value)} placeholder="目标" type="number" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
|
||
<Select value={p.protocol} onValueChange={v => updatePort(i, 'protocol', v)}>
|
||
<SelectTrigger className="h-7 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"><SelectValue /></SelectTrigger>
|
||
<SelectContent><SelectItem value="TCP">TCP</SelectItem><SelectItem value="UDP">UDP</SelectItem></SelectContent>
|
||
</Select>
|
||
{svcType === 'NodePort' ? (
|
||
<Input value={p.nodePort} onChange={e => updatePort(i, 'nodePort', e.target.value)} placeholder="NodePort" type="number" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
|
||
) : <div />}
|
||
<Button variant="ghost" size="icon" className="h-7 w-7 dg-text-red hover:dg-text-red" onClick={() => removePort(i)}><Trash2 className="h-3 w-3" /></Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{ports.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs">暂无端口,点击「添加端口」</div>}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Selector */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<Label className="text-[var(--dg-text-secondary)] text-sm">选择器 (Selector)</Label>
|
||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addSelector}><Plus className="h-3 w-3 mr-1" />添加标签</Button>
|
||
</div>
|
||
<div className="space-y-2">
|
||
{selector.map((s, i) => (
|
||
<div key={i} className="flex items-center gap-2 min-w-0">
|
||
<Input value={s.key} onChange={e => updateSelector(i, 'key', e.target.value)} placeholder="键 (如 app)" className="flex-1 min-w-0 font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
|
||
<span className="text-[var(--dg-text-muted)] text-xs shrink-0">=</span>
|
||
<Input value={s.value} onChange={e => updateSelector(i, 'value', e.target.value)} placeholder="值 (如 nginx)" className="flex-1 min-w-0 font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
|
||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 dg-text-red hover:dg-text-red" onClick={() => removeSelector(i)}><Trash2 className="h-3 w-3" /></Button>
|
||
</div>
|
||
))}
|
||
{selector.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs">暂无选择器</div>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="shrink-0 flex justify-end gap-2 pt-3 mt-2 border-t border-[var(--dg-border)]">
|
||
<Button size="sm" className="bg-blue-600 hover:bg-blue-700" onClick={handleSave} disabled={saving}>
|
||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <Save className="h-3.5 w-3.5 mr-1" />}
|
||
保存 Service
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════
|
||
// Ingress 编辑器 — Host/Path 路由规则
|
||
// ══════════════════════════════════════════════
|
||
function IngressEditor({ manifest, clusterCode, onSave, saving, isCreate }: { manifest: any; clusterCode: string; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
|
||
const spec = manifest?.spec || {};
|
||
const [resName, setResName] = useState(manifest?.metadata?.name || '');
|
||
const [ingressClass, setIngressClass] = useState(spec.ingressClassName || 'nginx');
|
||
// IngressClass 列表(关联集群中的 IngressController)
|
||
const [ingressClasses, setIngressClasses] = useState<Array<{ name: string; controller: string }>>([]);
|
||
const [loadingClasses, setLoadingClasses] = useState(false);
|
||
|
||
// 加载集群 IngressClass 列表
|
||
useEffect(() => {
|
||
if (!clusterCode) return;
|
||
setLoadingClasses(true);
|
||
kubernetesApi.getIngressClasses(clusterCode).then((res: any) => {
|
||
const list = Array.isArray(res) ? res : (res?.list || res || []);
|
||
setIngressClasses(list);
|
||
// 新建时如果还没指定且有列表,尝试默认选第一个
|
||
if (isCreate && (!spec.ingressClassName || spec.ingressClassName === 'nginx') && list.length > 0) {
|
||
// 优先选 nginx 或第一个
|
||
const prefer = list.find((c: any) => c.name?.toLowerCase().includes('nginx')) || list[0];
|
||
if (prefer?.name) setIngressClass(prefer.name);
|
||
}
|
||
}).catch(() => {}).finally(() => setLoadingClasses(false));
|
||
}, [clusterCode]);
|
||
const [rules, setRules] = useState<{ host: string; paths: { path: string; pathType: string; serviceName: string; servicePort: string }[] }[]>(
|
||
(spec.rules || []).map(( r: any) => ({
|
||
host: r.host || '',
|
||
paths: (r.http?.backend?.servicePort != null
|
||
? // Old-style single backend
|
||
[{ path: '/', pathType: 'Prefix', serviceName: r.http?.backend?.serviceName || '', servicePort: String(r.http?.backend?.servicePort || '80') }]
|
||
: (r.http?.paths || []).map(( p: any) => ({
|
||
path: p.path || '/',
|
||
pathType: p.pathType || 'Prefix',
|
||
serviceName: p.backend?.service?.name || p.backend?.serviceName || '',
|
||
servicePort: String(p.backend?.service?.port?.number ?? p.backend?.servicePort ?? '80'),
|
||
}))
|
||
),
|
||
}))
|
||
);
|
||
|
||
const updateRule = (ri: number, host: string) => {
|
||
const next = [...rules];
|
||
next[ri] = { ...next[ri], host };
|
||
setRules(next);
|
||
};
|
||
const addRule = () => setRules([...rules, { host: '', paths: [{ path: '/', pathType: 'Prefix', serviceName: '', servicePort: '80' }] }]);
|
||
const removeRule = (ri: number) => setRules(rules.filter((_, idx) => idx !== ri));
|
||
|
||
const updatePath = (ri: number, pi: number, field: string, val: string) => {
|
||
const next = [...rules];
|
||
next[ri].paths[pi] = { ...next[ri].paths[pi], [field]: val };
|
||
setRules(next);
|
||
};
|
||
const addPath = (ri: number) => {
|
||
const next = [...rules];
|
||
next[ri].paths.push({ path: '/', pathType: 'Prefix', serviceName: '', servicePort: '80' });
|
||
setRules(next);
|
||
};
|
||
const removePath = (ri: number, pi: number) => {
|
||
const next = [...rules];
|
||
next[ri].paths = next[ri].paths.filter((_, idx) => idx !== pi);
|
||
setRules(next);
|
||
};
|
||
|
||
const handleSave = () => {
|
||
const newRules = rules.map(r => ({
|
||
host: r.host,
|
||
http: {
|
||
paths: r.paths.map(p => ({
|
||
path: p.path,
|
||
pathType: p.pathType,
|
||
backend: { service: { name: p.serviceName, port: { number: parseInt(p.servicePort) || 80 } } },
|
||
})),
|
||
},
|
||
}));
|
||
const newSpec: Record<string, any> = { ...spec, rules: newRules };
|
||
if (ingressClass.trim()) newSpec.ingressClassName = ingressClass.trim();
|
||
else delete newSpec.ingressClassName;
|
||
onSave({ ...manifest, metadata: { ...manifest.metadata, name: resName.trim() }, spec: newSpec });
|
||
};
|
||
|
||
return (
|
||
<div className="flex flex-col h-full min-h-0">
|
||
<div className="shrink-0 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div className="min-w-0">
|
||
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label>
|
||
<Input value={resName} onChange={(e) => setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<Label className="text-[var(--dg-text-secondary)] text-xs flex items-center gap-1">
|
||
Ingress Class
|
||
<span className="text-[10px] text-[var(--dg-text-muted)]">(可选)</span>
|
||
</Label>
|
||
{/* IngressClass 下拉 + 手工输入支持 // Logan.Li */}
|
||
<div className="space-y-1 mt-1">
|
||
<Select
|
||
value={ingressClass || ''}
|
||
onValueChange={(val) => {
|
||
if (val && val !== '__manual__') {
|
||
setIngressClass(val);
|
||
}
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-8 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||
<SelectValue placeholder={loadingClasses ? '加载中...' : '选择或输入 IngressClass'} />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{ingressClasses.length > 0 ? (
|
||
ingressClasses.map((ic) => (
|
||
<SelectItem key={ic.name} value={ic.name}>
|
||
{ic.name} {ic.controller ? <span className="text-[var(--dg-text-muted)] text-[10px]">({ic.controller})</span> : ''}
|
||
</SelectItem>
|
||
))
|
||
) : (
|
||
!loadingClasses && <div className="px-2 py-1.5 text-xs text-[var(--dg-text-muted)]">未发现 IngressClass,请手动输入</div>
|
||
)}
|
||
{/* 当前自定义值作为选项,确保选中态 */}
|
||
{ingressClass && !ingressClasses.some((ic) => ic.name === ingressClass) && (
|
||
<SelectItem value={ingressClass}>{ingressClass} (手动输入)</SelectItem>
|
||
)}
|
||
<SelectItem value="__manual__">—— 继续手动输入 ——</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
{/* 总是显示输入框支持手工输入或修改 */}
|
||
<div className="text-[10px] text-[var(--dg-text-muted)] flex items-center gap-1">
|
||
手工输入:
|
||
<Input
|
||
value={ingressClass}
|
||
onChange={(e) => setIngressClass(e.target.value)}
|
||
placeholder="alb / nginx / my-ingress-class"
|
||
className="flex-1 font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
|
||
/>
|
||
</div>
|
||
|
||
{/* 帮助提示 */}
|
||
<div className="text-[10px] text-[var(--dg-text-muted)] leading-tight">
|
||
您的集群中可能有多个 IngressController,您可在此指定当前 Ingress 对应哪个 IngressController。<br />
|
||
从 IngressClass 列表选择,或手工输入名称。
|
||
</div>
|
||
{/* 常见示例 */}
|
||
{(ingressClasses.length === 0 || ingressClass) && (
|
||
<div className="text-[10px] text-[var(--dg-text-muted)]">
|
||
常见:<span className="font-mono">alb</span> → ingress.k8s.alibabacloud/alb <span className="font-mono">nginx</span> → k8s.io/ingress-nginx
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex-1 min-h-0 flex flex-col mt-3">
|
||
<div className="flex items-center justify-between mb-2 shrink-0">
|
||
<Label className="text-[var(--dg-text-secondary)] text-sm">路由规则</Label>
|
||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRule}><Plus className="h-3 w-3 mr-1" />添加 Host</Button>
|
||
</div>
|
||
<div className="flex-1 min-h-0 overflow-y-auto pr-1 space-y-3 border border-[var(--dg-border)] rounded-lg p-2 bg-[var(--dg-bg-secondary)]">
|
||
{rules.map((rule, ri) => (
|
||
<div key={ri} className="p-3 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] space-y-2 min-w-0">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<Input
|
||
value={rule.host}
|
||
onChange={e => updateRule(ri, e.target.value)}
|
||
placeholder="域名 (如 api.example.com)"
|
||
className="flex-1 min-w-0 font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||
title={rule.host}
|
||
/>
|
||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 dg-text-red hover:dg-text-red" onClick={() => removeRule(ri)}><Trash2 className="h-3 w-3" /></Button>
|
||
</div>
|
||
{rule.paths.map((p, pi) => (
|
||
<div key={pi} className="overflow-x-auto pl-4">
|
||
<div className="grid grid-cols-[minmax(80px,1fr)_100px_minmax(80px,1fr)_60px_28px] gap-1.5 items-center min-w-[420px]">
|
||
<Input value={p.path} onChange={e => updatePath(ri, pi, 'path', e.target.value)} placeholder="路径 /" className="font-mono text-xs h-7 min-w-0 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
|
||
<Select value={p.pathType} onValueChange={v => updatePath(ri, pi, 'pathType', v)}>
|
||
<SelectTrigger className="h-7 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"><SelectValue /></SelectTrigger>
|
||
<SelectContent><SelectItem value="Prefix">Prefix</SelectItem><SelectItem value="Exact">Exact</SelectItem></SelectContent>
|
||
</Select>
|
||
<Input value={p.serviceName} onChange={e => updatePath(ri, pi, 'serviceName', e.target.value)} placeholder="Service" className="font-mono text-xs h-7 min-w-0 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
|
||
<Input value={p.servicePort} onChange={e => updatePath(ri, pi, 'servicePort', e.target.value)} placeholder="端口" type="number" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
|
||
<Button variant="ghost" size="icon" className="h-7 w-7 dg-text-red hover:dg-text-red" onClick={() => removePath(ri, pi)}><Trash2 className="h-3 w-3" /></Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
<Button variant="ghost" size="sm" className="h-6 text-xs text-[var(--dg-text-muted)]" onClick={() => addPath(ri)}><Plus className="h-3 w-3 mr-1" />添加路径</Button>
|
||
</div>
|
||
))}
|
||
{rules.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs">暂无路由规则,点击「添加 Host」</div>}
|
||
</div>
|
||
</div>
|
||
<div className="shrink-0 flex justify-end gap-2 pt-3 mt-2 border-t border-[var(--dg-border)]">
|
||
<Button size="sm" className="bg-blue-600 hover:bg-blue-700" onClick={handleSave} disabled={saving}>
|
||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <Save className="h-3.5 w-3.5 mr-1" />}
|
||
保存 Ingress
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════
|
||
// PVC 编辑器 — 存储大小/访问模式/StorageClass
|
||
// ══════════════════════════════════════════════
|
||
function PvcEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
|
||
const spec = manifest?.spec || {};
|
||
const resources = spec.resources?.requests || {};
|
||
const [resName, setResName] = useState(manifest?.metadata?.name || '');
|
||
const [size, setSize] = useState(resources.storage || '1Gi');
|
||
const [accessMode, setAccessMode] = useState(spec.accessModes?.[0] || 'ReadWriteOnce');
|
||
const [storageClass, setStorageClass] = useState(spec.storageClassName || '');
|
||
|
||
const handleSave = () => {
|
||
onSave({
|
||
...manifest,
|
||
metadata: { ...manifest.metadata, name: resName.trim() },
|
||
spec: {
|
||
...spec,
|
||
accessModes: [accessMode],
|
||
storageClassName: storageClass || undefined,
|
||
resources: { requests: { storage: size } },
|
||
},
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<div className="shrink-0">
|
||
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label>
|
||
<Input value={resName} onChange={(e) => setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4 mt-3 shrink-0">
|
||
<div>
|
||
<Label className="text-[var(--dg-text-secondary)] text-xs">存储大小</Label>
|
||
<Input
|
||
value={size}
|
||
onChange={e => setSize(e.target.value)}
|
||
placeholder="如 10Gi"
|
||
className="mt-1 font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||
/>
|
||
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">单位:Gi (GiB), Mi (MiB), Ti (TiB)</p>
|
||
</div>
|
||
<div>
|
||
<Label className="text-[var(--dg-text-secondary)] text-xs">访问模式</Label>
|
||
<Select value={accessMode} onValueChange={setAccessMode}>
|
||
<SelectTrigger className="mt-1 h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="ReadWriteOnce">ReadWriteOnce (单节点读写)</SelectItem>
|
||
<SelectItem value="ReadOnlyMany">ReadOnlyMany (多节点只读)</SelectItem>
|
||
<SelectItem value="ReadWriteMany">ReadWriteMany (多节点读写)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<div className="shrink-0 mt-1">
|
||
<Label className="text-[var(--dg-text-secondary)] text-xs">StorageClass</Label>
|
||
<Input
|
||
value={storageClass}
|
||
onChange={e => setStorageClass(e.target.value)}
|
||
placeholder="留空使用默认 StorageClass"
|
||
className="mt-1 font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex-1" />
|
||
|
||
<div className="shrink-0 flex justify-end gap-2 pt-3 mt-2 border-t border-[var(--dg-border)]">
|
||
<Button size="sm" className="bg-blue-600 hover:bg-blue-700" onClick={handleSave} disabled={saving}>
|
||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <Save className="h-3.5 w-3.5 mr-1" />}
|
||
保存 PVC
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|