Initial eth_hedge_sim: P0 market, auth UI, one-click deploy.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>eth_hedge_sim</title>
|
||||
<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=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</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": "eth-hedge-sim-web",
|
||||
"private": true,
|
||||
"version": "0.2.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,91 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { NavLink, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { clearSession, getToken, getUsername } from "./api/client";
|
||||
import LoginPage from "./pages/Login";
|
||||
import PlanPage from "./pages/Plan";
|
||||
import TradesPage from "./pages/Trades";
|
||||
import StatsPage from "./pages/Stats";
|
||||
import SettingsPage from "./pages/Settings";
|
||||
|
||||
function Shell({ children }: { children: ReactNode }) {
|
||||
const user = getUsername();
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<nav className="topnav">
|
||||
<div className="brand">eth_hedge_sim</div>
|
||||
<NavLink to="/plan" className={({ isActive }) => (isActive ? "active" : "")}>
|
||||
自动对冲计划
|
||||
</NavLink>
|
||||
<NavLink to="/trades" className={({ isActive }) => (isActive ? "active" : "")}>
|
||||
交易记录
|
||||
</NavLink>
|
||||
<NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>
|
||||
统计
|
||||
</NavLink>
|
||||
<NavLink to="/settings" className={({ isActive }) => (isActive ? "active" : "")}>
|
||||
系统设置
|
||||
</NavLink>
|
||||
<div className="spacer" />
|
||||
<span className="meta mono">{user}</span>
|
||||
<button
|
||||
className="btn ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearSession();
|
||||
window.location.href = "/login";
|
||||
}}
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</nav>
|
||||
<main className="page">{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="/plan"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<PlanPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/trades"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<TradesPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/stats"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<StatsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<SettingsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to="/plan" replace />} />
|
||||
<Route path="*" element={<Navigate to="/plan" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
const API_KEY = "eth_hedge_api_base";
|
||||
const TOKEN_KEY = "eth_hedge_token";
|
||||
const USER_KEY = "eth_hedge_user";
|
||||
|
||||
export function getApiBase(): string {
|
||||
const saved = localStorage.getItem(API_KEY);
|
||||
if (saved && saved.trim()) return saved.trim().replace(/\/$/, "");
|
||||
// same-origin default when UI is served by backend on :5155
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
export function setApiBase(url: string) {
|
||||
localStorage.setItem(API_KEY, url.trim().replace(/\/$/, ""));
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_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 function getUsername(): string | null {
|
||||
return localStorage.getItem(USER_KEY);
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const base = getApiBase();
|
||||
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(`${base}${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 type LoginResult = {
|
||||
token: string;
|
||||
username: string;
|
||||
expires_in: number;
|
||||
env_name: string;
|
||||
mode: string;
|
||||
};
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
return apiFetch<LoginResult>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export type MarketSnapshot = {
|
||||
connected: boolean;
|
||||
updated_at_ms: number | null;
|
||||
index_px: number | null;
|
||||
pair: {
|
||||
expiry_ymd: string;
|
||||
strike: number;
|
||||
call_inst_id: string;
|
||||
put_inst_id: string;
|
||||
} | null;
|
||||
perp: Quote | null;
|
||||
call: Quote | null;
|
||||
put: Quote | null;
|
||||
ask_compare: {
|
||||
call_ask: number | null;
|
||||
put_ask: number | null;
|
||||
bias: string;
|
||||
};
|
||||
};
|
||||
|
||||
type Quote = {
|
||||
inst_id: string;
|
||||
bid: number | null;
|
||||
ask: number | null;
|
||||
bid_sz: number | null;
|
||||
ask_sz: number | null;
|
||||
mark_px: number | 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/app.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,79 @@
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getApiBase, login, setApiBase, setSession } from "../api/client";
|
||||
|
||||
export default function LoginPage() {
|
||||
const nav = useNavigate();
|
||||
const [apiBase, setApi] = useState(getApiBase());
|
||||
const [username, setUsername] = useState("admin");
|
||||
const [password, setPassword] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const hint = useMemo(
|
||||
() => "默认同域 API。跨机访问时填写如 http://47.236.184.99:5155",
|
||||
[],
|
||||
);
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setErr("");
|
||||
setLoading(true);
|
||||
try {
|
||||
setApiBase(apiBase);
|
||||
const res = await login(username.trim(), password);
|
||||
setSession(res.token, res.username);
|
||||
nav("/plan", { 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>eth_hedge_sim</h1>
|
||||
<p>模拟盘登录 · 可自定义 API 地址</p>
|
||||
{err ? <div className="err">{err}</div> : null}
|
||||
<div className="field">
|
||||
<label htmlFor="api">API 地址</label>
|
||||
<input
|
||||
id="api"
|
||||
className="mono"
|
||||
value={apiBase}
|
||||
onChange={(e) => setApi(e.target.value)}
|
||||
placeholder="http://host:5155"
|
||||
autoComplete="url"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="user">用户名</label>
|
||||
<input
|
||||
id="user"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="pass">密码</label>
|
||||
<input
|
||||
id="pass"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button className="btn block" type="submit" disabled={loading}>
|
||||
{loading ? "登录中…" : "登录"}
|
||||
</button>
|
||||
<div className="hint">{hint}</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiFetch, MarketSnapshot } from "../api/client";
|
||||
|
||||
function fmt(n: number | null | undefined, d = 2) {
|
||||
if (n == null || Number.isNaN(n)) return "—";
|
||||
return n.toFixed(d);
|
||||
}
|
||||
|
||||
export default function PlanPage() {
|
||||
const [snap, setSnap] = useState<MarketSnapshot | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await apiFetch<MarketSnapshot>("/api/market/snapshot");
|
||||
if (alive) {
|
||||
setSnap(data);
|
||||
setErr("");
|
||||
}
|
||||
} catch (e) {
|
||||
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
load();
|
||||
const t = window.setInterval(load, 2000);
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearInterval(t);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const bias = snap?.ask_compare?.bias;
|
||||
const biasTag =
|
||||
bias === "call_ask_gt_put" ? (
|
||||
<span className="tag up">Call卖一 > Put卖一 → 永续多+期权空</span>
|
||||
) : bias === "put_ask_gt_call" ? (
|
||||
<span className="tag down">Put卖一 > Call卖一 → 永续空+期权多</span>
|
||||
) : (
|
||||
<span className="tag">等待 / 相等</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginTop: 0 }}>自动对冲计划</h2>
|
||||
<p style={{ color: "var(--muted)", marginTop: -8 }}>
|
||||
P0 行情只读 · 策略开平仓待拍板后接入
|
||||
</p>
|
||||
{err ? <div className="err">{err}</div> : null}
|
||||
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div className="kv">
|
||||
<span>模式</span>
|
||||
<span className="mono">SIM · 测试环境</span>
|
||||
</div>
|
||||
<div className="kv">
|
||||
<span>行情连接</span>
|
||||
<span className="mono">{snap?.connected ? "WS 已连接" : "REST/未连"}</span>
|
||||
</div>
|
||||
<div className="kv">
|
||||
<span>指数</span>
|
||||
<span className="mono">{fmt(snap?.index_px)}</span>
|
||||
</div>
|
||||
<div className="kv">
|
||||
<span>选约</span>
|
||||
<span className="mono">
|
||||
{snap?.pair
|
||||
? `${snap.pair.expiry_ymd} @ ${snap.pair.strike}`
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="kv">
|
||||
<span>选向</span>
|
||||
{biasTag}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid-2">
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>永续 ETH-USDT-SWAP</h3>
|
||||
<div className="kv">
|
||||
<span>买一</span>
|
||||
<span className="mono">{fmt(snap?.perp?.bid)} × {fmt(snap?.perp?.bid_sz, 2)}</span>
|
||||
</div>
|
||||
<div className="kv">
|
||||
<span>卖一</span>
|
||||
<span className="mono">{fmt(snap?.perp?.ask)} × {fmt(snap?.perp?.ask_sz, 2)}</span>
|
||||
</div>
|
||||
<div className="kv">
|
||||
<span>标记</span>
|
||||
<span className="mono">{fmt(snap?.perp?.mark_px)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>期权 ATM</h3>
|
||||
<div className="kv">
|
||||
<span>Call 卖一</span>
|
||||
<span className="mono">{fmt(snap?.call?.ask)} / 买一 {fmt(snap?.call?.bid)}</span>
|
||||
</div>
|
||||
<div className="kv">
|
||||
<span>Put 卖一</span>
|
||||
<span className="mono">{fmt(snap?.put?.ask)} / 买一 {fmt(snap?.put?.bid)}</span>
|
||||
</div>
|
||||
<div className="kv">
|
||||
<span>Call</span>
|
||||
<span className="mono" style={{ fontSize: 12 }}>
|
||||
{snap?.pair?.call_inst_id || "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="kv">
|
||||
<span>Put</span>
|
||||
<span className="mono" style={{ fontSize: 12 }}>
|
||||
{snap?.pair?.put_inst_id || "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { getApiBase, setApiBase } from "../api/client";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [apiBase, setApi] = useState(getApiBase());
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
function onSave(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setApiBase(apiBase);
|
||||
setSaved(true);
|
||||
window.setTimeout(() => setSaved(false), 1500);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ maxWidth: 560 }}>
|
||||
<h2 style={{ marginTop: 0 }}>系统设置</h2>
|
||||
<p style={{ color: "var(--muted)" }}>
|
||||
前端可单独指定后端 API;同机部署默认用当前域名端口 5155。
|
||||
</p>
|
||||
<form onSubmit={onSave}>
|
||||
<div className="field">
|
||||
<label htmlFor="api">API 地址</label>
|
||||
<input
|
||||
id="api"
|
||||
className="mono"
|
||||
value={apiBase}
|
||||
onChange={(e) => setApi(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn" type="submit">
|
||||
保存
|
||||
</button>
|
||||
{saved ? <span style={{ marginLeft: 10, color: "var(--up)" }}>已保存</span> : null}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function StatsPage() {
|
||||
return (
|
||||
<div className="card">
|
||||
<h2 style={{ marginTop: 0 }}>统计</h2>
|
||||
<p style={{ color: "var(--muted)" }}>胜率 / 盈亏 / 手续费曲线将在有成交后展示。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function TradesPage() {
|
||||
return (
|
||||
<div className="card">
|
||||
<h2 style={{ marginTop: 0 }}>交易记录</h2>
|
||||
<p style={{ color: "var(--muted)" }}>按组成交明细将在 P1/P2 接入。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
:root {
|
||||
--bg: #0b0e11;
|
||||
--bg-elev: #12161c;
|
||||
--bg-panel: #151a21;
|
||||
--line: #1e2630;
|
||||
--text: #eaecef;
|
||||
--muted: #848e9c;
|
||||
--accent: #f0b90b;
|
||||
--up: #0ecb81;
|
||||
--down: #f6465d;
|
||||
--input: #0f141a;
|
||||
--danger: #f6465d;
|
||||
font-family: "IBM Plex Sans", sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(1200px 600px at 10% -10%, rgba(240, 185, 11, 0.08), transparent 55%),
|
||||
radial-gradient(900px 500px at 100% 0%, rgba(14, 203, 129, 0.05), transparent 50%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.topnav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(11, 14, 17, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
margin-right: 12px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.topnav a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.topnav a.active {
|
||||
color: var(--text);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
|
||||
.topnav .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.topnav .meta {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 16px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.kv {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.kv:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.kv span:first-child {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.login-wrap {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
width: min(420px, 100%);
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 28px 24px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.login-box h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.login-box p {
|
||||
margin: 0 0 20px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.field input {
|
||||
background: var(--input);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
border-color: rgba(240, 185, 11, 0.55);
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
background: var(--accent);
|
||||
color: #111;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.btn.block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.err {
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tag.up {
|
||||
color: var(--up);
|
||||
border-color: rgba(14, 203, 129, 0.35);
|
||||
}
|
||||
|
||||
.tag.down {
|
||||
color: var(--down);
|
||||
border-color: rgba(246, 70, 93, 0.35);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"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": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:5155",
|
||||
"/health": "http://127.0.0.1:5155",
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user