chore: sync local changes (2026-03-12)

This commit is contained in:
2026-03-12 18:58:53 +08:00
parent 74f15c282e
commit d861a9937b
38 changed files with 3570 additions and 2926 deletions

View File

@@ -5,8 +5,12 @@
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="萌芽Ping - 网站监控面板" />
<meta name="theme-color" content="#10b981" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<title>萌芽Ping - 网站监控面板</title>
<link rel="apple-touch-icon" href="/logo.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">

View File

@@ -5,9 +5,11 @@
"type": "module",
"scripts": {
"dev": "vite",
"dev:local": "vite --host 0.0.0.0 --port 5173 --strictPort",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"preview:local": "vite preview --host 0.0.0.0 --port 4173 --strictPort"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

View File

@@ -0,0 +1,32 @@
{
"name": "萌芽Ping 网站监控",
"short_name": "萌芽Ping",
"description": "轻量网站可用性监控面板",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ecfdf5",
"theme_color": "#10b981",
"lang": "zh-CN",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View File

@@ -0,0 +1,70 @@
const CACHE_NAME = 'mengyaping-shell-v1'
const SHELL_FILES = [
'/',
'/index.html',
'/manifest.webmanifest',
'/favicon.ico',
'/icons/icon-192.png',
'/icons/icon-512.png',
'/icons/icon-512-maskable.png',
]
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_FILES))
)
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) =>
Promise.all(
cacheNames
.filter((cacheName) => cacheName !== CACHE_NAME)
.map((cacheName) => caches.delete(cacheName))
)
)
)
self.clients.claim()
})
self.addEventListener('fetch', (event) => {
const { request } = event
if (request.method !== 'GET') {
return
}
const url = new URL(request.url)
// Only cache same-origin requests, leave API calls untouched.
if (url.origin !== self.location.origin) {
return
}
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).catch(() => caches.match('/index.html'))
)
return
}
event.respondWith(
caches.match(request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse
}
return fetch(request).then((networkResponse) => {
if (!networkResponse || networkResponse.status !== 200) {
return networkResponse
}
const responseClone = networkResponse.clone()
caches.open(CACHE_NAME).then((cache) => cache.put(request, responseClone))
return networkResponse
})
})
)
})

View File

@@ -1,7 +1,7 @@
import Dashboard from './pages/Dashboard'
function App() {
return <Dashboard />
}
export default App
import Dashboard from './pages/Dashboard'
function App() {
return <Dashboard />
}
export default App

View File

@@ -1,145 +1,145 @@
import { formatUptime, getUptimeColor } from '../hooks/useMonitor';
// 统计概览卡片
export default function StatsCard({ websites }) {
// 计算统计数据
const stats = {
total: websites?.length || 0,
online: 0,
offline: 0,
avgUptime24h: 0,
avgUptime7d: 0,
avgLatency: 0,
};
if (websites && websites.length > 0) {
let totalUptime24h = 0;
let totalUptime7d = 0;
let totalLatency = 0;
let latencyCount = 0;
websites.forEach(site => {
// 检查所有URL的状态
const hasOnlineUrl = site.url_statuses?.some(us => us.current_state?.is_up);
if (hasOnlineUrl) {
stats.online++;
} else {
stats.offline++;
}
totalUptime24h += site.uptime_24h || 0;
totalUptime7d += site.uptime_7d || 0;
site.url_statuses?.forEach(us => {
if (us.current_state?.latency) {
totalLatency += us.current_state.latency;
latencyCount++;
}
});
});
stats.avgUptime24h = totalUptime24h / stats.total;
stats.avgUptime7d = totalUptime7d / stats.total;
stats.avgLatency = latencyCount > 0 ? Math.round(totalLatency / latencyCount) : 0;
}
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{/* 监控网站数 */}
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">监控网站</p>
<p className="text-2xl font-bold text-gray-800 mt-1">{stats.total}</p>
</div>
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center">
<svg className="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
</div>
</div>
<div className="flex items-center mt-3 text-xs">
<span className="text-green-500 flex items-center">
<span className="w-2 h-2 rounded-full bg-green-500 mr-1"></span>
{stats.online} 在线
</span>
<span className="text-gray-300 mx-2">|</span>
<span className="text-red-500 flex items-center">
<span className="w-2 h-2 rounded-full bg-red-500 mr-1"></span>
{stats.offline} 离线
</span>
</div>
</div>
{/* 24小时可用率 */}
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">24h 可用率</p>
<p className={`text-2xl font-bold mt-1 ${getUptimeColor(stats.avgUptime24h)}`}>
{formatUptime(stats.avgUptime24h)}
</p>
</div>
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-50 to-emerald-100 flex items-center justify-center">
<svg className="w-6 h-6 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div className="mt-3 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-emerald-400 to-green-500 rounded-full"
style={{ width: `${Math.min(stats.avgUptime24h, 100)}%` }}
/>
</div>
</div>
{/* 7天可用率 */}
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">7d 可用率</p>
<p className={`text-2xl font-bold mt-1 ${getUptimeColor(stats.avgUptime7d)}`}>
{formatUptime(stats.avgUptime7d)}
</p>
</div>
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-green-50 to-green-100 flex items-center justify-center">
<svg className="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
</div>
<div className="mt-3 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-green-400 to-teal-500 rounded-full"
style={{ width: `${Math.min(stats.avgUptime7d, 100)}%` }}
/>
</div>
</div>
{/* 平均延迟 */}
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">平均延迟</p>
<p className="text-2xl font-bold text-gray-800 mt-1">
{stats.avgLatency}
<span className="text-sm font-normal text-gray-500 ml-1">ms</span>
</p>
</div>
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-50 to-purple-100 flex items-center justify-center">
<svg className="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
</div>
<div className="flex items-center mt-3 text-xs text-gray-500">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
每5分钟检测一次
</div>
</div>
</div>
);
}
import { formatUptime, getUptimeColor } from '../hooks/useMonitor';
// 统计概览卡片
export default function StatsCard({ websites }) {
// 计算统计数据
const stats = {
total: websites?.length || 0,
online: 0,
offline: 0,
avgUptime24h: 0,
avgUptime7d: 0,
avgLatency: 0,
};
if (websites && websites.length > 0) {
let totalUptime24h = 0;
let totalUptime7d = 0;
let totalLatency = 0;
let latencyCount = 0;
websites.forEach(site => {
// 检查所有URL的状态
const hasOnlineUrl = site.url_statuses?.some(us => us.current_state?.is_up);
if (hasOnlineUrl) {
stats.online++;
} else {
stats.offline++;
}
totalUptime24h += site.uptime_24h || 0;
totalUptime7d += site.uptime_7d || 0;
site.url_statuses?.forEach(us => {
if (us.current_state?.latency) {
totalLatency += us.current_state.latency;
latencyCount++;
}
});
});
stats.avgUptime24h = totalUptime24h / stats.total;
stats.avgUptime7d = totalUptime7d / stats.total;
stats.avgLatency = latencyCount > 0 ? Math.round(totalLatency / latencyCount) : 0;
}
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{/* 监控网站数 */}
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">监控网站</p>
<p className="text-2xl font-bold text-gray-800 mt-1">{stats.total}</p>
</div>
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center">
<svg className="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
</div>
</div>
<div className="flex items-center mt-3 text-xs">
<span className="text-green-500 flex items-center">
<span className="w-2 h-2 rounded-full bg-green-500 mr-1"></span>
{stats.online} 在线
</span>
<span className="text-gray-300 mx-2">|</span>
<span className="text-red-500 flex items-center">
<span className="w-2 h-2 rounded-full bg-red-500 mr-1"></span>
{stats.offline} 离线
</span>
</div>
</div>
{/* 24小时可用率 */}
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">24h 可用率</p>
<p className={`text-2xl font-bold mt-1 ${getUptimeColor(stats.avgUptime24h)}`}>
{formatUptime(stats.avgUptime24h)}
</p>
</div>
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-50 to-emerald-100 flex items-center justify-center">
<svg className="w-6 h-6 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div className="mt-3 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-emerald-400 to-green-500 rounded-full"
style={{ width: `${Math.min(stats.avgUptime24h, 100)}%` }}
/>
</div>
</div>
{/* 7天可用率 */}
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">7d 可用率</p>
<p className={`text-2xl font-bold mt-1 ${getUptimeColor(stats.avgUptime7d)}`}>
{formatUptime(stats.avgUptime7d)}
</p>
</div>
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-green-50 to-green-100 flex items-center justify-center">
<svg className="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
</div>
<div className="mt-3 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-green-400 to-teal-500 rounded-full"
style={{ width: `${Math.min(stats.avgUptime7d, 100)}%` }}
/>
</div>
</div>
{/* 平均延迟 */}
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">平均延迟</p>
<p className="text-2xl font-bold text-gray-800 mt-1">
{stats.avgLatency}
<span className="text-sm font-normal text-gray-500 ml-1">ms</span>
</p>
</div>
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-50 to-purple-100 flex items-center justify-center">
<svg className="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
</div>
<div className="flex items-center mt-3 text-xs text-gray-500">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
每5分钟检测一次
</div>
</div>
</div>
);
}

View File

@@ -1,212 +1,212 @@
import { useMemo } from 'react';
// 简易折线图组件
export default function UptimeChart({ data, height = 120, showLabels = true }) {
// 处理数据
const chartData = useMemo(() => {
if (!data || data.length === 0) {
// 生成模拟数据点
return Array(24).fill(null).map((_, i) => ({
hour: i,
uptime: null,
avgLatency: 0,
}));
}
// 按时间排序
const sorted = [...data].sort((a, b) =>
new Date(a.hour).getTime() - new Date(b.hour).getTime()
);
return sorted.map(item => ({
hour: new Date(item.hour).getHours(),
uptime: item.uptime,
avgLatency: item.avg_latency,
}));
}, [data]);
// 计算图表尺寸
const padding = { top: 20, right: 20, bottom: showLabels ? 30 : 10, left: showLabels ? 40 : 10 };
const width = 400;
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
// 生成路径
const pathData = useMemo(() => {
const validPoints = chartData.filter(d => d.uptime !== null);
if (validPoints.length === 0) return '';
const xStep = chartWidth / (chartData.length - 1 || 1);
let path = '';
let lastValidIndex = -1;
chartData.forEach((d, i) => {
if (d.uptime !== null) {
const x = i * xStep;
const y = chartHeight - (d.uptime / 100 * chartHeight);
if (lastValidIndex === -1) {
path += `M ${x} ${y}`;
} else {
path += ` L ${x} ${y}`;
}
lastValidIndex = i;
}
});
return path;
}, [chartData, chartWidth, chartHeight]);
// 生成填充区域
const areaPath = useMemo(() => {
if (!pathData) return '';
const validPoints = chartData.filter(d => d.uptime !== null);
if (validPoints.length === 0) return '';
const xStep = chartWidth / (chartData.length - 1 || 1);
const firstValidIndex = chartData.findIndex(d => d.uptime !== null);
const lastValidIndex = chartData.length - 1 - [...chartData].reverse().findIndex(d => d.uptime !== null);
const startX = firstValidIndex * xStep;
const endX = lastValidIndex * xStep;
return `${pathData} L ${endX} ${chartHeight} L ${startX} ${chartHeight} Z`;
}, [pathData, chartData, chartWidth, chartHeight]);
// 获取颜色
const getColor = (uptime) => {
if (uptime >= 99) return '#10b981'; // emerald-500
if (uptime >= 95) return '#34d399'; // emerald-400
if (uptime >= 90) return '#fbbf24'; // amber-400
return '#ef4444'; // red-500
};
// 计算平均可用率
const avgUptime = useMemo(() => {
const validPoints = chartData.filter(d => d.uptime !== null);
if (validPoints.length === 0) return null;
return validPoints.reduce((sum, d) => sum + d.uptime, 0) / validPoints.length;
}, [chartData]);
const color = avgUptime !== null ? getColor(avgUptime) : '#d1d5db';
return (
<div className="w-full">
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full h-auto"
preserveAspectRatio="xMidYMid meet"
>
<defs>
<linearGradient id="uptimeGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
<stop offset="100%" stopColor={color} stopOpacity="0.05" />
</linearGradient>
</defs>
<g transform={`translate(${padding.left}, ${padding.top})`}>
{/* 网格线 */}
{[0, 25, 50, 75, 100].map(v => (
<g key={v}>
<line
x1={0}
y1={chartHeight - (v / 100 * chartHeight)}
x2={chartWidth}
y2={chartHeight - (v / 100 * chartHeight)}
stroke="#e5e7eb"
strokeDasharray="4 2"
/>
{showLabels && (
<text
x={-8}
y={chartHeight - (v / 100 * chartHeight) + 4}
fill="#9ca3af"
fontSize="10"
textAnchor="end"
>
{v}%
</text>
)}
</g>
))}
{/* 填充区域 */}
{areaPath && (
<path
d={areaPath}
fill="url(#uptimeGradient)"
/>
)}
{/* 折线 */}
{pathData && (
<path
d={pathData}
fill="none"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
{/* 数据点 */}
{chartData.map((d, i) => {
if (d.uptime === null) return null;
const xStep = chartWidth / (chartData.length - 1 || 1);
const x = i * xStep;
const y = chartHeight - (d.uptime / 100 * chartHeight);
return (
<circle
key={i}
cx={x}
cy={y}
r="3"
fill="white"
stroke={getColor(d.uptime)}
strokeWidth="2"
/>
);
})}
{/* X轴时间标签 */}
{showLabels && chartData.length > 0 && (
<>
{[0, Math.floor(chartData.length / 2), chartData.length - 1].map(i => {
if (i >= chartData.length) return null;
const xStep = chartWidth / (chartData.length - 1 || 1);
return (
<text
key={i}
x={i * xStep}
y={chartHeight + 20}
fill="#9ca3af"
fontSize="10"
textAnchor="middle"
>
{chartData[i]?.hour || 0}:00
</text>
);
})}
</>
)}
</g>
{/* 无数据提示 */}
{!pathData && (
<text
x={width / 2}
y={height / 2}
fill="#9ca3af"
fontSize="12"
textAnchor="middle"
>
暂无数据
</text>
)}
</svg>
</div>
);
}
import { useMemo } from 'react';
// 简易折线图组件
export default function UptimeChart({ data, height = 120, showLabels = true }) {
// 处理数据
const chartData = useMemo(() => {
if (!data || data.length === 0) {
// 生成模拟数据点
return Array(24).fill(null).map((_, i) => ({
hour: i,
uptime: null,
avgLatency: 0,
}));
}
// 按时间排序
const sorted = [...data].sort((a, b) =>
new Date(a.hour).getTime() - new Date(b.hour).getTime()
);
return sorted.map(item => ({
hour: new Date(item.hour).getHours(),
uptime: item.uptime,
avgLatency: item.avg_latency,
}));
}, [data]);
// 计算图表尺寸
const padding = { top: 20, right: 20, bottom: showLabels ? 30 : 10, left: showLabels ? 40 : 10 };
const width = 400;
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
// 生成路径
const pathData = useMemo(() => {
const validPoints = chartData.filter(d => d.uptime !== null);
if (validPoints.length === 0) return '';
const xStep = chartWidth / (chartData.length - 1 || 1);
let path = '';
let lastValidIndex = -1;
chartData.forEach((d, i) => {
if (d.uptime !== null) {
const x = i * xStep;
const y = chartHeight - (d.uptime / 100 * chartHeight);
if (lastValidIndex === -1) {
path += `M ${x} ${y}`;
} else {
path += ` L ${x} ${y}`;
}
lastValidIndex = i;
}
});
return path;
}, [chartData, chartWidth, chartHeight]);
// 生成填充区域
const areaPath = useMemo(() => {
if (!pathData) return '';
const validPoints = chartData.filter(d => d.uptime !== null);
if (validPoints.length === 0) return '';
const xStep = chartWidth / (chartData.length - 1 || 1);
const firstValidIndex = chartData.findIndex(d => d.uptime !== null);
const lastValidIndex = chartData.length - 1 - [...chartData].reverse().findIndex(d => d.uptime !== null);
const startX = firstValidIndex * xStep;
const endX = lastValidIndex * xStep;
return `${pathData} L ${endX} ${chartHeight} L ${startX} ${chartHeight} Z`;
}, [pathData, chartData, chartWidth, chartHeight]);
// 获取颜色
const getColor = (uptime) => {
if (uptime >= 99) return '#10b981'; // emerald-500
if (uptime >= 95) return '#34d399'; // emerald-400
if (uptime >= 90) return '#fbbf24'; // amber-400
return '#ef4444'; // red-500
};
// 计算平均可用率
const avgUptime = useMemo(() => {
const validPoints = chartData.filter(d => d.uptime !== null);
if (validPoints.length === 0) return null;
return validPoints.reduce((sum, d) => sum + d.uptime, 0) / validPoints.length;
}, [chartData]);
const color = avgUptime !== null ? getColor(avgUptime) : '#d1d5db';
return (
<div className="w-full">
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full h-auto"
preserveAspectRatio="xMidYMid meet"
>
<defs>
<linearGradient id="uptimeGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
<stop offset="100%" stopColor={color} stopOpacity="0.05" />
</linearGradient>
</defs>
<g transform={`translate(${padding.left}, ${padding.top})`}>
{/* 网格线 */}
{[0, 25, 50, 75, 100].map(v => (
<g key={v}>
<line
x1={0}
y1={chartHeight - (v / 100 * chartHeight)}
x2={chartWidth}
y2={chartHeight - (v / 100 * chartHeight)}
stroke="#e5e7eb"
strokeDasharray="4 2"
/>
{showLabels && (
<text
x={-8}
y={chartHeight - (v / 100 * chartHeight) + 4}
fill="#9ca3af"
fontSize="10"
textAnchor="end"
>
{v}%
</text>
)}
</g>
))}
{/* 填充区域 */}
{areaPath && (
<path
d={areaPath}
fill="url(#uptimeGradient)"
/>
)}
{/* 折线 */}
{pathData && (
<path
d={pathData}
fill="none"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
{/* 数据点 */}
{chartData.map((d, i) => {
if (d.uptime === null) return null;
const xStep = chartWidth / (chartData.length - 1 || 1);
const x = i * xStep;
const y = chartHeight - (d.uptime / 100 * chartHeight);
return (
<circle
key={i}
cx={x}
cy={y}
r="3"
fill="white"
stroke={getColor(d.uptime)}
strokeWidth="2"
/>
);
})}
{/* X轴时间标签 */}
{showLabels && chartData.length > 0 && (
<>
{[0, Math.floor(chartData.length / 2), chartData.length - 1].map(i => {
if (i >= chartData.length) return null;
const xStep = chartWidth / (chartData.length - 1 || 1);
return (
<text
key={i}
x={i * xStep}
y={chartHeight + 20}
fill="#9ca3af"
fontSize="10"
textAnchor="middle"
>
{chartData[i]?.hour || 0}:00
</text>
);
})}
</>
)}
</g>
{/* 无数据提示 */}
{!pathData && (
<text
x={width / 2}
y={height / 2}
fill="#9ca3af"
fontSize="12"
textAnchor="middle"
>
暂无数据
</text>
)}
</svg>
</div>
);
}

View File

@@ -1,207 +1,266 @@
import { useState } from 'react';
import { formatLatency, formatUptime, formatTime, getStatusColor, getUptimeColor, getLatencyColor } from '../hooks/useMonitor';
// 网站状态卡片组件
export default function WebsiteCard({ website, onRefresh, onEdit, onDelete }) {
const [expanded, setExpanded] = useState(false);
// 获取第一个URL的状态作为主状态
const primaryStatus = website.url_statuses?.[0];
const isUp = primaryStatus?.current_state?.is_up ?? false;
const statusCode = primaryStatus?.current_state?.status_code ?? 0;
const latency = primaryStatus?.current_state?.latency ?? 0;
const primaryUrl = website.website?.urls?.[0]?.url || website.url_statuses?.[0]?.url_info?.url;
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-shadow">
{/* 卡片头部 */}
<div
className="p-3 cursor-pointer"
onClick={() => setExpanded(!expanded)}
>
{/* 第一行:图标、名称、状态 */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2 min-w-0 flex-1">
{/* Favicon */}
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-green-50 to-emerald-100 flex items-center justify-center overflow-hidden flex-shrink-0">
{website.website?.favicon ? (
<img
src={website.website.favicon}
alt=""
className="w-7 h-7 object-contain drop-shadow"
onError={(e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'flex';
}}
/>
) : null}
<span
className={`text-base font-bold text-emerald-600 ${website.website?.favicon ? 'hidden' : ''}`}
style={{ display: website.website?.favicon ? 'none' : 'flex' }}
>
{website.website?.name?.[0] || '?'}
</span>
</div>
{/* 网站名称 */}
<h3 className="font-semibold text-gray-800 truncate">
{website.website?.name || '未知网站'}
</h3>
</div>
{/* 展开箭头 */}
<svg
className={`w-4 h-4 text-gray-400 transform transition-transform flex-shrink-0 ${expanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
{/* 第二行:状态、延迟、访问按钮 */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
{/* 状态徽章 */}
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(isUp, statusCode)}`}>
{isUp ? `${statusCode}` : '离线'}
</span>
{/* 延迟 */}
<span className={`text-xs font-medium ${getLatencyColor(latency)}`}>
{formatLatency(latency)}
</span>
</div>
{/* 访问按钮 */}
<button
onClick={(e) => {
e.stopPropagation();
if (primaryUrl) {
window.open(primaryUrl, '_blank', 'noopener,noreferrer');
}
}}
disabled={!primaryUrl}
className={`flex items-center space-x-1 px-2.5 py-1 text-xs font-medium rounded-full transition-all flex-shrink-0 ${
primaryUrl
? 'text-white bg-gradient-to-r from-emerald-500 to-green-500 hover:from-emerald-600 hover:to-green-600 shadow-sm hover:shadow'
: 'text-gray-400 bg-gray-100 cursor-not-allowed'
}`}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
<span>访问</span>
</button>
</div>
{/* 网站描述 */}
<p className="text-xs text-gray-500 truncate mb-2">
{website.website?.title || website.website?.urls?.[0]?.url || '-'}
</p>
{/* 可用率条 */}
<div className="mt-3">
<div className="flex justify-between text-xs text-gray-500 mb-1">
<span>24h可用率</span>
<span className={getUptimeColor(website.uptime_24h || 0)}>
{formatUptime(website.uptime_24h)}
</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-emerald-400 to-green-500 rounded-full transition-all"
style={{ width: `${Math.min(website.uptime_24h || 0, 100)}%` }}
/>
</div>
</div>
</div>
{/* 展开详情 */}
{expanded && (
<div className="border-t border-gray-100 bg-gray-50 p-4">
{/* URL列表 */}
<div className="space-y-3">
{website.url_statuses?.map((urlStatus, index) => (
<div
key={urlStatus.url_info?.id || index}
className="bg-white rounded-lg p-3 border border-gray-100"
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-600 truncate flex-1 mr-2">
{urlStatus.url_info?.url}
</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
getStatusColor(urlStatus.current_state?.is_up, urlStatus.current_state?.status_code)
}`}>
{urlStatus.current_state?.is_up ? urlStatus.current_state?.status_code : '离线'}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<span className="text-gray-400">延迟</span>
<p className={`font-medium ${getLatencyColor(urlStatus.current_state?.latency)}`}>
{formatLatency(urlStatus.current_state?.latency)}
</p>
</div>
<div>
<span className="text-gray-400">24h</span>
<p className={`font-medium ${getUptimeColor(urlStatus.uptime_24h)}`}>
{formatUptime(urlStatus.uptime_24h)}
</p>
</div>
<div>
<span className="text-gray-400">7d</span>
<p className={`font-medium ${getUptimeColor(urlStatus.uptime_7d)}`}>
{formatUptime(urlStatus.uptime_7d)}
</p>
</div>
</div>
</div>
))}
</div>
{/* 操作按钮 */}
<div className="flex justify-end space-x-2 mt-4">
<button
onClick={(e) => {
e.stopPropagation();
onRefresh?.(website.website?.id);
}}
className="px-3 py-1.5 text-xs font-medium text-emerald-600 bg-emerald-50 rounded-lg hover:bg-emerald-100 transition-colors"
>
立即检测
</button>
<button
onClick={(e) => {
e.stopPropagation();
onEdit?.(website);
}}
className="px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
编辑
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (confirm('确定要删除这个网站吗?')) {
onDelete?.(website.website?.id);
}
}}
className="px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
>
删除
</button>
</div>
{/* 最后检测时间 */}
<div className="text-xs text-gray-400 text-right mt-2">
最后检测: {formatTime(website.last_checked)}
</div>
</div>
)}
</div>
);
}
import { useState, useMemo } from 'react';
import { formatLatency, formatUptime, formatTime, getStatusColor, getUptimeColor, getLatencyColor } from '../hooks/useMonitor';
const HISTORY_DAYS = 90;
function getBarColor(bar) {
if (bar.uptime === null) return 'bg-gray-200';
if (bar.uptime >= 99) return 'bg-emerald-400';
if (bar.uptime >= 95) return 'bg-yellow-400';
if (bar.uptime >= 80) return 'bg-orange-400';
return 'bg-red-500';
}
function getBarHoverColor(bar) {
if (bar.uptime === null) return 'hover:bg-gray-300';
if (bar.uptime >= 99) return 'hover:bg-emerald-500';
if (bar.uptime >= 95) return 'hover:bg-yellow-500';
if (bar.uptime >= 80) return 'hover:bg-orange-500';
return 'hover:bg-red-600';
}
export default function WebsiteCard({ website, onRefresh, onEdit, onDelete }) {
const [expanded, setExpanded] = useState(false);
const [hoveredBar, setHoveredBar] = useState(null);
const primaryStatus = website.url_statuses?.[0];
const isUp = primaryStatus?.current_state?.is_up ?? false;
const statusCode = primaryStatus?.current_state?.status_code ?? 0;
const latency = primaryStatus?.current_state?.latency ?? 0;
const primaryUrl = website.website?.urls?.[0]?.url || website.url_statuses?.[0]?.url_info?.url;
const dailyBars = useMemo(() => {
const bars = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
const statsMap = {};
(website.daily_history || []).forEach(stat => {
const d = new Date(stat.date);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
statsMap[key] = stat;
});
for (let i = HISTORY_DAYS - 1; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const stat = statsMap[key];
bars.push({
date: key,
uptime: stat ? stat.uptime : null,
totalCount: stat?.total_count ?? 0,
avgLatency: stat?.avg_latency ?? 0,
});
}
return bars;
}, [website.daily_history]);
const statusDisplay = (() => {
if (!primaryStatus?.current_state?.checked_at || primaryStatus.current_state.checked_at === '0001-01-01T00:00:00Z') {
return { text: '等待检测', color: 'text-gray-400' };
}
if (isUp) {
return { text: '运行正常', color: 'text-emerald-500' };
}
return { text: '服务异常', color: 'text-red-500' };
})();
const uptime90d = website.uptime_90d;
const hasUptimeData = uptime90d != null && uptime90d > 0;
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-shadow">
{/* 主体区域 */}
<div
className="p-3 sm:p-4 cursor-pointer select-none"
onClick={() => setExpanded(!expanded)}
>
{/* 头部:图标 + 名称 + 状态 + 访问按钮 */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2.5 min-w-0 flex-1">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-green-50 to-emerald-100 flex items-center justify-center overflow-hidden flex-shrink-0">
{primaryUrl ? (
<img
src={`https://cf-favicon.pages.dev/api/favicon?url=${encodeURIComponent(primaryUrl)}`}
alt=""
className="w-7 h-7 object-contain drop-shadow"
onError={(e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'flex';
}}
/>
) : null}
<span
className={`text-base font-bold text-emerald-600 ${primaryUrl ? 'hidden' : ''}`}
style={{ display: primaryUrl ? 'none' : 'flex' }}
>
{website.website?.name?.[0] || '?'}
</span>
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-gray-800 truncate">
{website.website?.name || '未知网站'}
</h3>
<span className={`text-xs font-medium ${statusDisplay.color}`}>
{statusDisplay.text}
</span>
</div>
</div>
{primaryUrl && (
<button
onClick={(e) => {
e.stopPropagation();
window.open(primaryUrl, '_blank', 'noopener,noreferrer');
}}
className="flex items-center space-x-1 px-2.5 py-1 text-xs font-medium text-white bg-gradient-to-r from-emerald-500 to-green-500 rounded-full hover:from-emerald-600 hover:to-green-600 transition-all shadow-sm hover:shadow flex-shrink-0 ml-2"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
<span>访问</span>
</button>
)}
</div>
{/* 90 天可用率竖条图 */}
<div className="relative">
<div className="flex items-stretch gap-[1px] h-[26px]">
{dailyBars.map((bar, i) => (
<div
key={i}
className={`flex-1 rounded-[1px] transition-colors ${getBarColor(bar)} ${getBarHoverColor(bar)}`}
onMouseEnter={() => setHoveredBar(i)}
onMouseLeave={() => setHoveredBar(null)}
/>
))}
</div>
{/* 悬浮提示 */}
{hoveredBar !== null && dailyBars[hoveredBar] && (
<div
className="absolute -top-10 px-2 py-1 bg-gray-800 text-white text-[10px] rounded shadow-lg whitespace-nowrap pointer-events-none z-10"
style={{
left: `${(hoveredBar / HISTORY_DAYS) * 100}%`,
transform: 'translateX(-50%)',
}}
>
{dailyBars[hoveredBar].date}
{' · '}
{dailyBars[hoveredBar].uptime !== null
? `${dailyBars[hoveredBar].uptime.toFixed(1)}%`
: '无数据'}
</div>
)}
</div>
{/* 底部标签 */}
<div className="flex justify-between items-center text-[10px] text-gray-400 mt-1.5">
<span>{HISTORY_DAYS} 天前</span>
<span className="font-medium text-gray-500">
{hasUptimeData ? `${uptime90d.toFixed(2)}% 可用率` : '-'}
</span>
<span>今天</span>
</div>
{/* 状态 / 延迟 / 24h */}
<div className="flex items-center gap-3 mt-2 text-[10px]">
<span className="text-gray-400">状态 <span className={`font-semibold ${getStatusColor(isUp, statusCode)}`}>{isUp ? statusCode : '离线'}</span></span>
<span className="text-gray-400">延迟 <span className={`font-semibold ${getLatencyColor(latency)}`}>{formatLatency(latency)}</span></span>
<span className="text-gray-400">24h <span className={`font-semibold ${getUptimeColor(website.uptime_24h || 0)}`}>{formatUptime(website.uptime_24h)}</span></span>
</div>
{/* IP 地址IPv4 / IPv6 分开显示) */}
{website.website?.ip_addresses?.length > 0 && (() => {
const ipv4 = website.website.ip_addresses.filter(ip => !ip.includes(':'));
const ipv6 = website.website.ip_addresses.filter(ip => ip.includes(':'));
return (ipv4.length > 0 || ipv6.length > 0) && (
<div className="mt-2 space-y-1">
{ipv4.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[10px] text-gray-400 w-7 flex-shrink-0">IPv4</span>
{ipv4.map((ip, i) => (
<span key={i} className="px-1.5 py-0.5 bg-gray-50 text-gray-600 rounded text-[10px] font-mono">{ip}</span>
))}
</div>
)}
{ipv6.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[10px] text-gray-400 w-7 flex-shrink-0">IPv6</span>
{ipv6.map((ip, i) => (
<span key={i} className="px-1.5 py-0.5 bg-gray-50 text-gray-600 rounded text-[10px] font-mono truncate max-w-full">{ip}</span>
))}
</div>
)}
</div>
);
})()}
</div>
{/* 展开详情 */}
{expanded && (
<div className="border-t border-gray-100 bg-gray-50/80 p-3 sm:p-4">
{/* URL 列表 */}
<div className="space-y-2">
{website.url_statuses?.map((urlStatus, index) => (
<div
key={urlStatus.url_info?.id || index}
className="bg-white rounded-lg p-2.5 border border-gray-100"
>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-600 truncate flex-1 mr-2">
{urlStatus.url_info?.url}
</span>
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
getStatusColor(urlStatus.current_state?.is_up, urlStatus.current_state?.status_code)
}`}>
{urlStatus.current_state?.is_up ? urlStatus.current_state?.status_code : '离线'}
</span>
</div>
</div>
))}
</div>
{/* 操作按钮 */}
<div className="flex items-center justify-between mt-3">
<span className="text-[10px] text-gray-400">
{formatTime(website.last_checked)}
</span>
<div className="flex space-x-1.5">
<button
onClick={(e) => {
e.stopPropagation();
onRefresh?.(website.website?.id);
}}
className="px-2.5 py-1 text-[10px] font-medium text-emerald-600 bg-emerald-50 rounded-md hover:bg-emerald-100 transition-colors"
>
检测
</button>
<button
onClick={(e) => {
e.stopPropagation();
onEdit?.(website);
}}
className="px-2.5 py-1 text-[10px] font-medium text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
>
编辑
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (confirm('确定要删除这个网站吗?')) {
onDelete?.(website.website?.id);
}
}}
className="px-2.5 py-1 text-[10px] font-medium text-red-600 bg-red-50 rounded-md hover:bg-red-100 transition-colors"
>
删除
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,223 +1,254 @@
import { useState, useEffect } from 'react';
import { getGroups, createWebsite, updateWebsite } from '../services/api';
export default function WebsiteModal({ isOpen, onClose, onSuccess, editData }) {
const [formData, setFormData] = useState({
name: '',
group: 'normal',
urls: [''],
});
const [groups, setGroups] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (isOpen) {
loadGroups();
if (editData) {
setFormData({
name: editData.website?.name || '',
group: editData.website?.group || 'normal',
urls: editData.website?.urls?.map(u => u.url) || [''],
});
} else {
setFormData({ name: '', group: 'normal', urls: [''] });
}
setError('');
}
}, [isOpen, editData]);
const loadGroups = async () => {
try {
const data = await getGroups();
setGroups(data || []);
} catch (err) {
console.error('加载分组失败:', err);
}
};
const handleAddUrl = () => {
setFormData({ ...formData, urls: [...formData.urls, ''] });
};
const handleRemoveUrl = (index) => {
if (formData.urls.length > 1) {
const newUrls = formData.urls.filter((_, i) => i !== index);
setFormData({ ...formData, urls: newUrls });
}
};
const handleUrlChange = (index, value) => {
const newUrls = [...formData.urls];
newUrls[index] = value;
setFormData({ ...formData, urls: newUrls });
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
// 验证
if (!formData.name.trim()) {
setError('请输入网站名称');
return;
}
const validUrls = formData.urls.filter(url => url.trim());
if (validUrls.length === 0) {
setError('请至少输入一个网站地址');
return;
}
// 验证URL格式
for (const url of validUrls) {
try {
new URL(url);
} catch {
setError(`无效的URL: ${url}`);
return;
}
}
setLoading(true);
try {
const data = {
name: formData.name.trim(),
group: formData.group,
urls: validUrls,
};
if (editData) {
await updateWebsite(editData.website.id, data);
} else {
await createWebsite(data);
}
onSuccess?.();
onClose();
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md max-h-[90vh] overflow-hidden">
{/* 标题 */}
<div className="px-6 py-4 border-b border-gray-100 bg-gradient-to-r from-emerald-50 to-green-50">
<h2 className="text-lg font-semibold text-gray-800">
{editData ? '编辑网站' : '添加监控网站'}
</h2>
</div>
{/* 表单 */}
<form onSubmit={handleSubmit} className="p-6 overflow-y-auto max-h-[calc(90vh-140px)]">
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-100 rounded-lg text-sm text-red-600">
{error}
</div>
)}
{/* 网站名称 */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
网站名称 <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例如:我的博客"
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none transition-all"
/>
</div>
{/* 所属分组 */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
所属分组
</label>
<select
value={formData.group}
onChange={(e) => setFormData({ ...formData, group: e.target.value })}
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none transition-all bg-white"
>
{groups.map(group => (
<option key={group.id} value={group.id}>{group.name}</option>
))}
</select>
</div>
{/* 网站地址列表 */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
网站地址 <span className="text-red-500">*</span>
</label>
<p className="text-xs text-gray-500 mb-2">
一个网站可以有多个访问地址将分别监控
</p>
<div className="space-y-2">
{formData.urls.map((url, index) => (
<div key={index} className="flex space-x-2">
<input
type="text"
value={url}
onChange={(e) => handleUrlChange(index, e.target.value)}
placeholder="https://example.com"
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none transition-all"
/>
{formData.urls.length > 1 && (
<button
type="button"
onClick={() => handleRemoveUrl(index)}
className="px-3 py-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)}
</div>
))}
</div>
<button
type="button"
onClick={handleAddUrl}
className="mt-2 text-sm text-emerald-600 hover:text-emerald-700 flex items-center"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
添加更多地址
</button>
</div>
</form>
{/* 按钮 */}
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50 flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-4 py-2 text-sm font-medium text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
取消
</button>
<button
onClick={handleSubmit}
disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-emerald-500 to-green-500 rounded-lg hover:from-emerald-600 hover:to-green-600 transition-all disabled:opacity-50"
>
{loading ? '处理中...' : (editData ? '保存' : '添加')}
</button>
</div>
</div>
</div>
);
}
import { useState, useEffect } from 'react';
import { getGroups, createWebsite, updateWebsite } from '../services/api';
export default function WebsiteModal({ isOpen, onClose, onSuccess, editData }) {
const [formData, setFormData] = useState({
name: '',
groups: [],
urls: [''],
});
const [groups, setGroups] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (isOpen) {
loadGroups();
if (editData) {
const editGroups = editData.website?.groups
|| (editData.website?.group ? [editData.website.group] : []);
setFormData({
name: editData.website?.name || '',
groups: editGroups,
urls: editData.website?.urls?.map(u => u.url) || [''],
});
} else {
setFormData({ name: '', groups: [], urls: [''] });
}
setError('');
}
}, [isOpen, editData]);
const loadGroups = async () => {
try {
const data = await getGroups();
setGroups(data || []);
} catch (err) {
console.error('加载分组失败:', err);
}
};
const handleAddUrl = () => {
setFormData({ ...formData, urls: [...formData.urls, ''] });
};
const handleRemoveUrl = (index) => {
if (formData.urls.length > 1) {
const newUrls = formData.urls.filter((_, i) => i !== index);
setFormData({ ...formData, urls: newUrls });
}
};
const handleUrlChange = (index, value) => {
const newUrls = [...formData.urls];
newUrls[index] = value;
setFormData({ ...formData, urls: newUrls });
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
// 验证
if (!formData.name.trim()) {
setError('请输入网站名称');
return;
}
const validUrls = formData.urls.filter(url => url.trim());
if (validUrls.length === 0) {
setError('请至少输入一个网站地址');
return;
}
// 验证URL格式
for (const url of validUrls) {
try {
new URL(url);
} catch {
setError(`无效的URL: ${url}`);
return;
}
}
if (formData.groups.length === 0) {
setError('请至少选择一个分组');
return;
}
setLoading(true);
try {
const data = {
name: formData.name.trim(),
groups: formData.groups,
urls: validUrls,
};
if (editData) {
await updateWebsite(editData.website.id, data);
} else {
await createWebsite(data);
}
onSuccess?.();
onClose();
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md max-h-[90vh] overflow-hidden">
{/* 标题 */}
<div className="px-6 py-4 border-b border-gray-100 bg-gradient-to-r from-emerald-50 to-green-50">
<h2 className="text-lg font-semibold text-gray-800">
{editData ? '编辑网站' : '添加监控网站'}
</h2>
</div>
{/* 表单 */}
<form onSubmit={handleSubmit} className="p-6 overflow-y-auto max-h-[calc(90vh-140px)]">
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-100 rounded-lg text-sm text-red-600">
{error}
</div>
)}
{/* 网站名称 */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
网站名称 <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例如:我的博客"
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none transition-all"
/>
</div>
{/* 所属分组(多选) */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
所属分组 <span className="text-xs text-gray-400 font-normal">可多选</span>
</label>
<div className="flex flex-wrap gap-2 p-3 border border-gray-200 rounded-lg bg-white">
{groups.map(group => {
const checked = formData.groups.includes(group.id);
return (
<label
key={group.id}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-sm cursor-pointer transition-all select-none ${
checked
? 'bg-emerald-100 text-emerald-700 ring-1 ring-emerald-300'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
<input
type="checkbox"
className="sr-only"
checked={checked}
onChange={() => {
const next = checked
? formData.groups.filter(id => id !== group.id)
: [...formData.groups, group.id];
setFormData({ ...formData, groups: next });
}}
/>
{checked && (
<svg className="w-3.5 h-3.5 mr-1 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
)}
{group.name}
</label>
);
})}
</div>
</div>
{/* 网站地址列表 */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
网站地址 <span className="text-red-500">*</span>
</label>
<p className="text-xs text-gray-500 mb-2">
一个网站可以有多个访问地址将分别监控
</p>
<div className="space-y-2">
{formData.urls.map((url, index) => (
<div key={index} className="flex space-x-2">
<input
type="text"
value={url}
onChange={(e) => handleUrlChange(index, e.target.value)}
placeholder="https://example.com"
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none transition-all"
/>
{formData.urls.length > 1 && (
<button
type="button"
onClick={() => handleRemoveUrl(index)}
className="px-3 py-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)}
</div>
))}
</div>
<button
type="button"
onClick={handleAddUrl}
className="mt-2 text-sm text-emerald-600 hover:text-emerald-700 flex items-center"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
添加更多地址
</button>
</div>
</form>
{/* 按钮 */}
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50 flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-4 py-2 text-sm font-medium text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
取消
</button>
<button
onClick={handleSubmit}
disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-emerald-500 to-green-500 rounded-lg hover:from-emerald-600 hover:to-green-600 transition-all disabled:opacity-50"
>
{loading ? '处理中...' : (editData ? '保存' : '添加')}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,83 +1,83 @@
import { useState, useEffect, useCallback } from 'react';
// 自动刷新数据Hook
export function useAutoRefresh(fetchFn, interval = 30000) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const refresh = useCallback(async () => {
try {
setLoading(true);
const result = await fetchFn();
setData(result);
setError(null);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [fetchFn]);
useEffect(() => {
refresh();
const timer = setInterval(refresh, interval);
return () => clearInterval(timer);
}, [refresh, interval]);
return { data, loading, error, refresh };
}
// 格式化时间
export function formatTime(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
// 格式化延迟
export function formatLatency(ms) {
if (ms === undefined || ms === null) return '-';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
// 格式化可用率
export function formatUptime(uptime) {
if (uptime === undefined || uptime === null) return '-';
return `${uptime.toFixed(2)}%`;
}
// 获取状态颜色类名
export function getStatusColor(isUp, statusCode) {
if (!isUp) return 'text-red-500 bg-red-100';
if (statusCode >= 200 && statusCode < 300) return 'text-green-500 bg-green-100';
if (statusCode >= 300 && statusCode < 400) return 'text-yellow-500 bg-yellow-100';
return 'text-red-500 bg-red-100';
}
// 获取可用率颜色
export function getUptimeColor(uptime) {
if (uptime >= 99) return 'text-green-500';
if (uptime >= 95) return 'text-green-400';
if (uptime >= 90) return 'text-yellow-500';
if (uptime >= 80) return 'text-orange-500';
return 'text-red-500';
}
// 获取延迟颜色
export function getLatencyColor(ms) {
if (ms < 200) return 'text-green-500';
if (ms < 500) return 'text-green-400';
if (ms < 1000) return 'text-yellow-500';
if (ms < 2000) return 'text-orange-500';
return 'text-red-500';
}
import { useState, useEffect, useCallback } from 'react';
// 自动刷新数据Hook
export function useAutoRefresh(fetchFn, interval = 30000) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const refresh = useCallback(async () => {
try {
setLoading(true);
const result = await fetchFn();
setData(result);
setError(null);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [fetchFn]);
useEffect(() => {
refresh();
const timer = setInterval(refresh, interval);
return () => clearInterval(timer);
}, [refresh, interval]);
return { data, loading, error, refresh };
}
// 格式化时间
export function formatTime(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
// 格式化延迟
export function formatLatency(ms) {
if (ms === undefined || ms === null) return '-';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
// 格式化可用率
export function formatUptime(uptime) {
if (uptime === undefined || uptime === null) return '-';
return `${uptime.toFixed(2)}%`;
}
// 获取状态颜色类名
export function getStatusColor(isUp, statusCode) {
if (!isUp) return 'text-red-500 bg-red-100';
if (statusCode >= 200 && statusCode < 300) return 'text-green-500 bg-green-100';
if (statusCode >= 300 && statusCode < 400) return 'text-yellow-500 bg-yellow-100';
return 'text-red-500 bg-red-100';
}
// 获取可用率颜色
export function getUptimeColor(uptime) {
if (uptime >= 99) return 'text-green-500';
if (uptime >= 95) return 'text-green-400';
if (uptime >= 90) return 'text-yellow-500';
if (uptime >= 80) return 'text-orange-500';
return 'text-red-500';
}
// 获取延迟颜色
export function getLatencyColor(ms) {
if (ms < 200) return 'text-green-500';
if (ms < 500) return 'text-green-400';
if (ms < 1000) return 'text-yellow-500';
if (ms < 2000) return 'text-orange-500';
return 'text-red-500';
}

View File

@@ -1,68 +1,68 @@
@import "tailwindcss";
:root {
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light;
color: #374151;
background-color: #f0fdf4;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #10b981;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #059669;
}
/* 动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out;
}
/* 响应式适配 */
@media (max-width: 640px) {
html {
font-size: 14px;
}
}
@import "tailwindcss";
:root {
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light;
color: #374151;
background-color: #f0fdf4;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #10b981;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #059669;
}
/* 动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out;
}
/* 响应式适配 */
@media (max-width: 640px) {
html {
font-size: 14px;
}
}

View File

@@ -3,6 +3,14 @@ import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch((err) => {
console.error('Service worker registration failed:', err)
})
})
}
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />

View File

@@ -1,274 +1,280 @@
import { useState, useCallback, useMemo } from 'react';
import WebsiteCard from '../components/WebsiteCard';
import WebsiteModal from '../components/WebsiteModal';
import StatsCard from '../components/StatsCard';
import { useAutoRefresh } from '../hooks/useMonitor';
import { getWebsites, deleteWebsite, checkWebsiteNow, getGroups } from '../services/api';
export default function Dashboard() {
const [modalOpen, setModalOpen] = useState(false);
const [editData, setEditData] = useState(null);
const [selectedGroup, setSelectedGroup] = useState('all');
const [searchTerm, setSearchTerm] = useState('');
// 获取网站数据
const fetchData = useCallback(() => getWebsites(), []);
const { data: websites, loading, error, refresh } = useAutoRefresh(fetchData, 30000);
// 获取分组数据
const fetchGroups = useCallback(() => getGroups(), []);
const { data: groups } = useAutoRefresh(fetchGroups, 60000);
// 处理添加网站
const handleAdd = () => {
setEditData(null);
setModalOpen(true);
};
// 处理编辑网站
const handleEdit = (website) => {
setEditData(website);
setModalOpen(true);
};
// 处理删除网站
const handleDelete = async (id) => {
try {
await deleteWebsite(id);
refresh();
} catch (err) {
alert('删除失败: ' + err.message);
}
};
// 处理立即检测
const handleRefresh = async (id) => {
try {
await checkWebsiteNow(id);
setTimeout(refresh, 2000); // 2秒后刷新数据
} catch (err) {
alert('检测失败: ' + err.message);
}
};
// 按分组和搜索过滤网站
const filteredWebsites = useMemo(() => {
if (!websites) return [];
return websites.filter(site => {
// 分组过滤
if (selectedGroup !== 'all' && site.website?.group !== selectedGroup) {
return false;
}
// 搜索过滤
if (searchTerm) {
const term = searchTerm.toLowerCase();
return (
site.website?.name?.toLowerCase().includes(term) ||
site.website?.title?.toLowerCase().includes(term) ||
site.website?.urls?.some(u => u.url.toLowerCase().includes(term))
);
}
return true;
});
}, [websites, selectedGroup, searchTerm]);
// 按分组分类网站
const groupedWebsites = useMemo(() => {
const grouped = {};
filteredWebsites.forEach(site => {
const groupId = site.website?.group || 'normal';
if (!grouped[groupId]) {
grouped[groupId] = [];
}
grouped[groupId].push(site);
});
return grouped;
}, [filteredWebsites]);
// 获取分组名称
const getGroupName = (groupId) => {
const group = groups?.find(g => g.id === groupId);
return group?.name || groupId;
};
return (
<div className="min-h-screen bg-gradient-to-br from-emerald-50 via-green-50 to-teal-50">
{/* 顶部导航 */}
<header className="bg-white/80 backdrop-blur-md shadow-sm sticky top-0 z-40">
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<div className="flex items-center space-x-3">
<div className="w-10 h-10 rounded-xl overflow-hidden shadow-lg shadow-emerald-200">
<img
src="/logo.png"
alt="萌芽Ping"
className="w-full h-full object-cover"
/>
</div>
<div>
<h1 className="text-xl font-bold bg-gradient-to-r from-emerald-600 to-green-600 bg-clip-text text-transparent">
萌芽Ping
</h1>
<p className="text-xs text-gray-500">网站监控面板</p>
</div>
</div>
{/* 添加按钮 */}
<button
onClick={handleAdd}
className="flex items-center px-4 py-2 bg-gradient-to-r from-emerald-500 to-green-500 text-white rounded-lg hover:from-emerald-600 hover:to-green-600 transition-all shadow-md shadow-emerald-200"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span className="hidden sm:inline">添加监控</span>
</button>
</div>
</div>
</header>
{/* 主内容区 */}
<main className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* 统计概览 */}
<section className="mb-6">
<StatsCard websites={websites} />
</section>
{/* 过滤和搜索 */}
<section className="mb-6">
<div className="flex flex-col sm:flex-row gap-4">
{/* 分组选择 */}
<div className="flex items-center space-x-2 overflow-x-auto pb-2 sm:pb-0">
<button
onClick={() => setSelectedGroup('all')}
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-all ${
selectedGroup === 'all'
? 'bg-emerald-500 text-white shadow-md shadow-emerald-200'
: 'bg-white text-gray-600 hover:bg-gray-50'
}`}
>
全部
</button>
{groups?.map(group => (
<button
key={group.id}
onClick={() => setSelectedGroup(group.id)}
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-all ${
selectedGroup === group.id
? 'bg-emerald-500 text-white shadow-md shadow-emerald-200'
: 'bg-white text-gray-600 hover:bg-gray-50'
}`}
>
{group.name}
</button>
))}
</div>
{/* 搜索框 */}
<div className="flex-1 sm:max-w-xs">
<div className="relative">
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="搜索网站..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none transition-all"
/>
</div>
</div>
</div>
</section>
{/* 网站列表 */}
<section>
{loading && !websites ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-500"></div>
<span className="ml-3 text-gray-500">加载中...</span>
</div>
) : error ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 flex items-center justify-center">
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<p className="text-gray-500 mb-4">加载失败: {error}</p>
<button
onClick={refresh}
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 transition-colors"
>
重试
</button>
</div>
) : filteredWebsites.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-100 flex items-center justify-center">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
</div>
<p className="text-gray-500 mb-4">
{searchTerm ? '没有找到匹配的网站' : '还没有添加任何监控网站'}
</p>
{!searchTerm && (
<button
onClick={handleAdd}
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 transition-colors"
>
添加第一个网站
</button>
)}
</div>
) : (
<div className="space-y-6">
{Object.keys(groupedWebsites).map(groupId => (
<div key={groupId}>
{selectedGroup === 'all' && (
<h2 className="text-sm font-medium text-gray-600 mb-3 flex items-center">
<span className="w-2 h-2 rounded-full bg-emerald-500 mr-2"></span>
{getGroupName(groupId)}
<span className="ml-2 text-gray-400">({groupedWebsites[groupId].length})</span>
</h2>
)}
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4">
{groupedWebsites[groupId].map(website => (
<WebsiteCard
key={website.website?.id}
website={website}
onRefresh={handleRefresh}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
</div>
))}
</div>
)}
</section>
</main>
{/* 底部信息 */}
<footer className="py-6 text-center text-sm text-gray-500">
<p>萌芽Ping © 2026 </p>
</footer>
{/* 添加/编辑弹窗 */}
<WebsiteModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSuccess={refresh}
editData={editData}
/>
</div>
);
}
import { useState, useCallback, useMemo } from 'react';
import WebsiteCard from '../components/WebsiteCard';
import WebsiteModal from '../components/WebsiteModal';
import StatsCard from '../components/StatsCard';
import { useAutoRefresh } from '../hooks/useMonitor';
import { getWebsites, deleteWebsite, checkWebsiteNow, getGroups } from '../services/api';
export default function Dashboard() {
const [modalOpen, setModalOpen] = useState(false);
const [editData, setEditData] = useState(null);
const [selectedGroup, setSelectedGroup] = useState('all');
const [searchTerm, setSearchTerm] = useState('');
// 获取网站数据
const fetchData = useCallback(() => getWebsites(), []);
const { data: websites, loading, error, refresh } = useAutoRefresh(fetchData, 30000);
// 获取分组数据
const fetchGroups = useCallback(() => getGroups(), []);
const { data: groups } = useAutoRefresh(fetchGroups, 60000);
// 处理添加网站
const handleAdd = () => {
setEditData(null);
setModalOpen(true);
};
// 处理编辑网站
const handleEdit = (website) => {
setEditData(website);
setModalOpen(true);
};
// 处理删除网站
const handleDelete = async (id) => {
try {
await deleteWebsite(id);
refresh();
} catch (err) {
alert('删除失败: ' + err.message);
}
};
// 处理立即检测
const handleRefresh = async (id) => {
try {
await checkWebsiteNow(id);
setTimeout(refresh, 2000); // 2秒后刷新数据
} catch (err) {
alert('检测失败: ' + err.message);
}
};
// 按分组和搜索过滤网站
const filteredWebsites = useMemo(() => {
if (!websites) return [];
return websites.filter(site => {
// 分组过滤(支持多分组)
if (selectedGroup !== 'all') {
const siteGroups = site.website?.groups || (site.website?.group ? [site.website.group] : []);
if (!siteGroups.includes(selectedGroup)) {
return false;
}
}
// 搜索过滤
if (searchTerm) {
const term = searchTerm.toLowerCase();
return (
site.website?.name?.toLowerCase().includes(term) ||
site.website?.title?.toLowerCase().includes(term) ||
site.website?.urls?.some(u => u.url.toLowerCase().includes(term))
);
}
return true;
});
}, [websites, selectedGroup, searchTerm]);
// 按分组分类网站(一个网站可出现在多个分组下)
const groupedWebsites = useMemo(() => {
const grouped = {};
filteredWebsites.forEach(site => {
const siteGroups = site.website?.groups || (site.website?.group ? [site.website.group] : ['normal']);
siteGroups.forEach(groupId => {
if (selectedGroup !== 'all' && groupId !== selectedGroup) return;
if (!grouped[groupId]) {
grouped[groupId] = [];
}
grouped[groupId].push(site);
});
});
return grouped;
}, [filteredWebsites, selectedGroup]);
// 获取分组名称
const getGroupName = (groupId) => {
const group = groups?.find(g => g.id === groupId);
return group?.name || groupId;
};
return (
<div className="min-h-screen bg-gradient-to-br from-emerald-50 via-green-50 to-teal-50">
{/* 顶部导航 */}
<header className="bg-white/80 backdrop-blur-md shadow-sm sticky top-0 z-40">
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<div className="flex items-center space-x-3">
<div className="w-10 h-10 rounded-xl overflow-hidden shadow-lg shadow-emerald-200">
<img
src="/logo.png"
alt="萌芽Ping"
className="w-full h-full object-cover"
/>
</div>
<div>
<h1 className="text-xl font-bold bg-gradient-to-r from-emerald-600 to-green-600 bg-clip-text text-transparent">
萌芽Ping
</h1>
<p className="text-xs text-gray-500">网站监控面板</p>
</div>
</div>
{/* 添加按钮 */}
<button
onClick={handleAdd}
className="flex items-center px-4 py-2 bg-gradient-to-r from-emerald-500 to-green-500 text-white rounded-lg hover:from-emerald-600 hover:to-green-600 transition-all shadow-md shadow-emerald-200"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span className="hidden sm:inline">添加监控</span>
</button>
</div>
</div>
</header>
{/* 主内容区 */}
<main className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* 统计概览 */}
<section className="mb-6">
<StatsCard websites={websites} />
</section>
{/* 过滤和搜索 */}
<section className="mb-6">
<div className="flex flex-col sm:flex-row gap-4">
{/* 分组选择 */}
<div className="flex items-center space-x-2 overflow-x-auto pb-2 sm:pb-0">
<button
onClick={() => setSelectedGroup('all')}
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-all ${
selectedGroup === 'all'
? 'bg-emerald-500 text-white shadow-md shadow-emerald-200'
: 'bg-white text-gray-600 hover:bg-gray-50'
}`}
>
全部
</button>
{groups?.map(group => (
<button
key={group.id}
onClick={() => setSelectedGroup(group.id)}
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-all ${
selectedGroup === group.id
? 'bg-emerald-500 text-white shadow-md shadow-emerald-200'
: 'bg-white text-gray-600 hover:bg-gray-50'
}`}
>
{group.name}
</button>
))}
</div>
{/* 搜索框 */}
<div className="flex-1 sm:max-w-xs">
<div className="relative">
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="搜索网站..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none transition-all"
/>
</div>
</div>
</div>
</section>
{/* 网站列表 */}
<section>
{loading && !websites ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-500"></div>
<span className="ml-3 text-gray-500">加载中...</span>
</div>
) : error ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 flex items-center justify-center">
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<p className="text-gray-500 mb-4">加载失败: {error}</p>
<button
onClick={refresh}
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 transition-colors"
>
重试
</button>
</div>
) : filteredWebsites.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-100 flex items-center justify-center">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
</div>
<p className="text-gray-500 mb-4">
{searchTerm ? '没有找到匹配的网站' : '还没有添加任何监控网站'}
</p>
{!searchTerm && (
<button
onClick={handleAdd}
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 transition-colors"
>
添加第一个网站
</button>
)}
</div>
) : (
<div className="space-y-6">
{Object.keys(groupedWebsites).map(groupId => (
<div key={groupId}>
{selectedGroup === 'all' && (
<h2 className="text-sm font-medium text-gray-600 mb-3 flex items-center">
<span className="w-2 h-2 rounded-full bg-emerald-500 mr-2"></span>
{getGroupName(groupId)}
<span className="ml-2 text-gray-400">({groupedWebsites[groupId].length})</span>
</h2>
)}
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4">
{groupedWebsites[groupId].map(website => (
<WebsiteCard
key={website.website?.id}
website={website}
onRefresh={handleRefresh}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
</div>
))}
</div>
)}
</section>
</main>
{/* 底部信息 */}
<footer className="py-6 text-center text-sm text-gray-500">
<p>萌芽Ping © 2026 </p>
</footer>
{/* 添加/编辑弹窗 */}
<WebsiteModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSuccess={refresh}
editData={editData}
/>
</div>
);
}

View File

@@ -1,77 +1,77 @@
// API服务
// 根据环境变量判断使用哪个 API 地址
const API_BASE = import.meta.env.PROD
? 'https://ping.api.shumengya.top/api' // 生产环境
: 'http://localhost:8080/api'; // 开发环境
// 通用请求方法
async function request(url, options = {}) {
const response = await fetch(`${API_BASE}${url}`, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
});
const data = await response.json();
if (data.code !== 0) {
throw new Error(data.message || '请求失败');
}
return data.data;
}
// 获取所有网站状态
export async function getWebsites() {
return request('/websites');
}
// 获取单个网站状态
export async function getWebsite(id) {
return request(`/websites/${id}`);
}
// 创建网站
export async function createWebsite(data) {
return request('/websites', {
method: 'POST',
body: JSON.stringify(data),
});
}
// 更新网站
export async function updateWebsite(id, data) {
return request(`/websites/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
// 删除网站
export async function deleteWebsite(id) {
return request(`/websites/${id}`, {
method: 'DELETE',
});
}
// 立即检测网站
export async function checkWebsiteNow(id) {
return request(`/websites/${id}/check`, {
method: 'POST',
});
}
// 获取所有分组
export async function getGroups() {
return request('/groups');
}
// 添加分组
export async function addGroup(name) {
return request('/groups', {
method: 'POST',
body: JSON.stringify({ name }),
});
}
// API服务
// 根据环境变量判断使用哪个 API 地址
const API_BASE = import.meta.env.PROD
? 'https://ping.api.shumengya.top/api' // 生产环境
: 'http://localhost:8080/api'; // 开发环境
// 通用请求方法
async function request(url, options = {}) {
const response = await fetch(`${API_BASE}${url}`, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
});
const data = await response.json();
if (data.code !== 0) {
throw new Error(data.message || '请求失败');
}
return data.data;
}
// 获取所有网站状态
export async function getWebsites() {
return request('/websites');
}
// 获取单个网站状态
export async function getWebsite(id) {
return request(`/websites/${id}`);
}
// 创建网站
export async function createWebsite(data) {
return request('/websites', {
method: 'POST',
body: JSON.stringify(data),
});
}
// 更新网站
export async function updateWebsite(id, data) {
return request(`/websites/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
// 删除网站
export async function deleteWebsite(id) {
return request(`/websites/${id}`, {
method: 'DELETE',
});
}
// 立即检测网站
export async function checkWebsiteNow(id) {
return request(`/websites/${id}/check`, {
method: 'POST',
});
}
// 获取所有分组
export async function getGroups() {
return request('/groups');
}
// 添加分组
export async function addGroup(name) {
return request('/groups', {
method: 'POST',
body: JSON.stringify({ name }),
});
}

View File

@@ -1,8 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
}
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -5,4 +5,14 @@ import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
},
preview: {
host: '0.0.0.0',
port: 4173,
strictPort: true,
},
})