Add Fleet control plane and split manage.sh deploy menu.
Strategy nodes gain fleet token APIs; control/ app for local ops; manage.sh offers strategy vs control one-click deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>比特骆驼中控</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1920
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "bitcamel-control-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { NavLink, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { clearSession, getToken, getUsername } from "./api";
|
||||
import LoginPage from "./pages/Login";
|
||||
import MonitorPage from "./pages/Monitor";
|
||||
import SettingsPage from "./pages/Settings";
|
||||
|
||||
function Shell({ children }: { children: ReactNode }) {
|
||||
const user = getUsername();
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="header">
|
||||
<div className="brand">比特骆驼中控</div>
|
||||
<nav className="nav">
|
||||
<NavLink to="/monitor" className={({ isActive }) => (isActive ? "active" : "")}>
|
||||
监控区
|
||||
</NavLink>
|
||||
<NavLink to="/settings" className={({ isActive }) => (isActive ? "active" : "")}>
|
||||
系统设置
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className="header-right">
|
||||
<span className="meta">{user}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={() => {
|
||||
clearSession();
|
||||
window.location.href = "/login";
|
||||
}}
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="main">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequireAuth({ children }: { children: ReactNode }) {
|
||||
if (!getToken()) return <Navigate to="/login" replace />;
|
||||
return <Shell>{children}</Shell>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/monitor"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<MonitorPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<SettingsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to="/monitor" replace />} />
|
||||
<Route path="*" element={<Navigate to="/monitor" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
const TOKEN_KEY = "control_token";
|
||||
const USER_KEY = "control_user";
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function getUsername(): string | null {
|
||||
return localStorage.getItem(USER_KEY);
|
||||
}
|
||||
|
||||
export function setSession(token: string, username: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
localStorage.setItem(USER_KEY, username);
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (!headers.has("Content-Type") && options.body) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
const token = getToken();
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
const res = await fetch(path, { ...options, headers });
|
||||
if (res.status === 401) {
|
||||
clearSession();
|
||||
throw new Error("unauthorized");
|
||||
}
|
||||
const text = await res.text();
|
||||
let data: unknown = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = { detail: text };
|
||||
}
|
||||
if (!res.ok) {
|
||||
const detail =
|
||||
typeof data === "object" && data && "detail" in data
|
||||
? String((data as { detail: unknown }).detail)
|
||||
: res.statusText;
|
||||
throw new Error(detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
const res = await apiFetch<{ token: string; username: string }>(
|
||||
"/api/auth/login",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
},
|
||||
);
|
||||
setSession(res.token, res.username);
|
||||
return res;
|
||||
}
|
||||
|
||||
export type NodeCard = {
|
||||
id: number;
|
||||
name: string;
|
||||
base_url: string;
|
||||
token_configured: boolean;
|
||||
online?: boolean;
|
||||
health?: Record<string, unknown> | null;
|
||||
fleet?: Record<string, unknown> | null;
|
||||
error?: string | null;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { login } from "../api";
|
||||
|
||||
export default function LoginPage() {
|
||||
const nav = useNavigate();
|
||||
const [username, setUsername] = useState("admin");
|
||||
const [password, setPassword] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setErr("");
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(username.trim(), password);
|
||||
nav("/monitor", { replace: true });
|
||||
} catch (ex) {
|
||||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-wrap">
|
||||
<form className="login-box" onSubmit={onSubmit}>
|
||||
<h1>比特骆驼中控</h1>
|
||||
<p className="meta">本地运维面板 · 默认 admin / admin123</p>
|
||||
{err ? <div className="err">{err}</div> : null}
|
||||
<label>
|
||||
用户名
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
密码
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button className="btn" type="submit" disabled={loading}>
|
||||
{loading ? "登录中…" : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { apiFetch, type NodeCard } from "../api";
|
||||
|
||||
function pickStrategy(n: NodeCard) {
|
||||
const fleet = (n.fleet || {}) as Record<string, unknown>;
|
||||
const health = (n.health || {}) as Record<string, unknown>;
|
||||
const strat =
|
||||
(fleet.strategy as Record<string, unknown> | undefined) ||
|
||||
(health.strategy as Record<string, unknown> | undefined) ||
|
||||
{};
|
||||
return {
|
||||
mode: String(fleet.mode || health.mode || "-"),
|
||||
running: strat.running,
|
||||
phase: String(strat.phase ?? "-"),
|
||||
rounds: strat.rounds_done,
|
||||
market: fleet.market_connected ?? health.market_connected,
|
||||
exchange: String(fleet.exchange || health.exchange || "-"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function MonitorPage() {
|
||||
const [nodes, setNodes] = useState<NodeCard[]>([]);
|
||||
const [err, setErr] = useState("");
|
||||
const [busy, setBusy] = useState<Record<number, string>>({});
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [pollSec, setPollSec] = useState(8);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const r = await apiFetch<{ nodes: NodeCard[] }>("/api/nodes/status/all");
|
||||
setNodes(r.nodes || []);
|
||||
setErr("");
|
||||
} catch (ex) {
|
||||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ poll_interval_sec?: number }>("/api/auth/me")
|
||||
.then((m) => {
|
||||
if (m.poll_interval_sec) setPollSec(m.poll_interval_sec);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
void refresh();
|
||||
const id = window.setInterval(() => void refresh(), pollSec * 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [refresh, pollSec]);
|
||||
|
||||
async function act(id: number, action: "start" | "pause" | "update" | "login") {
|
||||
setBusy((b) => ({ ...b, [id]: action }));
|
||||
setErr("");
|
||||
try {
|
||||
if (action === "login") {
|
||||
const r = await apiFetch<{ url: string }>(`/api/nodes/${id}/login-url`, {
|
||||
method: "POST",
|
||||
});
|
||||
window.open(r.url, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
await apiFetch(`/api/nodes/${id}/${action === "pause" ? "pause" : action}`, {
|
||||
method: "POST",
|
||||
});
|
||||
await refresh();
|
||||
}
|
||||
} catch (ex) {
|
||||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||
} finally {
|
||||
setBusy((b) => {
|
||||
const n = { ...b };
|
||||
delete n[id];
|
||||
return n;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function batchUpdate() {
|
||||
const ids = [...selected];
|
||||
if (!ids.length) return;
|
||||
setErr("");
|
||||
try {
|
||||
await apiFetch("/api/nodes/update-batch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
await refresh();
|
||||
} catch (ex) {
|
||||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(id: number) {
|
||||
setSelected((prev) => {
|
||||
const n = new Set(prev);
|
||||
if (n.has(id)) n.delete(id);
|
||||
else n.add(id);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="toolbar">
|
||||
<h2>监控区</h2>
|
||||
<div className="toolbar-actions">
|
||||
<button type="button" className="btn ghost" onClick={() => void refresh()}>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={!selected.size}
|
||||
onClick={() => void batchUpdate()}
|
||||
>
|
||||
勾选更新 ({selected.size})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{err ? <div className="err">{err}</div> : null}
|
||||
<div className="card-grid">
|
||||
{nodes.map((n) => {
|
||||
const s = pickStrategy(n);
|
||||
const running = s.running === true || s.running === 1;
|
||||
return (
|
||||
<article key={n.id} className={`node-card ${n.online ? "online" : "offline"}`}>
|
||||
<header className="node-card-head">
|
||||
<label className="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(n.id)}
|
||||
onChange={() => toggle(n.id)}
|
||||
/>
|
||||
<strong>{n.name}</strong>
|
||||
</label>
|
||||
<span className={`pill ${n.online ? "ok" : "bad"}`}>
|
||||
{n.online ? "在线" : "离线"}
|
||||
</span>
|
||||
</header>
|
||||
<div className="node-meta mono">{n.base_url}</div>
|
||||
<dl className="kv">
|
||||
<div>
|
||||
<dt>模式</dt>
|
||||
<dd>{s.mode}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>交易所</dt>
|
||||
<dd>{s.exchange}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>策略</dt>
|
||||
<dd>
|
||||
{running ? "运行中" : "已停"} · {s.phase}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>轮次</dt>
|
||||
<dd>{s.rounds ?? "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>行情</dt>
|
||||
<dd>{s.market ? "已连接" : "断开"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Token</dt>
|
||||
<dd>{n.token_configured ? "已配对" : "未配对"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{n.error ? <div className="err soft">{n.error}</div> : null}
|
||||
<div className="node-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={!!busy[n.id] || !n.token_configured}
|
||||
onClick={() => void act(n.id, "start")}
|
||||
>
|
||||
启动
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
disabled={!!busy[n.id] || !n.token_configured}
|
||||
onClick={() => void act(n.id, "pause")}
|
||||
>
|
||||
停止
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={!!busy[n.id] || !n.token_configured}
|
||||
onClick={() => void act(n.id, "login")}
|
||||
>
|
||||
登录策略机
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
disabled={!!busy[n.id] || !n.token_configured}
|
||||
onClick={() => void act(n.id, "update")}
|
||||
>
|
||||
更新代码
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!nodes.length ? (
|
||||
<p className="meta">暂无策略机。请到「系统设置」添加并生成 Token。</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { apiFetch, getUsername, setSession, type NodeCard } from "../api";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [nodes, setNodes] = useState<NodeCard[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState("https://");
|
||||
const [err, setErr] = useState("");
|
||||
const [ok, setOk] = useState("");
|
||||
const [lastToken, setLastToken] = useState<{ id: number; token: string } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [newUsername, setNewUsername] = useState(getUsername() || "admin");
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [credBusy, setCredBusy] = useState(false);
|
||||
|
||||
async function load() {
|
||||
const r = await apiFetch<{ nodes: NodeCard[] }>("/api/nodes/");
|
||||
setNodes(r.nodes || []);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load().catch((ex) => setErr(ex instanceof Error ? ex.message : String(ex)));
|
||||
}, []);
|
||||
|
||||
async function onAdd(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setErr("");
|
||||
setOk("");
|
||||
try {
|
||||
await apiFetch("/api/nodes/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: name.trim(), base_url: baseUrl.trim() }),
|
||||
});
|
||||
setName("");
|
||||
setOk("已添加策略机");
|
||||
await load();
|
||||
} catch (ex) {
|
||||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaveCreds(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setErr("");
|
||||
setOk("");
|
||||
if (newPassword !== confirmPassword) {
|
||||
setErr("两次新密码不一致");
|
||||
return;
|
||||
}
|
||||
setCredBusy(true);
|
||||
try {
|
||||
const r = await apiFetch<{ token: string; username: string }>(
|
||||
"/api/auth/change-credentials",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_username: newUsername.trim(),
|
||||
new_password: newPassword,
|
||||
}),
|
||||
},
|
||||
);
|
||||
setSession(r.token, r.username);
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setOk("中控账号已更新");
|
||||
} catch (ex) {
|
||||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||
} finally {
|
||||
setCredBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function genToken(id: number) {
|
||||
setErr("");
|
||||
setOk("");
|
||||
try {
|
||||
const r = await apiFetch<{ token: string; msg: string }>(
|
||||
`/api/nodes/${id}/generate-token`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
setLastToken({ id, token: r.token });
|
||||
setOk(r.msg || "已生成 Token");
|
||||
await load();
|
||||
} catch (ex) {
|
||||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!window.confirm("确认删除该策略机?")) return;
|
||||
setErr("");
|
||||
try {
|
||||
await apiFetch(`/api/nodes/${id}`, { method: "DELETE" });
|
||||
await load();
|
||||
} catch (ex) {
|
||||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>系统设置</h2>
|
||||
<p className="meta">
|
||||
中控不访问交易所。生成 Token 后登录策略机,在「登录账户」中保存同一 Token。
|
||||
</p>
|
||||
{err ? <div className="err">{err}</div> : null}
|
||||
{ok ? <div className="ok">{ok}</div> : null}
|
||||
|
||||
<form className="add-form" onSubmit={onSaveCreds}>
|
||||
<h3>中控登录账号</h3>
|
||||
<p className="meta">默认 admin / admin123,建议首次登录后修改。</p>
|
||||
<label>
|
||||
新用户名
|
||||
<input
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
当前密码
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
新密码
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
确认新密码
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button className="btn" type="submit" disabled={credBusy}>
|
||||
{credBusy ? "保存中…" : "保存账号"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{lastToken ? (
|
||||
<div className="token-box">
|
||||
<div>
|
||||
节点 #{lastToken.id} 新 Token(只显示一次,请复制):
|
||||
</div>
|
||||
<code className="mono">{lastToken.token}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(lastToken.token);
|
||||
setOk("已复制到剪贴板");
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className="add-form" onSubmit={onAdd}>
|
||||
<h3>添加策略机</h3>
|
||||
<label>
|
||||
名称
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="例如 云机-A"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
公网 Base URL
|
||||
<input
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://dc.hyf2.cc"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button className="btn" type="submit">
|
||||
添加
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>名称</th>
|
||||
<th>URL</th>
|
||||
<th>Token</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodes.map((n) => (
|
||||
<tr key={n.id}>
|
||||
<td>{n.id}</td>
|
||||
<td>{n.name}</td>
|
||||
<td className="mono">{n.base_url}</td>
|
||||
<td>{n.token_configured ? "已生成" : "无"}</td>
|
||||
<td className="row-actions">
|
||||
<button type="button" className="btn" onClick={() => void genToken(n.id)}>
|
||||
生成 Token
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={() => void remove(n.id)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
:root {
|
||||
--bg: #0f1419;
|
||||
--panel: #1a222c;
|
||||
--line: #2a3542;
|
||||
--text: #e8eef4;
|
||||
--muted: #8b9aab;
|
||||
--accent: #3d9cf0;
|
||||
--ok: #3cb371;
|
||||
--bad: #e35d5d;
|
||||
--radius: 10px;
|
||||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: var(--text);
|
||||
background: radial-gradient(1200px 600px at 10% -10%, #1a3048, var(--bg));
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.shell {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 20px 40px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.nav a.active,
|
||||
.nav a:hover {
|
||||
color: var(--text);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.main h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 0;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.err {
|
||||
background: rgba(227, 93, 93, 0.15);
|
||||
color: #ffb4b4;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.err.soft {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.ok {
|
||||
background: rgba(60, 179, 113, 0.15);
|
||||
color: #9df0c9;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.login-wrap {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
width: min(380px, 100%);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-box h1 {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.login-box label,
|
||||
.add-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input {
|
||||
background: #10161d;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.node-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.node-card.offline {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.node-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
font-size: 0.75rem;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.pill.ok {
|
||||
color: var(--ok);
|
||||
border-color: rgba(60, 179, 113, 0.4);
|
||||
}
|
||||
|
||||
.pill.bad {
|
||||
color: var(--bad);
|
||||
border-color: rgba(227, 93, 93, 0.4);
|
||||
}
|
||||
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.kv dt {
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.kv dd {
|
||||
margin: 2px 0 0;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.node-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.add-form {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 16px 0;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.token-box {
|
||||
background: #132033;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 12px 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:5160",
|
||||
"/health": "http://127.0.0.1:5160",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user