import { useEffect, useMemo, useState, type ReactNode } from 'react' import { CalendarClock, X } from 'lucide-react' import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, CreateContainerRequest, HostInfo, IPv6Status, Template } from '../services/api' import { useDialog } from './Dialog' import { useLanguage, type Language } from '../contexts/LanguageContext' interface CreateContainerModalProps { isOpen: boolean onClose: () => void onSuccess: (containers: CreateContainerRequest[]) => void | Promise existingNames?: string[] } const defaultForm: CreateContainerRequest = { name: '', virtualization: 'lxc', template_id: '', vcpu: 1, cpu_percent: 100, ram_mb: 512, disk_gb: 10, network_bw_mbps: 0, monthly_traffic_gb: 0, traffic_mode: 'total', traffic_in_gb: 0, traffic_out_gb: 0, io_speed_mbps: 0, extra_ports: [], port_mapping_count: 2, assign_nat: true, snapshot_limit: 1, assign_ipv4: false, ipv4_count: 1, public_ipv4s: [], assign_ipv6: false, ipv6_count: 1, ipv6_addresses: [], expires_at: '', } export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) { const dialog = useDialog() const { language } = useLanguage() const networkText = createNetworkText[language] const [templates, setTemplates] = useState([]) const [loading, setLoading] = useState(false) const [batchCount, setBatchCount] = useState(1) const [form, setForm] = useState(defaultForm) const [hostInfo, setHostInfo] = useState(null) const [ipv6Status, setIPv6Status] = useState(null) const [nameError, setNameError] = useState('') useEffect(() => { if (!isOpen) return getEnabledImages(form.virtualization) .then((res) => { const data = res.data.data || [] setTemplates(data) setForm((prev) => { const templateID = data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '') return applyTemplateDefaults({ ...prev, template_id: templateID }) }) }) .catch(console.error) getIPv6Status() .then((res) => { const status = res.data.data || null setIPv6Status(status) if (!status?.available) { setForm((prev) => ({ ...prev, assign_ipv6: false })) } }) .catch(() => { setIPv6Status({ available: false, reachable: false, reason: 'IPv6 status check failed', prefixes: [] }) setForm((prev) => ({ ...prev, assign_ipv6: false })) }) getHostInfo() .then((res) => setHostInfo(res.data.data || null)) .catch(() => setHostInfo(null)) }, [isOpen, form.virtualization]) const ipv6Available = !!ipv6Status?.available const ipv6Prefixes = ipv6Status?.prefixes || [] const ipv6Prefix = ipv6Prefixes.length > 1 ? `${ipv6Prefixes.length} prefixes configured` : (ipv6Prefixes[0]?.prefix || '') const publicIPv4s = hostInfo?.network.public_ipv4_addresses || [] const ipv4Available = publicIPv4s.length > 0 const manualIPv4s = form.public_ipv4s || [] const maxVCPU = hostInfo?.cpu.cores || 64 const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB) const natEnabled = form.assign_nat !== false const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2) : 0 const autoPorts = useMemo(() => { if (!natEnabled) return [] const count = natPortCount return Array.from({ length: count - 1 }, (_, index) => 22002 + index) }, [natEnabled, natPortCount]) // SSH port preview (will be allocated sequentially, starting around 22000+) const sshPortPreview = 22000 // Find next available batch index to avoid name conflicts const batchStartIndex = useMemo(() => { if (batchCount <= 1 || !form.name) return 1 const prefix = `${form.name}-` let maxIdx = 0 for (const existing of existingNames) { if (existing.startsWith(prefix)) { const suffix = existing.slice(prefix.length) const idx = parseInt(suffix, 10) if (!isNaN(idx) && idx > maxIdx) { maxIdx = idx } } } return maxIdx + 1 }, [form.name, batchCount, existingNames]) const handleNameChange = (value: string) => { setForm({ ...form, name: value }) if (/\s/.test(value)) { setNameError('容器名称不能包含空格') } else if (value && existingNames.includes(value) && batchCount === 1) { setNameError('该容器名称已存在') } else { setNameError('') } } const handleSubmit = async () => { if (!form.name || !form.template_id) { dialog.alert('提示', '请填写容器名称并选择系统模板') return } if (Object.keys(resourceErrors).length > 0) { dialog.alert('资源配置有误', '请按红色提示修改 vCPU、内存或磁盘配置') return } if (!form.assign_ipv4 && !form.assign_ipv6 && form.assign_nat === false) { dialog.alert('提示', '请勾选任意一个可用网络') return } const boundedForm = normalizeCreateForm(form) const wantsNAT = boundedForm.assign_nat !== false // Build batch of containers const containers: CreateContainerRequest[] = [] const startIndex = batchStartIndex for (let i = 0; i < batchCount; i++) { const name = batchCount > 1 ? `${boundedForm.name}-${startIndex + i}` : boundedForm.name containers.push({ ...boundedForm, name, assign_nat: wantsNAT, port_mapping_count: wantsNAT ? Math.max(2, boundedForm.port_mapping_count || 2) : 0, snapshot_limit: Math.max(1, boundedForm.snapshot_limit || 3), ipv4_count: boundedForm.assign_ipv4 ? Math.max(1, boundedForm.ipv4_count || 1) : 0, ipv6_count: boundedForm.assign_ipv6 ? Math.max(1, boundedForm.ipv6_count || 1) : 0, extra_ports: [], }) } setLoading(true) try { await batchCreate(containers) await onSuccess(containers) onClose() setBatchCount(1) setForm({ ...defaultForm, template_id: templates[0]?.id || '' }) } catch (err: unknown) { const error = err as { response?: { data?: { message?: string } } } dialog.alert('创建失败', error.response?.data?.message || '请稍后重试') } finally { setLoading(false) } } if (!isOpen) return null return (

创建新容器

handleNameChange(event.target.value)} className={`${inputClass} ${nameError ? 'border-red-400 focus:ring-red-400 focus:border-red-400' : ''}`} placeholder="my-container" required /> {nameError &&

{nameError}

}
setBatchCount(Math.max(1, value || 1))} />
{batchCount > 1 &&

将创建 {batchCount} 个容器:{form.name}-{batchStartIndex} 至 {form.name}-{batchStartIndex + batchCount - 1}

}
{templates.length === 0 ? (
暂无可用的{form.virtualization === 'kvm' ? ' KVM' : ' LXC'}系统镜像,请先在「镜像管理」中下载镜像模板。
) : ( )}
{form.assign_ipv4 && (
setForm({ ...form, ipv4_count: Math.max(1, Math.round(value || 1)) })} />
{manualIPv4s.length > 0 && (
{publicIPv4s.map((ip) => ( ))}
)}
)}
{form.assign_ipv6 && ( setForm({ ...form, ipv6_count: Math.max(1, Math.round(value || 1)) })} /> )}
{natEnabled && ( setForm({ ...form, port_mapping_count: Math.max(2, value || 2), assign_nat: true })} /> )}
{natEnabled && (
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -> {isWindowsTemplate(form.template_id) ? 3389 : 22} {autoPorts.map((port) => ( {port} -> {port} ))}
)}
setForm({ ...form, vcpu: value })} /> {resourceErrors.vcpu &&

{resourceErrors.vcpu}

}
setForm({ ...form, ram_mb: value })} /> {resourceErrors.ram_mb &&

{resourceErrors.ram_mb}

}
setForm({ ...form, disk_gb: value })} /> {resourceErrors.disk_gb &&

{resourceErrors.disk_gb}

}
setForm({ ...form, network_bw_mbps: value })} /> setForm({ ...form, io_speed_mbps: value })} />
{/* Traffic control */}
{form.traffic_mode === 'total' ? (
setForm({ ...form, monthly_traffic_gb: value })} /> GB (0=不限制)
) : (
setForm({ ...form, traffic_in_gb: value || 0 })} /> setForm({ ...form, traffic_out_gb: value || 0 })} />
)}
setForm({ ...form, snapshot_limit: Math.max(1, Math.round(value || 1)) })} />
setForm({ ...form, expires_at: event.target.value })} min={new Date().toISOString().slice(0, 10)} className={`${inputClass} pl-10`} />

不选择则长期有效;选择日期后,到期会自动关机。

) } function Field({ label, children }: { label: string; children: ReactNode }) { return (
{children}
) } function NumberInput({ value, min, max, step, invalid, onChange, }: { value: number min?: number max?: number step?: number invalid?: boolean onChange: (value: number) => void }) { const [draft, setDraft] = useState(Number.isFinite(value) ? String(value) : '') const [focused, setFocused] = useState(false) useEffect(() => { if (!focused) { setDraft(Number.isFinite(value) ? String(value) : '') } }, [focused, value]) return ( setFocused(true)} onBlur={() => { setFocused(false) setDraft(Number.isFinite(value) ? String(value) : '') }} onChange={(event) => { const raw = event.target.value setDraft(raw) const next = step && !Number.isInteger(step) ? parseFloat(raw) : parseInt(raw, 10) onChange(next) }} aria-invalid={invalid || undefined} data-min={min} data-max={max} data-step={step} className={`${inputClass} ${invalid ? 'border-red-400 focus:border-red-400 focus:ring-red-400' : ''}`} /> ) } function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, maxRAMMB?: number, maxDiskGB?: number) { const errors: Partial> = {} const windows = isWindowsTemplate(form.template_id) const minVCPU = windows ? 2 : (form.virtualization === 'kvm' ? 1 : 0.25) const minRAMMB = windows ? 2048 : 128 const minDiskGB = windows ? 30 : 1 if (!Number.isFinite(form.vcpu)) { errors.vcpu = '请输入 vCPU' } else if (form.vcpu < minVCPU) { errors.vcpu = `不能小于 ${minVCPU} 核` } else if (form.vcpu > maxVCPU) { errors.vcpu = `不能大于 ${maxVCPU} 核` } else if (form.virtualization === 'kvm' && form.vcpu !== Math.round(form.vcpu)) { errors.vcpu = 'KVM vCPU 必须是整数' } if (!Number.isFinite(form.ram_mb)) { errors.ram_mb = '请输入内存' } else if (form.ram_mb < minRAMMB) { errors.ram_mb = `不能小于 ${minRAMMB} MB` } else if (maxRAMMB && form.ram_mb > maxRAMMB) { errors.ram_mb = `不能大于 ${maxRAMMB} MB` } if (!Number.isFinite(form.disk_gb)) { errors.disk_gb = '请输入磁盘' } else if (form.disk_gb < minDiskGB) { errors.disk_gb = `不能小于 ${minDiskGB} GB` } else if (maxDiskGB && form.disk_gb > maxDiskGB) { errors.disk_gb = `不能大于 ${maxDiskGB} GB` } return errors } function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest { const normalized = applyTemplateDefaults(form) const wantsNAT = normalized.assign_nat !== false const wantsIPv4 = !!normalized.assign_ipv4 const wantsIPv6 = !!normalized.assign_ipv6 return { ...normalized, vcpu: normalized.virtualization === 'kvm' ? Math.round(normalized.vcpu) : normalizeLXCvCPU(normalized.vcpu), ram_mb: Math.round(normalized.ram_mb), disk_gb: Math.round(normalized.disk_gb), assign_nat: wantsNAT, port_mapping_count: wantsNAT ? clampInt(normalized.port_mapping_count, 2, 64, 2) : 0, assign_ipv4: wantsIPv4, ipv4_count: wantsIPv4 ? clampInt(normalized.ipv4_count || 1, 1, 64, 1) : 0, public_ipv4s: wantsIPv4 ? (normalized.public_ipv4s || []) : [], assign_ipv6: wantsIPv6, ipv6_count: wantsIPv6 ? clampInt(normalized.ipv6_count || 1, 1, 64, 1) : 0, ipv6_addresses: wantsIPv6 ? (normalized.ipv6_addresses || []) : [], snapshot_limit: clampInt(normalized.snapshot_limit, 1, undefined, 3), } } function applyTemplateDefaults(form: CreateContainerRequest): CreateContainerRequest { if (!isWindowsTemplate(form.template_id)) return form return { ...form, virtualization: 'kvm', vcpu: Math.max(2, Math.round(Number.isFinite(form.vcpu) ? form.vcpu : 2)), ram_mb: Math.max(2048, Math.round(Number.isFinite(form.ram_mb) ? form.ram_mb : 2048)), disk_gb: Math.max(30, Math.round(Number.isFinite(form.disk_gb) ? form.disk_gb : 30)), } } function isWindowsTemplate(templateID: string) { return templateID.toLowerCase().includes('windows') } function normalizeLXCvCPU(value: number) { const rounded = Math.round((Number.isFinite(value) ? value : 1) * 4) / 4 return Number(rounded.toFixed(2)) } function clampInt(value: number, min: number, max?: number, fallback = min) { const next = Math.round(Number.isFinite(value) ? value : fallback) return Math.min(Math.max(next, min), max ?? next) } const createNetworkText = { zh: { publicIPv4: '公网 IPv4', noAllocatableIPv4: '未检测到可分配公网 IPv4', publicIPv6: '公网 IPv6', use: '使用', checkingIPv6Prefix: '正在检测 IPv6 前缀...', publicNAT: '公网 NAT', noNATPorts: '不分配 NAT 端口', }, en: { publicIPv4: 'Public IPv4', noAllocatableIPv4: 'No allocatable public IPv4 detected', publicIPv6: 'Public IPv6', use: 'Use', checkingIPv6Prefix: 'Checking IPv6 prefix...', publicNAT: 'Public NAT', noNATPorts: 'No NAT ports will be assigned', }, } as const function formatAllocatableIPv4Count(count: number, language: Language) { return language === 'en' ? `${count} allocatable address${count === 1 ? '' : 'es'} detected` : `检测到 ${count} 个可分配地址` } function formatNATPortCount(count: number, language: Language) { return language === 'en' ? `${count} NAT ports will be assigned` : `将分配 ${count} 个 NAT 端口` } const inputClass = 'w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black'