release: v1.0.7

This commit is contained in:
MengMengCode
2026-06-06 11:17:57 +08:00
parent 28f81a8f1c
commit 21b87d3d56
15 changed files with 336 additions and 70 deletions
+12
View File
@@ -9,6 +9,7 @@ import (
"clicd/internal/config" "clicd/internal/config"
"clicd/internal/lxc" "clicd/internal/lxc"
"clicd/internal/version"
) )
var lxcManager = lxc.NewManager() var lxcManager = lxc.NewManager()
@@ -451,3 +452,14 @@ func deletePortMapping(w http.ResponseWriter, r *http.Request, id int, indexStr
} }
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mappings}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mappings})
} }
// HandleVersion returns the current CLICD version.
func HandleVersion(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
"version": version.Current(),
}})
}
+3
View File
@@ -112,6 +112,9 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys))) mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete))) mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete)))
// Version (public)
mux.HandleFunc("/api/version", corsMiddleware(api.HandleVersion))
// Static files // Static files
if webFS != nil { if webFS != nil {
fs := http.FileServer(webFS) fs := http.FileServer(webFS)
-1
View File
@@ -1 +0,0 @@
+2 -1
View File
@@ -1,7 +1,7 @@
package version package version
var ( var (
Version = "1.0.6" Version = "1.0.7"
Repo = "MengMengCode/CLICD" Repo = "MengMengCode/CLICD"
) )
@@ -12,3 +12,4 @@ func Current() string {
return Version return Version
} }
+9 -1
View File
@@ -5,8 +5,16 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CLICD - LXC Container Manager</title> <title>CLICD - LXC Container Manager</title>
<script>
(function() {
var theme = localStorage.getItem('clicd_theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
</script>
</head> </head>
<body class="bg-white text-black"> <body class="bg-white text-black dark:bg-gray-950 dark:text-white">
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
+2 -2
View File
@@ -19,8 +19,8 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-white"> <div className="min-h-screen flex items-center justify-center bg-white dark:bg-gray-950">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black dark:border-white"></div>
</div> </div>
) )
} }
+1 -1
View File
@@ -6,7 +6,7 @@ export default function Layout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
return ( return (
<div className="min-h-screen bg-gray-50 flex"> <div className="min-h-screen bg-gray-50 flex dark:bg-gray-950">
<Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} /> <Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
<main className={`flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}> <main className={`flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}>
<div className="p-6"> <div className="p-6">
+35 -21
View File
@@ -1,5 +1,6 @@
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { RefreshCw } from 'lucide-react' import { RefreshCw } from 'lucide-react'
import { useTheme } from '../contexts/ThemeContext'
export type StatsRangeKey = '30m' | '1h' | '1d' | '1w' export type StatsRangeKey = '30m' | '1h' | '1d' | '1w'
@@ -45,17 +46,19 @@ export default function ResourceStatsPanel({
charts: ResourceChartConfig[] charts: ResourceChartConfig[]
}) { }) {
return ( return (
<section className="border border-gray-200 rounded-lg bg-white overflow-hidden"> <section className="border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 overflow-hidden">
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-gray-200 bg-white"> <div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
<h2 className="text-sm font-semibold text-gray-950"></h2> <h2 className="text-sm font-semibold text-gray-950 dark:text-white"></h2>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="inline-flex rounded border border-gray-200 bg-gray-50 p-0.5"> <div className="inline-flex rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-0.5">
{(Object.keys(rangeLabels) as StatsRangeKey[]).map((item) => ( {(Object.keys(rangeLabels) as StatsRangeKey[]).map((item) => (
<button <button
key={item} key={item}
onClick={() => onRangeChange(item)} onClick={() => onRangeChange(item)}
className={`h-7 px-3 rounded text-xs font-medium transition-colors ${ className={`h-7 px-3 rounded text-xs font-medium transition-colors ${
range === item ? 'bg-gray-800 text-white shadow-sm' : 'text-gray-500 hover:text-gray-900' range === item
? 'bg-gray-800 text-white shadow-sm dark:bg-white dark:text-black'
: 'text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white'
}`} }`}
> >
{rangeLabels[item]} {rangeLabels[item]}
@@ -64,7 +67,7 @@ export default function ResourceStatsPanel({
</div> </div>
<button <button
onClick={onRefresh} onClick={onRefresh}
className="h-8 w-8 inline-flex items-center justify-center rounded border border-gray-200 text-gray-500 hover:bg-gray-50 hover:text-gray-900" className="h-8 w-8 inline-flex items-center justify-center rounded border border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white"
title="刷新" title="刷新"
> >
<RefreshCw className="w-4 h-4" /> <RefreshCw className="w-4 h-4" />
@@ -90,11 +93,11 @@ function DetailedChart({ chart, className }: { chart: ResourceChartConfig; class
<div className={`p-4 ${className}`}> <div className={`p-4 ${className}`}>
<div className="flex items-start justify-between gap-3 mb-2"> <div className="flex items-start justify-between gap-3 mb-2">
<div> <div>
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-950"> <div className="flex items-center gap-1.5 text-sm font-semibold text-gray-950 dark:text-white">
<span className="text-gray-500">{chart.icon}</span> <span className="text-gray-500 dark:text-gray-400">{chart.icon}</span>
<span>{chart.title}</span> <span>{chart.title}</span>
</div> </div>
{chart.detail && <p className="mt-0.5 text-[11px] text-gray-400">{chart.detail}</p>} {chart.detail && <p className="mt-0.5 text-[11px] text-gray-400 dark:text-gray-500">{chart.detail}</p>}
</div> </div>
<div className="grid grid-cols-3 gap-3 text-right"> <div className="grid grid-cols-3 gap-3 text-right">
<Stat label="当前" value={chart.formatValue(chart.current)} /> <Stat label="当前" value={chart.formatValue(chart.current)} />
@@ -115,8 +118,8 @@ function DetailedChart({ chart, className }: { chart: ResourceChartConfig; class
function Stat({ label, value }: { label: string; value: string }) { function Stat({ label, value }: { label: string; value: string }) {
return ( return (
<div> <div>
<div className="text-[10px] text-gray-400">{label}</div> <div className="text-[10px] text-gray-400 dark:text-gray-500">{label}</div>
<div className="text-xs font-semibold text-gray-900 tabular-nums whitespace-nowrap">{value}</div> <div className="text-xs font-semibold text-gray-900 dark:text-gray-100 tabular-nums whitespace-nowrap">{value}</div>
</div> </div>
) )
} }
@@ -132,6 +135,9 @@ function LineAreaChart({
formatValue: (value: number) => string formatValue: (value: number) => string
unitLabel?: string unitLabel?: string
}) { }) {
const { theme } = useTheme()
const isDark = theme === 'dark'
const width = 520 const width = 520
const height = 150 const height = 150
const left = 50 const left = 50
@@ -158,12 +164,20 @@ function LineAreaChart({
const yTicks = [1, 0.5, 0] const yTicks = [1, 0.5, 0]
const xTicks = [0, 0.5, 1] const xTicks = [0, 0.5, 1]
// Dark mode colors
const gridStroke = isDark ? '#374151' : '#e5e7eb'
const gridStrokeV = isDark ? '#1f2937' : '#edf0f2'
const axisStroke = isDark ? '#9ca3af' : '#888'
const lineStroke = isDark ? '#f9fafb' : '#444'
const gradientTop = isDark ? '#f9fafb' : '#555'
const gradientBottom = isDark ? '#374151' : '#555'
return ( return (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[140px]" preserveAspectRatio="none"> <svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[140px]" preserveAspectRatio="none">
<defs> <defs>
<linearGradient id="resource-chart-fill" x1="0" x2="0" y1="0" y2="1"> <linearGradient id="resource-chart-fill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#555" stopOpacity="0.25" /> <stop offset="0%" stopColor={gradientTop} stopOpacity="0.25" />
<stop offset="100%" stopColor="#555" stopOpacity="0.02" /> <stop offset="100%" stopColor={gradientBottom} stopOpacity="0.02" />
</linearGradient> </linearGradient>
</defs> </defs>
@@ -171,8 +185,8 @@ function LineAreaChart({
const y = top + (1 - tick) * innerHeight const y = top + (1 - tick) * innerHeight
return ( return (
<g key={tick}> <g key={tick}>
<line x1={left} y1={y} x2={left + innerWidth} y2={y} stroke="#e5e7eb" strokeDasharray="3 3" /> <line x1={left} y1={y} x2={left + innerWidth} y2={y} stroke={gridStroke} strokeDasharray="3 3" />
<text x={left - 8} y={y + 3} textAnchor="end" fontSize="10" fill="#888"> <text x={left - 8} y={y + 3} textAnchor="end" fontSize="10" fill={axisStroke}>
{formatValue(maxValue * tick)} {formatValue(maxValue * tick)}
</text> </text>
</g> </g>
@@ -184,8 +198,8 @@ function LineAreaChart({
const ts = minTs + tick * span const ts = minTs + tick * span
return ( return (
<g key={tick}> <g key={tick}>
<line x1={x} y1={top} x2={x} y2={top + innerHeight} stroke="#edf0f2" strokeDasharray="3 3" /> <line x1={x} y1={top} x2={x} y2={top + innerHeight} stroke={gridStrokeV} strokeDasharray="3 3" />
<text x={x} y={height - 5} textAnchor={tick === 0 ? 'start' : tick === 1 ? 'end' : 'middle'} fontSize="10" fill="#888"> <text x={x} y={height - 5} textAnchor={tick === 0 ? 'start' : tick === 1 ? 'end' : 'middle'} fontSize="10" fill={axisStroke}>
{formatTime(ts)} {formatTime(ts)}
</text> </text>
</g> </g>
@@ -193,15 +207,15 @@ function LineAreaChart({
})} })}
{unitLabel && ( {unitLabel && (
<text x={left - 45} y={top + 10} fontSize="10" fill="#888"> <text x={left - 45} y={top + 10} fontSize="10" fill={axisStroke}>
{unitLabel} {unitLabel}
</text> </text>
)} )}
<line x1={left} y1={top} x2={left} y2={top + innerHeight} stroke="#888" /> <line x1={left} y1={top} x2={left} y2={top + innerHeight} stroke={axisStroke} />
<line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} stroke="#888" /> <line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} stroke={axisStroke} />
<polygon points={area} fill="url(#resource-chart-fill)" /> <polygon points={area} fill="url(#resource-chart-fill)" />
<polyline points={line} fill="none" stroke="#444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> <polyline points={line} fill="none" stroke={lineStroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
) )
} }
+14 -7
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { useTheme } from '../contexts/ThemeContext'
interface RingStatProps { interface RingStatProps {
value: number value: number
@@ -10,11 +11,17 @@ interface RingStatProps {
} }
export function RingStat({ value, max = 100, label, subLabel, size = 120, strokeWidth = 8 }: RingStatProps) { export function RingStat({ value, max = 100, label, subLabel, size = 120, strokeWidth = 8 }: RingStatProps) {
const { theme } = useTheme()
const isDark = theme === 'dark'
const radius = (size - strokeWidth) / 2 const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI const circumference = radius * 2 * Math.PI
const percentage = Math.min(Math.max(value / max * 100, 0), 100) const percentage = Math.min(Math.max(value / max * 100, 0), 100)
const strokeDashoffset = circumference - (percentage / 100) * circumference const strokeDashoffset = circumference - (percentage / 100) * circumference
const bgStroke = isDark ? '#374151' : '#f3f4f6'
const progressStroke = isDark ? '#f9fafb' : '#000000'
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="relative" style={{ width: size, height: size }}> <div className="relative" style={{ width: size, height: size }}>
@@ -25,7 +32,7 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
cy={size / 2} cy={size / 2}
r={radius} r={radius}
fill="none" fill="none"
stroke="#f3f4f6" stroke={bgStroke}
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
/> />
{/* Progress ring */} {/* Progress ring */}
@@ -34,7 +41,7 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
cy={size / 2} cy={size / 2}
r={radius} r={radius}
fill="none" fill="none"
stroke="#000000" stroke={progressStroke}
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
strokeLinecap="round" strokeLinecap="round"
strokeDasharray={circumference} strokeDasharray={circumference}
@@ -44,12 +51,12 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
</svg> </svg>
{/* Center value */} {/* Center value */}
<div className="absolute inset-0 flex flex-col items-center justify-center"> <div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-bold text-black">{value.toFixed(percentage < 1 ? 2 : 1)}%</span> <span className="text-2xl font-bold text-black dark:text-white">{value.toFixed(percentage < 1 ? 2 : 1)}%</span>
</div> </div>
</div> </div>
<div className="mt-2 text-center"> <div className="mt-2 text-center">
<div className="text-sm font-medium text-gray-800">{label}</div> <div className="text-sm font-medium text-gray-800 dark:text-gray-200">{label}</div>
{subLabel && <div className="text-xs text-gray-400 mt-0.5">{subLabel}</div>} {subLabel && <div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{subLabel}</div>}
</div> </div>
</div> </div>
) )
@@ -96,8 +103,8 @@ export default function RingStats({
const hasSwap = swapTotal > 0 const hasSwap = swapTotal > 0
return ( return (
<div className="bg-white border border-gray-200 rounded-lg p-5"> <div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-5">
<h2 className="text-sm font-semibold text-black mb-4"></h2> <h2 className="text-sm font-semibold text-black dark:text-white mb-4"></h2>
<div className={`grid ${hasSwap ? 'grid-cols-5' : 'grid-cols-4'} gap-3`}> <div className={`grid ${hasSwap ? 'grid-cols-5' : 'grid-cols-4'} gap-3`}>
<RingStat <RingStat
value={cpuPercent} value={cpuPercent}
+71 -28
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { import {
ChevronLeft, ChevronLeft,
@@ -6,15 +7,19 @@ import {
Camera, Camera,
LayoutDashboard, LayoutDashboard,
LogOut, LogOut,
Moon,
Package, Package,
Route, Route,
ScrollText, ScrollText,
Server, Server,
Settings2, Settings2,
ShieldAlert, ShieldAlert,
Sun,
UserCog, UserCog,
} from 'lucide-react' } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useTheme } from '../contexts/ThemeContext'
import { getVersion } from '../services/api'
import AppIcon from './AppIcon' import AppIcon from './AppIcon'
interface SidebarProps { interface SidebarProps {
@@ -26,6 +31,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { logout, isSubUser } = useAuth() const { logout, isSubUser } = useAuth()
const { theme, toggleTheme } = useTheme()
const [version, setVersion] = useState('')
useEffect(() => {
getVersion()
.then(res => {
if (res.data?.data?.version) {
setVersion(res.data.data.version)
}
})
.catch(() => {})
}, [])
const isContainerPage = const isContainerPage =
location.pathname.startsWith('/containers') || location.pathname.startsWith('/containers') ||
@@ -42,27 +59,27 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
return ( return (
<aside <aside
className={`fixed left-0 top-0 h-full bg-white border-r border-gray-200 flex flex-col transition-all duration-300 z-30 ${ className={`fixed left-0 top-0 h-full bg-white border-r border-gray-200 flex flex-col transition-all duration-300 z-30 dark:bg-gray-900 dark:border-gray-700 ${
collapsed ? 'w-16' : 'w-60' collapsed ? 'w-16' : 'w-60'
}`} }`}
> >
<div className="flex items-center justify-between h-14 px-4 border-b border-gray-200"> <div className="flex items-center justify-between h-14 px-4 border-b border-gray-200 dark:border-gray-700">
{!collapsed && ( {!collapsed && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center"> <div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center dark:bg-gray-800">
<AppIcon className="w-5 h-5" /> <AppIcon className="w-5 h-5" />
</div> </div>
<span className="font-bold text-black text-sm">CLICD</span> <span className="font-bold text-black text-sm dark:text-white">CLICD</span>
</div> </div>
)} )}
{collapsed && ( {collapsed && (
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center mx-auto"> <div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center mx-auto dark:bg-gray-800">
<AppIcon className="w-5 h-5" /> <AppIcon className="w-5 h-5" />
</div> </div>
)} )}
<button <button
onClick={onToggle} onClick={onToggle}
className="p-1 rounded hover:bg-gray-100 text-gray-500" className="p-1 rounded hover:bg-gray-100 text-gray-500 dark:hover:bg-gray-800 dark:text-gray-400"
title="切换侧边栏" title="切换侧边栏"
> >
{collapsed ? ( {collapsed ? (
@@ -79,8 +96,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/')} onClick={() => navigate('/')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
location.pathname === '/' location.pathname === '/'
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<LayoutDashboard className="w-4 h-4" /> <LayoutDashboard className="w-4 h-4" />
@@ -92,8 +109,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/containers')} onClick={() => navigate('/containers')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isContainerPage isContainerPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<Server className="w-4 h-4" /> <Server className="w-4 h-4" />
@@ -105,8 +122,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/images')} onClick={() => navigate('/images')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isImagesPage isImagesPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<Package className="w-4 h-4" /> <Package className="w-4 h-4" />
@@ -120,8 +137,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/oversell')} onClick={() => navigate('/oversell')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isOversellPage isOversellPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<Settings2 className="w-4 h-4" /> <Settings2 className="w-4 h-4" />
@@ -132,8 +149,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/security')} onClick={() => navigate('/security')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isSecurityPage isSecurityPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<ShieldAlert className="w-4 h-4" /> <ShieldAlert className="w-4 h-4" />
@@ -144,8 +161,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/snapshots')} onClick={() => navigate('/snapshots')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isSnapshotsPage isSnapshotsPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<Camera className="w-4 h-4" /> <Camera className="w-4 h-4" />
@@ -156,8 +173,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/routing')} onClick={() => navigate('/routing')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isRoutingPage isRoutingPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<Route className="w-4 h-4" /> <Route className="w-4 h-4" />
@@ -168,8 +185,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/audit-logs')} onClick={() => navigate('/audit-logs')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isAuditLogsPage isAuditLogsPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<ScrollText className="w-4 h-4" /> <ScrollText className="w-4 h-4" />
@@ -180,8 +197,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/api-integration')} onClick={() => navigate('/api-integration')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isApiIntegrationPage isApiIntegrationPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<Code2 className="w-4 h-4" /> <Code2 className="w-4 h-4" />
@@ -192,8 +209,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/settings')} onClick={() => navigate('/settings')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isSettingsPage isSettingsPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<UserCog className="w-4 h-4" /> <UserCog className="w-4 h-4" />
@@ -203,10 +220,36 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
)} )}
</nav> </nav>
<div className="border-t border-gray-200 p-2"> <div className="border-t border-gray-200 dark:border-gray-700 p-2 space-y-1">
{/* Theme Toggle */}
<button
onClick={toggleTheme}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800"
title={theme === 'dark' ? '切换亮色模式' : '切换暗黑模式'}
>
{theme === 'dark' ? (
<Sun className="w-4 h-4" />
) : (
<Moon className="w-4 h-4" />
)}
{!collapsed && <span>{theme === 'dark' ? '亮色模式' : '暗黑模式'}</span>}
</button>
{/* Version */}
{version && (
<div className={`px-3 py-2 text-xs text-gray-400 dark:text-gray-500 ${collapsed ? 'text-center' : ''}`}>
{collapsed ? (
<span title={`v${version}`}>v{version.split('.').slice(0, 2).join('.')}</span>
) : (
<span>v{version}</span>
)}
</div>
)}
{/* Logout */}
<button <button
onClick={logout} onClick={logout}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors" className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800"
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
{!collapsed && <span>退</span>} {!collapsed && <span>退</span>}
+43
View File
@@ -0,0 +1,43 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
type Theme = 'light' | 'dark'
interface ThemeContextType {
theme: Theme
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType>({ theme: 'light', toggleTheme: () => {} })
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window === 'undefined') return 'light'
const stored = localStorage.getItem('clicd_theme') as Theme | null
if (stored === 'dark' || stored === 'light') return stored
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
})
useEffect(() => {
const root = document.documentElement
if (theme === 'dark') {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
localStorage.setItem('clicd_theme', theme)
}, [theme])
const toggleTheme = useCallback(() => {
setTheme(prev => (prev === 'dark' ? 'light' : 'dark'))
}, [])
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
return useContext(ThemeContext)
}
+131 -3
View File
@@ -14,19 +14,147 @@ body {
color: #000000; color: #000000;
} }
/* Scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: #f1f1f1; background: #f1f1f1;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #888; background: #888;
border-radius: 3px; border-radius: 3px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #555; background: #555;
} }
/* ============ DARK MODE OVERRIDES ============ */
.dark ::-webkit-scrollbar-track {
background: #1f2937;
}
.dark ::-webkit-scrollbar-thumb {
background: #4b5563;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
.dark body {
background-color: #030712;
color: #f9fafb;
}
/* Background overrides */
.dark .bg-white { background-color: #111827 !important; }
.dark .bg-gray-50 { background-color: #030712 !important; }
.dark .bg-gray-100 { background-color: #1f2937 !important; }
.dark .bg-gray-200 { background-color: #374151 !important; }
/* Text overrides */
.dark .text-black { color: #f9fafb !important; }
.dark .text-gray-950 { color: #f9fafb !important; }
.dark .text-gray-900 { color: #f3f4f6 !important; }
.dark .text-gray-800 { color: #e5e7eb !important; }
.dark .text-gray-700 { color: #d1d5db !important; }
.dark .text-gray-600 { color: #9ca3af !important; }
.dark .text-gray-500 { color: #9ca3af !important; }
.dark .text-gray-400 { color: #6b7280 !important; }
/* Border overrides */
.dark .border-gray-100 { border-color: #1f2937 !important; }
.dark .border-gray-200 { border-color: #374151 !important; }
.dark .border-gray-300 { border-color: #4b5563 !important; }
/* Divider overrides */
.dark .divide-gray-50 > :not([hidden]) ~ :not([hidden]) { border-color: #1f2937 !important; }
.dark .divide-gray-100 > :not([hidden]) ~ :not([hidden]) { border-color: #1f2937 !important; }
/* Hover background overrides */
.dark .hover\:bg-gray-50:hover { background-color: #1f2937 !important; }
.dark .hover\:bg-gray-100:hover { background-color: #1f2937 !important; }
.dark .hover\:bg-gray-200:hover { background-color: #374151 !important; }
/* Hover text overrides */
.dark .hover\:text-black:hover { color: #f9fafb !important; }
.dark .hover\:text-gray-900:hover { color: #f3f4f6 !important; }
/* 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; }
/* bg-black buttons in dark mode -> light */
.dark .bg-black { background-color: #f9fafb !important; }
.dark .bg-black + span,
.dark button.bg-black { color: #111827 !important; }
.dark button.bg-black span { color: #111827 !important; }
/* Fix for CTA buttons (bg-black text-white) */
.dark button.bg-black,
.dark a.bg-black {
background-color: #f9fafb !important;
color: #111827 !important;
}
/* Fix nested text-white inside bg-black in dark mode */
.dark .bg-black .text-white,
.dark .bg-black.text-white {
color: #111827 !important;
}
/* Invert sidebar active state */
.dark button.bg-black.text-white,
.dark button.bg-black > span {
color: #111827 !important;
}
.dark button.bg-black svg {
color: #111827 !important;
}
/* Hover: bg-gray-800 in dark mode */
.dark .hover\:bg-gray-800:hover { background-color: #e5e7eb !important; color: #111827 !important; }
/* Status badge backgrounds */
.dark .bg-green-50 { background-color: #064e3b !important; }
.dark .bg-red-50 { background-color: #450a0a !important; }
.dark .bg-amber-50 { background-color: #451a03 !important; }
.dark .bg-emerald-50 { background-color: #064e3b !important; }
.dark .bg-amber-100 { background-color: #78350f !important; }
/* Status badge text */
.dark .text-green-700 { color: #6ee7b7 !important; }
.dark .text-red-600 { color: #fca5a5 !important; }
.dark .text-red-700 { color: #fca5a5 !important; }
.dark .text-amber-600 { color: #fcd34d !important; }
.dark .text-amber-700 { color: #fcd34d !important; }
.dark .text-emerald-700 { color: #6ee7b7 !important; }
/* Focus ring */
.dark .focus\:ring-black:focus { --tw-ring-color: #f9fafb !important; }
.dark .focus\:border-black:focus { border-color: #f9fafb !important; }
/* Accent */
.dark .accent-black { accent-color: #f9fafb !important; }
/* Spinner */
.dark .border-black { border-color: #f9fafb !important; }
.dark .border-b-black { border-bottom-color: #f9fafb !important; }
.dark .border-t-black { border-top-color: #f9fafb !important; }
.dark .animate-spin.rounded-full { border-color: #f9fafb !important; border-bottom-color: transparent !important; }
/* Placeholder */
.dark .placeholder-gray-400::placeholder { color: #6b7280 !important; }
/* Success/Error text standalone */
.dark .text-green-600 { color: #6ee7b7 !important; }
/* Modal backdrop */
.dark .bg-black\/50 { background-color: rgba(0,0,0,0.7) !important; }
/* Toggle / switch */
.dark .bg-gray-300 { background-color: #4b5563 !important; }
.dark .peer-checked\:bg-black:checked ~ * { background-color: #f9fafb !important; }
.dark .peer-checked\:bg-black:checked + *,
.dark input.peer:checked + .peer-checked\:bg-black { background-color: #f9fafb !important; }
+3
View File
@@ -3,17 +3,20 @@ import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import App from './App' import App from './App'
import { AuthProvider } from './contexts/AuthContext' import { AuthProvider } from './contexts/AuthContext'
import { ThemeProvider } from './contexts/ThemeContext'
import { DialogProvider } from './components/Dialog' import { DialogProvider } from './components/Dialog'
import './index.css' import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
<ThemeProvider>
<AuthProvider> <AuthProvider>
<DialogProvider> <DialogProvider>
<App /> <App />
</DialogProvider> </DialogProvider>
</AuthProvider> </AuthProvider>
</ThemeProvider>
</BrowserRouter> </BrowserRouter>
</React.StrictMode>, </React.StrictMode>,
) )
+4
View File
@@ -553,4 +553,8 @@ export const getSecuritySummary = () =>
export const createWebSSHTicket = (containerName: string) => export const createWebSSHTicket = (containerName: string) =>
api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName }) api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName })
// Version
export const getVersion = () =>
api.get<APIResponse<{ version: string }>>('/version')
export default api export default api
+1
View File
@@ -1,5 +1,6 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
darkMode: 'class',
content: [ content: [
"./index.html", "./index.html",
"./src/**/*.{js,ts,jsx,tsx}", "./src/**/*.{js,ts,jsx,tsx}",