first commit
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>手工交易中控</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1216;
|
||||
--panel: #171b22;
|
||||
--text: #e8eaed;
|
||||
--muted: #8b929a;
|
||||
--border: #2a313c;
|
||||
--green: #3fb950;
|
||||
--red: #f85149;
|
||||
--accent: #58a6ff;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
padding: 16px clamp(16px, 4vw, 56px);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.page {
|
||||
max-width: 1040px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
h1 { font-size: 1.1rem; font-weight: 600; margin: 0 0 12px; }
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.toolbar span { color: var(--muted); font-size: 12px; }
|
||||
button {
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
button:hover { border-color: var(--accent); }
|
||||
button.danger { border-color: var(--red); color: var(--red); }
|
||||
button.danger:hover { background: #2d1514; }
|
||||
button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.card-head strong { font-size: 14px; }
|
||||
.card-head .meta { color: var(--muted); font-size: 12px; word-break: break-all; }
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.metrics div span { color: var(--muted); display: block; font-size: 11px; }
|
||||
.metrics-row-balance-upnl {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px 28px;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.metric-inline {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
.metric-inline .metric-lbl { color: var(--muted); font-size: 12px; }
|
||||
.metric-inline .metric-num {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
.metric-inline .metric-num.pnl-pos { color: var(--green); }
|
||||
.metric-inline .metric-num.pnl-neg { color: var(--red); }
|
||||
.pnl-pos { color: var(--green); }
|
||||
.pnl-neg { color: var(--red); }
|
||||
th.hl-pnl,
|
||||
td.hl-pnl {
|
||||
background: rgba(88, 166, 255, 0.08);
|
||||
border-left: 2px solid rgba(88, 166, 255, 0.55);
|
||||
}
|
||||
th.hl-pnl { color: var(--accent); font-weight: 600; }
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
th, td { padding: 8px 10px; text-align: left; border-top: 1px solid var(--border); }
|
||||
th { color: var(--muted); font-weight: 500; }
|
||||
.err { color: var(--red); padding: 12px; font-size: 13px; }
|
||||
.card-disabled { opacity: 0.72; border-style: dashed; }
|
||||
.card-disabled .card-head { border-bottom-style: dashed; }
|
||||
.off-note { padding: 12px 14px; color: var(--muted); font-size: 13px; }
|
||||
.monitor-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); cursor: pointer; user-select: none; }
|
||||
.monitor-toggle input { cursor: pointer; }
|
||||
.monitor-toggle input:disabled { cursor: not-allowed; }
|
||||
#toast {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
max-width: min(420px, 90vw);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
display: none;
|
||||
z-index: 20;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
#toast.show { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>手工交易 · 多账户中控</h1>
|
||||
<div class="toolbar">
|
||||
<button type="button" id="btn-refresh">立即刷新</button>
|
||||
<label style="color:var(--muted);font-size:12px;display:flex;align-items:center;gap:6px;">
|
||||
<input type="checkbox" id="auto-refresh" checked /> 每 3 秒自动刷新
|
||||
</label>
|
||||
<button type="button" id="btn-close-all" class="danger">全局一键全平</button>
|
||||
<span style="color:var(--muted);font-size:12px;">关闭的账户不轮询、不参与全平(本机记住);账户显示名由中控环境变量 <code style="font-size:11px;">HUB_AGENT_NAMES</code> 配置,所有访问同一中控的电脑一致。</span>
|
||||
<span id="last-updated"></span>
|
||||
</div>
|
||||
<div id="root"></div>
|
||||
</div>
|
||||
<div id="toast"></div>
|
||||
<script>
|
||||
const LS_EXCLUDED = "manual_trading_hub_excluded";
|
||||
const root = document.getElementById("root");
|
||||
const toast = document.getElementById("toast");
|
||||
const lastUpdated = document.getElementById("last-updated");
|
||||
let timer = null;
|
||||
let agentsList = [];
|
||||
let envExcludedSet = new Set();
|
||||
let rowById = new Map();
|
||||
|
||||
function loadExcludedLS() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_EXCLUDED);
|
||||
const arr = raw ? JSON.parse(raw) : [];
|
||||
return new Set((Array.isArray(arr) ? arr : []).map(String));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function saveExcludedLS(set) {
|
||||
localStorage.setItem(LS_EXCLUDED, JSON.stringify([...set]));
|
||||
}
|
||||
|
||||
function showToast(msg, isErr) {
|
||||
toast.textContent = msg;
|
||||
toast.style.borderColor = isErr ? "var(--red)" : "var(--border)";
|
||||
toast.classList.add("show");
|
||||
clearTimeout(showToast._t);
|
||||
showToast._t = setTimeout(() => toast.classList.remove("show"), 6000);
|
||||
}
|
||||
|
||||
function fmtNum(x, d) {
|
||||
if (x === null || x === undefined || Number.isNaN(Number(x))) return "—";
|
||||
const n = Number(x);
|
||||
return n.toLocaleString(undefined, { maximumFractionDigits: d });
|
||||
}
|
||||
|
||||
function pnlClass(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || n === 0) return "";
|
||||
return n > 0 ? "pnl-pos" : "pnl-neg";
|
||||
}
|
||||
|
||||
function renderActiveCard(r) {
|
||||
const err = r.error || (r.payload && r.payload.error);
|
||||
const p = r.payload || {};
|
||||
let inner;
|
||||
if (!r.http_ok || err) {
|
||||
inner = `<div class="err">${escapeHtml(String(err || ("HTTP " + (r.status_code ?? "?"))))}</div>`;
|
||||
} else {
|
||||
const pos = Array.isArray(p.positions) ? p.positions : [];
|
||||
const rows = pos.map(
|
||||
(x) =>
|
||||
`<tr>
|
||||
<td>${escapeHtml(x.symbol || "")}</td>
|
||||
<td>${escapeHtml(x.side || "")}</td>
|
||||
<td>${fmtNum(x.contracts, 6)}</td>
|
||||
<td>${fmtNum(x.notional_usdt, 2)}</td>
|
||||
<td class="hl-pnl ${pnlClass(x.unrealized_pnl)}">${fmtNum(x.unrealized_pnl, 4)}</td>
|
||||
<td>${fmtNum(x.entry_price, 6)}</td>
|
||||
</tr>`
|
||||
);
|
||||
const topBalUpnl = `<div class="metrics-row-balance-upnl">
|
||||
<span class="metric-inline"><span class="metric-lbl">余额 USDT</span><span class="metric-num">${fmtNum(p.balance_usdt, 2)}</span></span>
|
||||
<span class="metric-inline"><span class="metric-lbl">未实现盈亏合计</span><span class="metric-num ${pnlClass(p.total_unrealized_pnl)}">${fmtNum(p.total_unrealized_pnl, 4)}</span></span>
|
||||
</div>`;
|
||||
inner = `
|
||||
<div class="metrics">
|
||||
${topBalUpnl}
|
||||
<div><span>交易所</span>${escapeHtml(p.exchange || "—")}</div>
|
||||
<div><span>持仓模式</span>${escapeHtml(p.position_mode || "—")}</div>
|
||||
</div>
|
||||
${
|
||||
pos.length
|
||||
? `<table><thead><tr><th>合约</th><th>方向</th><th>张数</th><th>名义(约)</th><th class="hl-pnl">未实现盈亏</th><th>均价</th></tr></thead><tbody>${rows}</tbody></table>`
|
||||
: `<div style="padding:12px;color:var(--muted)">无持仓</div>`
|
||||
}`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="card" data-agent-id="${escapeHtml(r.id)}">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<strong>${escapeHtml(r.name)}</strong>
|
||||
<div class="meta">${escapeHtml(r.url)}</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
|
||||
<label class="monitor-toggle">
|
||||
<input type="checkbox" class="toggle-monitor" data-agent-id="${escapeHtml(r.id)}" checked />
|
||||
参与监控
|
||||
</label>
|
||||
<button type="button" class="danger btn-close-one" data-agent-id="${escapeHtml(r.id)}">该账户全平</button>
|
||||
</div>
|
||||
</div>
|
||||
${inner}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderDisabledCard(agent, reason) {
|
||||
const server = reason === "server";
|
||||
const inputAttrs = server
|
||||
? `class="toggle-monitor" data-agent-id="${escapeHtml(agent.id)}" disabled`
|
||||
: `class="toggle-monitor" data-agent-id="${escapeHtml(agent.id)}"`;
|
||||
const note = server
|
||||
? "已在服务端关闭(环境变量 HUB_DISABLED_IDS),不轮询、不参与全局全平。"
|
||||
: "已在本浏览器关闭。勾选「参与监控」可重新纳入轮询与全局全平。";
|
||||
return `
|
||||
<div class="card card-disabled" data-agent-id="${escapeHtml(agent.id)}">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<strong>${escapeHtml(agent.name)}</strong>
|
||||
<div class="meta">${escapeHtml(agent.url)}</div>
|
||||
</div>
|
||||
<label class="monitor-toggle">
|
||||
<input type="checkbox" ${inputAttrs} />
|
||||
参与监控
|
||||
</label>
|
||||
</div>
|
||||
<div class="off-note">${note}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const lsEx = loadExcludedLS();
|
||||
const csv = [...lsEx].join(",");
|
||||
const qs = csv ? "?exclude_ids=" + encodeURIComponent(csv) : "";
|
||||
const [ar, sr] = await Promise.all([
|
||||
fetch("/api/agents").then((r) => r.json()),
|
||||
fetch("/api/snapshot" + qs).then((r) => r.json()),
|
||||
]);
|
||||
agentsList = ar.agents || [];
|
||||
envExcludedSet = new Set((sr.env_excluded_ids || []).map(String));
|
||||
rowById = new Map((sr.rows || []).map((row) => [String(row.id), row]));
|
||||
|
||||
const parts = [];
|
||||
for (const agent of agentsList) {
|
||||
const id = String(agent.id);
|
||||
const serverOff = envExcludedSet.has(id);
|
||||
const clientOff = lsEx.has(id);
|
||||
if (serverOff) {
|
||||
parts.push(renderDisabledCard(agent, "server"));
|
||||
} else if (clientOff) {
|
||||
parts.push(renderDisabledCard(agent, "client"));
|
||||
} else {
|
||||
const row = rowById.get(id);
|
||||
if (row) {
|
||||
parts.push(renderActiveCard(row));
|
||||
} else {
|
||||
parts.push(
|
||||
renderActiveCard({
|
||||
id,
|
||||
name: agent.name,
|
||||
url: agent.url,
|
||||
http_ok: false,
|
||||
error: "无快照",
|
||||
payload: null,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
root.innerHTML = parts.join("") || '<div class="err">无账户配置</div>';
|
||||
lastUpdated.textContent = "更新于 " + new Date().toLocaleTimeString();
|
||||
root.querySelectorAll(".btn-close-one").forEach((btn) => {
|
||||
btn.onclick = () => closeOne(btn.getAttribute("data-agent-id"));
|
||||
});
|
||||
}
|
||||
|
||||
async function closeOne(id) {
|
||||
if (!confirm("确认对该账户市价全平所有永续持仓?")) return;
|
||||
try {
|
||||
const res = await fetch("/api/close/" + encodeURIComponent(id), { method: "POST" });
|
||||
const j = await res.json();
|
||||
showToast(JSON.stringify(j, null, 2), !res.ok);
|
||||
await loadAll();
|
||||
} catch (e) {
|
||||
showToast(String(e), true);
|
||||
}
|
||||
}
|
||||
|
||||
async function closeAll() {
|
||||
const lsEx = loadExcludedLS();
|
||||
const activeCount = agentsList.filter(
|
||||
(a) => !envExcludedSet.has(String(a.id)) && !lsEx.has(String(a.id))
|
||||
).length;
|
||||
if (!confirm(`对当前 ${activeCount} 个已开启监控的账户执行市价全平?此操作不可撤销。`)) return;
|
||||
try {
|
||||
const res = await fetch("/api/close-all", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ exclude_ids: [...lsEx] }),
|
||||
});
|
||||
const j = await res.json();
|
||||
showToast(JSON.stringify(j, null, 2), !res.ok);
|
||||
await loadAll();
|
||||
} catch (e) {
|
||||
showToast(String(e), true);
|
||||
}
|
||||
}
|
||||
|
||||
root.addEventListener("change", (ev) => {
|
||||
const t = ev.target;
|
||||
if (!t.classList || !t.classList.contains("toggle-monitor")) return;
|
||||
if (t.disabled) return;
|
||||
const id = t.getAttribute("data-agent-id");
|
||||
if (!id) return;
|
||||
const set = loadExcludedLS();
|
||||
if (t.checked) set.delete(id);
|
||||
else set.add(id);
|
||||
saveExcludedLS(set);
|
||||
loadAll().catch((e) => showToast(String(e), true));
|
||||
});
|
||||
|
||||
document.getElementById("btn-refresh").onclick = () => loadAll().catch((e) => showToast(String(e), true));
|
||||
document.getElementById("btn-close-all").onclick = closeAll;
|
||||
|
||||
function schedule() {
|
||||
clearInterval(timer);
|
||||
if (document.getElementById("auto-refresh").checked)
|
||||
timer = setInterval(() => loadAll().catch(() => {}), 3000);
|
||||
}
|
||||
document.getElementById("auto-refresh").onchange = schedule;
|
||||
|
||||
loadAll().catch((e) => {
|
||||
root.innerHTML = `<div class="err">${escapeHtml(String(e))}</div>`;
|
||||
});
|
||||
schedule();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user