中控增加下单,关键位,系统设置

This commit is contained in:
dekun
2026-05-22 10:04:28 +08:00
parent ed6b56ff87
commit 46f73fce43
19 changed files with 1844 additions and 568 deletions
+102
View File
@@ -0,0 +1,102 @@
: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: 0;
font-size: 14px;
line-height: 1.45;
}
a { color: var(--accent); }
.top-nav {
display: flex;
gap: 4px;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
background: #12161c;
flex-wrap: wrap;
}
.top-nav a {
padding: 8px 16px;
border-radius: 6px;
text-decoration: none;
color: var(--muted);
}
.top-nav a.active { background: var(--panel); color: var(--text); border: 1px solid var(--border); }
.page { max-width: 1200px; margin: 0 auto; padding: 16px 20px 40px; }
.page.hidden { display: none; }
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 14px; }
button, .btn {
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: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 {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.card-body { padding: 10px 12px; }
.grid-2 { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.form-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 8px; }
.form-row input, .form-row select, .form-row textarea {
background: #0d1117;
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 7px 10px;
font-size: 13px;
}
.form-row input { min-width: 100px; }
.rule-tip { font-size: 12px; color: var(--muted); margin: 8px 0; line-height: 1.5; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { padding: 6px 8px; border-top: 1px solid var(--border); text-align: left; }
th { color: var(--muted); }
.pnl-pos { color: var(--green); }
.pnl-neg { color: var(--red); }
.err { color: var(--red); }
.badge { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: #1f3a5a; color: #8fc8ff; }
.tabs { display: flex; gap: 6px; margin-bottom: 12px; flex-wrap: wrap; }
.tabs button.active { border-color: var(--accent); color: var(--accent); }
#toast {
position: fixed; bottom: 16px; right: 16px;
max-width: min(480px, 90vw);
background: var(--panel);
border: 1px solid var(--border);
padding: 10px 14px;
border-radius: 8px;
display: none;
z-index: 30;
white-space: pre-wrap;
}
#toast.show { display: block; }
.settings-table input { width: 100%; min-width: 80px; }
.chk-row { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; }
+480
View File
@@ -0,0 +1,480 @@
(function () {
const toast = document.getElementById("toast");
let settingsCache = null;
let tradeMeta = {};
let trendPreviewId = null;
let monitorTimer = null;
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"), 7000);
}
function esc(s) {
return String(s)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function fmt(n, d) {
if (n === null || n === undefined || Number.isNaN(Number(n))) return "—";
return Number(n).toLocaleString(undefined, { maximumFractionDigits: d });
}
function pnlCls(v) {
const n = Number(v);
if (!Number.isFinite(n) || n === 0) return "";
return n > 0 ? "pnl-pos" : "pnl-neg";
}
function currentPage() {
const p = window.location.pathname.replace(/\/$/, "") || "/monitor";
if (p.includes("trade")) return "trade";
if (p.includes("settings")) return "settings";
return "monitor";
}
function setActiveNav() {
const page = currentPage();
document.querySelectorAll(".top-nav a").forEach((a) => {
a.classList.toggle("active", a.getAttribute("href").includes(page));
});
document.querySelectorAll(".page").forEach((el) => {
el.classList.toggle("hidden", !el.id.includes(page));
});
if (page === "monitor") startMonitorPoll();
else stopMonitorPoll();
if (page === "trade") initTradePage();
if (page === "settings") loadSettingsUI();
}
function stopMonitorPoll() {
clearInterval(monitorTimer);
monitorTimer = null;
}
function startMonitorPoll() {
stopMonitorPoll();
loadMonitorBoard();
if (document.getElementById("auto-monitor").checked) {
monitorTimer = setInterval(loadMonitorBoard, 5000);
}
}
async function loadSettings() {
const r = await fetch("/api/settings");
settingsCache = await r.json();
return settingsCache;
}
function enabledAccounts() {
return (settingsCache?.exchanges || []).filter((x) => x.enabled);
}
async function loadMonitorBoard() {
const box = document.getElementById("monitor-grid");
try {
const r = await fetch("/api/monitor/board");
const data = await r.json();
document.getElementById("monitor-updated").textContent =
"更新于 " + (data.updated_at || "").replace("T", " ");
const parts = (data.rows || []).map(renderMonitorCard);
box.innerHTML = parts.join("") || '<div class="err">无已启用账户</div>';
box.querySelectorAll(".btn-close-ex").forEach((btn) => {
btn.onclick = () => closeOne(btn.dataset.id);
});
} catch (e) {
box.innerHTML = `<div class="err">${esc(e)}</div>`;
}
}
function renderMonitorCard(row) {
const ag = row.agent || {};
const pos = Array.isArray(ag.positions) ? ag.positions : [];
const hm = row.hub_monitor || {};
const keys = hm.keys || [];
const orders = hm.orders || [];
const trends = hm.trends || [];
const kmap = {};
(row.key_prices || []).forEach((k) => {
kmap[k.id] = k;
});
let inner = "";
if (!row.http_ok) {
inner = `<div class="err">${esc(row.error || "子代理不可用")}</div>`;
} else {
const posRows = pos
.map(
(x) =>
`<tr><td>${esc(x.symbol)}</td><td>${esc(x.side)}</td><td>${fmt(x.contracts, 4)}</td><td class="${pnlCls(x.unrealized_pnl)}">${fmt(x.unrealized_pnl, 4)}</td></tr>`
)
.join("");
inner = `<div class="rule-tip">余额 ${fmt(ag.balance_usdt, 2)} U · 浮盈合计 <span class="${pnlCls(ag.total_unrealized_pnl)}">${fmt(ag.total_unrealized_pnl, 4)}</span></div>`;
inner += pos.length
? `<table><tr><th>合约</th><th>方向</th><th>张数</th><th>浮盈</th></tr>${posRows}</table>`
: `<div style="color:var(--muted);padding:6px 0">交易所无持仓</div>`;
if (orders.length) {
inner += `<div style="margin-top:8px;font-size:12px;color:#b8c4ff">机器人持仓 ${orders.length} 笔</div>`;
orders.forEach((o) => {
inner += `<div class="rule-tip">${esc(o.symbol)} ${o.direction} 成交${o.trigger_price}</div>`;
});
}
if ((row.capabilities || []).includes("key") && keys.length) {
inner += `<div style="margin-top:8px;font-size:12px;color:#b8c4ff">关键位 ${keys.length} 条</div>`;
keys.slice(0, 6).forEach((k) => {
const kp = kmap[k.id] || {};
inner += `<div class="rule-tip">${esc(k.symbol)} ${esc(k.monitor_type)}${k.upper}/下${k.lower} 门控:${esc(kp.gate_summary || "-")}</div>`;
});
}
if (trends.length) {
inner += `<div style="margin-top:8px;font-size:12px;color:#b8c4ff">趋势计划 ${trends.length} 个运行中</div>`;
trends.forEach((t) => {
inner += `<div class="rule-tip">#${t.id} ${esc(t.symbol)} ${t.direction} SL${t.stop_loss} TP${t.take_profit}</div>`;
});
}
}
const review = row.review_url
? `<a href="${esc(row.review_url)}" target="_blank" rel="noopener">复盘</a>`
: "";
return `<div class="card">
<div class="card-head">
<div><strong>${esc(row.name)}</strong><div class="rule-tip">${esc(row.flask_url || "")}</div></div>
<div style="display:flex;gap:8px;align-items:center">
${review}
<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">该户全平</button>
</div>
</div>
<div class="card-body">${inner}</div>
</div>`;
}
async function closeOne(id) {
if (!confirm("确认对该账户市价全平?")) return;
try {
const r = await fetch("/api/close/" + encodeURIComponent(id), { method: "POST" });
const j = await r.json();
showToast(JSON.stringify(j, null, 2), !r.ok);
loadMonitorBoard();
} catch (e) {
showToast(String(e), true);
}
}
async function closeAll() {
const n = enabledAccounts().length;
if (!confirm(`${n} 个已启用账户执行紧急全平?`)) return;
try {
const r = await fetch("/api/close-all", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ exclude_ids: [] }),
});
const j = await r.json();
showToast(JSON.stringify(j, null, 2), !r.ok);
loadMonitorBoard();
} catch (e) {
showToast(String(e), true);
}
}
function initTradePage() {
loadSettings().then(() => {
const sel = document.getElementById("trade-account");
const prev = sel.value;
sel.innerHTML = enabledAccounts()
.map(
(x) =>
`<option value="${esc(x.id)}">${esc(x.name)}</option>`
)
.join("");
if (prev) sel.value = prev;
syncTradeTabs();
loadTradeMeta();
});
}
function accountCaps() {
const id = document.getElementById("trade-account").value;
const ex = (settingsCache?.exchanges || []).find((x) => String(x.id) === String(id));
return ex?.capabilities || [];
}
function syncTradeTabs() {
const caps = accountCaps();
document.querySelectorAll(".tabs button").forEach((btn) => {
const tab = btn.dataset.tab;
let ok = false;
if (tab === "order") ok = caps.includes("order");
if (tab === "key") ok = caps.includes("key");
if (tab === "trend") ok = caps.includes("trend");
btn.disabled = !ok;
btn.style.opacity = ok ? "1" : "0.4";
});
let active = document.querySelector(".tabs button.active");
if (active && active.disabled) {
const first = [...document.querySelectorAll(".tabs button")].find((b) => !b.disabled);
if (first) switchTradeTab(first.dataset.tab);
}
["order", "key", "trend"].forEach((t) => {
document.getElementById("panel-" + t).classList.toggle(
"hidden",
!document.querySelector(`.tabs button[data-tab="${t}"]`).classList.contains("active")
);
});
}
function switchTradeTab(tab) {
document.querySelectorAll(".tabs button").forEach((b) => {
b.classList.toggle("active", b.dataset.tab === tab);
});
["order", "key", "trend"].forEach((t) => {
document.getElementById("panel-" + t).classList.toggle("hidden", t !== tab);
});
trendPreviewId = null;
document.getElementById("trend-preview-box").style.display = "none";
}
async function loadTradeMeta() {
const id = document.getElementById("trade-account").value;
if (!id) return;
try {
const r = await fetch("/api/trade/meta/" + encodeURIComponent(id));
const data = await r.json();
tradeMeta = data.meta?.meta || data.meta || {};
const el = document.getElementById("trade-meta");
if (tradeMeta.key_gate_rule_text) {
el.textContent = tradeMeta.key_gate_rule_text;
} else if (tradeMeta.trend_pullback_preview_ttl) {
el.textContent =
`预览有效期 ${tradeMeta.trend_pullback_preview_ttl}s · 补仓档 ${tradeMeta.trend_pullback_dca_legs} · 余额偏差≤${tradeMeta.trend_preview_max_drift_pct}%`;
} else {
el.textContent = "";
}
} catch (e) {
document.getElementById("trade-meta").textContent = "";
}
}
async function submitForm(path, formEl) {
const id = document.getElementById("trade-account").value;
const fd = new FormData(formEl);
try {
const r = await fetch(path + encodeURIComponent(id), { method: "POST", body: fd });
const j = await r.json();
const res = j.result || {};
const msgs = (res.messages || []).join("\n") || JSON.stringify(res, null, 2);
showToast(msgs, !res.ok);
if (res.ok && res.preview) {
showTrendPreview(res);
}
loadTradeMeta();
} catch (e) {
showToast(String(e), true);
}
}
function showTrendPreview(res) {
trendPreviewId = res.preview_id;
const p = res.preview || {};
const box = document.getElementById("trend-preview-box");
const levels = (p.grid_levels || [])
.map((r) => `<tr><td>${r.i}</td><td>${r.price}</td><td>${r.contracts}</td></tr>`)
.join("");
box.innerHTML = `
<div class="rule-tip">预览 #${esc(p.id || trendPreviewId)} 剩余 ${p.expires_in_sec ?? "?"}s</div>
<div class="rule-tip">${esc(p.symbol)} ${esc(p.direction)} ${p.leverage}x · 快照 ${fmt(p.snapshot_available_usdt, 2)} U</div>
<table><tr><th>#</th><th>补仓价</th><th>张数</th></tr>${levels}</table>
<div class="form-row" style="margin-top:8px">
<button type="button" id="btn-trend-exec">确认执行(实盘)</button>
</div>`;
box.style.display = "block";
document.getElementById("btn-trend-exec").onclick = executeTrend;
}
async function executeTrend() {
if (!trendPreviewId) {
showToast("请先生成预览", true);
return;
}
if (!confirm("确认按预览参数实盘下单?")) return;
const id = document.getElementById("trade-account").value;
const fd = new FormData();
fd.set("preview_id", trendPreviewId);
try {
const r = await fetch("/api/trade/trend/execute/" + encodeURIComponent(id), {
method: "POST",
body: fd,
});
const j = await r.json();
const res = j.result || {};
showToast((res.messages || []).join("\n") || JSON.stringify(res), !res.ok);
document.getElementById("trend-preview-box").style.display = "none";
trendPreviewId = null;
} catch (e) {
showToast(String(e), true);
}
}
async function loadSettingsMetaLine() {
try {
const r = await fetch("/api/settings/meta");
const m = await r.json();
const el = document.getElementById("settings-meta-line");
if (!el) return;
const parts = [];
if (m.hub_bridge_token_set) parts.push("中控已配置 HUB_BRIDGE_TOKEN");
else parts.push("中控未设 HUB_BRIDGE_TOKEN(实例需 APP_AUTH_DISABLED 或同令牌)");
if ((m.env_disabled_ids || []).length)
parts.push("环境强制关闭 id: " + m.env_disabled_ids.join(", "));
el.textContent = parts.join(" · ");
} catch (_) {}
}
function loadSettingsUI() {
loadSettingsMetaLine();
loadSettings().then((data) => {
const tbody = document.getElementById("settings-tbody");
tbody.innerHTML = (data.exchanges || [])
.map((ex, idx) => renderSettingsRow(ex, idx))
.join("");
tbody.querySelectorAll(".btn-del-ex").forEach((btn) => {
btn.onclick = () => {
const i = Number(btn.dataset.idx);
data.exchanges.splice(i, 1);
settingsCache = data;
loadSettingsUI();
};
});
});
}
function renderSettingsRow(ex, idx) {
const caps = ex.capabilities || [];
const envOff = ex.env_disabled
? ' <span class="badge">环境变量强制关</span>'
: "";
return `<tr data-idx="${idx}" data-key="${esc(ex.key || ex.id || "")}">
<td><input type="checkbox" class="ex-enabled" ${ex.enabled ? "checked" : ""} ${ex.env_disabled ? "disabled" : ""}/>${envOff}</td>
<td><input class="ex-name" value="${esc(ex.name || "")}" /></td>
<td><input class="ex-flask" value="${esc(ex.flask_url || "")}" /></td>
<td><input class="ex-agent" value="${esc(ex.agent_url || "")}" /></td>
<td><input class="ex-review" value="${esc(ex.review_url || "")}" /></td>
<td class="chk-row">
<label><input type="checkbox" class="cap-order" ${caps.includes("order") ? "checked" : ""}/>下单</label>
<label><input type="checkbox" class="cap-key" ${caps.includes("key") ? "checked" : ""}/>关键位</label>
<label><input type="checkbox" class="cap-trend" ${caps.includes("trend") ? "checked" : ""}/>趋势</label>
</td>
<td><input class="ex-id" value="${esc(ex.id || "")}" style="width:48px" /></td>
<td><button type="button" class="btn-del-ex" data-idx="${idx}">删</button></td>
</tr>`;
}
function collectSettingsFromUI() {
const rows = [...document.querySelectorAll("#settings-tbody tr")];
return {
version: 1,
exchanges: rows.map((tr) => {
const caps = [];
if (tr.querySelector(".cap-order").checked) caps.push("order");
if (tr.querySelector(".cap-key").checked) caps.push("key");
if (tr.querySelector(".cap-trend").checked) caps.push("trend");
const id = tr.querySelector(".ex-id").value.trim();
const stableKey = (tr.dataset.key || id).trim();
return {
id: id,
key: stableKey,
name: tr.querySelector(".ex-name").value.trim(),
flask_url: tr.querySelector(".ex-flask").value.trim(),
agent_url: tr.querySelector(".ex-agent").value.trim(),
review_url: tr.querySelector(".ex-review").value.trim(),
enabled: tr.querySelector(".ex-enabled").checked,
capabilities: caps,
};
}),
};
}
async function saveSettings() {
const body = collectSettingsFromUI();
try {
const r = await fetch("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const j = await r.json();
if (j.ok) {
showToast("设置已保存(已写入 hub_settings.json");
await loadSettingsUI();
} else showToast("保存失败", true);
} catch (e) {
showToast(String(e), true);
}
}
document.getElementById("btn-monitor-refresh").onclick = loadMonitorBoard;
document.getElementById("auto-monitor").onchange = startMonitorPoll;
document.getElementById("btn-close-all").onclick = closeAll;
document.getElementById("trade-account").onchange = () => {
syncTradeTabs();
loadTradeMeta();
};
document.querySelectorAll(".tabs button").forEach((btn) => {
btn.onclick = () => {
if (!btn.disabled) switchTradeTab(btn.dataset.tab);
};
});
document.getElementById("form-order").onsubmit = (e) => {
e.preventDefault();
submitForm("/api/trade/order/", e.target);
};
document.getElementById("form-key").onsubmit = (e) => {
e.preventDefault();
submitForm("/api/trade/key/", e.target);
};
document.getElementById("form-trend").onsubmit = (e) => {
e.preventDefault();
submitForm("/api/trade/trend/preview/", e.target);
};
document.getElementById("order-sltp-mode").onchange = function () {
const pct = this.value === "pct";
document.getElementById("order-sl").style.display = pct ? "none" : "";
document.getElementById("order-tp").style.display = pct ? "none" : "";
document.getElementById("order-sl-pct").style.display = pct ? "" : "none";
document.getElementById("order-tp-pct").style.display = pct ? "" : "none";
};
document.getElementById("key-sl-tp-mode").onchange = function () {
const manual = this.value === "trend_manual";
document.getElementById("key-manual-tp").style.display = manual ? "" : "none";
};
document.getElementById("trend-direction").onchange = function () {
const inp = document.getElementById("trend-add-upper");
inp.placeholder = this.value === "short" ? "补仓下沿价" : "补仓上沿价";
};
document.getElementById("btn-settings-save").onclick = saveSettings;
document.getElementById("btn-settings-reload").onclick = loadSettingsUI;
document.getElementById("btn-settings-add").onclick = () => {
const data = settingsCache || { exchanges: [] };
const nid = String(Date.now() % 100000);
data.exchanges.push({
id: nid,
key: "custom_" + nid,
name: "新交易所",
flask_url: "http://127.0.0.1:5000",
agent_url: "http://127.0.0.1:15200",
review_url: "",
enabled: false,
capabilities: ["order"],
});
settingsCache = data;
loadSettingsUI();
};
setActiveNav();
window.addEventListener("popstate", setActiveNav);
})();
+149 -386
View File
@@ -3,396 +3,159 @@
<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>
<title>多账户交易中控</title>
<link rel="stylesheet" href="/assets/app.css" />
</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>
<nav class="top-nav">
<a href="/monitor" id="nav-monitor">监控区</a>
<a href="/trade" id="nav-trade">下单区</a>
<a href="/settings" id="nav-settings">系统设置</a>
</nav>
<div id="page-monitor" class="page">
<h1>监控区</h1>
<div class="toolbar">
<button type="button" id="btn-monitor-refresh">立即刷新</button>
<label style="color:var(--muted);font-size:12px;display:flex;align-items:center;gap:6px">
<input type="checkbox" id="auto-monitor" checked /> 每 5 秒刷新
</label>
<button type="button" id="btn-close-all" class="danger">全局紧急全平</button>
<span id="monitor-updated" style="color:var(--muted);font-size:12px"></span>
</div>
<div id="monitor-grid" class="grid-2"></div>
</div>
<div id="root"></div>
<div id="page-trade" class="page hidden">
<h1>下单区</h1>
<div class="form-row">
<label>账户</label>
<select id="trade-account"></select>
</div>
<div class="tabs">
<button type="button" data-tab="order" class="active">人工下单</button>
<button type="button" data-tab="key">关键位</button>
<button type="button" data-tab="trend">趋势回调</button>
</div>
<div id="trade-meta" class="rule-tip"></div>
<div id="panel-order" class="card">
<div class="card-head"><strong>人工下单</strong></div>
<div class="card-body">
<form id="form-order" class="form-row">
<input name="symbol" placeholder="BTC 或 BTC/USDT" required />
<select name="direction" required>
<option value="">方向</option>
<option value="long">做多</option>
<option value="short">做空</option>
</select>
<select name="sltp_mode" id="order-sltp-mode">
<option value="price">止盈止损:价格</option>
<option value="pct">止盈止损:百分比</option>
</select>
<select name="trade_style" required>
<option value="trend">趋势单</option>
<option value="swing">波段单</option>
</select>
<input name="leverage" type="number" min="1" step="1" placeholder="杠杆(可选)" />
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--muted)">
<input type="checkbox" name="breakeven_enabled" value="1" checked /> 移动保本
</label>
<input name="sl" id="order-sl" step="any" placeholder="止损价" required />
<input name="tgt" id="order-tp" step="any" placeholder="止盈价" required />
<input name="sl_pct" id="order-sl-pct" type="number" step="0.01" placeholder="止损%" style="display:none" />
<input name="tp_pct" id="order-tp-pct" type="number" step="0.01" placeholder="止盈%" style="display:none" />
<button type="submit">开仓(以损定仓)</button>
</form>
</div>
</div>
<div id="panel-key" class="card hidden">
<div class="card-head"><strong>添加关键位</strong></div>
<div class="card-body">
<form id="form-key" class="form-row">
<input name="symbol" placeholder="BTC 或 BTC/USDT" required />
<select name="type" required>
<option value="箱体突破">箱体突破</option>
<option value="收敛突破">收敛突破</option>
<option value="斐波回调0.618">斐波回调0.618</option>
<option value="斐波回调0.786">斐波回调0.786</option>
<option value="关键阻力位">关键阻力位</option>
<option value="关键支撑位">关键支撑位</option>
</select>
<select name="direction" required>
<option value="">方向</option>
<option value="long">做多</option>
<option value="short">做空</option>
</select>
<input name="upper" step="any" placeholder="上沿/阻力" required />
<input name="lower" step="any" placeholder="下沿/支撑" required />
<select name="sl_tp_mode" id="key-sl-tp-mode">
<option value="standard">标准突破</option>
<option value="box_1p5">箱体1R·止盈1.5H</option>
<option value="trend_manual">趋势单·自填止盈</option>
</select>
<input name="manual_take_profit" id="key-manual-tp" step="any" placeholder="趋势单止盈价" style="display:none" />
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--muted)">
<input type="checkbox" name="breakeven_enabled" value="1" id="key-be-cb" /> 移动保本
</label>
<button type="submit">添加关键位</button>
</form>
</div>
</div>
<div id="panel-trend" class="card hidden">
<div class="card-head"><strong>趋势回调</strong></div>
<div class="card-body">
<form id="form-trend" class="form-row">
<input name="symbol" placeholder="BTC 或 ETH/USDT" required />
<select name="direction" id="trend-direction" required>
<option value="">方向</option>
<option value="long">做多</option>
<option value="short">做空</option>
</select>
<input name="leverage" type="number" min="1" step="1" placeholder="杠杆" required />
<input name="risk_percent" type="number" min="0.1" step="0.1" value="5" placeholder="风险%" />
<input name="sl" step="any" placeholder="止损价" required />
<input name="add_upper" id="trend-add-upper" step="any" placeholder="补仓上沿价" required />
<input name="take_profit" step="any" placeholder="止盈价" required />
<button type="submit">生成预览</button>
</form>
<div id="trend-preview-box" style="margin-top:12px;display:none"></div>
</div>
</div>
</div>
<div id="page-settings" class="page hidden">
<h1>系统设置</h1>
<p class="rule-tip">
配置各交易所 Flask 地址与子代理地址,点击「保存设置」写入本目录
<code>hub_settings.json</code>(重启 hub 后仍生效)。OKX 默认关闭;环境变量
<code>HUB_DISABLED_IDS=1</code> 会强制关闭对应 id(勾选框灰掉)。实例须配置与中控一致的
<code>HUB_BRIDGE_TOKEN</code>,或本机调试时 <code>APP_AUTH_DISABLED=true</code>
</p>
<p id="settings-meta-line" class="rule-tip"></p>
<div class="toolbar">
<button type="button" id="btn-settings-save">保存设置</button>
<button type="button" id="btn-settings-add">添加交易所</button>
<button type="button" id="btn-settings-reload">重新加载</button>
</div>
<div class="card">
<div class="card-body" style="overflow:auto">
<table class="settings-table" id="settings-table">
<thead>
<tr>
<th>启用</th><th>显示名</th><th>Flask URL</th><th>Agent URL</th><th>复盘链接</th>
<th>能力</th><th>id</th><th></th>
</tr>
</thead>
<tbody id="settings-tbody"></tbody>
</table>
</div>
</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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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>
<script src="/assets/app.js"></script>
</body>
</html>