· 修复了一些已知问题

· 增加了局域网DHCP IP分配适配
· 完善了多盘兼容支持 #17
This commit is contained in:
MengMengCode
2026-07-18 19:44:32 +08:00
parent 3dabd93d2f
commit ebba97f1d6
34 changed files with 3309 additions and 417 deletions
+2
View File
@@ -13,6 +13,7 @@ import Settings from './pages/Settings'
import ImageManagement from './pages/ImageManagement'
import Snapshots from './pages/Snapshots'
import Routing from './pages/Routing'
import Storage from './pages/Storage'
import SubUserManagement from './pages/SubUserManagement'
import Layout from './components/Layout'
@@ -63,6 +64,7 @@ function App() {
<Route path="security" element={<Security />} />
<Route path="snapshots" element={<Snapshots />} />
<Route path="routing" element={<Routing />} />
<Route path="storage" element={<Storage />} />
<Route path="audit-logs" element={<AuditLogs />} />
<Route path="api-integration" element={<ApiIntegration />} />
<Route path="host-report" element={<HostReport />} />
@@ -1,19 +1,21 @@
import { useEffect } from 'react'
import { useLanguage } from '../contexts/LanguageContext'
import { useDialog } from './Dialog'
export default function BrowserDialogTranslator() {
const { t } = useLanguage()
const { alert: showAlert } = useDialog()
useEffect(() => {
const originalAlert = window.alert
const originalConfirm = window.confirm
window.alert = (message?: unknown) => originalAlert(t(String(message ?? '')))
window.alert = (message?: unknown) => { void showAlert('提示', String(message ?? '')) }
window.confirm = (message?: string) => originalConfirm(t(String(message ?? '')))
return () => {
window.alert = originalAlert
window.confirm = originalConfirm
}
}, [t])
}, [showAlert, t])
return null
}
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { CalendarClock, RefreshCw, X } from 'lucide-react'
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, getHostReport, CreateContainerRequest, HostInfo, HostProbeReport, IPv6Status, Template } from '../services/api'
import { useNavigate } from 'react-router-dom'
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, getHostReport, getStorageInfo, CreateContainerRequest, HostInfo, HostProbeReport, IPv6Status, StorageInfo, Template } from '../services/api'
import { useDialog } from './Dialog'
import { useLanguage, type Language } from '../contexts/LanguageContext'
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type SSHAuthMode } from '../utils/sshAuth'
@@ -16,6 +17,7 @@ const defaultForm: CreateContainerRequest = {
name: '',
virtualization: 'lxc',
template_id: '',
storage_pool_id: '',
vcpu: 1,
cpu_percent: 100,
ram_mb: 512,
@@ -54,6 +56,7 @@ const defaultForm: CreateContainerRequest = {
}
export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) {
const navigate = useNavigate()
const dialog = useDialog()
const { language } = useLanguage()
const networkText = createNetworkText[language]
@@ -63,6 +66,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
const [form, setForm] = useState<CreateContainerRequest>(defaultForm)
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
const [hostReport, setHostReport] = useState<HostProbeReport | null>(null)
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null)
const [storageLoading, setStorageLoading] = useState(true)
const [ipv6Status, setIPv6Status] = useState<IPv6Status | null>(null)
const [nameError, setNameError] = useState('')
@@ -107,8 +112,26 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
getHostReport()
.then((res) => setHostReport(res.data.data || null))
.catch(() => setHostReport(null))
}, [isOpen, form.virtualization])
useEffect(() => {
if (!isOpen) return
let active = true
setStorageLoading(true)
getStorageInfo()
.then((res) => {
if (active) setStorageInfo(res.data.data || null)
})
.catch(() => {
if (active) setStorageInfo(null)
})
.finally(() => {
if (active) setStorageLoading(false)
})
return () => { active = false }
}, [isOpen])
const ipv6Available = !!ipv6Status?.available
const ipv6Prefixes = ipv6Status?.prefixes || []
const ipv6Prefix = ipv6Prefixes.length > 1 ? `${ipv6Prefixes.length} prefixes configured` : (ipv6Prefixes[0]?.prefix || '')
@@ -118,6 +141,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
const maxVCPU = hostInfo?.cpu.cores || 64
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
const kvmAvailable = !!hostInfo?.runtime?.kvm_available
const storagePools = useMemo(() => {
const content = form.virtualization === 'kvm' ? 'kvm' : 'lxc'
return (storageInfo?.pools || []).filter((pool) => pool.enabled && pool.available !== false && (pool.content_types || []).includes(content))
}, [storageInfo, form.virtualization])
const storageReady = storagePools.length > 0
useEffect(() => {
if (hostInfo && !kvmAvailable && form.virtualization === 'kvm') {
@@ -197,6 +225,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
}
}
if (!storageReady) {
dialog.alert('未配置存储', `请先在存储管理中为 ${form.virtualization === 'kvm' ? 'KVM 磁盘' : 'LXC 容器'}开启至少一块存储磁盘`)
return
}
const authError = validateSSHAuthInputs(form)
if (authError) {
dialog.alert('登录方式有误', authError)
@@ -273,7 +306,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '', allowed_image_ids: [], image_limit_configured: false }))}
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '', storage_pool_id: '', allowed_image_ids: [], image_limit_configured: false }))}
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'lxc' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
>
LXC
@@ -284,7 +317,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
title={kvmAvailable ? '' : '当前宿主机不支持 KVM'}
onClick={() => {
if (kvmAvailable) {
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '', allowed_image_ids: [], image_limit_configured: false }))
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '', storage_pool_id: '', allowed_image_ids: [], image_limit_configured: false }))
}
}}
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-gray-50 disabled:text-gray-400 ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
@@ -320,6 +353,39 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</Field>
<Field label="存储磁盘">
{storageLoading ? (
<div className="flex items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-600">
<RefreshCw className="h-4 w-4 animate-spin" />
...
</div>
) : storagePools.length > 0 ? (
<select
value={form.storage_pool_id || ''}
onChange={(event) => setForm({ ...form, storage_pool_id: event.target.value })}
className={inputClass}
>
<option value=""></option>
{storagePools.map((pool) => (
<option key={pool.id} value={pool.id}>
{pool.name} · {pool.mount_point || pool.path}
</option>
))}
</select>
) : (
<div className="flex items-center justify-between gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
<span>{form.virtualization === 'kvm' ? ' KVM 磁盘' : ' LXC 容器'}</span>
<button
type="button"
onClick={() => { onClose(); navigate('/storage') }}
className="shrink-0 rounded-md border border-amber-300 bg-white px-2.5 py-1.5 text-xs font-medium text-amber-800 hover:bg-amber-100"
>
</button>
</div>
)}
</Field>
{templates.length > 0 && (
<Field label="子用户可用镜像">
<div className="rounded-md border border-gray-200 bg-gray-50 p-3">
@@ -821,7 +887,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</button>
<button
onClick={handleSubmit}
disabled={loading}
disabled={loading || storageLoading || !storageReady}
className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '创建中...' : '创建容器'}
+87 -41
View File
@@ -1,17 +1,23 @@
import { useState, useCallback, createContext, useContext, ReactNode } from 'react'
import { AlertTriangle, CheckCircle, X } from 'lucide-react'
import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'
import { AlertTriangle, CheckCircle2, CircleAlert, Info, X } from 'lucide-react'
import { useLanguage } from '../contexts/LanguageContext'
type DialogType = 'confirm' | 'alert'
interface DialogState {
open: boolean
type: DialogType
title: string
message: string
resolve?: (value: boolean) => void
}
type ToastTone = 'success' | 'error' | 'warning' | 'info'
interface ToastState {
id: number
title: string
message: string
tone: ToastTone
}
interface DialogContextType {
confirm: (title: string, message: string) => Promise<boolean>
alert: (title: string, message: string) => Promise<void>
@@ -19,67 +25,107 @@ interface DialogContextType {
const DialogContext = createContext<DialogContextType | undefined>(undefined)
const toastStyles = {
success: { icon: CheckCircle2, iconClass: 'bg-emerald-50 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-300', borderClass: 'border-emerald-200 dark:border-emerald-800' },
error: { icon: CircleAlert, iconClass: 'bg-red-50 text-red-600 dark:bg-red-950 dark:text-red-300', borderClass: 'border-red-200 dark:border-red-800' },
warning: { icon: AlertTriangle, iconClass: 'bg-amber-50 text-amber-600 dark:bg-amber-950 dark:text-amber-300', borderClass: 'border-amber-200 dark:border-amber-800' },
info: { icon: Info, iconClass: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300', borderClass: 'border-gray-200 dark:border-gray-700' },
}
function toastTone(title: string): ToastTone {
if (/失败|错误|异常|不可用|failed|error/i.test(title)) return 'error'
if (/提示|警告|未配置|格式|配额|封禁|warning/i.test(title)) return 'warning'
if (/完成|成功|已保存|success/i.test(title)) return 'success'
return 'info'
}
export function DialogProvider({ children }: { children: ReactNode }) {
const [dialog, setDialog] = useState<DialogState>({ open: false, type: 'alert', title: '', message: '' })
const [dialog, setDialog] = useState<DialogState>({ open: false, title: '', message: '' })
const [toasts, setToasts] = useState<ToastState[]>([])
const toastID = useRef(0)
const toastTimers = useRef(new Map<number, number>())
const { t } = useLanguage()
const confirm = useCallback((title: string, message: string) => {
return new Promise<boolean>((resolve) => {
setDialog({ open: true, type: 'confirm', title, message, resolve })
setDialog({ open: true, title, message, resolve })
})
}, [])
const dismissToast = useCallback((id: number) => {
setToasts((current) => current.filter((toast) => toast.id !== id))
const timer = toastTimers.current.get(id)
if (timer !== undefined) window.clearTimeout(timer)
toastTimers.current.delete(id)
}, [])
const alert = useCallback((title: string, message: string) => {
return new Promise<void>((resolve) => {
setDialog({ open: true, type: 'alert', title, message, resolve: () => resolve() })
})
const id = ++toastID.current
setToasts((current) => [...current, { id, title, message, tone: toastTone(title) }].slice(-4))
const timer = window.setTimeout(() => dismissToast(id), 4200)
toastTimers.current.set(id, timer)
return Promise.resolve()
}, [dismissToast])
useEffect(() => () => {
toastTimers.current.forEach((timer) => window.clearTimeout(timer))
toastTimers.current.clear()
}, [])
const close = (result: boolean) => {
dialog.resolve?.(result)
setDialog({ open: false, type: 'alert', title: '', message: '' })
setDialog({ open: false, title: '', message: '' })
}
return (
<DialogContext.Provider value={{ confirm, alert }}>
{children}
{dialog.open && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4">
<div className="bg-white rounded-lg shadow-xl border border-gray-200 w-full max-w-sm overflow-hidden">
<div className="flex items-center gap-3 px-5 py-4 border-b border-gray-100">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
dialog.type === 'confirm' ? 'bg-amber-50 text-amber-600' : 'bg-gray-100 text-gray-600'
}`}>
{dialog.type === 'confirm' ? <AlertTriangle className="w-4 h-4" /> : <CheckCircle className="w-4 h-4" />}
</div>
<h3 className="text-sm font-semibold text-black flex-1">{t(dialog.title)}</h3>
{dialog.type === 'alert' && (
<button onClick={() => close(true)} className="p-1 text-gray-400 hover:text-black rounded">
<X className="w-4 h-4" />
<div className="pointer-events-none fixed right-4 top-4 z-[120] flex w-[calc(100vw-2rem)] max-w-sm flex-col gap-2" aria-live="polite" aria-atomic="true">
{toasts.map((toast) => {
const style = toastStyles[toast.tone]
const ToastIcon = style.icon
return (
<div key={toast.id} className={`pointer-events-auto rounded-lg border bg-white shadow-lg dark:bg-gray-900 dark:shadow-black/40 ${style.borderClass}`} role="status">
<div className="flex items-start gap-3 p-3.5">
<div className={`mt-0.5 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full ${style.iconClass}`}>
<ToastIcon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-gray-900 dark:text-white">{t(toast.title)}</div>
<div className="mt-0.5 break-words text-sm leading-5 text-gray-600 dark:text-gray-300">{t(toast.message)}</div>
</div>
<button onClick={() => dismissToast(toast.id)} className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-black dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-white" title={t('关闭')}>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
)
})}
</div>
{dialog.open && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 dark:bg-black/70">
<div className="w-full max-w-sm overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-900">
<div className="flex items-center gap-3 border-b border-gray-100 px-5 py-4 dark:border-gray-700">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-amber-50 text-amber-600 dark:bg-amber-950 dark:text-amber-300">
<AlertTriangle className="h-4 w-4" />
</div>
<h3 className="flex-1 text-sm font-semibold text-black dark:text-white">{t(dialog.title)}</h3>
</div>
<div className="px-5 py-4">
<p className="text-sm text-gray-600">{t(dialog.message)}</p>
<p className="text-sm text-gray-600 dark:text-gray-300">{t(dialog.message)}</p>
</div>
<div className="flex justify-end gap-2 px-5 py-3 bg-gray-50 border-t border-gray-100">
{dialog.type === 'confirm' && (
<button
onClick={() => close(false)}
className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-200 rounded-md transition-colors"
>
{t('取消')}
</button>
)}
<div className="flex justify-end gap-2 border-t border-gray-100 bg-gray-50 px-5 py-3 dark:border-gray-700 dark:bg-gray-800">
<button
onClick={() => close(false)}
className="rounded-md px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-700"
>
{t('取消')}
</button>
<button
onClick={() => close(true)}
className={`px-4 py-2 text-sm rounded-md transition-colors ${
dialog.type === 'confirm'
? 'bg-black text-white hover:bg-gray-800'
: 'bg-black text-white hover:bg-gray-800'
}`}
className="rounded-md bg-black px-4 py-2 text-sm text-white transition-colors hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200"
>
{dialog.type === 'confirm' ? t('确认') : t('确定')}
{t('确认')}
</button>
</div>
</div>
+14
View File
@@ -6,6 +6,7 @@ import {
Code2,
Cpu,
Camera,
HardDrive,
LayoutDashboard,
LogOut,
Moon,
@@ -83,6 +84,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
const isSnapshotsPage = location.pathname.startsWith('/snapshots')
const isRoutingPage = location.pathname.startsWith('/routing')
const isStoragePage = location.pathname.startsWith('/storage')
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
const isHostReportPage = location.pathname.startsWith('/host-report')
@@ -201,6 +203,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
{!collapsed && <span></span>}
</button>
<button
onClick={() => navigate('/storage')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isStoragePage
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`}
>
<HardDrive className="w-4 h-4" />
{!collapsed && <span></span>}
</button>
<button
onClick={() => navigate('/audit-logs')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
+42
View File
@@ -83,6 +83,8 @@ body {
/* Shadow */
.dark .shadow-sm { box-shadow: 0 1px 2px 0 rgba(0,0,0,0.3) !important; }
.dark .shadow-md { box-shadow: 0 4px 6px -1px rgba(0,0,0,0.4) !important; }
.dark .shadow-lg,
.dark .shadow-xl { box-shadow: 0 12px 28px rgba(0,0,0,0.45) !important; }
/* bg-black buttons in dark mode -> light */
.dark .bg-black { background-color: #f9fafb !important; }
@@ -121,6 +123,7 @@ body {
.dark .bg-amber-50 { background-color: #451a03 !important; }
.dark .bg-emerald-50 { background-color: #064e3b !important; }
.dark .bg-amber-100 { background-color: #78350f !important; }
.dark .bg-indigo-50 { background-color: #1e1b4b !important; }
/* Status badge text */
.dark .text-green-700 { color: #6ee7b7 !important; }
@@ -129,6 +132,14 @@ body {
.dark .text-amber-600 { color: #fcd34d !important; }
.dark .text-amber-700 { color: #fcd34d !important; }
.dark .text-emerald-700 { color: #6ee7b7 !important; }
.dark .text-emerald-600 { color: #6ee7b7 !important; }
.dark .text-amber-800 { color: #fde68a !important; }
.dark .text-indigo-700 { color: #a5b4fc !important; }
/* Colored notification borders */
.dark .border-emerald-200 { border-color: #065f46 !important; }
.dark .border-red-200 { border-color: #991b1b !important; }
.dark .border-amber-200 { border-color: #92400e !important; }
/* Focus ring */
.dark .focus\:ring-black:focus { --tw-ring-color: #f9fafb !important; }
@@ -137,6 +148,37 @@ body {
/* Accent */
.dark .accent-black { accent-color: #f9fafb !important; }
/* Native form controls */
.dark input,
.dark select,
.dark textarea { color-scheme: dark; }
/* Explicit dark variants take precedence over the compatibility overrides above. */
.dark .dark\:bg-white { background-color: #f9fafb !important; }
.dark .dark\:bg-gray-950 { background-color: #030712 !important; }
.dark .dark\:bg-gray-900 { background-color: #111827 !important; }
.dark .dark\:bg-gray-800 { background-color: #1f2937 !important; }
.dark .dark\:bg-gray-700 { background-color: #374151 !important; }
.dark .dark\:bg-emerald-950 { background-color: #022c22 !important; }
.dark .dark\:bg-red-950 { background-color: #450a0a !important; }
.dark .dark\:bg-amber-950 { background-color: #451a03 !important; }
.dark .dark\:text-white { color: #f9fafb !important; }
.dark .dark\:text-black { color: #111827 !important; }
.dark .dark\:text-gray-300 { color: #d1d5db !important; }
.dark .dark\:text-gray-400 { color: #9ca3af !important; }
.dark .dark\:text-gray-500 { color: #6b7280 !important; }
.dark .dark\:text-emerald-300 { color: #6ee7b7 !important; }
.dark .dark\:text-red-300 { color: #fca5a5 !important; }
.dark .dark\:text-amber-300 { color: #fcd34d !important; }
.dark .dark\:border-gray-700 { border-color: #374151 !important; }
.dark .dark\:border-emerald-800 { border-color: #065f46 !important; }
.dark .dark\:border-red-800 { border-color: #991b1b !important; }
.dark .dark\:border-amber-800 { border-color: #92400e !important; }
.dark .dark\:hover\:bg-gray-800:hover { background-color: #1f2937 !important; color: inherit !important; }
.dark .dark\:hover\:bg-gray-700:hover { background-color: #374151 !important; color: inherit !important; }
.dark .dark\:hover\:bg-gray-200:hover { background-color: #e5e7eb !important; color: #111827 !important; }
.dark .dark\:hover\:text-white:hover { color: #f9fafb !important; }
/* Spinner */
.dark .border-black { border-color: #f9fafb !important; }
.dark .border-b-black { border-bottom-color: #f9fafb !important; }
+80 -7
View File
@@ -44,6 +44,7 @@ import {
getContainerSnapshots,
getContainerUsage,
getHostInfo,
getStorageInfo,
getTrafficInfo,
HostInfo,
TrafficInfo,
@@ -61,6 +62,7 @@ import {
stopContainer,
Snapshot,
SnapshotSchedule,
StorageInfo,
Template,
updateContainerExpiry,
updateFirewall,
@@ -75,6 +77,7 @@ import {
} from '../services/api'
import { useDialog } from '../components/Dialog'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
import WebSSHViewer from '../components/WebSSHViewer'
import WebVNCViewer from '../components/WebVNCViewer'
import { RingStat } from '../components/RingStats'
@@ -127,6 +130,7 @@ export default function ContainerDetail() {
const navigate = useNavigate()
const dialog = useDialog()
const { isSubUser } = useAuth()
const { t } = useLanguage()
const [container, setContainer] = useState<Container | null>(null)
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
const [usage, setUsage] = useState<ContainerUsage | null>(null)
@@ -182,6 +186,9 @@ export default function ContainerDetail() {
const [editingSnapshotQuota, setEditingSnapshotQuota] = useState(false)
const [snapshotSchedule, setSnapshotSchedule] = useState<SnapshotSchedule | null>(null)
const [snapshotBusy, setSnapshotBusy] = useState('')
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null)
const [storageLoading, setStorageLoading] = useState(!isSubUser)
const [snapshotStoragePoolID, setSnapshotStoragePoolID] = useState('')
const [showSnapshotSchedule, setShowSnapshotSchedule] = useState(false)
const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' })
const [showFirewall, setShowFirewall] = useState(false)
@@ -224,6 +231,23 @@ export default function ContainerDetail() {
}
}, [containerIdentifier, container?.snapshot_limit])
const fetchStorage = useCallback(async () => {
if (isSubUser) {
setStorageLoading(false)
return
}
setStorageLoading(true)
try {
const res = await getStorageInfo()
setStorageInfo(res.data.data || null)
} catch (err) {
console.error('Failed to fetch storage:', err)
setStorageInfo(null)
} finally {
setStorageLoading(false)
}
}, [isSubUser])
const fetchMetricHistory = useCallback(async () => {
if (!containerIdentifier) return
try {
@@ -297,8 +321,11 @@ export default function ContainerDetail() {
}, [fetchMetricHistory])
useEffect(() => {
if (showSnapshots) fetchSnapshots()
}, [showSnapshots, fetchSnapshots])
if (showSnapshots) {
fetchSnapshots()
fetchStorage()
}
}, [showSnapshots, fetchSnapshots, fetchStorage])
// Poll task status for this container
useEffect(() => {
@@ -774,6 +801,10 @@ export default function ContainerDetail() {
const handleCreateSnapshot = async () => {
if (!containerIdentifier) return
if (!(await ensureSubUserCanOperate())) return
if (!snapshotStorageReady) {
await dialog.alert('未配置快照存储', '请先在存储管理中为快照开启至少一块存储磁盘。')
return
}
if (isSubUser && snapshots.length >= snapshotQuota) {
await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。')
return
@@ -787,7 +818,7 @@ export default function ContainerDetail() {
}
setSnapshotBusy('create')
try {
await createContainerSnapshot(containerIdentifier)
await createContainerSnapshot(containerIdentifier, { storage_pool_id: snapshotStoragePoolID || undefined })
await Promise.all([fetchSnapshots(), fetchContainer()])
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
@@ -799,6 +830,10 @@ export default function ContainerDetail() {
const openSnapshotSchedule = () => {
if (isSubUser && container?.policy_blocked) return
if (!snapshotStorageReady) {
dialog.alert('未配置快照存储', '请先在存储管理中为快照开启至少一块存储磁盘。')
return
}
setSnapshotScheduleDraft({
intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24),
time: snapshotSchedule?.time || '03:00',
@@ -924,6 +959,10 @@ export default function ContainerDetail() {
const hasIndependentIPv4 = assignedIPv4List.length > 0
const hasIndependentIPv6 = ipv6List.length > 0
const defaultConnPort = isWindows ? 3389 : 22
const snapshotStoragePools = (storageInfo?.pools || []).filter((pool) =>
pool.enabled !== false && pool.available !== false && (pool.content_types || []).includes('snapshots')
)
const snapshotStorageReady = isSubUser || snapshotStoragePools.length > 0
let publicEndpoint = '-'
let sshCommand = ''
@@ -1231,8 +1270,8 @@ export default function ContainerDetail() {
<PlainRow label="vCPU" value={`${container.vcpu}`} />
<PlainRow label="内存" value={`${container.ram_mb} MB`} />
<PlainRow label="磁盘" value={`${container.disk_gb} GB`} />
<PlainRow label="网络速率" value={formatDirectionalLimit('下行', networkDownLimit, '上行', networkUpLimit, 'Mbps')} />
<PlainRow label="IO 速度" value={formatDirectionalLimit('读取', ioReadLimit, '写入', ioWriteLimit, 'MB/s')} />
<PlainRow label="网络速率" value={formatDirectionalLimit(t('下行'), networkDownLimit, t('上行'), networkUpLimit, 'Mbps')} />
<PlainRow label="IO 速度" value={formatDirectionalLimit(t('读取'), ioReadLimit, t('写入'), ioWriteLimit, 'MB/s')} />
</Panel>
<Panel title="实时状态">
@@ -1492,7 +1531,7 @@ export default function ContainerDetail() {
<div className="flex items-center gap-2">
<button
onClick={openSnapshotSchedule}
disabled={!!snapshotBusy}
disabled={!!snapshotBusy || storageLoading || !snapshotStorageReady}
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs ${
snapshotSchedule?.enabled
? 'border border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100'
@@ -1504,7 +1543,7 @@ export default function ContainerDetail() {
</button>
<button
onClick={handleCreateSnapshot}
disabled={!!snapshotBusy || (isSubUser && snapshots.length >= snapshotQuota)}
disabled={!!snapshotBusy || storageLoading || !snapshotStorageReady || (isSubUser && snapshots.length >= snapshotQuota)}
className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-1.5 text-xs text-white hover:bg-gray-800 disabled:opacity-50"
>
<Camera className="w-3.5 h-3.5" />
@@ -1514,6 +1553,20 @@ export default function ContainerDetail() {
}
>
<div className="space-y-4">
{storageLoading && !isSubUser && (
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600">
<RefreshCw className="h-4 w-4 animate-spin" />
...
</div>
)}
{!storageLoading && !snapshotStorageReady && (
<div className="flex items-center justify-between gap-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<span></span>
<button onClick={() => { setShowSnapshots(false); navigate('/storage') }} className="shrink-0 rounded-md border border-amber-300 bg-white px-3 py-1.5 text-xs font-medium hover:bg-amber-100">
</button>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-xs text-gray-600">
<div>
@@ -1549,6 +1602,26 @@ export default function ContainerDetail() {
)}
</div>
{!isSubUser && snapshotStoragePools.length > 0 && (
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3">
<Field label="新建快照存储磁盘">
<select
value={snapshotStoragePoolID}
onChange={(event) => setSnapshotStoragePoolID(event.target.value)}
className="w-72 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"
>
<option value=""></option>
{snapshotStoragePools.map((pool) => (
<option key={pool.id} value={pool.id}>
{pool.name} · {pool.mount_point || pool.path}
</option>
))}
</select>
</Field>
<div className="pb-2 text-xs text-gray-400">使</div>
</div>
)}
{editingSnapshotQuota && !isSubUser && (
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3">
<Field label="子用户每台容器快照上限">
+35 -26
View File
@@ -21,6 +21,7 @@ import {
} from 'lucide-react'
import CreateContainerModal from '../components/CreateContainerModal'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
import {
Container,
CreateContainerRequest,
@@ -391,7 +392,7 @@ export default function Containers() {
{pageContainers.map((container) => {
const isRunning = container.status === 'running'
const isInitializing = container.status === 'initializing'
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
const task = (container.id > 0 ? taskStatusMap[container.id] : undefined) || taskNameMap[container.name] || container.createTask
const isPlaceholder = !!container.isPlaceholder
const isPolicyBlocked = !!container.policy_blocked
const usage = usageByName[container.name]
@@ -581,12 +582,13 @@ type DisplayContainer = Container & {
}
function StatusBadge({ running, initializing, task, placeholder, policyBlocked }: { running: boolean; initializing?: boolean; task?: Task; placeholder?: boolean; policyBlocked?: boolean }) {
const { t } = useLanguage()
const baseClass = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap"
if (policyBlocked) {
return (
<span className={`${baseClass} bg-red-50 text-red-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
{t('策略封禁')}
</span>
)
}
@@ -595,7 +597,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
return (
<span className={`${baseClass} bg-red-50 text-red-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
{t('初始化失败')}
</span>
)
}
@@ -604,16 +606,17 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
return (
<span className={`${baseClass} bg-emerald-50 text-emerald-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
{t('初始化完成')}
</span>
)
}
if (task?.type === 'create' && task.status === 'running') {
const detail = t(task.stage_detail || '正在初始化')
return (
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
<span className={`${baseClass} max-w-[210px] bg-amber-50 text-amber-700`} title={`${t('正在初始化')}: ${detail}`}>
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-amber-500 animate-pulse"></span>
<span className="truncate">{detail}</span>
</span>
)
}
@@ -622,7 +625,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
return (
<span className={`${baseClass} bg-gray-100 text-gray-500`}>
<span className="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
{t('排队等待')}
</span>
)
}
@@ -634,7 +637,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
return (
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
{taskLabels[task.type] || '处理中'}
{t(taskLabels[task.type] || '处理中')}
</span>
)
}
@@ -643,7 +646,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
return (
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
{t('正在初始化')}
</span>
)
}
@@ -651,7 +654,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
return (
<span className={`${baseClass} ${running ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-600'}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${running ? 'bg-green-500' : 'bg-red-500'}`}></span>
{running ? '在线' : '离线'}
{t(running ? '在线' : '离线')}
</span>
)
}
@@ -788,7 +791,7 @@ type ContainerFilters = {
function filterContainers(containers: DisplayContainer[], filters: ContainerFilters): DisplayContainer[] {
const keyword = filters.search.trim().toLowerCase()
return containers.filter((container) => {
const task = (container.id > 0 ? filters.taskStatusMap[container.id] : filters.taskNameMap[container.name]) || container.createTask
const task = (container.id > 0 ? filters.taskStatusMap[container.id] : undefined) || filters.taskNameMap[container.name] || container.createTask
if (filters.system !== 'all' && getSystemFilterValue(container.template) !== filters.system) {
return false
}
@@ -865,6 +868,7 @@ function getContainerStatusFilterValue(container: DisplayContainer, task?: Task)
function taskLineLabel(task: Task, actionLabels: Record<string, string>) {
if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败'
if (task.type === 'create' && task.status === 'done') return '初始化完成'
if (task.type === 'create' && task.status === 'running') return task.stage_detail || '正在初始化'
return actionLabels[task.type] || '处理中...'
}
@@ -873,13 +877,14 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
onRefresh: () => void | Promise<void>
onClose: () => void
}) {
const { t } = useLanguage()
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="flex max-h-[86vh] w-full max-w-5xl flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
<div className="flex max-h-[86vh] w-full max-w-6xl flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
<div className="flex items-center justify-between gap-4 border-b border-gray-200 px-5 py-4">
<div>
<h2 className="text-base font-semibold text-black"></h2>
<p className="mt-0.5 text-xs text-gray-500"> {tasks.length} </p>
<h2 className="text-base font-semibold text-black">{t('任务队列')}</h2>
<p className="mt-0.5 text-xs text-gray-500">{t(`${tasks.length} 个任务`)}</p>
</div>
<div className="flex items-center gap-2">
<button
@@ -887,26 +892,27 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<RefreshCw className="h-4 w-4" />
{t('刷新')}
</button>
<button onClick={onClose} className="rounded p-2 text-gray-500 hover:bg-gray-100" title="关闭">
<button onClick={onClose} className="rounded p-2 text-gray-500 hover:bg-gray-100" title={t('关闭')}>
<X className="h-4 w-4" />
</button>
</div>
</div>
{tasks.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-500"></div>
<div className="p-8 text-center text-sm text-gray-500">{t('暂无任务')}</div>
) : (
<div className="overflow-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 bg-gray-50 text-left text-xs font-medium text-gray-500">
<th className="whitespace-nowrap px-4 py-2.5"></th>
<th className="whitespace-nowrap px-4 py-2.5"></th>
<th className="whitespace-nowrap px-4 py-2.5"></th>
<th className="whitespace-nowrap px-4 py-2.5"></th>
<th className="px-4 py-2.5"></th>
<th className="whitespace-nowrap px-4 py-2.5">{t('状态')}</th>
<th className="whitespace-nowrap px-4 py-2.5">{t('操作')}</th>
<th className="whitespace-nowrap px-4 py-2.5">{t('容器')}</th>
<th className="whitespace-nowrap px-4 py-2.5">{t('当前阶段')}</th>
<th className="whitespace-nowrap px-4 py-2.5">{t('创建时间')}</th>
<th className="px-4 py-2.5">{t('错误')}</th>
<th className="whitespace-nowrap px-4 py-2.5 w-10"></th>
</tr>
</thead>
@@ -915,11 +921,14 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
<tr key={task.id} className="hover:bg-gray-50">
<td className="whitespace-nowrap px-4 py-2.5">
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${taskStatusClass(task.status)}`}>
{taskStatusLabel(task.status)}
{t(taskStatusLabel(task.status))}
</span>
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-gray-800">{actionLabel(task.type)}</td>
<td className="whitespace-nowrap px-4 py-2.5 text-gray-800">{t(actionLabel(task.type))}</td>
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-gray-700">{task.container_name}</td>
<td className="min-w-[210px] px-4 py-2.5 text-xs text-gray-700">
{task.type === 'create' ? t(task.stage_detail || (task.status === 'pending' ? '排队等待' : '-')) : '-'}
</td>
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-gray-500">{task.created_at}</td>
<td className="min-w-[260px] px-4 py-2.5 text-gray-600">{task.error || '-'}</td>
<td className="whitespace-nowrap px-2 py-2.5">
@@ -932,7 +941,7 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
} catch { /* ignore */ }
}}
className="p-1 rounded hover:bg-red-50 text-gray-400 hover:text-red-600 transition-colors"
title="取消任务"
title={t('取消任务')}
>
<X className="w-3.5 h-3.5" />
</button>
+60 -7
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Download,
Trash2,
@@ -11,15 +12,18 @@ import {
AlertCircle,
X,
} from 'lucide-react'
import { getImages, downloadImage, cancelImageDownload, deleteImage, toggleImage, ImageInfo } from '../services/api'
import { getImages, getStorageInfo, downloadImage, cancelImageDownload, deleteImage, toggleImage, ImageInfo, StorageInfo } from '../services/api'
import { useDialog } from '../components/Dialog'
export default function ImageManagement() {
const dialog = useDialog()
const navigate = useNavigate()
const [images, setImages] = useState<ImageInfo[]>([])
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [error, setError] = useState('')
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null)
const [storageLoading, setStorageLoading] = useState(true)
const fetchImages = useCallback(async () => {
try {
@@ -33,9 +37,22 @@ export default function ImageManagement() {
}
}, [])
const fetchStorage = useCallback(async () => {
setStorageLoading(true)
try {
const res = await getStorageInfo()
setStorageInfo(res.data.data || null)
} catch {
setStorageInfo(null)
} finally {
setStorageLoading(false)
}
}, [])
useEffect(() => {
fetchImages()
}, [fetchImages])
fetchStorage()
}, [fetchImages, fetchStorage])
useEffect(() => {
const hasDownloads = images.some((img) => img.downloading)
@@ -101,6 +118,9 @@ export default function ImageManagement() {
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 (
@@ -121,7 +141,7 @@ export default function ImageManagement() {
</p>
</div>
<button
onClick={fetchImages}
onClick={() => { fetchImages(); fetchStorage() }}
className="flex items-center gap-1.5 px-3 py-1.5 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors text-xs font-medium"
>
<RefreshCw className="w-3.5 h-3.5" />
@@ -136,6 +156,25 @@ export default function ImageManagement() {
</div>
)}
{storageLoading && (
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600">
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
...
</div>
)}
{!storageLoading && !imageStorageReady && (
<div className="flex items-center justify-between gap-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 shrink-0" />
</div>
<button onClick={() => navigate('/storage')} className="shrink-0 rounded-md border border-amber-300 bg-white px-3 py-1.5 text-xs font-medium hover:bg-amber-100">
</button>
</div>
)}
<ImageTable
title="LXC 容器镜像"
images={lxcImages}
@@ -146,6 +185,8 @@ export default function ImageManagement() {
onCancelDownload={handleCancelDownload}
onDelete={handleDelete}
onToggle={handleToggle}
storageReady={imageStorageReady}
storageLoading={storageLoading}
/>
{kvmImages.length > 0 && (
@@ -159,6 +200,8 @@ export default function ImageManagement() {
onCancelDownload={handleCancelDownload}
onDelete={handleDelete}
onToggle={handleToggle}
storageReady={imageStorageReady}
storageLoading={storageLoading}
/>
)}
</div>
@@ -175,6 +218,8 @@ function ImageTable({
onCancelDownload,
onDelete,
onToggle,
storageReady,
storageLoading,
}: {
title: string
images: ImageInfo[]
@@ -185,6 +230,8 @@ function ImageTable({
onCancelDownload: (id: string) => void
onDelete: (id: string) => void
onToggle: (id: string, enabled: boolean) => void
storageReady: boolean
storageLoading: boolean
}) {
return (
<div className="space-y-3">
@@ -253,7 +300,8 @@ function ImageTable({
{!img.downloaded && !img.downloading && (
<button
onClick={() => onDownload(img.id)}
disabled={isBusy}
disabled={isBusy || storageLoading || !storageReady}
title={storageLoading ? '正在检查存储配置...' : storageReady ? '下载镜像' : '请先在存储管理中开启镜像缓存存储'}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md hover:bg-gray-800 transition-colors text-xs font-medium disabled:opacity-50"
>
{isBusy ? (
@@ -281,7 +329,8 @@ function ImageTable({
<>
<button
onClick={() => onToggle(img.id, img.enabled)}
disabled={isBusy}
disabled={isBusy || storageLoading || !storageReady}
title={storageLoading ? '正在检查存储配置...' : storageReady ? (img.enabled ? '禁用镜像' : '启用镜像') : '请先在存储管理中开启镜像缓存存储'}
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${
img.enabled
? 'bg-emerald-50 text-emerald-700 border border-emerald-200 hover:bg-emerald-100'
@@ -317,7 +366,7 @@ function ImageTable({
function StatusBadge({ img }: { img: ImageInfo }) {
if (img.downloading) {
const progress = Math.max(0, Math.min(100, img.progress || 0))
const showProgress = img.stage === 'downloading' && progress > 0
const showProgress = img.stage === 'downloading' && (progress > 0 || img.downloaded_bytes > 0)
return (
<div className="inline-flex flex-col gap-1">
<span
@@ -329,7 +378,10 @@ function StatusBadge({ img }: { img: ImageInfo }) {
</span>
{showProgress && (
<span className="block h-1 w-24 overflow-hidden rounded-full bg-amber-100">
<span className="block h-full rounded-full bg-amber-500 transition-all" style={{ width: `${progress}%` }} />
<span
className={`block h-full rounded-full bg-amber-500 transition-all ${progress <= 0 ? 'animate-pulse' : ''}`}
style={{ width: progress > 0 ? `${progress}%` : '35%' }}
/>
</span>
)}
</div>
@@ -375,6 +427,7 @@ function downloadStatusLabel(img: ImageInfo) {
if (img.stage === 'converting') return '转换中'
if (img.stage === 'lxc-create') return '下载中'
if (img.progress > 0) return `下载中 ${Math.min(100, img.progress)}%`
if (img.downloaded_bytes > 0) return `下载中 · ${formatSize(img.downloaded_bytes)}`
return '下载中'
}
+233 -73
View File
@@ -1,23 +1,38 @@
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'
import { Clock, Globe, Lock, LogIn, Monitor, RefreshCw, ShieldCheck, Terminal, Upload, UserCog } from 'lucide-react'
import { Clock, Globe, ListTodo, Lock, LogIn, Minus, Monitor, Plus, RefreshCw, Save, ShieldCheck, Terminal, Upload, UserCog } from 'lucide-react'
import {
changePassword,
changeUsername,
getLoginLogs,
getSSLSettings,
getTaskQueueSettings,
getWebSSHOriginSettings,
LoginLog,
SSLSettings,
TaskQueueSettings,
updateTaskQueueSettings,
updateSSLSettings,
updateWebSSHOriginSettings,
WebSSHOriginSettings,
} from '../services/api'
import { useDialog } from '../components/Dialog'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
type SettingsSection = 'tasks' | 'account' | 'webssh' | 'ssl' | 'logs'
const settingsSections = [
{ id: 'tasks', label: '任务队列', icon: ListTodo },
{ id: 'account', label: '账号设置', icon: UserCog },
{ id: 'webssh', label: 'WebSSH 访问', icon: Terminal },
{ id: 'ssl', label: 'SSL 证书', icon: ShieldCheck },
{ id: 'logs', label: '登录日志', icon: LogIn },
] as const
export default function Settings() {
const dialog = useDialog()
const { username } = useAuth()
const { t } = useLanguage()
const [logs, setLogs] = useState<LoginLog[]>([])
const [loading, setLoading] = useState(true)
const [logPage, setLogPage] = useState(1)
@@ -39,6 +54,10 @@ export default function Settings() {
const [webSSHOrigins, setWebSSHOrigins] = useState<WebSSHOriginSettings | null>(null)
const [webSSHOriginsText, setWebSSHOriginsText] = useState('')
const [savingWebSSHOrigins, setSavingWebSSHOrigins] = useState(false)
const [taskQueue, setTaskQueue] = useState<TaskQueueSettings | null>(null)
const [taskConcurrency, setTaskConcurrency] = useState(2)
const [savingTaskQueue, setSavingTaskQueue] = useState(false)
const [activeSection, setActiveSection] = useState<SettingsSection>('tasks')
const fetchLogs = useCallback(async () => {
try {
@@ -78,13 +97,49 @@ export default function Settings() {
}
}, [])
const fetchTaskQueue = useCallback(async () => {
try {
const res = await getTaskQueueSettings()
const data = res.data.data
if (!data) return
setTaskQueue(data)
setTaskConcurrency(data.concurrency)
} catch (err) {
console.error(err)
}
}, [])
useEffect(() => {
fetchLogs()
fetchSSL()
fetchWebSSHOrigins()
const timer = setInterval(fetchLogs, 15000)
return () => clearInterval(timer)
}, [fetchLogs, fetchSSL, fetchWebSSHOrigins])
fetchTaskQueue()
const logTimer = setInterval(fetchLogs, 15000)
const taskTimer = setInterval(fetchTaskQueue, 5000)
return () => {
clearInterval(logTimer)
clearInterval(taskTimer)
}
}, [fetchLogs, fetchSSL, fetchTaskQueue, fetchWebSSHOrigins])
const handleSaveTaskQueue = async () => {
const concurrency = Math.max(1, Math.min(16, Math.round(taskConcurrency || 1)))
setSavingTaskQueue(true)
try {
const res = await updateTaskQueueSettings(concurrency)
const data = res.data.data
if (data) {
setTaskQueue(data)
setTaskConcurrency(data.concurrency)
}
dialog.alert('完成', '任务队列并发设置已保存并立即生效')
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } }
dialog.alert('失败', e.response?.data?.message || '任务队列设置保存失败')
} finally {
setSavingTaskQueue(false)
}
}
const handleSSLModeChange = (mode: SSLSettings['mode']) => {
setSSLMode(mode)
@@ -190,72 +245,173 @@ export default function Settings() {
const totalPages = Math.ceil(logs.length / pageSize)
return (
<div className="space-y-6">
<div className="space-y-5">
<div>
<h1 className="text-2xl font-bold text-black"></h1>
<p className="mt-1 text-sm text-gray-500"></p>
<h1 className="text-2xl font-bold text-black dark:text-white"></h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">访</p>
</div>
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
<div className="space-y-6">
<SSLCard
ssl={ssl}
sslEnabled={sslEnabled}
sslMode={sslMode}
sslTarget={sslTarget}
sslEmail={sslEmail}
certPEM={certPEM}
keyPEM={keyPEM}
applyNow={applyNow}
savingSSL={savingSSL}
onRefresh={fetchSSL}
onEnabledChange={setSSLEnabled}
onModeChange={handleSSLModeChange}
onTargetChange={setSSLTarget}
onEmailChange={setSSLEmail}
onCertChange={setCertPEM}
onKeyChange={setKeyPEM}
onApplyNowChange={setApplyNow}
onSave={handleSaveSSL}
/>
<div className="grid items-start gap-4 lg:grid-cols-[210px_minmax(0,1fr)]">
<aside className="overflow-x-auto rounded-lg border border-gray-200 bg-white p-2 dark:border-gray-700 dark:bg-gray-900 lg:sticky lg:top-4">
<nav className="flex min-w-max gap-1 lg:min-w-0 lg:flex-col" aria-label="设置分类">
{settingsSections.map((section) => {
const Icon = section.icon
const active = activeSection === section.id
return (
<button
key={section.id}
type="button"
onClick={() => setActiveSection(section.id)}
className={`flex items-center gap-2 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors ${active ? 'bg-black text-white dark:bg-white dark:text-black' : 'text-gray-600 hover:bg-gray-100 hover:text-black dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-white'}`}
>
<Icon className="h-4 w-4 flex-shrink-0" />
<span>{t(section.label)}</span>
</button>
)
})}
</nav>
</aside>
<WebSSHOriginCard
settings={webSSHOrigins}
originsText={webSSHOriginsText}
saving={savingWebSSHOrigins}
onOriginsTextChange={setWebSSHOriginsText}
onRefresh={fetchWebSSHOrigins}
onSave={handleSaveWebSSHOrigins}
/>
<section className="min-w-0">
{activeSection === 'tasks' && (
<TaskQueueCard
settings={taskQueue}
concurrency={taskConcurrency}
saving={savingTaskQueue}
onConcurrencyChange={setTaskConcurrency}
onRefresh={fetchTaskQueue}
onSave={handleSaveTaskQueue}
/>
)}
{activeSection === 'account' && (
<div className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-900">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
<UserCog className="h-4 w-4" />
</h2>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-500" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black dark:border-gray-700 dark:bg-gray-950 dark:text-white" placeholder="至少 3 位" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black dark:border-gray-700 dark:bg-gray-950 dark:text-white" placeholder="至少 6 位" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black dark:border-gray-700 dark:bg-gray-950 dark:text-white" placeholder="输入当前密码以确认修改" />
</div>
</div>
<div className="mt-4 flex justify-end">
<button onClick={handleSaveAccount} className="rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200"></button>
</div>
</div>
)}
{activeSection === 'webssh' && (
<WebSSHOriginCard
settings={webSSHOrigins}
originsText={webSSHOriginsText}
saving={savingWebSSHOrigins}
onOriginsTextChange={setWebSSHOriginsText}
onRefresh={fetchWebSSHOrigins}
onSave={handleSaveWebSSHOrigins}
/>
)}
{activeSection === 'ssl' && (
<SSLCard
ssl={ssl}
sslEnabled={sslEnabled}
sslMode={sslMode}
sslTarget={sslTarget}
sslEmail={sslEmail}
certPEM={certPEM}
keyPEM={keyPEM}
applyNow={applyNow}
savingSSL={savingSSL}
onRefresh={fetchSSL}
onEnabledChange={setSSLEnabled}
onModeChange={handleSSLModeChange}
onTargetChange={setSSLTarget}
onEmailChange={setSSLEmail}
onCertChange={setCertPEM}
onKeyChange={setKeyPEM}
onApplyNowChange={setApplyNow}
onSave={handleSaveSSL}
/>
)}
{activeSection === 'logs' && (
<LoginLogCard logs={logs} logPage={logPage} pageSize={pageSize} totalPages={totalPages} setLogPage={setLogPage} />
)}
</section>
</div>
</div>
)
}
interface TaskQueueCardProps {
settings: TaskQueueSettings | null
concurrency: number
saving: boolean
onConcurrencyChange: (value: number) => void
onRefresh: () => void
onSave: () => void
}
function TaskQueueCard(props: TaskQueueCardProps) {
const setBounded = (value: number) => props.onConcurrencyChange(Math.max(1, Math.min(16, value)))
return (
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
<ListTodo className="h-4 w-4" />
</h2>
<button onClick={props.onRefresh} className="rounded-md border border-gray-200 p-1.5 text-gray-500 hover:bg-gray-50" title="刷新">
<RefreshCw className="h-4 w-4" />
</button>
</div>
<div className="grid grid-cols-2 divide-x divide-gray-200 border-y border-gray-100 bg-gray-50">
<div className="px-3 py-2">
<div className="text-[11px] text-gray-500"></div>
<div className="mt-0.5 text-lg font-semibold text-gray-900">{props.settings?.active ?? 0}</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
<UserCog className="h-4 w-4" />
</h2>
<div className="space-y-4">
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 3 位" />
</div>
<div className="border-t border-gray-100 pt-3">
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 6 位" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="输入当前密码以确认修改" />
</div>
<button onClick={handleSaveAccount} className="w-full rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800"></button>
</div>
<div className="px-3 py-2">
<div className="text-[11px] text-gray-500"></div>
<div className="mt-0.5 text-lg font-semibold text-gray-900">{props.settings?.pending ?? 0}</div>
</div>
</div>
<LoginLogCard logs={logs} logPage={logPage} pageSize={pageSize} totalPages={totalPages} setLogPage={setLogPage} />
<div className="mt-4">
<label className="mb-1.5 block text-xs text-gray-500"></label>
<div className="flex h-9 items-stretch">
<button type="button" onClick={() => setBounded(props.concurrency - 1)} disabled={props.concurrency <= 1} className="flex w-10 items-center justify-center rounded-l-md border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:opacity-30" title="减少并发">
<Minus className="h-4 w-4" />
</button>
<input
type="number"
min={1}
max={16}
value={props.concurrency}
onChange={(event) => setBounded(Number(event.target.value) || 1)}
className="min-w-0 flex-1 border-y border-gray-300 px-2 text-center text-sm font-medium text-black outline-none focus:ring-2 focus:ring-inset focus:ring-black"
/>
<button type="button" onClick={() => setBounded(props.concurrency + 1)} disabled={props.concurrency >= 16} className="flex w-10 items-center justify-center rounded-r-md border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:opacity-30" title="增加并发">
<Plus className="h-4 w-4" />
</button>
</div>
</div>
<div className="mt-4 flex justify-end">
<button onClick={props.onSave} disabled={props.saving} className="inline-flex items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Save className="h-4 w-4" />
{props.saving ? '保存中...' : '保存队列设置'}
</button>
</div>
</div>
)
}
@@ -292,7 +448,7 @@ interface WebSSHOriginCardProps {
function WebSSHOriginCard(props: WebSSHOriginCardProps) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
<Terminal className="h-4 w-4" />WebSSH Origin
@@ -307,7 +463,7 @@ function WebSSHOriginCard(props: WebSSHOriginCardProps) {
<textarea
value={props.originsText}
onChange={(e) => props.onOriginsTextChange(e.target.value)}
rows={5}
rows={4}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black"
/>
</div>
@@ -315,10 +471,12 @@ function WebSSHOriginCard(props: WebSSHOriginCardProps) {
<div className="truncate font-mono" title={props.settings?.current_origin || ''}>{props.settings?.current_origin || '-'}</div>
<div className="mt-1"> Origin</div>
</div>
<button onClick={props.onSave} disabled={props.saving} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Upload className="h-4 w-4" />
{props.saving ? '保存中...' : '保存 Origin 白名单'}
</button>
<div className="flex justify-end">
<button onClick={props.onSave} disabled={props.saving} className="inline-flex items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Upload className="h-4 w-4" />
{props.saving ? '保存中...' : '保存 Origin 白名单'}
</button>
</div>
</div>
</div>
)
@@ -425,10 +583,12 @@ function SSLCard(props: SSLCardProps) {
</label>
<button onClick={props.onSave} disabled={props.savingSSL} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Upload className="h-4 w-4" />
{props.savingSSL ? '保存中...' : '保存 SSL 设置'}
</button>
<div className="flex justify-end">
<button onClick={props.onSave} disabled={props.savingSSL} className="inline-flex items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Upload className="h-4 w-4" />
{props.savingSSL ? '保存中...' : '保存 SSL 设置'}
</button>
</div>
</div>
</div>
)
+369
View File
@@ -0,0 +1,369 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { AlertCircle, CheckCircle2, HardDrive, RefreshCw, Save } from 'lucide-react'
import { getStorageInfo, updateStoragePools, StorageDisk, StorageInfo, StoragePool } from '../services/api'
import { useLanguage } from '../contexts/LanguageContext'
const contentOptions = [
['lxc', 'LXC 容器'],
['kvm', 'KVM 磁盘'],
['images', '镜像缓存'],
['snapshots', '快照'],
['backups', '备份'],
] as const
const contentLabels = Object.fromEntries(contentOptions)
const contentColors: Record<string, string> = {
lxc: '#2563eb',
kvm: '#7c3aed',
images: '#d97706',
snapshots: '#059669',
backups: '#0891b2',
}
export default function Storage() {
const { t } = useLanguage()
const [info, setInfo] = useState<StorageInfo | null>(null)
const [pools, setPools] = useState<StoragePool[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const fetchData = useCallback(async () => {
setLoading(true)
try {
const res = await getStorageInfo()
const data = res.data.data || { pools: [], disks: [], content_types: [] }
setInfo(data)
setPools(data.pools || [])
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
useEffect(() => {
if (!saveMessage) return
const timer = window.setTimeout(() => setSaveMessage(null), 3500)
return () => window.clearTimeout(timer)
}, [saveMessage])
const mountedDisks = useMemo(() => (info?.disks || []).filter((disk) => !!disk.mount_point), [info?.disks])
const save = async () => {
setSaveMessage(null)
setSaving(true)
try {
const normalized = pools
.map((pool) => ({
...pool,
id: (pool.id || pool.name || '').trim(),
name: (pool.name || '').trim(),
path: (pool.path || '').trim(),
content_types: pool.content_types || [],
default_contents: (pool.default_contents || []).filter((item) => (pool.content_types || []).includes(item)),
enabled: pool.enabled !== false,
}))
const res = await updateStoragePools(normalized)
const data = res.data.data
if (data) {
setInfo(data)
setPools(data.pools || [])
}
setSaveMessage({ type: 'success', text: '存储配置已保存' })
} catch (err: any) {
setSaveMessage({ type: 'error', text: err?.response?.data?.message || '保存存储配置失败' })
} finally {
setSaving(false)
}
}
const updateDiskPool = (disk: StorageDisk, updater: (pool: StoragePool) => StoragePool) => {
setPools((current) => {
const index = current.findIndex((pool) => poolForDisk(pool, disk))
const base = index >= 0 ? current[index] : defaultPoolForDisk(disk)
const nextPool = updater(base)
if (index >= 0) {
return current.map((item, i) => i === index ? nextPool : item)
}
return [...current, nextPool]
})
}
const toggleContent = (disk: StorageDisk, content: string) => {
updateDiskPool(disk, (pool) => {
const current = pool.content_types || []
const enabled = current.includes(content)
const contentTypes = enabled ? current.filter((item) => item !== content) : [...current, content]
return {
...pool,
enabled: true,
content_types: contentTypes,
default_contents: (pool.default_contents || []).filter((item) => contentTypes.includes(item)),
}
})
}
const toggleDefault = (disk: StorageDisk, content: string) => {
setPools((current) => {
const index = current.findIndex((pool) => poolForDisk(pool, disk))
const base = index >= 0 ? current[index] : defaultPoolForDisk(disk)
if (!(base.content_types || []).includes(content)) return current
const hasDefault = (base.default_contents || []).includes(content)
const baseDefaults = (base.default_contents || []).filter((value) => value !== content)
const cleared = current.map((item) => ({
...item,
default_contents: (item.default_contents || []).filter((value) => value !== content),
}))
const nextPool = {
...base,
default_contents: hasDefault ? baseDefaults : [...baseDefaults, content],
}
if (index >= 0) {
return cleared.map((item, i) => i === index ? nextPool : item)
}
return [...cleared, nextPool]
})
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black"></div>
</div>
)
}
return (
<div className="space-y-5">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-black dark:text-white">{t('存储管理')}</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{t('只显示已挂载磁盘;勾选后,对应功能可以选择该磁盘保存数据。')}</p>
</div>
<div className="flex gap-2">
<button onClick={fetchData} className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">
<RefreshCw className="h-4 w-4" />{t('刷新')}
</button>
<button onClick={save} disabled={saving} className="inline-flex items-center gap-2 rounded-md bg-black px-3 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Save className="h-4 w-4" />{t(saving ? '保存中...' : '保存')}
</button>
</div>
</div>
{saveMessage && (
<div
role="status"
aria-live="polite"
className={`flex items-center gap-2 rounded-md border px-3 py-2 text-sm ${
saveMessage.type === 'success'
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
: 'border-red-200 bg-red-50 text-red-700'
}`}
>
{saveMessage.type === 'success'
? <CheckCircle2 className="h-4 w-4 shrink-0" />
: <AlertCircle className="h-4 w-4 shrink-0" />}
<span>{t(saveMessage.text)}</span>
</div>
)}
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<table className="w-full min-w-[1240px] text-sm">
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
<tr>
<th className="px-4 py-3 text-left font-medium">{t('磁盘')}</th>
<th className="px-4 py-3 text-left font-medium">{t('空间分布')}</th>
<th className="px-4 py-3 text-left font-medium">{t('用于存储')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{mountedDisks.length === 0 ? (
<tr><td colSpan={3} className="px-4 py-10 text-center text-gray-400">{t('未检测到已挂载磁盘')}</td></tr>
) : mountedDisks.map((disk) => {
const pool = pools.find((item) => poolForDisk(item, disk))
const contentUsage = contentUsageMap(pool?.content_usage || disk.content_usage || [])
const clicdUsed = pool?.clicd_used_bytes || disk.clicd_used_bytes || 0
return (
<tr key={`${disk.path}-${disk.mount_point}`} className="align-top hover:bg-gray-50/70">
<td className="px-4 py-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 items-center justify-center rounded-md bg-gray-100 text-gray-600">
<HardDrive className="h-5 w-5" />
</div>
<div>
<div className="font-mono text-xs font-medium text-gray-900">{disk.path || disk.name}</div>
<div className="mt-1 text-xs text-gray-500">{disk.model || disk.fstype || disk.type || '-'}</div>
<div className="mt-1 font-mono text-xs text-gray-400">{disk.mount_point}</div>
</div>
</div>
</td>
<td className="px-4 py-4">
<DiskUsageBar disk={disk} contentUsage={contentUsage} clicdUsed={clicdUsed} />
</td>
<td className="px-4 py-4">
<div className="flex min-w-[620px] flex-nowrap items-start gap-2">
{contentOptions.map(([value, label]) => {
const checked = (pool?.content_types || []).includes(value)
const isDefault = (pool?.default_contents || []).includes(value)
return (
<div key={value} className={`w-[116px] shrink-0 rounded-md border px-2.5 py-2 ${checked ? 'border-gray-300 bg-white' : 'border-gray-200 bg-gray-50'}`}>
<label className="flex cursor-pointer items-center gap-2 text-xs text-gray-700">
<input type="checkbox" checked={checked} onChange={() => toggleContent(disk, value)} />
{t(label)}
</label>
{checked && (
<div className="mt-1.5 flex items-center justify-between gap-2 border-t border-gray-100 pt-1.5">
<span className="text-[11px] text-gray-500">{t('默认盘')}</span>
<button
type="button"
role="switch"
aria-checked={isDefault}
title={isDefault ? `${t('关闭')} ${t(label)} ${t('默认盘')}` : `${t('设为')} ${t(label)} ${t('默认盘')}`}
onClick={() => toggleDefault(disk, value)}
className={`relative inline-flex h-5 w-9 shrink-0 appearance-none items-center rounded-full border p-0 transition-colors focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 ${isDefault ? 'border-black bg-black' : 'border-gray-300 bg-gray-200'}`}
>
<span className={`pointer-events-none absolute left-0.5 top-0.5 block h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ${isDefault ? 'translate-x-4' : 'translate-x-0'}`} />
</button>
</div>
)}
</div>
)
})}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
function DiskUsageBar({
disk,
contentUsage,
clicdUsed,
}: {
disk: StorageDisk
contentUsage: Record<string, number>
clicdUsed: number
}) {
const { t } = useLanguage()
const total = Math.max(0, disk.size_bytes || 0)
const free = Math.max(0, Math.min(total, disk.free_bytes || 0))
const used = Math.max(0, total - free)
const rawContentSegments = contentOptions.map(([value, label]) => ({
key: value,
label,
size: Math.max(0, contentUsage[value] || 0),
color: contentColors[value],
}))
const rawContentTotal = rawContentSegments.reduce((sum, segment) => sum + segment.size, 0)
const normalizedClicdUsed = Math.max(0, Math.min(used, Math.max(clicdUsed || 0, rawContentTotal)))
const contentScale = rawContentTotal > normalizedClicdUsed && rawContentTotal > 0
? normalizedClicdUsed / rawContentTotal
: 1
const contentSegments = rawContentSegments.map((segment) => ({ ...segment, size: segment.size * contentScale }))
const categorizedClicdUsed = contentSegments.reduce((sum, segment) => sum + segment.size, 0)
const unclassifiedClicdUsed = Math.max(0, normalizedClicdUsed - categorizedClicdUsed)
const nonClicdUsed = Math.max(0, used - normalizedClicdUsed)
const segments = [
...contentSegments,
{ key: 'clicd-other', label: 'CLICD 其他', size: unclassifiedClicdUsed, color: '#111827' },
{ key: 'other', label: '非 CLICD', size: nonClicdUsed, color: '#4b5563' },
{ key: 'free', label: '可用空间', size: free, color: '#e5e7eb' },
].filter((segment) => segment.size > 0)
return (
<div className="min-w-[420px] max-w-[620px]">
<div className="flex items-center justify-between gap-4 text-xs text-gray-600">
<span>{t('已用')} {formatBytes(used)} / {formatBytes(total)}</span>
<span>{usagePct(used, total).toFixed(1)}% · {t('可用')} {formatBytes(free)}</span>
</div>
<div className="mt-2 flex h-8 w-full overflow-hidden rounded-md border border-gray-300 bg-gray-100">
{segments.map((segment) => {
const pct = usagePct(segment.size, total)
return (
<div
key={segment.key}
title={`${t(segment.label)}: ${formatBytes(segment.size)} (${pct.toFixed(2)}%)`}
className="flex h-full items-center justify-center overflow-hidden border-r border-white/70 text-[10px] font-medium text-white last:border-r-0"
style={{ width: `${pct}%`, minWidth: pct > 0 && pct < 0.6 ? '3px' : undefined, backgroundColor: segment.color }}
>
{pct >= 9 && <span className={segment.key === 'free' ? 'text-gray-600' : ''}>{t(segment.label)}</span>}
</div>
)
})}
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1.5">
{segments.map((segment) => (
<div key={segment.key} className="flex items-center gap-1.5 text-[11px] text-gray-600">
<span className="h-2.5 w-2.5 shrink-0 rounded-sm border border-black/5" style={{ backgroundColor: segment.color }} />
<span>{t(segment.label)}</span>
<span className="font-medium text-gray-800">{formatBytes(segment.size)}</span>
<span className="text-gray-400">{usagePct(segment.size, total).toFixed(1)}%</span>
</div>
))}
</div>
</div>
)
}
function poolForDisk(pool: StoragePool, disk: StorageDisk) {
if (!disk.mount_point) return false
const mount = cleanPath(disk.mount_point)
const poolMount = cleanPath(pool.mount_point || '')
const poolPath = cleanPath(pool.path || '')
return poolMount === mount || poolPath === mount || poolPath.startsWith(`${mount}/`)
}
function defaultPoolForDisk(disk: StorageDisk): StoragePool {
const mount = cleanPath(disk.mount_point || '/')
const baseName = mount === '/' ? 'system' : mount.split('/').filter(Boolean).pop() || disk.name || 'disk'
const primaryContents = mount === '/' ? contentOptions.map(([value]) => value) : []
return {
id: `disk-${slugID(mount === '/' ? 'root' : baseName)}`,
name: `${baseName} (${disk.path || disk.name})`,
path: mount === '/' ? '/var/lib/clicd' : `${mount}/clicd`,
content_types: primaryContents,
default_contents: [...primaryContents],
enabled: true,
mount_point: disk.mount_point,
}
}
function cleanPath(value: string) {
return value.replace(/\\/g, '/').replace(/\/+$/g, '') || '/'
}
function slugID(value: string) {
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'storage'
}
function contentUsageMap(items: Array<{ content_type: string; size_bytes: number }>) {
return items.reduce<Record<string, number>>((acc, item) => {
acc[item.content_type] = (acc[item.content_type] || 0) + (item.size_bytes || 0)
return acc
}, {})
}
function usagePct(used: number, total: number) {
if (!total || total <= 0) return 0
return Math.max(0, Math.min(100, (used / total) * 100))
}
function formatBytes(bytes: number) {
if (!bytes) return '-'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = bytes
let index = 0
while (value >= 1024 && index < units.length - 1) {
value /= 1024
index++
}
return `${value.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
}
+6 -2
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { Copy, HardDrive, KeyRound, LogIn, RefreshCw, Save, ScrollText, UserCog, X } from 'lucide-react'
import { useDialog } from '../components/Dialog'
import { useLanguage } from '../contexts/LanguageContext'
import api, { AuditLog, ImageInfo, LoginLog, getImages, updateSubUserImages } from '../services/api'
import { copyToClipboard } from '../utils/clipboard'
@@ -31,6 +32,7 @@ interface AuditLogExt extends AuditLog {
export default function SubUserManagement() {
const dialog = useDialog()
const { t } = useLanguage()
const [users, setUsers] = useState<SubUserItem[]>([])
const [loading, setLoading] = useState(true)
const [auditLogs, setAuditLogs] = useState<AuditLogExt[] | null>(null)
@@ -173,8 +175,10 @@ export default function SubUserManagement() {
return (
<div className="space-y-5">
<div>
<h1 className="text-xl font-semibold text-black dark:text-white"></h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> {users.length} </p>
<h1 className="text-xl font-semibold text-black dark:text-white">{t('子用户管理')}</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t('容器分配的子用户列表,共')} {users.length} {t('个')}
</p>
</div>
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
+74 -2
View File
@@ -75,6 +75,8 @@ export interface Container {
uuid: string
name: string
virtualization?: string
storage_pool_id?: string
storage_path?: string
template: string
vcpu: number
ram_mb: number
@@ -143,6 +145,7 @@ export interface CreateContainerRequest {
name: string
virtualization: string
template_id: string
storage_pool_id?: string
vcpu: number
cpu_percent: number
ram_mb: number
@@ -180,6 +183,51 @@ export interface CreateContainerRequest {
expires_at: string
}
export interface StoragePool {
id: string
name: string
path: string
content_types: string[]
default_contents?: string[]
enabled: boolean
available?: boolean
exists?: boolean
size_bytes?: number
used_bytes?: number
free_bytes?: number
mount_point?: string
clicd_used_bytes?: number
content_usage?: StorageContentUsage[]
error?: string
}
export interface StorageContentUsage {
content_type: string
size_bytes: number
}
export interface StorageDisk {
name: string
path: string
type: string
fstype: string
mount_point: string
model: string
size_bytes: number
used_bytes: number
free_bytes: number
storage_pool_id?: string
storage_path?: string
clicd_used_bytes?: number
content_usage?: StorageContentUsage[]
}
export interface StorageInfo {
pools: StoragePool[]
disks: StorageDisk[]
content_types: string[]
}
export interface ReinstallContainerOptions {
ssh_auth_mode?: string
ssh_password?: string
@@ -258,6 +306,10 @@ export interface HostInfo {
}
}
export interface CreateSnapshotOptions {
storage_pool_id?: string
}
export interface HostMetricPoint {
ts: number
cpu: number
@@ -430,6 +482,18 @@ export interface AuditLog {
export const getLoginLogs = () =>
api.get<APIResponse<LoginLog[]>>('/login-logs')
export interface TaskQueueSettings {
concurrency: number
active: number
pending: number
}
export const getTaskQueueSettings = () =>
api.get<APIResponse<TaskQueueSettings>>('/task-queue/settings')
export const updateTaskQueueSettings = (concurrency: number) =>
api.put<APIResponse<TaskQueueSettings>>('/task-queue/settings', { concurrency })
export interface SSLCertificateInfo {
subject: string
issuer: string
@@ -741,6 +805,12 @@ export const getHostHistory = () =>
export const getHostReport = () =>
api.get<APIResponse<HostProbeReport>>('/host-report')
export const getStorageInfo = () =>
api.get<APIResponse<StorageInfo>>('/storage')
export const updateStoragePools = (pools: StoragePool[]) =>
api.put<APIResponse<StorageInfo>>('/storage', { pools })
// Snapshots
export interface Snapshot {
id: string
@@ -775,8 +845,8 @@ export const getSnapshots = () =>
export const getContainerSnapshots = (id: ContainerIdentifier) =>
api.get<APIResponse<ContainerSnapshotsResponse>>(`/containers/${id}/snapshots`)
export const createContainerSnapshot = (id: ContainerIdentifier) =>
api.post<APIResponse<Snapshot>>(`/containers/${id}/snapshots`, {}, { timeout: 600000 })
export const createContainerSnapshot = (id: ContainerIdentifier, options?: CreateSnapshotOptions) =>
api.post<APIResponse<Snapshot>>(`/containers/${id}/snapshots`, options || {}, { timeout: 600000 })
export const deleteContainerSnapshot = (id: ContainerIdentifier, snapshotId: string) =>
api.delete<APIResponse>(`/containers/${id}/snapshots/${snapshotId}`, { timeout: 600000 })
@@ -818,6 +888,8 @@ export interface Task {
container_name: string
status: string
error?: string
stage?: string
stage_detail?: string
created_at: string
template_id?: string
config?: CreateContainerRequest
+176
View File
@@ -392,6 +392,23 @@ const exact: Record<string, string> = {
'暂未获取到宿主机信息': 'No host information available',
'面板资源状态与容器概览': 'Panel resource status and container overview',
'宿主机资源状态与容器概览': 'Host resource status and container overview',
'存储管理': 'Storage Management',
'只显示已挂载磁盘;勾选后,对应功能可以选择该磁盘保存数据。': 'Only mounted disks are shown. Enable a content type to make that disk available to the corresponding feature.',
'空间分布': 'Space Distribution',
'用于存储': 'Storage Usage',
'未检测到已挂载磁盘': 'No mounted disks detected',
'镜像缓存': 'Image Cache',
'备份': 'Backups',
'默认盘': 'Default Disk',
'设为': 'Set as',
'CLICD 其他': 'Other CLICD Data',
'非 CLICD': 'Non-CLICD Data',
'可用空间': 'Free Space',
'存储配置已保存': 'Storage settings saved',
'保存存储配置失败': 'Failed to save storage settings',
'任务队列、账号、安全证书与访问记录': 'Task queue, account, certificates, and access records',
'设置分类': 'Settings categories',
'WebSSH 访问': 'WebSSH Access',
'账号设置': 'Account Settings',
'当前用户名': 'Current Username',
'新用户名,留空则不修改': 'New Username, leave blank to keep unchanged',
@@ -401,6 +418,12 @@ const exact: Record<string, string> = {
'至少 6 位': 'At least 6 characters',
'输入当前密码以确认修改': 'Enter current password to confirm changes',
'保存修改': 'Save Changes',
'总并发上限': 'Total Concurrency Limit',
'减少并发': 'Decrease concurrency',
'增加并发': 'Increase concurrency',
'保存队列设置': 'Save Queue Settings',
'任务队列并发设置已保存并立即生效': 'Task queue concurrency saved and applied immediately',
'任务队列设置保存失败': 'Failed to save task queue settings',
'SSL 证书': 'SSL Certificate',
'启用 HTTPS / WSS': 'Enable HTTPS / WSS',
'IP / 域名': 'IP / Domain',
@@ -761,6 +784,31 @@ const exact: Record<string, string> = {
'初始化失败': 'Initialization failed',
'初始化完成': 'Initialization complete',
'排队等待': 'Queued',
'当前阶段': 'Current Stage',
'准备初始化环境': 'Preparing initialization environment',
'检查模板与创建参数': 'Checking template and creation settings',
'下载模板并创建基础文件系统': 'Downloading template and creating root filesystem',
'复制容器数据到存储磁盘': 'Copying container data to storage disk',
'创建容量限制磁盘并复制 rootfs': 'Creating quota disk and copying rootfs',
'配置 CPU、内存与网络限制': 'Configuring CPU, memory, and network limits',
'分配 IPv4、IPv6 与 NAT 端口': 'Allocating IPv4, IPv6, and NAT ports',
'保存容器配置': 'Saving container configuration',
'写入容器网络配置': 'Writing container network configuration',
'安装并配置 SSH 服务': 'Installing and configuring SSH',
'转换非特权容器文件权限': 'Converting unprivileged container permissions',
'设置容器登录凭据': 'Setting container login credentials',
'启动容器并等待网络就绪': 'Starting container and waiting for network',
'启动虚拟机并等待网络就绪': 'Starting VM and waiting for network',
'检查 KVM 镜像与创建参数': 'Checking KVM image and creation settings',
'选择虚拟机存储磁盘': 'Selecting VM storage disk',
'分配 IPv4 与 IPv6 地址': 'Allocating IPv4 and IPv6 addresses',
'创建 Windows 虚拟磁盘': 'Creating Windows virtual disk',
'生成 Windows 自动应答配置': 'Generating Windows unattended setup',
'创建 KVM 系统磁盘': 'Creating KVM system disk',
'生成 cloud-init 初始化配置': 'Generating cloud-init configuration',
'注册 KVM 虚拟机': 'Registering KVM virtual machine',
'分配并配置 NAT 端口': 'Allocating and configuring NAT ports',
'保存虚拟机配置': 'Saving virtual machine configuration',
'处理中': 'Processing',
'未知系统': 'Unknown system',
'处理失败': 'Failed',
@@ -886,6 +934,132 @@ const exact: Record<string, string> = {
'生成新密码': 'Generate new password',
'自定义密码': 'Custom password',
'生成密码': 'Generate password',
'不限速': 'Unlimited',
'下': 'Down',
'不限': 'Unlimited',
'/ 上': '/ Up',
'请选择登录方式': 'Select a login method',
'未检测到可分配公网 IPv4': 'No allocatable public IPv4 detected',
'使用': 'Use',
'正在检测 IPv6 前缀...': 'Checking IPv6 prefixes...',
'公网 NAT': 'Public NAT',
'不分配 NAT 端口': 'Do not assign NAT ports',
'未检测到可分配 IPv6 前缀;宿主机只有 /128 单个 IPv6 地址,不能分配给容器。': 'No allocatable IPv6 prefix was detected. The host only has a single /128 IPv6 address, which cannot be assigned to containers.',
'宿主机检测到 IPv6 前缀,但 IPv6 出站连通性测试失败。': 'The host detected an IPv6 prefix, but the outbound IPv6 connectivity test failed.',
'个可分配地址': 'allocatable addresses',
'将分配': 'Will assign',
'请勾选任意一个可用网络': 'Select at least one available network',
'局域网 IPv4 配置有误': 'Invalid LAN IPv4 configuration',
'请填写有效的 IPv4 地址、子网掩码和网关': 'Enter a valid IPv4 address, subnet mask, and gateway',
'未配置存储': 'Storage not configured',
'请先在存储管理中为': 'In Storage Management, enable storage for',
'开启至少一块存储磁盘': 'Enable at least one storage disk',
'登录方式有误': 'Invalid login method',
'至': 'to',
'当前宿主机不支持 KVM': 'The current host does not support KVM',
'系统镜像,请先在「镜像管理」中下载镜像模板。': 'system images available. Download an image template from Images first.',
'存储磁盘': 'Storage Disk',
'自动选择(默认盘优先,空间不足自动切换)': 'Automatic selection (prefer default disk and switch when space is insufficient)',
'尚未开启': 'Not enabled',
'存储,当前无法创建。': 'storage is not enabled, so creation is currently unavailable.',
'去开启': 'Configure Now',
'默认勾选当前系统;取消后,子用户也不能重装该系统。': 'The current system is selected by default. Clearing it also prevents sub-users from reinstalling that system.',
'局域网 DHCP': 'LAN DHCP',
'macvlan 独立局域网 IP': 'Independent LAN IP via macvlan',
'未检测到可用上联网卡': 'No available uplink interface detected',
'DHCP 自动获取': 'Obtain automatically via DHCP',
'子网掩码': 'Subnet Mask',
'不选则长期有效': 'Leave blank for no expiration',
'均': 'Avg',
'/ 峰': '/ Peak',
'到期': 'Expires',
'未分配': 'Unassigned',
'下行': 'Download',
'上行': 'Upload',
'修改公网 IP 分配': 'Change Public IP Assignment',
'尚未开启快照存储,无法新建或启用定时快照。': 'Snapshot storage is not enabled. New and scheduled snapshots are unavailable.',
'新建快照存储磁盘': 'Storage Disk for New Snapshots',
'仅影响手动新建快照;定时快照使用默认磁盘。': 'Only affects manually created snapshots. Scheduled snapshots use the default disk.',
'在': 'at',
'IPv4 规则覆盖': 'IPv4 rules cover',
'独立公网 IPv4': 'independent public IPv4',
'公网 IP 分配': 'Public IP Assignment',
'修改后会重放端口映射、SNAT 和防火墙规则。': 'Changing assignments reapplies port mappings, SNAT, and firewall rules.',
'随机数量': 'Random Count',
'没有可选择的公网 IPv4,请先到路由管理配置 IPv4 池。': 'No public IPv4 addresses are available. Configure the IPv4 pool in Routing first.',
'独立 IPv6': 'Independent IPv6',
'自定义地址必须落在路由管理配置的 IPv6 前缀内。': 'Custom addresses must be within an IPv6 prefix configured in Routing.',
'未分配 IPv4 NAT 端口配额': 'No IPv4 NAT port quota assigned',
'已达到管理员分配的 IPv4 NAT 端口配额': 'The administrator-assigned IPv4 NAT port quota has been reached',
'不分配': 'Do Not Assign',
'随机分配': 'Random Allocation',
'自定义': 'Custom',
'SSH Key 格式不正确': 'Invalid SSH key format',
'公网 IP 分配失败': 'Public IP assignment failed',
'请检查地址是否可用或已被占用': 'Check whether the address is available or already in use',
'未分配 IPv4 NAT': 'IPv4 NAT not assigned',
'该容器未分配 IPv4 NAT 端口配额。': 'This container has no IPv4 NAT port quota.',
'未配置快照存储': 'Snapshot storage not configured',
'请先在存储管理中为快照开启至少一块存储磁盘。': 'Enable at least one snapshot storage disk in Storage Management first.',
'个月': 'months',
'个任务': 'tasks',
'剩余': 'Remaining',
'磨损': 'Wear',
'启停': 'Power Cycles',
'线程': 'threads',
'块硬盘': 'disks',
'个进程': 'processes',
'虚拟': 'Virtual',
'尚未开启镜像缓存存储,无法下载新镜像。': 'Image cache storage is not enabled, so new images cannot be downloaded.',
'请先在存储管理中开启镜像缓存存储': 'Enable image cache storage in Storage Management first',
'正在检查存储配置...': 'Checking storage configuration...',
'池内': 'In Pool',
'范围': 'Range',
'条映射': 'mappings',
'模式': 'Mode',
'NAT4、公网 IPv4 池和 IPv6 地址分配': 'NAT4, public IPv4 pool, and IPv6 address assignment',
'编辑 NAT4 范围': 'Edit NAT4 Range',
'起始端口': 'Start Port',
'结束端口': 'End Port',
'NAT4 范围必须是 1-65535,且起始端口不能大于结束端口': 'The NAT4 range must be within 1-65535, and the start port cannot exceed the end port',
'保存 NAT4 范围失败': 'Failed to save NAT4 range',
'剩余 / 总数': 'Remaining / Total',
'由局域网 DHCP 分配': 'Assigned by LAN DHCP',
'局域网 DHCP 分配': 'LAN DHCP Assignments',
'暂无局域网 DHCP 分配': 'No LAN DHCP assignments',
'公网 IPv4 池': 'Public IPv4 Pool',
'编辑 IP 池': 'Edit IP Pool',
'暂未配置公网 IPv4 池': 'No public IPv4 pool configured',
'掩码': 'Mask',
'分配给': 'Assigned To',
'空闲': 'Free',
'编辑 IPv4 池': 'Edit IPv4 Pool',
'IPv4 网关不能为空': 'IPv4 gateway is required',
'IPv4 地址不能为空': 'IPv4 address is required',
'保存 IPv4 池失败': 'Failed to save IPv4 pool',
'打开容器': 'Open Container',
'IPv4 池内暂无地址': 'No addresses in the IPv4 pool',
'添加 IPv4': 'Add IPv4',
'检测到的 IPv6 前缀': 'Detected IPv6 Prefixes',
'暂无 IPv6 前缀': 'No IPv6 prefixes',
'IPv6 网卡不能为空': 'IPv6 interface is required',
'本机': 'Local',
'暂无 IPv4 NAT 映射': 'No IPv4 NAT mappings',
'运行时名称': 'Runtime Name',
'客户机 IPv4': 'Guest IPv4',
'宿主 IPv4': 'Host IPv4',
'宿主端口': 'Host Port',
'客户机端口': 'Guest Port',
'大量': 'Large',
'的快照吗?此操作不可恢复。': ' snapshot? This action cannot be undone.',
'· 默认勾选当前系统,取消后将禁止重装该系统': ' · the current system is selected by default; clearing it prevents reinstalling that system',
'暂无已下载并启用的镜像': 'No downloaded and enabled images',
'已选择': 'Selected',
'加载失败': 'Loading failed',
'请填写 SSH 公钥': 'Enter an SSH public key',
'SSH 公钥长度不能超过 8192 字符': 'The SSH public key cannot exceed 8192 characters',
'SSH 公钥只能填写一行': 'The SSH public key must be on one line',
'SSH 公钥格式不正确': 'Invalid SSH public key format',
}
const artifactPatterns: RegExp[] = [
@@ -939,6 +1113,7 @@ const replacements: Array<[RegExp, string]> = [
[/告警列表\s*\((\d+)\)/g, 'Alert List ($1)'],
[/共\s*(\d+)\s*个\s*Container/g, 'Total $1 containers'],
[/共\s*(\d+)\s*个\s*容器/g, 'Total $1 containers'],
[/共\s*(\d+)\s*个\s*任务/g, 'Total $1 tasks'],
[/(\d+)\s*个前缀,(\d+)\s*个地址已分配/g, '$1 prefixes, $2 addresses assigned'],
[/共\s*(\d+)\s*条/g, 'Total $1'],
[/共\s*(\d+)\s*个/g, 'Total $1 items'],
@@ -988,6 +1163,7 @@ const replacements: Array<[RegExp, string]> = [
[/^(.+?)\s*-\s*登录日志$/g, '$1 - Login Logs'],
[/^(.+?)。下次登录生效$/g, '$1. Takes effect at next login'],
[/阶段:(.+)$/g, 'Stage: $1'],
[/正在初始化:(.+)$/g, 'Initializing: $1'],
[/\$\{days\}天/g, '${days} days'],
[/\$\{hours\}小时/g, '${hours} hours'],
[/\$\{hours\}\s*小时/g, '${hours} hours'],