import { useCallback, useEffect, useState, type ReactNode } from 'react' import { useNavigate } from 'react-router' import { Download, Trash2, RefreshCw, CheckCircle2, XCircle, ToggleLeft, ToggleRight, Loader2, AlertCircle, X, Plus, Unlink, CloudDownload, } from 'lucide-react' import { getImages, getStorageInfo, downloadImage, cancelImageDownload, deleteImage, toggleImage, createCustomKVMImage, removeCustomKVMImage, ImageInfo, StorageInfo, CustomKVMImageInput, } from '../services/api' import { useDialog } from '../components/Dialog' import { useLanguage } from '../contexts/LanguageContext' export default function ImageManagement() { const dialog = useDialog() const { t } = useLanguage() const navigate = useNavigate() const [images, setImages] = useState([]) const [loading, setLoading] = useState(true) const [actionLoading, setActionLoading] = useState(null) const [error, setError] = useState('') const [storageInfo, setStorageInfo] = useState(null) const [storageLoading, setStorageLoading] = useState(true) const [customModalOpen, setCustomModalOpen] = useState<'lxc' | 'kvm' | null>(null) const fetchImages = useCallback(async () => { try { const res = await getImages() setImages(res.data.data || []) setError('') } catch { setError('获取镜像列表失败') } finally { setLoading(false) } }, []) const fetchStorage = useCallback(async () => { setStorageLoading(true) try { const res = await getStorageInfo() setStorageInfo(res.data.data || null) } catch { setStorageInfo(null) } finally { setStorageLoading(false) } }, []) useEffect(() => { fetchImages() fetchStorage() }, [fetchImages, fetchStorage]) useEffect(() => { const hasDownloads = images.some((img) => img.downloading) const interval = setInterval(fetchImages, hasDownloads ? 1500 : 5000) return () => clearInterval(interval) }, [fetchImages, images]) const handleDownload = async (templateId: string) => { setActionLoading(templateId) setError('') try { await downloadImage(templateId) await fetchImages() } catch (err: unknown) { setError(apiErrorMessage(err, '下载失败')) } finally { setActionLoading(null) } } const handleCancelDownload = async (templateId: string) => { setActionLoading(templateId) setError('') try { await cancelImageDownload(templateId) await fetchImages() } catch (err: unknown) { setError(apiErrorMessage(err, '取消失败')) } finally { setActionLoading(null) } } const handleDelete = async (templateId: string) => { if (!(await dialog.confirm('删除镜像', '确定要删除该镜像缓存吗?删除后需要重新下载才能使用。'))) return setActionLoading(templateId) setError('') try { await deleteImage(templateId) await fetchImages() } catch (err: unknown) { const msg = err instanceof Error ? err.message : '删除失败' setError(msg) } finally { setActionLoading(null) } } const handleToggle = async (templateId: string, enabled: boolean) => { setActionLoading(templateId) setError('') try { await toggleImage(templateId, !enabled) await fetchImages() } catch (err: unknown) { const msg = err instanceof Error ? err.message : '操作失败' setError(msg) } finally { setActionLoading(null) } } const handleRemoveCustom = async (templateId: string) => { if (!(await dialog.confirm('移除第三方镜像', '确定移除该镜像源和已下载的缓存吗?正在使用该镜像的虚拟机不会允许移除。'))) return setActionLoading(templateId) setError('') try { await removeCustomKVMImage(templateId) await fetchImages() dialog.alert('完成', '第三方镜像已移除') } catch (err: unknown) { dialog.alert('失败', apiErrorMessage(err, '移除第三方镜像失败')) } finally { setActionLoading(null) } } const handleCustomCreated = async (payload: CustomKVMImageInput) => { const response = await createCustomKVMImage(payload) const image = response.data.data if (!image) throw new Error('镜像源保存成功,但服务器没有返回镜像 ID') try { await downloadImage(image.id) dialog.alert('完成', '第三方镜像已添加,下载任务已启动') } catch (err: unknown) { dialog.alert('提示', `镜像源已保存,但下载未启动:${apiErrorMessage(err, '请在列表中重试')}`) } await fetchImages() } const downloadedCount = images.filter((img) => img.downloaded).length const lxcImages = images.filter((img) => img.type === 'lxc') const kvmImages = images.filter((img) => img.type === 'kvm') const imageStorageReady = (storageInfo?.pools || []).some((pool) => pool.enabled !== false && pool.available !== false && (pool.content_types || []).includes('images') ) if (loading) { return (
) } return (

镜像管理

管理 LXC / KVM 系统镜像,下载后的镜像才能用于创建容器/虚拟机。 已下载 {downloadedCount}/{images.length}

{error && (
{error}
)} {storageLoading && (
正在检查存储配置...
)} {!storageLoading && !imageStorageReady && (
尚未开启镜像缓存存储,无法下载新镜像。
)} img.downloaded).length} totalCount={lxcImages.length} onDownload={handleDownload} onCancelDownload={handleCancelDownload} onDelete={handleDelete} onToggle={handleToggle} onRemoveCustom={handleRemoveCustom} storageReady={imageStorageReady} storageLoading={storageLoading} headerAction={( )} /> {kvmImages.length > 0 && ( img.downloaded).length} totalCount={kvmImages.length} onDownload={handleDownload} onCancelDownload={handleCancelDownload} onDelete={handleDelete} onToggle={handleToggle} onRemoveCustom={handleRemoveCustom} storageReady={imageStorageReady} storageLoading={storageLoading} headerAction={( )} /> )} {customModalOpen !== null && ( setCustomModalOpen(null)} onSubmit={handleCustomCreated} /> )}
) } const emptyCustomImage = (arch: string, virtualization: 'lxc' | 'kvm'): CustomKVMImageInput => ({ type: virtualization, name: '', description: '', distro: '', release: '', arch, url: '', provisioner: virtualization === 'lxc' ? 'lxc-rootfs' : 'linux-cloud-init', sha256: '', }) function CustomKVMImageModal({ virtualization, arch, onClose, onSubmit, }: { virtualization: 'lxc' | 'kvm' arch: string onClose: () => void onSubmit: (payload: CustomKVMImageInput) => Promise }) { const { t } = useLanguage() const [form, setForm] = useState(() => emptyCustomImage(arch, virtualization)) const [submitting, setSubmitting] = useState(false) const [formError, setFormError] = useState('') const windows = virtualization === 'kvm' && form.provisioner !== 'linux-cloud-init' useEffect(() => { const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape' && !submitting) onClose() } window.addEventListener('keydown', closeOnEscape) return () => window.removeEventListener('keydown', closeOnEscape) }, [onClose, submitting]) const updateProvisioner = (provisioner: 'linux-cloud-init' | 'windows-10' | 'windows-11') => { setForm((current) => ({ ...current, provisioner, distro: provisioner === 'linux-cloud-init' ? (current.distro === 'windows' ? '' : current.distro) : 'windows', release: provisioner === 'windows-10' ? '10' : provisioner === 'windows-11' ? '11' : (current.distro === 'windows' ? '' : current.release), })) } const submit = async () => { if (!form.name.trim() || !form.distro.trim() || !form.release.trim() || !form.url.trim()) { setFormError(t('请填写名称、发行版、版本和下载地址')) return } if (form.sha256 && !/^[a-fA-F0-9]{64}$/.test(form.sha256.trim())) { setFormError(t('SHA-256 必须是 64 位十六进制字符串')) return } setSubmitting(true) setFormError('') try { await onSubmit({ ...form, name: form.name.trim(), description: form.description.trim(), distro: form.distro.trim().toLowerCase(), release: form.release.trim().toLowerCase(), url: form.url.trim(), sha256: form.sha256?.trim().toLowerCase(), }) onClose() } catch (err: unknown) { setFormError(apiErrorMessage(err, t('添加第三方镜像失败'))) } finally { setSubmitting(false) } } const inputClass = 'mt-1.5 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black outline-none focus:border-black focus:ring-2 focus:ring-black/10 dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:focus:border-white dark:focus:ring-white/10' return (

{t(virtualization === 'lxc' ? '下载第三方 LXC 镜像' : '下载第三方 KVM 镜像')}

{t(virtualization === 'lxc' ? '支持 tar、tar.gz、tar.xz、tar.zst 格式的 Linux rootfs' : '镜像格式必须与所选无人值守安装模板匹配')}

{virtualization === 'kvm' &&
{([ ['linux-cloud-init', 'Linux cloud-init', 'QCOW2 / IMG'], ['windows-10', 'Windows 10', '安装 ISO'], ['windows-11', 'Windows 11', '安装 ISO'], ] as const).map(([value, label, hint]) => ( ))}
}