chore: sync local updates
This commit is contained in:
70
mengyaping-frontend/package-lock.json
generated
70
mengyaping-frontend/package-lock.json
generated
@@ -10,7 +10,8 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
@@ -58,7 +59,6 @@
|
||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
@@ -1633,7 +1633,6 @@
|
||||
"integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1675,7 +1674,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -1818,7 +1816,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -1915,6 +1912,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -2061,7 +2071,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -3030,7 +3039,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3057,7 +3065,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -3099,7 +3106,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -3126,6 +3132,44 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.14.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz",
|
||||
"integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.14.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz",
|
||||
"integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.14.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
@@ -3196,6 +3240,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -3348,7 +3398,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -3470,7 +3519,6 @@
|
||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import RandomBackdrop from './components/RandomBackdrop'
|
||||
import PublicDashboard from './pages/PublicDashboard'
|
||||
import AdminPanel from './pages/AdminPanel'
|
||||
|
||||
function App() {
|
||||
return <Dashboard />
|
||||
return (
|
||||
<RandomBackdrop>
|
||||
<Routes>
|
||||
<Route path="/" element={<PublicDashboard />} />
|
||||
<Route path="/admin" element={<AdminPanel />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</RandomBackdrop>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
179
mengyaping-frontend/src/components/AdminGroupsPanel.jsx
Normal file
179
mengyaping-frontend/src/components/AdminGroupsPanel.jsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { getGroups, addGroup, updateGroup, deleteGroup } from '../services/api';
|
||||
import { GROUPS_CHANGED_EVENT } from '../constants/events';
|
||||
|
||||
function notifyGroupsChanged() {
|
||||
window.dispatchEvent(new CustomEvent(GROUPS_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
export default function AdminGroupsPanel() {
|
||||
const [groups, setGroups] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState('');
|
||||
const [newName, setNewName] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [saveBusy, setSaveBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
const data = await getGroups();
|
||||
setGroups(data || []);
|
||||
} catch (e) {
|
||||
setErr(e.message || '加载分类失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function handleAdd(e) {
|
||||
e.preventDefault();
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
setAdding(true);
|
||||
setErr('');
|
||||
try {
|
||||
await addGroup(name);
|
||||
setNewName('');
|
||||
await load();
|
||||
notifyGroupsChanged();
|
||||
} catch (e) {
|
||||
setErr(e.message || '添加失败');
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(g) {
|
||||
setEditingId(g.id);
|
||||
setEditName(g.name);
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditingId(null);
|
||||
setEditName('');
|
||||
}
|
||||
|
||||
async function handleSaveEdit(e) {
|
||||
e.preventDefault();
|
||||
if (!editingId) return;
|
||||
const name = editName.trim();
|
||||
if (!name) return;
|
||||
setSaveBusy(true);
|
||||
setErr('');
|
||||
try {
|
||||
await updateGroup(editingId, { name });
|
||||
cancelEdit();
|
||||
await load();
|
||||
notifyGroupsChanged();
|
||||
} catch (e) {
|
||||
setErr(e.message || '保存失败');
|
||||
} finally {
|
||||
setSaveBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(g) {
|
||||
if (!confirm(`确定删除分类「${g.name}」?\n使用该分类的站点将自动归入其他分类。`)) {
|
||||
return;
|
||||
}
|
||||
setErr('');
|
||||
try {
|
||||
await deleteGroup(g.id);
|
||||
await load();
|
||||
notifyGroupsChanged();
|
||||
} catch (e) {
|
||||
setErr(e.message || '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-white/90 backdrop-blur border border-emerald-100 rounded-xl shadow-sm p-4 sm:p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-800 mb-1">网站分类</h2>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
增删改分类后,列表与筛选会同步更新;删除分类时,原归属站点会合并到剩余的第一个分类。
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleAdd} className="flex flex-col sm:flex-row gap-2 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="新分类名称"
|
||||
className="flex-1 px-3 py-2 rounded-lg border border-gray-200 text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={adding || !newName.trim()}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{adding ? '添加中…' : '添加分类'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{loading && !groups ? (
|
||||
<p className="text-sm text-gray-500">加载中…</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100 border border-gray-100 rounded-lg overflow-hidden">
|
||||
{(groups || []).map((g) => (
|
||||
<li key={g.id} className="flex flex-col sm:flex-row sm:items-center gap-2 px-3 py-2.5 bg-white text-sm">
|
||||
{editingId === g.id ? (
|
||||
<form onSubmit={handleSaveEdit} className="flex flex-1 flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
className="flex-1 min-w-[8rem] px-2 py-1.5 rounded border border-gray-200 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saveBusy || !editName.trim()}
|
||||
className="text-xs px-2 py-1 rounded bg-emerald-600 text-white disabled:opacity-50"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button type="button" onClick={cancelEdit} className="text-xs px-2 py-1 rounded border border-gray-200">
|
||||
取消
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-medium text-gray-800">{g.name}</span>
|
||||
<code className="ml-2 text-[10px] text-gray-400 font-mono">{g.id}</code>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEdit(g)}
|
||||
className="text-xs px-2 py-1 rounded border border-gray-200 text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(g)}
|
||||
className="text-xs px-2 py-1 rounded border border-red-100 text-red-600 hover:bg-red-50"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{err ? <p className="text-xs text-red-600 mt-3">{err}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
57
mengyaping-frontend/src/components/RandomBackdrop.jsx
Normal file
57
mengyaping-frontend/src/components/RandomBackdrop.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
const RANDOM_BG_API =
|
||||
'https://randbg.api.smyhub.com/api/random?format=json';
|
||||
|
||||
/** 高斯模糊强度:10%(相对参考最大模糊半径,便于以后微调) */
|
||||
const BLUR_PERCENT = 10;
|
||||
const BLUR_REFERENCE_PX = 24;
|
||||
|
||||
export default function RandomBackdrop({ children }) {
|
||||
const [imageUrl, setImageUrl] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(RANDOM_BG_API);
|
||||
const data = await res.json();
|
||||
const url = data?.url;
|
||||
if (!cancelled && typeof url === 'string' && url.length > 0) {
|
||||
setImageUrl(url);
|
||||
}
|
||||
} catch {
|
||||
/* 失败时仅用遮罩渐变,不阻塞页面 */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const blurPx = (BLUR_REFERENCE_PX * BLUR_PERCENT) / 100;
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen">
|
||||
<div
|
||||
className="fixed inset-0 z-0 overflow-hidden pointer-events-none"
|
||||
aria-hidden
|
||||
>
|
||||
{imageUrl ? (
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url(${imageUrl})`,
|
||||
filter: `blur(${blurPx}px)`,
|
||||
transform: 'scale(1.08)',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{/* 浅色罩层:保证前景可读,并保留原站绿色系氛围 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-50/82 via-green-50/78 to-teal-50/82" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 min-h-screen">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -133,12 +133,6 @@ export default function StatsCard({ websites }) {
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,10 @@ import { formatLatency, formatUptime, formatTime, getStatusColor, getUptimeColor
|
||||
|
||||
const HISTORY_DAYS = 90;
|
||||
|
||||
const INTRANET_FALLBACK_ICON =
|
||||
'https://api.iconify.design/mdi/lan-connect.svg?color=%23059669';
|
||||
const FAVICON_PROXY_BASE = 'https://favicon.smyhub.com/api/favicon?url=';
|
||||
|
||||
function getBarColor(bar) {
|
||||
if (bar.uptime === null) return 'bg-gray-200';
|
||||
if (bar.uptime >= 99) return 'bg-emerald-400';
|
||||
@@ -19,7 +23,7 @@ function getBarHoverColor(bar) {
|
||||
return 'hover:bg-red-600';
|
||||
}
|
||||
|
||||
export default function WebsiteCard({ website, onRefresh, onEdit, onDelete }) {
|
||||
export default function WebsiteCard({ website, onRefresh, onEdit, onDelete, readOnly = false }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [hoveredBar, setHoveredBar] = useState(null);
|
||||
|
||||
@@ -28,6 +32,10 @@ export default function WebsiteCard({ website, onRefresh, onEdit, onDelete }) {
|
||||
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 isIntranet = website.is_intranet === true;
|
||||
const cardIconSrc = isIntranet
|
||||
? (website.website?.favicon || INTRANET_FALLBACK_ICON)
|
||||
: (primaryUrl ? `${FAVICON_PROXY_BASE}${encodeURIComponent(primaryUrl)}` : null);
|
||||
|
||||
const dailyBars = useMemo(() => {
|
||||
const bars = [];
|
||||
@@ -81,9 +89,9 @@ export default function WebsiteCard({ website, onRefresh, onEdit, onDelete }) {
|
||||
<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 ? (
|
||||
{cardIconSrc ? (
|
||||
<img
|
||||
src={`https://cf-favicon.pages.dev/api/favicon?url=${encodeURIComponent(primaryUrl)}`}
|
||||
src={cardIconSrc}
|
||||
alt=""
|
||||
className="w-7 h-7 object-contain drop-shadow"
|
||||
onError={(e) => {
|
||||
@@ -93,8 +101,8 @@ export default function WebsiteCard({ website, onRefresh, onEdit, onDelete }) {
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
className={`text-base font-bold text-emerald-600 ${primaryUrl ? 'hidden' : ''}`}
|
||||
style={{ display: primaryUrl ? 'none' : 'flex' }}
|
||||
className={`text-base font-bold text-emerald-600 ${cardIconSrc ? 'hidden' : ''}`}
|
||||
style={{ display: cardIconSrc ? 'none' : 'flex' }}
|
||||
>
|
||||
{website.website?.name?.[0] || '?'}
|
||||
</span>
|
||||
@@ -172,7 +180,7 @@ export default function WebsiteCard({ website, onRefresh, onEdit, onDelete }) {
|
||||
</div>
|
||||
|
||||
{/* IP 地址(IPv4 / IPv6 分开显示) */}
|
||||
{website.website?.ip_addresses?.length > 0 && (() => {
|
||||
{!isIntranet && 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) && (
|
||||
@@ -222,42 +230,47 @@ export default function WebsiteCard({ website, onRefresh, onEdit, onDelete }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
{/* 操作按钮(仅管理后台) */}
|
||||
<div className={`flex items-center mt-3 ${readOnly ? 'justify-start' : 'justify-between'}`}>
|
||||
<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>
|
||||
{!readOnly && (
|
||||
<div className="flex space-x-1.5">
|
||||
<button
|
||||
type="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
|
||||
type="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
|
||||
type="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>
|
||||
)}
|
||||
|
||||
2
mengyaping-frontend/src/constants/events.js
Normal file
2
mengyaping-frontend/src/constants/events.js
Normal file
@@ -0,0 +1,2 @@
|
||||
/** 分类增删改后广播,便于监控页立即刷新分组与站点 */
|
||||
export const GROUPS_CHANGED_EVENT = 'mengyaping:groups-changed';
|
||||
@@ -1,5 +1,6 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
@@ -13,6 +14,8 @@ if (import.meta.env.PROD && 'serviceWorker' in navigator) {
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
206
mengyaping-frontend/src/pages/AdminPanel.jsx
Normal file
206
mengyaping-frontend/src/pages/AdminPanel.jsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Dashboard from './Dashboard';
|
||||
import {
|
||||
verifyAdminToken,
|
||||
setStoredAdminToken,
|
||||
getStoredAdminToken,
|
||||
getAdminConfig,
|
||||
updateAdminMonitorConfig,
|
||||
} from '../services/api';
|
||||
import AdminGroupsPanel from '../components/AdminGroupsPanel';
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ minutes: 10, label: '10 分钟' },
|
||||
{ minutes: 20, label: '20 分钟' },
|
||||
{ minutes: 30, label: '30 分钟' },
|
||||
{ minutes: 60, label: '1 小时' },
|
||||
{ minutes: 180, label: '3 小时' },
|
||||
{ minutes: 360, label: '6 小时' },
|
||||
];
|
||||
|
||||
function AdminScheduleToolbar({ onLogout }) {
|
||||
const [intervalMinutes, setIntervalMinutes] = useState(30);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
const c = await getAdminConfig();
|
||||
setIntervalMinutes(c.interval_minutes);
|
||||
} catch (e) {
|
||||
setErr(e.message || '加载配置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function handleSave(e) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setErr('');
|
||||
try {
|
||||
await updateAdminMonitorConfig({ interval_minutes: intervalMinutes });
|
||||
await load();
|
||||
} catch (e) {
|
||||
setErr(e.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-white/90 backdrop-blur border border-emerald-100 rounded-xl shadow-sm p-4 sm:p-5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-800">全局检测周期</h2>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
所有站点共用同一轮询间隔,保存后立即调整后台定时器(仍会先跑完上一轮逻辑)。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className="text-xs px-3 py-1.5 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
<Link
|
||||
to="/"
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
公开页
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-gray-500">加载中…</p>
|
||||
) : (
|
||||
<form onSubmit={handleSave} className="flex flex-col sm:flex-row sm:items-end gap-3">
|
||||
<label className="flex flex-col gap-1 text-xs text-gray-600 flex-1 sm:max-w-xs">
|
||||
检测间隔
|
||||
<select
|
||||
value={intervalMinutes}
|
||||
onChange={(e) => setIntervalMinutes(Number(e.target.value))}
|
||||
className="mt-0.5 px-3 py-2 rounded-lg border border-gray-200 text-sm text-gray-800 bg-white focus:ring-2 focus:ring-emerald-500 outline-none"
|
||||
>
|
||||
{INTERVAL_OPTIONS.map((o) => (
|
||||
<option key={o.minutes} value={o.minutes}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 disabled:opacity-60"
|
||||
>
|
||||
{saving ? '保存中…' : '保存周期'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{err ? <p className="text-xs text-red-600 mt-3">{err}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPanel() {
|
||||
const [authed, setAuthed] = useState(() => !!getStoredAdminToken());
|
||||
const [tokenInput, setTokenInput] = useState('');
|
||||
const [loginErr, setLoginErr] = useState('');
|
||||
const [loggingIn, setLoggingIn] = useState(false);
|
||||
|
||||
async function handleLogin(e) {
|
||||
e.preventDefault();
|
||||
const t = tokenInput.trim();
|
||||
if (!t) {
|
||||
setLoginErr('请输入管理员令牌');
|
||||
return;
|
||||
}
|
||||
setLoggingIn(true);
|
||||
setLoginErr('');
|
||||
try {
|
||||
await verifyAdminToken(t);
|
||||
setStoredAdminToken(t);
|
||||
setAuthed(true);
|
||||
setTokenInput('');
|
||||
} catch (err) {
|
||||
setLoginErr(err.message || '登录失败');
|
||||
} finally {
|
||||
setLoggingIn(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
setStoredAdminToken(null);
|
||||
setAuthed(false);
|
||||
}
|
||||
|
||||
if (!authed) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md bg-white rounded-2xl shadow-lg border border-gray-100 p-6 sm:p-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-11 h-11 rounded-xl overflow-hidden shadow">
|
||||
<img src="/logo.png" alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-gray-900">萌芽Ping · 管理后台</h1>
|
||||
<p className="text-xs text-gray-500">请输入与服务端 ADMIN_TOKEN 一致的口令</p>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<label className="block text-xs font-medium text-gray-600">
|
||||
管理员令牌
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={tokenInput}
|
||||
onChange={(e) => setTokenInput(e.target.value)}
|
||||
className="mt-1 w-full px-3 py-2 rounded-lg border border-gray-200 text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
|
||||
placeholder="环境变量 ADMIN_TOKEN"
|
||||
/>
|
||||
</label>
|
||||
{loginErr ? <p className="text-xs text-red-600">{loginErr}</p> : null}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loggingIn}
|
||||
className="w-full py-2.5 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 disabled:opacity-60"
|
||||
>
|
||||
{loggingIn ? '验证中…' : '进入管理'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-center mt-5">
|
||||
<Link to="/" className="text-sm text-emerald-700 hover:underline">
|
||||
← 返回公开页
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dashboard
|
||||
readOnly={false}
|
||||
topSlot={
|
||||
<>
|
||||
<AdminScheduleToolbar onLogout={handleLogout} />
|
||||
<div className="mt-4">
|
||||
<AdminGroupsPanel />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,107 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
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';
|
||||
import {
|
||||
getWebsites,
|
||||
deleteWebsite,
|
||||
checkWebsiteNow,
|
||||
getGroups,
|
||||
getMonitorSettings,
|
||||
verifyAdminToken,
|
||||
setStoredAdminToken,
|
||||
} from '../services/api';
|
||||
import { GROUPS_CHANGED_EVENT } from '../constants/events';
|
||||
|
||||
const LOGO_TAP_WINDOW_MS = 2000;
|
||||
const LOGO_TAPS_TO_OPEN = 5;
|
||||
|
||||
export default function Dashboard({ readOnly = false, topSlot = null }) {
|
||||
const navigate = useNavigate();
|
||||
const logoTapCountRef = useRef(0);
|
||||
const logoTapResetTimerRef = useRef(null);
|
||||
|
||||
export default function Dashboard() {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editData, setEditData] = useState(null);
|
||||
const [selectedGroup, setSelectedGroup] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const [adminGateOpen, setAdminGateOpen] = useState(false);
|
||||
const [adminGateToken, setAdminGateToken] = useState('');
|
||||
const [adminGateErr, setAdminGateErr] = useState('');
|
||||
const [adminGateBusy, setAdminGateBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const t = logoTapResetTimerRef.current;
|
||||
if (t != null) {
|
||||
clearTimeout(t);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLogoTap = () => {
|
||||
if (!readOnly) return;
|
||||
if (logoTapResetTimerRef.current != null) {
|
||||
clearTimeout(logoTapResetTimerRef.current);
|
||||
}
|
||||
logoTapCountRef.current += 1;
|
||||
if (logoTapCountRef.current >= LOGO_TAPS_TO_OPEN) {
|
||||
logoTapCountRef.current = 0;
|
||||
logoTapResetTimerRef.current = null;
|
||||
setAdminGateOpen(true);
|
||||
setAdminGateErr('');
|
||||
setAdminGateToken('');
|
||||
return;
|
||||
}
|
||||
logoTapResetTimerRef.current = setTimeout(() => {
|
||||
logoTapCountRef.current = 0;
|
||||
logoTapResetTimerRef.current = null;
|
||||
}, LOGO_TAP_WINDOW_MS);
|
||||
};
|
||||
|
||||
const handleAdminGateSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const t = adminGateToken.trim();
|
||||
if (!t) {
|
||||
setAdminGateErr('请输入令牌');
|
||||
return;
|
||||
}
|
||||
setAdminGateBusy(true);
|
||||
setAdminGateErr('');
|
||||
try {
|
||||
await verifyAdminToken(t);
|
||||
setStoredAdminToken(t);
|
||||
setAdminGateOpen(false);
|
||||
navigate('/admin');
|
||||
} catch (err) {
|
||||
setAdminGateErr(err.message || '验证失败');
|
||||
} finally {
|
||||
setAdminGateBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取网站数据
|
||||
const fetchData = useCallback(() => getWebsites(), []);
|
||||
const { data: websites, loading, error, refresh } = useAutoRefresh(fetchData, 30000);
|
||||
|
||||
// 获取分组数据
|
||||
const fetchGroups = useCallback(() => getGroups(), []);
|
||||
const { data: groups } = useAutoRefresh(fetchGroups, 60000);
|
||||
const { data: groups, refresh: refreshGroups } = useAutoRefresh(fetchGroups, 60000);
|
||||
|
||||
const fetchMonitorSettings = useCallback(() => getMonitorSettings(), []);
|
||||
const { data: monitorSettings } = useAutoRefresh(fetchMonitorSettings, 120000);
|
||||
|
||||
useEffect(() => {
|
||||
const onGroupsChanged = () => {
|
||||
refreshGroups();
|
||||
refresh();
|
||||
};
|
||||
window.addEventListener(GROUPS_CHANGED_EVENT, onGroupsChanged);
|
||||
return () => window.removeEventListener(GROUPS_CHANGED_EVENT, onGroupsChanged);
|
||||
}, [refreshGroups, refresh]);
|
||||
|
||||
// 处理添加网站
|
||||
const handleAdd = () => {
|
||||
@@ -102,44 +186,67 @@ export default function Dashboard() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-emerald-50 via-green-50 to-teal-50">
|
||||
<div className="min-h-screen">
|
||||
{/* 顶部导航 */}
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogoTap}
|
||||
className={`w-10 h-10 rounded-xl overflow-hidden shadow-lg shadow-emerald-200 border-0 p-0 bg-transparent ${readOnly ? 'cursor-pointer' : ''}`}
|
||||
aria-label="萌芽Ping"
|
||||
>
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="萌芽Ping"
|
||||
className="w-full h-full object-cover"
|
||||
className="w-full h-full object-cover pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
<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>
|
||||
{monitorSettings?.interval_minutes ? (
|
||||
<p className="text-[11px] text-emerald-700/80 mt-0.5">
|
||||
检测周期:每 {monitorSettings.interval_minutes} 分钟
|
||||
</p>
|
||||
) : null}
|
||||
</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 className="flex items-center gap-2">
|
||||
{readOnly ? null : (
|
||||
<>
|
||||
<Link
|
||||
to="/"
|
||||
className="hidden sm:flex items-center px-3 py-2 text-sm font-medium text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
公开页
|
||||
</Link>
|
||||
<button
|
||||
type="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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<main className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{topSlot ? <div className="mb-6">{topSlot}</div> : null}
|
||||
|
||||
{/* 统计概览 */}
|
||||
<section className="mb-6">
|
||||
<StatsCard websites={websites} />
|
||||
@@ -225,14 +332,18 @@ export default function Dashboard() {
|
||||
<p className="text-gray-500 mb-4">
|
||||
{searchTerm ? '没有找到匹配的网站' : '还没有添加任何监控网站'}
|
||||
</p>
|
||||
{!searchTerm && (
|
||||
{!searchTerm && !readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 transition-colors"
|
||||
>
|
||||
添加第一个网站
|
||||
</button>
|
||||
)}
|
||||
{!searchTerm && readOnly && (
|
||||
<p className="text-sm text-gray-400">站点由管理员在「管理后台」维护</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
@@ -250,9 +361,10 @@ export default function Dashboard() {
|
||||
<WebsiteCard
|
||||
key={website.website?.id}
|
||||
website={website}
|
||||
onRefresh={handleRefresh}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
readOnly={readOnly}
|
||||
onRefresh={readOnly ? undefined : handleRefresh}
|
||||
onEdit={readOnly ? undefined : handleEdit}
|
||||
onDelete={readOnly ? undefined : handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -269,12 +381,61 @@ export default function Dashboard() {
|
||||
</footer>
|
||||
|
||||
{/* 添加/编辑弹窗 */}
|
||||
<WebsiteModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSuccess={refresh}
|
||||
editData={editData}
|
||||
/>
|
||||
{!readOnly && (
|
||||
<WebsiteModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSuccess={refresh}
|
||||
editData={editData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{adminGateOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="admin-gate-title"
|
||||
onClick={() => setAdminGateOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-sm bg-white rounded-2xl shadow-xl border border-gray-100 p-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 id="admin-gate-title" className="text-base font-semibold text-gray-900">
|
||||
管理员验证
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500 mt-1">请输入与服务端配置一致的管理员令牌</p>
|
||||
<form onSubmit={handleAdminGateSubmit} className="mt-4 space-y-3">
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={adminGateToken}
|
||||
onChange={(e) => setAdminGateToken(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-200 text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
|
||||
placeholder="admin_token"
|
||||
/>
|
||||
{adminGateErr ? <p className="text-xs text-red-600">{adminGateErr}</p> : null}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdminGateOpen(false)}
|
||||
className="px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={adminGateBusy}
|
||||
className="px-3 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 disabled:opacity-60"
|
||||
>
|
||||
{adminGateBusy ? '验证中…' : '进入管理'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
5
mengyaping-frontend/src/pages/PublicDashboard.jsx
Normal file
5
mengyaping-frontend/src/pages/PublicDashboard.jsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import Dashboard from './Dashboard';
|
||||
|
||||
export default function PublicDashboard() {
|
||||
return <Dashboard readOnly />;
|
||||
}
|
||||
@@ -1,10 +1,29 @@
|
||||
// API服务
|
||||
// 根据环境变量判断使用哪个 API 地址
|
||||
const API_BASE = import.meta.env.PROD
|
||||
const API_BASE = import.meta.env.PROD
|
||||
? 'https://ping.api.shumengya.top/api' // 生产环境
|
||||
: 'http://localhost:8080/api'; // 开发环境
|
||||
|
||||
// 通用请求方法
|
||||
const ADMIN_TOKEN_KEY = 'mengyaping_admin_token';
|
||||
|
||||
export function getStoredAdminToken() {
|
||||
return localStorage.getItem(ADMIN_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setStoredAdminToken(token) {
|
||||
if (token) {
|
||||
localStorage.setItem(ADMIN_TOKEN_KEY, token);
|
||||
} else {
|
||||
localStorage.removeItem(ADMIN_TOKEN_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
async function parseJsonResponse(response) {
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
// 通用请求(公开读接口)
|
||||
async function request(url, options = {}) {
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
headers: {
|
||||
@@ -13,16 +32,62 @@ async function request(url, options = {}) {
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
const data = await parseJsonResponse(response);
|
||||
|
||||
if (data.code !== 0) {
|
||||
throw new Error(data.message || '请求失败');
|
||||
}
|
||||
|
||||
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async function adminRequest(url, options = {}) {
|
||||
const token = getStoredAdminToken();
|
||||
if (!token) {
|
||||
throw new Error('请先登录管理后台');
|
||||
}
|
||||
return request(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
'X-Admin-Token': token,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 校验管理员令牌(登录前调用,不落库) */
|
||||
export async function verifyAdminToken(token) {
|
||||
const response = await fetch(`${API_BASE}/admin/config`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Admin-Token': token,
|
||||
},
|
||||
});
|
||||
const data = await parseJsonResponse(response);
|
||||
if (data.code !== 0) {
|
||||
throw new Error(data.message || '令牌无效');
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// 公开:全局检测周期
|
||||
export async function getMonitorSettings() {
|
||||
return request('/monitor/settings');
|
||||
}
|
||||
|
||||
// 管理端:完整监控配置
|
||||
export async function getAdminConfig() {
|
||||
return adminRequest('/admin/config');
|
||||
}
|
||||
|
||||
export async function updateAdminMonitorConfig(payload) {
|
||||
return adminRequest('/admin/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
// 获取所有网站状态
|
||||
export async function getWebsites() {
|
||||
return request('/websites');
|
||||
@@ -35,7 +100,7 @@ export async function getWebsite(id) {
|
||||
|
||||
// 创建网站
|
||||
export async function createWebsite(data) {
|
||||
return request('/websites', {
|
||||
return adminRequest('/websites', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
@@ -43,7 +108,7 @@ export async function createWebsite(data) {
|
||||
|
||||
// 更新网站
|
||||
export async function updateWebsite(id, data) {
|
||||
return request(`/websites/${id}`, {
|
||||
return adminRequest(`/websites/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
@@ -51,14 +116,14 @@ export async function updateWebsite(id, data) {
|
||||
|
||||
// 删除网站
|
||||
export async function deleteWebsite(id) {
|
||||
return request(`/websites/${id}`, {
|
||||
return adminRequest(`/websites/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// 立即检测网站
|
||||
export async function checkWebsiteNow(id) {
|
||||
return request(`/websites/${id}/check`, {
|
||||
return adminRequest(`/websites/${id}/check`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
@@ -68,10 +133,30 @@ export async function getGroups() {
|
||||
return request('/groups');
|
||||
}
|
||||
|
||||
// 获取单个分组
|
||||
export async function getGroup(id) {
|
||||
return request(`/groups/${id}`);
|
||||
}
|
||||
|
||||
// 添加分组
|
||||
export async function addGroup(name) {
|
||||
return request('/groups', {
|
||||
return adminRequest('/groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
}
|
||||
|
||||
// 更新分组
|
||||
export async function updateGroup(id, payload) {
|
||||
return adminRequest(`/groups/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
// 删除分组
|
||||
export async function deleteGroup(id) {
|
||||
return adminRequest(`/groups/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user