Initialize crypto_monitor_user (user edition) from monitor codebase.

Retarget git remote, install path, and deploy docs from crypto_monitor to crypto_monitor_user.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-17 16:18:13 +08:00
commit 53863559f4
608 changed files with 162764 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+250
View File
@@ -0,0 +1,250 @@
/**
* 系统设置 · 备份与恢复
*/
(function () {
const page = document.getElementById("page-settings");
if (!page) return;
const elAuto = document.getElementById("backup-auto-enabled");
const elHour = document.getElementById("backup-auto-hour");
const elRetention = document.getElementById("backup-retention-days");
const elIncludeEnv = document.getElementById("backup-include-env");
const elIncludeImages = document.getElementById("backup-include-images");
const elRoot = document.getElementById("backup-root");
const elStatus = document.getElementById("backup-status-line");
const elList = document.getElementById("backup-list");
const elRun = document.getElementById("backup-run-now");
const elRestoreFile = document.getElementById("backup-restore-file");
const elRestoreBtn = document.getElementById("backup-restore-upload-btn");
let settingsCache = null;
let statusCache = null;
function fmtBytes(n) {
const v = Number(n);
if (!Number.isFinite(v) || v < 0) return "—";
if (v < 1024) return v + " B";
if (v < 1024 * 1024) return (v / 1024).toFixed(1) + " KB";
return (v / (1024 * 1024)).toFixed(2) + " MB";
}
function setStatus(msg, isErr) {
if (!elStatus) return;
elStatus.textContent = msg || "";
elStatus.className = "backup-status-line" + (isErr ? " err" : "");
}
function collectBackupFromUI() {
return {
auto_enabled: !!(elAuto && elAuto.checked),
auto_hour: Math.max(0, Math.min(23, parseInt(elHour && elHour.value, 10) || 0)),
retention_days: Math.max(1, Math.min(365, parseInt(elRetention && elRetention.value, 10) || 30)),
include_env: !!(elIncludeEnv && elIncludeEnv.checked),
include_exchange_images: !!(elIncludeImages && elIncludeImages.checked),
backup_root: (elRoot && elRoot.value || "").trim(),
};
}
function syncBackupUI(data) {
const b = (data && data.backup) || {};
if (elAuto) elAuto.checked = b.auto_enabled !== false;
if (elHour) elHour.value = b.auto_hour != null ? b.auto_hour : 0;
if (elRetention) elRetention.value = b.retention_days != null ? b.retention_days : 30;
if (elIncludeEnv) elIncludeEnv.checked = b.include_env !== false;
if (elIncludeImages) elIncludeImages.checked = !!b.include_exchange_images;
if (elRoot) elRoot.value = b.backup_root || "";
}
function renderBackupList(status) {
if (!elList) return;
const rows = (status && status.backups) || [];
const state = (status && status.state) || {};
const root = (status && status.backup_root) || "";
let html = '<div class="backup-meta">';
html += '<div>目录:<code>' + esc(root) + '</code></div>';
if (state.last_backup_at) {
html += '<div>上次备份:' + esc(state.last_backup_at) + '(' + esc(state.last_trigger || "") + ")</div>";
}
if (state.last_auto_at) {
html += '<div>上次自动:' + esc(state.last_auto_at) + "</div>";
}
if (state.last_restore_at) {
html += '<div>上次恢复:' + esc(state.last_restore_at) + " ← " + esc(state.last_restore_from || "") + "</div>";
}
html += "</div>";
if (!rows.length) {
html += '<p class="backup-empty">暂无备份文件</p>';
elList.innerHTML = html;
return;
}
html += '<table class="backup-table"><thead><tr><th>文件</th><th>大小</th><th>时间</th><th></th></tr></thead><tbody>';
rows.forEach(function (row) {
html +=
"<tr><td>" +
esc(row.name) +
"</td><td>" +
fmtBytes(row.size) +
"</td><td>" +
esc(row.modified_at || "") +
'</td><td class="backup-row-actions">' +
'<a class="ghost" href="/api/backup/download/' +
encodeURIComponent(row.name) +
'" download>下载</a> ' +
'<button type="button" class="danger backup-restore-local" data-name="' +
escAttr(row.name) +
'">恢复</button></td></tr>';
});
html += "</tbody></table>";
elList.innerHTML = html;
elList.querySelectorAll(".backup-restore-local").forEach(function (btn) {
btn.addEventListener("click", function () {
restoreLocal(btn.getAttribute("data-name"));
});
});
}
function esc(s) {
return String(s || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/"/g, "&quot;");
}
function escAttr(s) {
return esc(s).replace(/'/g, "&#39;");
}
async function loadSettingsData() {
const r = await fetch("/api/settings", { credentials: "same-origin" });
if (!r.ok) throw new Error("加载设置失败");
settingsCache = await r.json();
syncBackupUI(settingsCache);
}
async function loadBackupStatus() {
const r = await fetch("/api/backup/status", { credentials: "same-origin" });
if (!r.ok) throw new Error("加载备份状态失败");
statusCache = await r.json();
renderBackupList(statusCache);
}
async function saveBackupSettings() {
if (!settingsCache) await loadSettingsData();
const body = {
exchanges: settingsCache.exchanges || [],
display: settingsCache.display,
supervisor: settingsCache.supervisor,
backup: collectBackupFromUI(),
};
const r = await fetch("/api/settings", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error("保存失败");
settingsCache = (await r.json()).settings || settingsCache;
syncBackupUI(settingsCache);
await loadBackupStatus();
if (typeof showToast === "function") showToast("备份设置已保存");
}
async function runBackupNow() {
setStatus("备份中…");
const r = await fetch("/api/backup/run", { method: "POST", credentials: "same-origin" });
const data = await r.json().catch(function () {
return {};
});
if (!r.ok) {
setStatus(data.detail || "备份失败", true);
return;
}
setStatus("完成:" + (data.file || "") + "(" + fmtBytes(data.size) + ")");
await loadBackupStatus();
if (typeof showToast === "function") showToast("备份完成");
}
async function restoreLocal(name) {
if (!name) return;
if (!window.confirm("确认从服务器备份 " + name + " 恢复?\n恢复前会自动做 pre-restore 快照并重启 PM2.")) return;
if (window.prompt('请输入 RESTORE 确认恢复') !== "RESTORE") return;
setStatus("恢复中…");
const r = await fetch("/api/backup/restore-local", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name, confirm: "RESTORE" }),
});
const data = await r.json().catch(function () {
return {};
});
if (!r.ok) {
setStatus(data.detail || "恢复失败", true);
return;
}
setStatus("恢复完成,已恢复 " + ((data.restored && data.restored.length) || 0) + " 个文件");
await loadBackupStatus();
if (typeof showToast === "function") showToast("恢复完成,请刷新页面");
}
async function restoreUpload() {
const file = elRestoreFile && elRestoreFile.files && elRestoreFile.files[0];
if (!file) {
setStatus("请选择 .zip 备份文件", true);
return;
}
if (!window.confirm("确认上传并恢复 " + file.name + "?\n恢复前会自动做 pre-restore 快照并重启 PM2.")) return;
if (window.prompt('请输入 RESTORE 确认恢复') !== "RESTORE") return;
setStatus("上传并恢复中…");
const fd = new FormData();
fd.append("file", file);
fd.append("confirm", "RESTORE");
const r = await fetch("/api/backup/restore", {
method: "POST",
credentials: "same-origin",
body: fd,
});
const data = await r.json().catch(function () {
return {};
});
if (!r.ok) {
setStatus(data.detail || "恢复失败", true);
return;
}
setStatus("恢复完成,已恢复 " + ((data.restored && data.restored.length) || 0) + " 个文件");
if (elRestoreFile) elRestoreFile.value = "";
await loadBackupStatus();
if (typeof showToast === "function") showToast("恢复完成,请刷新页面");
}
window.initBackupSettingsUI = async function () {
try {
await loadSettingsData();
await loadBackupStatus();
setStatus("");
} catch (e) {
setStatus(e.message || String(e), true);
}
};
if (elRun) elRun.addEventListener("click", function () {
runBackupNow().catch(function (e) {
setStatus(e.message || String(e), true);
});
});
if (elRestoreBtn) elRestoreBtn.addEventListener("click", function () {
restoreUpload().catch(function (e) {
setStatus(e.message || String(e), true);
});
});
page.addEventListener("click", function (ev) {
const btn = ev.target.closest(".settings-section-save[data-settings-section='backup']");
if (!btn) return;
ev.preventDefault();
saveBackupSettings().catch(function (e) {
setStatus(e.message || String(e), true);
});
});
})();
+642
View File
@@ -0,0 +1,642 @@
/**
* 中控策略计算器:趋势回调 / 滚仓历史测算
*/
(function () {
const page = document.getElementById("page-calculator");
if (!page) return;
let inited = false;
const marketCache = {};
let calculatorExchanges = [];
function $(id) {
return document.getElementById(id);
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;");
}
function num(id) {
const el = $(id);
if (!el) return null;
const n = Number(el.value);
return Number.isFinite(n) ? n : null;
}
function text(id) {
const el = $(id);
if (!el) return "";
return String(el.value || "").trim();
}
function fmt(v, digits) {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return esc(v);
if (digits != null) return n.toFixed(digits);
return String(n);
}
/** 去掉尾部多余 0,用于乘数/精度展示 */
function fmtTrim(v, maxDigits) {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return esc(v);
let s = maxDigits != null ? n.toFixed(maxDigits) : String(n);
if (s.includes(".")) s = s.replace(/\.?0+$/, "");
return s;
}
function fmtU(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return "—";
return (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
}
function pnlClass(v) {
const n = Number(v);
if (!Number.isFinite(n) || n === 0) return "";
return n > 0 ? "calc-pnl-profit" : "calc-pnl-loss";
}
function decimalsFromMarket(data) {
if (!data || !data.market) return { price: 4, amount: 4 };
return {
price: Number(data.market.price_decimals),
amount: Number(data.market.amount_decimals),
};
}
function fmtMarketInfo(market, err) {
if (err) {
return '<span class="calc-market-err">' + esc(err) + "</span>";
}
if (!market) return "—";
const inst = market.exchange_name ? esc(market.exchange_name) + " · " : "";
const parts = [
inst + "<strong>" + esc(market.display_symbol || market.base || "") + "</strong> 永续",
"合约 " + esc(market.exchange_symbol || ""),
"乘数 " + fmtTrim(market.contract_size, 8),
"价格精度 " + fmtTrim(market.price_tick != null ? market.price_tick : Math.pow(10, -(market.price_decimals || 0))),
"张数精度 " + fmtTrim(Math.pow(10, -(market.amount_decimals || 0))),
];
if (market.min_amount != null) {
parts.push("最小张数 " + fmtTrim(market.min_amount, market.amount_decimals));
}
return parts.join(" · ");
}
function applyMarketSteps(prefix, market) {
const pxStep =
market && market.price_tick != null && Number(market.price_tick) > 0
? String(market.price_tick)
: market && market.price_decimals != null
? String(Math.pow(10, -Number(market.price_decimals)))
: "any";
const amtStep =
market && market.amount_decimals != null
? String(Math.pow(10, -Number(market.amount_decimals)))
: "any";
page.querySelectorAll("#" + prefix + "-form input[type='number']").forEach(function (el) {
if (el.classList.contains("calc-roll-leg-add") || el.classList.contains("calc-roll-leg-stop")) {
el.step = pxStep;
return;
}
if (el.id === prefix + "-capital" || el.id === prefix + "-risk" || el.id === prefix + "-leverage") {
return;
}
if (el.id === prefix + "-dca-legs" || el.id === prefix + "-legs-done") {
return;
}
el.step = pxStep;
});
page.querySelectorAll(".calc-roll-leg-add, .calc-roll-leg-stop").forEach(function (el) {
el.step = pxStep;
});
void amtStep;
}
async function refreshMarket(prefix) {
const exchangeEl = $(prefix + "-exchange");
const baseEl = $(prefix + "-base");
const infoEl = $(prefix + "-market-info");
if (!exchangeEl || !baseEl || !infoEl) return null;
const exchangeId = exchangeEl.value || (calculatorExchanges[0] && calculatorExchanges[0].id) || "0";
const base = text(prefix + "-base") || "ETH";
const cacheKey = exchangeId + ":" + base.toUpperCase();
infoEl.innerHTML = "加载合约信息…";
try {
const r = await fetch(
"/api/calculator/market?exchange_id=" +
encodeURIComponent(exchangeId) +
"&base=" +
encodeURIComponent(base),
{ credentials: "same-origin" }
);
const j = await r.json();
if (!j.ok) {
infoEl.innerHTML = fmtMarketInfo(null, j.msg || "加载失败");
marketCache[prefix] = null;
return null;
}
marketCache[prefix] = j.data;
marketCache[cacheKey] = j.data;
infoEl.innerHTML = fmtMarketInfo(j.data, null);
applyMarketSteps(prefix, j.data);
return j.data;
} catch (err) {
infoEl.innerHTML = fmtMarketInfo(null, String(err));
marketCache[prefix] = null;
return null;
}
}
function fillExchangeSelect(selectEl, selectedId) {
if (!selectEl) return;
selectEl.innerHTML = "";
if (!calculatorExchanges.length) {
selectEl.innerHTML = '<option value="">无已启用交易所</option>';
return;
}
calculatorExchanges.forEach(function (ex) {
const opt = document.createElement("option");
opt.value = String(ex.id);
opt.textContent = ex.name || ex.key || ex.id;
selectEl.appendChild(opt);
});
const want = selectedId != null ? String(selectedId) : String(calculatorExchanges[0].id);
if ([].some.call(selectEl.options, function (o) { return o.value === want; })) {
selectEl.value = want;
}
}
async function loadCalculatorExchanges() {
try {
const r = await fetch("/api/calculator/exchanges", { credentials: "same-origin" });
const j = await r.json();
calculatorExchanges = (j.ok && j.data) || [];
} catch (_err) {
calculatorExchanges = [];
}
fillExchangeSelect($("calc-trend-exchange"));
fillExchangeSelect($("calc-roll-exchange"));
}
function fmtRefreshTime() {
const d = new Date();
const h = String(d.getHours()).padStart(2, "0");
const m = String(d.getMinutes()).padStart(2, "0");
const s = String(d.getSeconds()).padStart(2, "0");
return h + ":" + m + ":" + s;
}
async function refreshPage() {
const btn = $("calc-btn-refresh");
const status = $("calc-refresh-status");
const trendId = $("calc-trend-exchange") && $("calc-trend-exchange").value;
const rollId = $("calc-roll-exchange") && $("calc-roll-exchange").value;
if (btn) btn.disabled = true;
if (status) status.textContent = "刷新中…";
Object.keys(marketCache).forEach(function (k) {
delete marketCache[k];
});
try {
await loadCalculatorExchanges();
fillExchangeSelect($("calc-trend-exchange"), trendId);
fillExchangeSelect($("calc-roll-exchange"), rollId);
await Promise.all([refreshMarket("calc-trend"), refreshMarket("calc-roll")]);
if (status) status.textContent = "已刷新 " + fmtRefreshTime();
} catch (err) {
if (status) status.textContent = "刷新失败";
} finally {
if (btn) btn.disabled = false;
}
}
function bindMarket(prefix) {
const exchangeEl = $(prefix + "-exchange");
const baseEl = $(prefix + "-base");
if (!exchangeEl || !baseEl) return;
const run = function () {
void refreshMarket(prefix);
};
if (!exchangeEl._calcMarketBound) {
exchangeEl._calcMarketBound = true;
exchangeEl.addEventListener("change", run);
}
if (!baseEl._calcMarketBound) {
baseEl._calcMarketBound = true;
baseEl.addEventListener("change", run);
baseEl.addEventListener("blur", run);
}
run();
}
function syncTrendAddLabel() {
const dir = ($("calc-trend-direction") && $("calc-trend-direction").value) || "long";
const lab = $("calc-trend-add-label");
if (lab) lab.textContent = dir === "short" ? "补仓下沿价" : "补仓上沿价";
}
function renderTrendTable(rows, dec) {
if (!rows || !rows.length) {
return '<p class="calc-empty">无档位数据</p>';
}
const px = dec.price != null ? dec.price : 4;
const amt = dec.amount != null ? dec.amount : 4;
let html =
'<div class="calc-table-wrap"><table class="calc-table"><thead><tr>' +
"<th>档位</th><th>触发价</th><th>张数</th><th>加仓后均价</th><th>止盈盈利</th><th>止损金额</th><th>盈亏比</th>" +
"</tr></thead><tbody>";
rows.forEach(function (r) {
html +=
"<tr>" +
"<td>" +
esc(r.label) +
"</td>" +
"<td>" +
fmt(r.price, px) +
"</td>" +
"<td>" +
fmt(r.contracts, amt) +
"</td>" +
"<td>" +
fmt(r.avg_entry, px) +
"</td>" +
'<td class="' +
pnlClass(r.profit_u) +
'">' +
fmtU(r.profit_u) +
"</td>" +
"<td>" +
fmtU(r.risk_u) +
"</td>" +
"<td>" +
(r.rr != null ? fmt(r.rr, 2) + ":1" : "—") +
"</td>" +
"</tr>";
});
html += "</tbody></table></div>";
return html;
}
function renderTrendResult(data) {
const box = $("calc-trend-result");
if (!box) return;
const dec = decimalsFromMarket(data);
box.classList.remove("hidden");
box.innerHTML =
'<div class="calc-summary">' +
"<div><span>合约</span><strong>" +
esc((data.market && data.market.display_symbol) || "—") +
"</strong></div>" +
"<div><span>计划保证金</span><strong>" +
fmt(data.plan_margin_u, 2) +
"U</strong></div>" +
"<div><span>止损预算</span><strong>" +
fmt(data.risk_budget_u, 2) +
"U</strong></div>" +
"<div><span>总张数</span><strong>" +
fmt(data.target_contracts, dec.amount) +
"</strong></div>" +
"<div><span>首仓张数</span><strong>" +
fmt(data.first_contracts, dec.amount) +
"</strong></div>" +
'<div><span>首仓止盈盈利</span><strong class="' +
pnlClass(data.first_profit_u) +
'">' +
fmtU(data.first_profit_u) +
"</strong></div>" +
"<div><span>首仓盈亏比</span><strong>" +
(data.first_rr != null ? fmt(data.first_rr, 2) + ":1" : "—") +
"</strong></div>" +
"</div>" +
renderTrendTable(data.rows, dec);
}
function renderRollResult(data) {
const box = $("calc-roll-result");
if (!box) return;
const dec = decimalsFromMarket(data);
const px = dec.price != null ? dec.price : 4;
const amt = dec.amount != null ? dec.amount : 4;
box.classList.remove("hidden");
let table =
'<div class="calc-table-wrap"><table class="calc-table"><thead><tr>' +
"<th>阶段</th><th>入场/加仓价</th><th>统一止损</th><th>本次张数</th><th>累计张数</th><th>均价</th><th>打到止损总亏</th><th>止盈盈利</th><th>盈亏比</th>" +
"</tr></thead><tbody>";
(data.rows || []).forEach(function (r) {
const tag = r.already_done ? ' <span class="calc-done-tag">已完成</span>' : "";
table +=
"<tr>" +
"<td>" +
esc(r.label) +
tag +
"</td>" +
"<td>" +
fmt(r.entry_or_add_price, px) +
"</td>" +
"<td>" +
fmt(r.stop_loss, px) +
"</td>" +
"<td>" +
fmt(r.add_contracts, amt) +
"</td>" +
"<td>" +
fmt(r.total_contracts, amt) +
"</td>" +
"<td>" +
fmt(r.avg_entry, px) +
"</td>" +
'<td class="calc-pnl-loss">' +
fmtU(-Math.abs(Number(r.loss_at_sl_u) || 0)) +
"</td>" +
'<td class="' +
pnlClass(r.profit_at_tp_u) +
'">' +
fmtU(r.profit_at_tp_u) +
"</td>" +
"<td>" +
(r.rr != null ? fmt(r.rr, 2) + ":1" : "—") +
"</td>" +
"</tr>";
});
table += "</tbody></table></div>";
box.innerHTML =
'<div class="calc-summary">' +
"<div><span>合约</span><strong>" +
esc((data.market && data.market.display_symbol) || "—") +
"</strong></div>" +
"<div><span>单次风险预算</span><strong>" +
fmt(data.risk_budget_u, 2) +
"U</strong></div>" +
"<div><span>首仓张数(自动)</span><strong>" +
fmt(data.first_contracts, amt) +
"</strong></div>" +
"<div><span>最终累计张数</span><strong>" +
fmt(data.final_contracts, amt) +
"</strong></div>" +
"<div><span>最终均价</span><strong>" +
fmt(data.final_avg_entry, px) +
"</strong></div>" +
'<div><span>最终止盈盈利</span><strong class="' +
pnlClass(data.final_profit_at_tp_u) +
'">' +
fmtU(data.final_profit_at_tp_u) +
"</strong></div>" +
"<div><span>最终盈亏比</span><strong>" +
(data.final_rr != null ? fmt(data.final_rr, 2) + ":1" : "—") +
"</strong></div>" +
"</div>" +
table;
}
const MAX_ROLL_LEGS = 3;
let rollLegCount = 0;
function maxRollLegsAllowed() {
const done = num("calc-roll-legs-done") || 0;
return Math.max(0, MAX_ROLL_LEGS - done);
}
function syncRollAddBtn() {
const btn = $("calc-roll-add-leg");
if (!btn) return;
btn.disabled = rollLegCount >= maxRollLegsAllowed();
}
function rollLegRowHtml(index) {
const step = (marketCache["calc-roll"] && marketCache["calc-roll"].price_tick) || "any";
return (
'<div class="calc-roll-leg" data-leg-index="' +
index +
'">' +
'<div class="calc-roll-leg-title">滚仓 ' +
index +
"</div>" +
'<div class="calc-roll-leg-grid">' +
'<label class="calc-field"><span>加仓价</span><input type="number" class="calc-roll-leg-add" min="0" step="' +
esc(step) +
'" required /></label>' +
'<label class="calc-field"><span>新统一止损</span><input type="number" class="calc-roll-leg-stop" min="0" step="' +
esc(step) +
'" required /></label>' +
"</div>" +
'<button type="button" class="ghost danger calc-roll-leg-remove">删除</button>' +
"</div>"
);
}
function renumberRollLegs() {
const list = $("calc-roll-legs-list");
if (!list) return;
const rows = list.querySelectorAll(".calc-roll-leg");
rollLegCount = rows.length;
rows.forEach(function (row, i) {
row.setAttribute("data-leg-index", String(i + 1));
const title = row.querySelector(".calc-roll-leg-title");
if (title) title.textContent = "滚仓 " + (i + 1);
});
syncRollAddBtn();
}
function addRollLegRow() {
if (rollLegCount >= maxRollLegsAllowed()) return;
const list = $("calc-roll-legs-list");
if (!list) return;
list.insertAdjacentHTML("beforeend", rollLegRowHtml(rollLegCount + 1));
rollLegCount += 1;
syncRollAddBtn();
}
function collectRollLegs() {
const legs = [];
document.querySelectorAll(".calc-roll-leg").forEach(function (row) {
const addEl = row.querySelector(".calc-roll-leg-add");
const stopEl = row.querySelector(".calc-roll-leg-stop");
const ap = addEl && addEl.value !== "" ? Number(addEl.value) : null;
const sl = stopEl && stopEl.value !== "" ? Number(stopEl.value) : null;
if (ap == null || sl == null || !Number.isFinite(ap) || !Number.isFinite(sl)) return;
legs.push({ add_price: ap, new_stop_loss: sl });
});
return legs;
}
function bindRollLegsUI() {
const addBtn = $("calc-roll-add-leg");
const list = $("calc-roll-legs-list");
const doneInput = $("calc-roll-legs-done");
if (addBtn && !addBtn._bound) {
addBtn._bound = true;
addBtn.addEventListener("click", addRollLegRow);
}
if (list && !list._bound) {
list._bound = true;
list.addEventListener("click", function (e) {
const btn = e.target.closest(".calc-roll-leg-remove");
if (!btn) return;
const row = btn.closest(".calc-roll-leg");
if (row) row.remove();
renumberRollLegs();
});
}
if (doneInput && !doneInput._bound) {
doneInput._bound = true;
doneInput.addEventListener("change", function () {
while (rollLegCount > maxRollLegsAllowed()) {
const rows = list && list.querySelectorAll(".calc-roll-leg");
if (rows && rows.length) rows[rows.length - 1].remove();
rollLegCount = list ? list.querySelectorAll(".calc-roll-leg").length : 0;
}
syncRollAddBtn();
});
}
syncRollAddBtn();
}
function showErr(boxId, msg) {
const box = $(boxId);
if (!box) return;
box.classList.remove("hidden");
box.innerHTML = '<p class="calc-error">' + esc(msg || "计算失败") + "</p>";
}
async function submitTrend(e) {
e.preventDefault();
const body = {
direction: ($("calc-trend-direction") && $("calc-trend-direction").value) || "long",
exchange_id: ($("calc-trend-exchange") && $("calc-trend-exchange").value) || "0",
base: text("calc-trend-base") || "ETH",
capital_usdt: num("calc-trend-capital"),
risk_percent: num("calc-trend-risk"),
leverage: num("calc-trend-leverage"),
entry_price: num("calc-trend-entry"),
stop_loss: num("calc-trend-sl"),
add_upper: num("calc-trend-add-upper"),
take_profit: num("calc-trend-tp"),
dca_legs: num("calc-trend-dca-legs") || 5,
};
try {
const r = await fetch("/api/calculator/trend", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const j = await r.json();
if (!j.ok) {
showErr("calc-trend-result", j.msg || "计算失败");
return;
}
renderTrendResult(j.data);
} catch (err) {
showErr("calc-trend-result", String(err));
}
}
async function submitRoll(e) {
e.preventDefault();
const body = {
direction: ($("calc-roll-direction") && $("calc-roll-direction").value) || "long",
exchange_id: ($("calc-roll-exchange") && $("calc-roll-exchange").value) || "0",
base: text("calc-roll-base") || "ETH",
capital_usdt: num("calc-roll-capital"),
risk_percent: num("calc-roll-risk"),
entry_price: num("calc-roll-entry"),
stop_loss: num("calc-roll-sl"),
take_profit: num("calc-roll-tp"),
add_legs: collectRollLegs(),
legs_done: num("calc-roll-legs-done") || 0,
};
try {
const r = await fetch("/api/calculator/roll", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const j = await r.json();
if (!j.ok) {
showErr("calc-roll-result", j.msg || "计算失败");
return;
}
renderRollResult(j.data);
} catch (err) {
showErr("calc-roll-result", String(err));
}
}
function applyCalcTab(tab) {
const t = tab === "roll" ? "roll" : "trend";
const layout = page.querySelector(".calc-layout");
if (layout) layout.setAttribute("data-calc-tab", t);
page.querySelectorAll(".calc-m-tab").forEach(function (btn) {
const on = (btn.getAttribute("data-calc-tab") || "") === t;
btn.classList.toggle("is-active", on);
btn.setAttribute("aria-selected", on ? "true" : "false");
});
try {
sessionStorage.setItem("hub_calc_tab", t);
} catch (e) {
/* ignore */
}
}
function bindCalcTabs() {
page.querySelectorAll(".calc-m-tab").forEach(function (btn) {
btn.addEventListener("click", function () {
applyCalcTab(btn.getAttribute("data-calc-tab") || "trend");
});
});
let saved = "trend";
try {
saved = sessionStorage.getItem("hub_calc_tab") || "trend";
} catch (e) {
saved = "trend";
}
applyCalcTab(saved);
}
async function bindOnce() {
if (inited) return;
inited = true;
await loadCalculatorExchanges();
const trendForm = $("calc-trend-form");
const rollForm = $("calc-roll-form");
const dirSel = $("calc-trend-direction");
if (trendForm) trendForm.addEventListener("submit", submitTrend);
if (rollForm) rollForm.addEventListener("submit", submitRoll);
if (dirSel) {
dirSel.addEventListener("change", syncTrendAddLabel);
syncTrendAddLabel();
}
bindRollLegsUI();
bindMarket("calc-trend");
bindMarket("calc-roll");
bindCalcTabs();
const refreshBtn = $("calc-btn-refresh");
if (refreshBtn) {
refreshBtn.addEventListener("click", function () {
void refreshPage();
});
}
}
window.hubCalculatorPage = {
init: function () {
if (inited) {
void refreshPage();
return;
}
void bindOnce();
},
refresh: refreshPage,
destroy: function () {},
};
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+730
View File
@@ -0,0 +1,730 @@
/* 数据看板 — 随中控亮/暗主题,卡片柔光 */
body.hub-page-dashboard {
--dash-card-bg: var(--panel);
--dash-card-border: var(--border-soft);
--dash-card-glow: 0 2px 12px rgba(0, 0, 0, 0.06);
--dash-section-bg: var(--panel);
--dash-muted: var(--muted);
--dash-text: var(--text);
--dash-accent: var(--accent);
--dash-ok: var(--green);
--dash-warn: var(--red);
}
html[data-theme="light"] body.hub-page-dashboard {
--dash-card-glow:
0 1px 2px rgba(15, 23, 42, 0.04),
0 8px 24px rgba(15, 23, 42, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.85);
}
html[data-theme="dark"] body.hub-page-dashboard {
--dash-card-glow:
0 4px 18px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
body.hub-page-dashboard .page#page-dashboard {
position: relative;
}
.dash-bg-grid {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.45;
background-image:
linear-gradient(color-mix(in srgb, var(--border-soft) 55%, transparent) 1px, transparent 1px),
linear-gradient(90deg, color-mix(in srgb, var(--border-soft) 55%, transparent) 1px, transparent 1px);
background-size: 40px 40px;
mask-image: radial-gradient(ellipse 85% 65% at 50% 0%, #000 15%, transparent 72%);
}
.dash-wrap {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 18px;
min-height: calc(100vh - 120px);
}
.dash-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.dash-head h1 {
font-size: clamp(1.35rem, 2.5vw, 1.75rem);
font-weight: 700;
letter-spacing: 0.02em;
margin: 0;
color: var(--dash-text);
}
.dash-head-tag {
display: inline-block;
font-family: JetBrains Mono, monospace;
font-size: 0.65rem;
color: var(--dash-accent);
border: 1px solid var(--dash-card-border);
padding: 2px 8px;
border-radius: 4px;
margin-right: 10px;
vertical-align: middle;
letter-spacing: 0.1em;
}
.dash-head-meta {
font-family: JetBrains Mono, monospace;
font-size: 0.78rem;
color: var(--dash-muted);
text-align: right;
}
.dash-head-meta strong {
color: var(--dash-text);
font-weight: 600;
}
.dash-pulse-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--dash-ok);
margin-right: 6px;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dash-ok) 25%, transparent);
animation: dash-pulse 2s ease-in-out infinite;
}
@keyframes dash-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.65;
transform: scale(0.9);
}
}
.dash-kpi-row {
display: block;
}
.dash-kpi-summary {
display: flex;
flex-wrap: nowrap;
align-items: stretch;
justify-content: space-between;
gap: 0;
width: 100%;
padding: 10px 4px;
border-radius: 12px;
background: var(--dash-card-bg);
border: 1px solid var(--dash-card-border);
box-shadow: var(--dash-card-glow);
overflow: hidden;
}
.dash-kpi-item {
flex: 1 1 0;
min-width: 0;
max-width: none;
padding: 4px 8px;
position: relative;
text-align: center;
}
.dash-kpi-item + .dash-kpi-item::before {
content: "";
position: absolute;
left: 0;
top: 18%;
bottom: 18%;
width: 1px;
background: color-mix(in srgb, var(--dash-card-border) 85%, transparent);
}
.dash-kpi,
.dash-section,
.dash-ac-card {
box-shadow: var(--dash-card-glow);
}
.dash-kpi {
position: relative;
padding: 16px 18px;
border-radius: 12px;
background: var(--dash-card-bg);
border: 1px solid var(--dash-card-border);
overflow: hidden;
}
.dash-kpi-label {
font-size: 0.65rem;
color: var(--dash-muted);
letter-spacing: 0.04em;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dash-kpi-value {
font-family: JetBrains Mono, monospace;
font-size: 0.92rem;
font-weight: 600;
line-height: 1.25;
color: var(--dash-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dash-kpi-value.pos {
color: var(--dash-ok);
}
.dash-kpi-value.neg {
color: var(--dash-warn);
}
.dash-kpi-sub {
margin-top: 6px;
font-size: 0.72rem;
color: var(--dash-muted);
}
.dash-alert-banner {
display: none;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--dash-warn) 45%, var(--dash-card-border));
background: color-mix(in srgb, var(--dash-warn) 8%, var(--dash-card-bg));
font-size: 0.85rem;
box-shadow: var(--dash-card-glow);
}
.dash-alert-banner.is-on {
display: flex;
}
.dash-alert-banner strong {
color: var(--dash-warn);
letter-spacing: 0.02em;
}
.dash-section {
border-radius: 14px;
border: 1px solid var(--dash-card-border);
background: var(--dash-section-bg);
overflow: hidden;
}
.dash-section-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--dash-card-border);
font-size: 0.82rem;
letter-spacing: 0.06em;
color: var(--dash-muted);
font-weight: 600;
}
.dash-section-body {
padding: 0;
}
.dash-ac-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
gap: 12px;
padding: 14px;
}
.dash-ac-card {
position: relative;
padding: 14px 16px;
border-radius: 10px;
border: 1px solid var(--dash-card-border);
background: var(--dash-card-bg);
transition: border-color 0.2s, box-shadow 0.2s;
}
.dash-ac-card.is-alert {
border-color: color-mix(in srgb, var(--dash-warn) 55%, var(--dash-card-border));
box-shadow:
var(--dash-card-glow),
0 0 0 1px color-mix(in srgb, var(--dash-warn) 18%, transparent);
}
.dash-ac-card.is-unmon {
opacity: 0.6;
}
.dash-ac-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.dash-ac-name {
font-weight: 600;
font-size: 0.92rem;
color: var(--dash-text);
}
.dash-ac-top-actions {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.dash-ac-badge {
font-size: 0.65rem;
font-weight: 700;
padding: 3px 8px;
border-radius: 4px;
letter-spacing: 0.04em;
white-space: nowrap;
}
.dash-ac-badge.alert {
color: #fff;
background: var(--dash-warn);
}
.dash-ac-badge.ok {
color: var(--dash-accent);
border: 1px solid var(--dash-card-border);
background: color-mix(in srgb, var(--dash-accent) 8%, var(--dash-card-bg));
}
.dash-ac-metrics {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px 12px;
font-family: JetBrains Mono, monospace;
font-size: 0.76rem;
}
.dash-ac-metrics-3col {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.dash-ac-section-label {
grid-column: 1 / -1;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--dash-accent);
margin-top: 2px;
padding-bottom: 2px;
border-bottom: 1px dashed color-mix(in srgb, var(--dash-card-border) 80%, transparent);
}
.dash-ac-section-label:not(:first-child) {
margin-top: 8px;
}
.dash-ac-metric-empty {
visibility: hidden;
min-height: 0;
padding: 0;
margin: 0;
}
.dash-ac-total-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--dash-card-border);
background: color-mix(in srgb, var(--dash-accent) 6%, var(--dash-card-bg));
font-family: JetBrains Mono, monospace;
font-size: 0.76rem;
}
.dash-ac-total-row span {
color: var(--dash-muted);
font-size: 0.68rem;
}
.dash-ac-total-row strong {
color: var(--dash-text);
font-size: 0.88rem;
}
.dash-ac-card-options .dash-ac-remark {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed color-mix(in srgb, var(--dash-card-border) 80%, transparent);
}
.dash-options-block {
margin-top: 8px;
}
.dash-options-block .dash-ac-section-label {
margin-bottom: 4px;
}
.dash-options-table-wrap {
overflow-x: auto;
}
.dash-options-table th,
.dash-options-table td {
font-size: 0.68rem;
white-space: nowrap;
}
.dash-ac-card-pos-only {
gap: 8px;
}
.dash-ac-pos-body {
display: flex;
flex-direction: column;
gap: 10px;
}
.dash-pos-block .dash-ac-section-label {
margin-bottom: 4px;
}
.dash-pos-source {
display: inline-block;
padding: 1px 6px;
border-radius: 999px;
font-size: 0.66rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.dash-pos-source.is-hedge {
color: #fbbf24;
background: rgba(245, 158, 11, 0.16);
border: 1px solid rgba(245, 158, 11, 0.4);
}
.dash-pos-source.is-roll {
color: #6ee7b7;
background: rgba(16, 185, 129, 0.16);
border: 1px solid rgba(16, 185, 129, 0.4);
}
.dash-pos-source.is-trend {
color: #93c5fd;
background: rgba(59, 130, 246, 0.18);
border: 1px solid rgba(59, 130, 246, 0.35);
}
.dash-pos-source.is-order {
color: #c4b5fd;
background: rgba(139, 92, 246, 0.18);
border: 1px solid rgba(139, 92, 246, 0.35);
}
.dash-pos-source.is-key {
color: #fdba74;
background: rgba(249, 115, 22, 0.16);
border: 1px solid rgba(249, 115, 22, 0.4);
}
.dash-pos-source.is-none {
color: var(--dash-muted);
background: rgba(148, 163, 184, 0.12);
border: 1px solid rgba(148, 163, 184, 0.28);
}
.dash-target-monitor {
color: var(--dash-muted);
}
.dash-target-monitor.is-on {
color: #4ade80;
font-weight: 600;
}
.dash-pos-source.is-perp {
color: #93c5fd;
background: rgba(59, 130, 246, 0.18);
border: 1px solid rgba(59, 130, 246, 0.35);
}
.dash-pos-source.is-opt {
color: #c4b5fd;
background: rgba(139, 92, 246, 0.18);
border: 1px solid rgba(139, 92, 246, 0.35);
}
.dash-pos-table th,
.dash-pos-table td {
font-size: 0.72rem;
white-space: nowrap;
}
.dash-ac-metrics-3col .dash-ac-metric {
text-align: center;
}
.dash-ac-metrics-3col .dash-ac-metric span,
.dash-ac-metrics-3col .dash-ac-metric strong {
text-align: center;
}
.dash-ac-metric span {
display: block;
color: var(--dash-muted);
font-size: 0.65rem;
margin-bottom: 2px;
}
.dash-ac-metric strong {
color: var(--dash-text);
}
.dash-ac-metric strong.pos {
color: var(--dash-ok);
}
.dash-ac-metric strong.neg {
color: var(--dash-warn);
}
.dash-loss-bar {
margin-top: 10px;
height: 4px;
border-radius: 2px;
background: color-mix(in srgb, var(--dash-muted) 18%, transparent);
overflow: hidden;
}
.dash-loss-bar i {
display: block;
height: 100%;
border-radius: 2px;
background: var(--dash-warn);
transition: width 0.6s ease;
}
.dash-ac-remark {
margin-top: 10px;
font-size: 0.7rem;
color: var(--dash-muted);
line-height: 1.4;
word-break: break-word;
display: flex;
flex-direction: column;
gap: 6px;
}
.dash-ac-monitor-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.dash-monitor-chip {
display: inline-flex;
align-items: center;
padding: 3px 8px;
border-radius: 6px;
font-size: 11px;
line-height: 1.3;
border: 1px solid transparent;
font-weight: 600;
}
.dash-monitor-chip.dash-monitor-key {
color: #b8a0ff;
background: rgba(123, 97, 255, 0.18);
border-color: rgba(123, 97, 255, 0.42);
}
.dash-monitor-chip.dash-monitor-order {
color: var(--dash-accent);
background: rgba(0, 212, 255, 0.14);
border-color: rgba(0, 212, 255, 0.38);
}
.dash-monitor-chip.dash-monitor-trend {
color: var(--dash-ok);
background: rgba(0, 255, 157, 0.1);
border-color: rgba(0, 255, 157, 0.38);
}
.dash-monitor-chip.dash-monitor-roll {
color: #ffb020;
background: rgba(255, 176, 32, 0.14);
border-color: rgba(255, 176, 32, 0.42);
}
.dash-ac-expand-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border-radius: 6px;
border: 1px solid var(--dash-card-border);
background: color-mix(in srgb, var(--dash-accent) 8%, var(--dash-card-bg));
color: var(--dash-accent);
cursor: pointer;
flex-shrink: 0;
}
.dash-ac-expand-btn:hover {
border-color: var(--dash-accent);
background: color-mix(in srgb, var(--dash-accent) 14%, var(--dash-card-bg));
}
.dash-ac-positions {
display: flex;
flex-direction: column;
gap: 3px;
}
.dash-ac-remark-line {
margin: 0;
padding: 3px 0;
border-top: 1px solid color-mix(in srgb, var(--dash-card-border) 65%, transparent);
}
.dash-ac-remark-line:first-child {
border-top: none;
padding-top: 0;
}
.dash-ac-remark-mon {
color: var(--dash-muted);
}
.dash-ac-remark-pos {
color: var(--dash-text);
}
.dash-ac-remark-pos .pos,
.dash-ac-remark-pos .neg {
font-weight: 600;
}
.dash-ac-remark-pos .pos {
color: var(--dash-ok);
}
.dash-ac-remark-pos .neg {
color: var(--dash-warn);
}
.dash-ac-remark-empty {
color: var(--dash-muted);
}
.dash-ac-remark-issue {
color: var(--dash-warn);
}
html[data-theme="light"] .dash-monitor-chip.dash-monitor-key {
color: #5b4fc7;
background: rgba(91, 79, 199, 0.1);
border-color: rgba(91, 79, 199, 0.28);
}
html[data-theme="light"] .dash-monitor-chip.dash-monitor-trend {
background: rgba(10, 143, 92, 0.1);
border-color: rgba(10, 143, 92, 0.28);
}
.dash-table-wrap {
overflow: auto;
max-height: min(52vh, 480px);
}
.dash-table {
width: 100%;
border-collapse: collapse;
font-family: JetBrains Mono, monospace;
font-size: 0.74rem;
}
.dash-table th {
position: sticky;
top: 0;
z-index: 1;
text-align: left;
padding: 10px 12px;
background: var(--inset-surface);
color: var(--dash-muted);
font-weight: 600;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--dash-card-border);
}
.dash-table td {
padding: 9px 12px;
border-bottom: 1px solid var(--dash-card-border);
color: var(--dash-text);
}
.dash-table tr:hover td {
background: color-mix(in srgb, var(--dash-accent) 6%, transparent);
}
.dash-table tr.is-alert-row td {
background: color-mix(in srgb, var(--dash-warn) 10%, transparent);
}
.dash-table .pos {
color: var(--dash-ok);
}
.dash-table .neg {
color: var(--dash-warn);
}
.dash-empty {
padding: 32px;
text-align: center;
color: var(--dash-muted);
font-size: 0.85rem;
}
.dash-status {
font-family: JetBrains Mono, monospace;
font-size: 0.75rem;
color: var(--dash-muted);
}
.dash-status.err {
color: var(--dash-warn);
}
@media (max-width: 720px) {
.dash-ac-grid {
grid-template-columns: 1fr;
}
.dash-head-meta {
text-align: left;
width: 100%;
}
}
+459
View File
@@ -0,0 +1,459 @@
/**
* 中控数据看板:后端 SSE 推送版本号,前端拉快照刷新(无轮询闪烁).
*/
(function () {
const page = document.getElementById("page-dashboard");
if (!page) return;
let dashEventSource = null;
let dashReconnectTimer = null;
let localDashVersion = 0;
let inited = false;
let loading = false;
const elStatus = document.getElementById("dash-status");
const elBanner = document.getElementById("dash-alert-banner");
const elBannerText = document.getElementById("dash-alert-banner-text");
const elKpi = document.getElementById("dash-kpi-row");
const elAccounts = document.getElementById("dash-accounts");
const elTrades = document.getElementById("dash-trades-body");
const elUpdated = document.getElementById("dash-updated-at");
const elDay = document.getElementById("dash-trading-day");
const btnRefresh = document.getElementById("dash-btn-refresh");
function fmt(n, d) {
if (n == null || n === "" || !Number.isFinite(Number(n))) return "—";
return Number(n).toFixed(d == null ? 2 : d);
}
function pnlClass(v) {
const n = Number(v);
if (!Number.isFinite(n) || Math.abs(n) < 1e-9) return "";
return n > 0 ? "pos" : "neg";
}
function pnlSigned(v, digits) {
const n = Number(v);
if (!Number.isFinite(n)) return "—";
const abs = fmt(Math.abs(n), digits);
if (Math.abs(n) < 1e-9) return `${abs}U`;
return `${n > 0 ? "+" : "-"}${abs}U`;
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function setStatus(msg, isErr) {
if (!elStatus) return;
elStatus.textContent = msg || "";
elStatus.className = "dash-status" + (isErr ? " err" : "");
}
function renderKpi(totals) {
if (!elKpi || !totals) return;
const closed = Number(totals.total_pnl_u);
const floating = Number(totals.float_pnl_u);
const funding = totals.total_funding_usdt;
const trading = totals.total_trading_usdt;
const funds =
funding != null && trading != null ? Number(funding) + Number(trading) : NaN;
const totalPos = Number(totals.open_position_count) || 0;
const optPos = Number(totals.options_open_position_count) || 0;
const perpPos =
totals.perpetual_open_position_count != null
? Number(totals.perpetual_open_position_count) || 0
: Math.max(0, totalPos - optPos);
const items = [
kpiItem("交易日", esc(totals.trading_day || "—")),
kpiItem("资金合计", Number.isFinite(funds) ? `${fmt(funds, 2)}U` : "—"),
kpiItem("总持仓数量", `${totalPos}`),
kpiItem("期权持仓", `${optPos}`),
kpiItem("永续持仓", `${perpPos}`),
kpiItem("平仓数量", `${totals.closed_count || 0}`),
kpiItem("平仓盈亏", pnlSigned(closed, 2), pnlClass(closed)),
kpiItem("浮盈亏", pnlSigned(floating, 2), pnlClass(floating)),
];
elKpi.innerHTML = `<div class="dash-kpi-summary">${items.join("")}</div>`;
}
function kpiItem(label, value, valCls) {
return `<div class="dash-kpi-item">
<div class="dash-kpi-label">${esc(label)}</div>
<div class="dash-kpi-value ${valCls || ""}">${value}</div>
</div>`;
}
function renderMonitorCountChips(counts) {
const mc = counts || {};
const chips = [];
const keys = Number(mc.keys) || 0;
const orders = Number(mc.orders) || 0;
const trends = Number(mc.trends) || 0;
const rolls = Number(mc.rolls) || 0;
if (keys > 0) chips.push(`<span class="dash-monitor-chip dash-monitor-key">关键位 ${keys}</span>`);
if (orders > 0) {
chips.push(`<span class="dash-monitor-chip dash-monitor-order">下单监控 ${orders}</span>`);
}
if (trends > 0) chips.push(`<span class="dash-monitor-chip dash-monitor-trend">趋势回调 ${trends}</span>`);
if (rolls > 0) chips.push(`<span class="dash-monitor-chip dash-monitor-roll">顺势加仓 ${rolls}</span>`);
return chips;
}
function dashOptionsExpiryCd(expMs) {
const ms = expMs != null && expMs !== "" ? String(expMs) : "";
if (!ms) return "—";
return `<span class="opt-expiry-cd" data-opt-exp-ms="${esc(ms)}">—</span>`;
}
function shortDashInst(instId) {
const s = String(instId || "");
if (s.length <= 18) return s;
return s.slice(0, 8) + "…" + s.slice(-6);
}
function accountPerpLines(ac) {
const positions = Array.isArray(ac && ac.position_lines) ? ac.position_lines : [];
if (ac && ac.options_layout) {
return positions.filter((ln) => (ln && ln.kind) !== "options");
}
return positions;
}
function accountHasOpenPositions(ac) {
const perp = accountPerpLines(ac);
const optionsPositions = Array.isArray(ac && ac.options_positions) ? ac.options_positions : [];
return perp.length > 0 || (ac && ac.options_layout && optionsPositions.length > 0);
}
function sourceBadgeClass(source) {
const s = String(source || "");
if (s.indexOf("对冲") >= 0) return "is-hedge";
if (s.indexOf("纯期权") >= 0 || s === "期权") return "is-opt";
if (s.indexOf("顺势") >= 0) return "is-roll";
if (s.indexOf("趋势") >= 0) return "is-trend";
if (s.indexOf("关键位") >= 0) return "is-key";
if (s.indexOf("下单") >= 0) return "is-order";
return "is-none";
}
function renderDashboardPerpTable(lines) {
const rows = Array.isArray(lines) ? lines : [];
if (!rows.length) return "";
const body = rows
.map((ln) => {
const source = String((ln && ln.source) || "—");
const symbol = esc((ln && (ln.symbol || ln.text)) || "—");
const side = esc((ln && ln.side) || "—");
const contracts =
ln && ln.contracts != null && ln.contracts !== "" ? esc(String(ln.contracts)) : "—";
const pnl = ln && ln.pnl != null ? Number(ln.pnl) : NaN;
return `<tr>
<td><span class="dash-pos-source ${sourceBadgeClass(source)}">${esc(source)}</span></td>
<td>${symbol}</td>
<td>${side}</td>
<td>${contracts}</td>
<td class="${pnlClass(pnl)}">${Number.isFinite(pnl) ? pnlSigned(pnl, 2) : "—"}</td>
</tr>`;
})
.join("");
return `<div class="dash-pos-block">
<div class="dash-ac-section-label">永续持仓</div>
<div class="dash-table-wrap">
<table class="dash-table dash-pos-table">
<thead><tr>
<th>来源</th><th></th><th></th><th></th><th></th>
</tr></thead>
<tbody>${body}</tbody>
</table>
</div>
</div>`;
}
function optionsNetPnl(p) {
if (!p || typeof p !== "object") return null;
if (p.net_pnl != null && Number.isFinite(Number(p.net_pnl))) return Number(p.net_pnl);
const preview = p.close_preview || {};
if (preview.estimated_pnl != null && Number.isFinite(Number(preview.estimated_pnl))) {
return Number(preview.estimated_pnl);
}
if (preview.total_received != null && p.premium_paid != null) {
const n = Number(preview.total_received) - Number(p.premium_paid);
return Number.isFinite(n) ? n : null;
}
return null;
}
function renderDashboardOptionsTable(positions) {
const pos = Array.isArray(positions) ? positions : [];
if (!pos.length) return "";
const rows = pos
.map((p) => {
const optType =
(p.opt_type || "").toUpperCase() === "C"
? "Call"
: (p.opt_type || "").toUpperCase() === "P"
? "Put"
: p.opt_type || "—";
const source = String(p.source_label || p.source || "纯期权");
const target = String(p.target_monitor_text || "—");
const targetCls = target && target !== "—" ? "dash-target-monitor is-on" : "dash-target-monitor";
const net = optionsNetPnl(p);
return `<tr>
<td><span class="dash-pos-source ${sourceBadgeClass(source)}">${esc(source)}</span></td>
<td title="${esc(p.inst_id || "")}">${esc(shortDashInst(p.inst_id))}</td>
<td>${esc(optType)}</td>
<td>${dashOptionsExpiryCd(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}</td>
<td>${p.idx_px != null ? fmt(p.idx_px, 0) : "—"}</td>
<td><span class="${targetCls}">${esc(target)}</span></td>
<td class="${pnlClass(net)}">${net != null ? pnlSigned(net, 2) : "—"}</td>
</tr>`;
})
.join("");
return `<div class="dash-pos-block dash-options-block">
<div class="dash-ac-section-label">期权持仓</div>
<div class="dash-table-wrap dash-options-table-wrap">
<table class="dash-table dash-options-table">
<thead><tr>
<th>来源</th><th></th><th></th><th></th><th></th><th></th><th></th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>`;
}
function renderAccountPositions(ac) {
const perpLines = accountPerpLines(ac);
const optionsPositions = Array.isArray(ac && ac.options_positions) ? ac.options_positions : [];
const issues = Array.isArray(ac && ac.issues) ? ac.issues : [];
const chips = renderMonitorCountChips((ac && ac.monitor_counts) || {});
const monitorRow = chips.length
? `<div class="dash-ac-monitor-row">${chips.join("")}</div>`
: "";
const perpHtml = renderDashboardPerpTable(perpLines);
const optionsHtml = ac && ac.options_layout ? renderDashboardOptionsTable(optionsPositions) : "";
const issueHtml = issues
.map((text) => `<div class="dash-ac-remark-line dash-ac-remark-issue">${esc(text)}</div>`)
.join("");
return `${monitorRow}${perpHtml}${optionsHtml}${issueHtml}`;
}
function bindDashboardExpand() {
if (!elAccounts) return;
elAccounts.querySelectorAll(".dash-ac-expand-btn").forEach((btn) => {
btn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
const id = btn.getAttribute("data-dash-ex-id");
if (id && window.hubOpenMonitorExpand) window.hubOpenMonitorExpand(id);
});
});
}
function renderAccounts(accounts, threshold) {
const rows = (Array.isArray(accounts) ? accounts : []).filter(accountHasOpenPositions);
if (!rows.length) {
elAccounts.innerHTML = '<div class="dash-empty">当前无持仓账户</div>';
return;
}
elAccounts.innerHTML = rows
.map((ac) => {
const alert = !!ac.loss_alert;
const unmon = !ac.monitored;
const lossPct = Number(ac.daily_loss_pct);
const barW =
alert && Number.isFinite(lossPct)
? Math.min(100, (lossPct / Math.max(threshold, 1)) * 100)
: 0;
const badge = alert
? `<span class="dash-ac-badge alert">单日亏损 ≥${threshold}%</span>`
: `<span class="dash-ac-badge ok">${esc(ac.status || "—")}</span>`;
const exId = ac && ac.id != null ? String(ac.id) : "";
const expandBtn = exId
? `<button type="button" class="dash-ac-expand-btn" data-dash-ex-id="${esc(exId)}" title="放大查看监控详情" aria-label="放大查看监控详情">` +
`<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path fill="currentColor" d="M15 3h6v6h-2V6.41l-7.29 7.3-1.42-1.42 7.3-7.29H15V3zM3 9h2v10h10v2H3V9z"/></svg>` +
`</button>`
: "";
const lossBar =
alert && barW > 0
? `<div class="dash-loss-bar" title="占资金合计 ${fmt(lossPct, 2)}%"><i style="width:${barW}%"></i></div>`
: "";
const cardCls = ac.options_layout ? " dash-ac-card-options" : "";
return `<article class="dash-ac-card dash-ac-card-pos-only${cardCls}${alert ? " is-alert" : ""}${unmon ? " is-unmon" : ""}">
<div class="dash-ac-top">
<div class="dash-ac-name">${esc(ac.name || "—")}</div>
<div class="dash-ac-top-actions">${badge}${expandBtn}</div>
</div>
${lossBar}
<div class="dash-ac-pos-body">${renderAccountPositions(ac)}</div>
</article>`;
})
.join("");
bindDashboardExpand();
if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) {
OptionsExpiryCountdown.ensureTimer();
}
}
function renderTrades(trades, accounts) {
if (!elTrades) return;
const rows = Array.isArray(trades) ? trades : [];
if (!rows.length) {
elTrades.innerHTML = '<div class="dash-empty">今日暂无平仓</div>';
return;
}
const alertNames = new Set(
(accounts || []).filter((a) => a.loss_alert).map((a) => String(a.name || ""))
);
const body = rows
.map((t) => {
const pnl = Number(t.pnl_amount);
const rowAlert = alertNames.has(String(t.account_name || ""));
return `<tr class="${rowAlert ? "is-alert-row" : ""}">
<td>${esc(t.trading_day || "—")}</td>
<td>${esc(t.account_name || "—")}</td>
<td>${esc(t.symbol || "—")}</td>
<td>${esc(t.direction || "—")}</td>
<td>${esc(t.result || "—")}</td>
<td class="${pnlClass(pnl)}">${pnlSigned(pnl, 2)}</td>
<td>${esc(t.closed_at || "—")}</td>
</tr>`;
})
.join("");
elTrades.innerHTML = `<div class="dash-table-wrap"><table class="dash-table">
<thead><tr>
<th>交易日</th><th></th><th></th><th></th><th></th><th></th><th></th>
</tr></thead>
<tbody>${body}</tbody>
</table></div>`;
}
function renderPayload(data) {
const totals = data.totals || {};
const threshold = Number(data.loss_alert_pct_threshold) || 5;
const alertCount = Number(data.loss_alert_count) || 0;
if (elDay) elDay.textContent = totals.trading_day || data.trading_day || "—";
if (elUpdated) elUpdated.textContent = data.updated_at || "—";
renderKpi(totals);
renderAccounts(data.accounts, threshold);
renderTrades(data.closed_trades, data.accounts);
if (elBanner && elBannerText) {
if (alertCount > 0) {
const names = (data.accounts || [])
.filter((a) => a.loss_alert)
.map((a) => a.name)
.join(",");
elBanner.classList.add("is-on");
elBannerText.textContent = `${alertCount} 户单日平仓亏损超过资金合计 ${threshold}%:${names}`;
} else {
elBanner.classList.remove("is-on");
elBannerText.textContent = "";
}
}
}
async function fetchDashboardSnapshot(opts) {
const options = opts || {};
if (loading && !options.force) return;
loading = true;
if (!options.silent) setStatus("同步中…");
try {
const r = await fetch("/api/dashboard/daily", { credentials: "same-origin" });
if (r.status === 401) {
location.href = "/login?next=" + encodeURIComponent(location.pathname);
return;
}
const data = await r.json();
if (!data.ok) throw new Error(data.detail || data.msg || data.error || "加载失败");
const ver = Number(data.dashboard_version) || 0;
if (ver) localDashVersion = ver;
renderPayload(data);
const sec = Number(data.poll_interval_sec) || 5;
setStatus(options.silent ? `SSE 已连接 · 后台每 ${sec}s 聚合` : `已更新 · 后台每 ${sec}s 聚合`);
} catch (e) {
setStatus(String(e.message || e), true);
} finally {
loading = false;
}
}
function closeDashboardStream() {
if (dashEventSource) {
dashEventSource.close();
dashEventSource = null;
}
if (dashReconnectTimer) {
clearTimeout(dashReconnectTimer);
dashReconnectTimer = null;
}
}
function connectDashboardStream() {
closeDashboardStream();
dashEventSource = new EventSource("/api/dashboard/stream");
dashEventSource.addEventListener("dashboard", (ev) => {
try {
const st = JSON.parse(ev.data || "{}");
const ver = Number(st.dashboard_version) || 0;
if (ver && ver !== localDashVersion) {
void fetchDashboardSnapshot({ silent: true });
} else if (st.aggregating) {
setStatus("后台聚合中…");
}
} catch (_) {
/* ignore */
}
});
dashEventSource.onerror = () => {
closeDashboardStream();
setStatus("SSE 断开,8s 后重连…", true);
dashReconnectTimer = setTimeout(() => {
if (inited) {
connectDashboardStream();
void fetchDashboardSnapshot({ silent: true });
}
}, 8000);
};
}
async function requestDashboardRefresh() {
try {
await fetch("/api/dashboard/refresh", { method: "POST", credentials: "same-origin" });
} catch (_) {
/* ignore */
}
}
function startLive() {
void fetchDashboardSnapshot();
connectDashboardStream();
}
function stopLive() {
closeDashboardStream();
setStatus("");
}
if (btnRefresh) {
btnRefresh.addEventListener("click", () => {
void requestDashboardRefresh();
void fetchDashboardSnapshot({ force: true });
});
}
window.hubDashboardPage = {
init() {
inited = true;
startLive();
},
destroy() {
inited = false;
stopLive();
},
};
})();
+529
View File
@@ -0,0 +1,529 @@
/**
* 中控资金概况:总资金曲线,分户资金与回撤(资金户+交易户,不含浮盈).
*/
(function () {
const page = document.getElementById("page-funds");
if (!page) return;
const elStatus = document.getElementById("funds-status");
const elTotal = document.getElementById("funds-total-usdt");
const elDdU = document.getElementById("funds-total-dd-u");
const elDdPct = document.getElementById("funds-total-dd-pct");
const elDelta = document.getElementById("funds-total-delta");
const elPeriod = document.getElementById("funds-total-period");
const elPeriodSub = document.getElementById("funds-total-period-sub");
const elPeriodBanner = document.getElementById("funds-period-delta");
const elPeriodPct = document.getElementById("funds-period-pct");
const elDayChip = document.getElementById("funds-day-chip");
const elPnlBanner = document.getElementById("funds-pnl-banner");
const elMeta = document.getElementById("funds-meta");
const elDescBody = document.getElementById("funds-desc-body");
const elChartSub = document.getElementById("funds-chart-sub");
const elChartHost = document.getElementById("funds-chart-total");
const elAccounts = document.getElementById("funds-accounts");
const elBtnRefresh = document.getElementById("funds-btn-refresh");
const elFs = document.getElementById("funds-fullscreen");
const elFsBackdrop = document.getElementById("funds-fs-backdrop");
const elFsClose = document.getElementById("funds-fs-close");
const elFsTitle = document.getElementById("funds-fs-title");
const elFsSub = document.getElementById("funds-fs-sub");
const elFsTotal = document.getElementById("funds-fs-total");
const elFsFunding = document.getElementById("funds-fs-funding");
const elFsTrading = document.getElementById("funds-fs-trading");
const elFsDelta = document.getElementById("funds-fs-delta");
const elFsDd = document.getElementById("funds-fs-dd");
const elFsChartHost = document.getElementById("funds-fs-chart");
let chart = null;
let lineSeries = null;
let fsChart = null;
let fsLineSeries = null;
let inited = false;
let loading = false;
let lastOverview = null;
let fsAccountKey = "";
function fmt(n, d) {
if (n == null || n === "" || !Number.isFinite(Number(n))) return "—";
return Number(n).toFixed(d == null ? 2 : d);
}
function fmtDelta(n) {
if (n == null || !Number.isFinite(Number(n))) return "—";
const v = Number(n);
const sign = v > 0 ? "+" : "";
return sign + v.toFixed(2) + " U";
}
function fmtPct(n) {
if (n == null || !Number.isFinite(Number(n))) return "—";
const v = Number(n);
const sign = v > 0 ? "+" : "";
return sign + v.toFixed(2) + "%";
}
function deltaClass(n) {
if (!Number.isFinite(Number(n))) return "";
if (Number(n) > 0) return "pos";
if (Number(n) < 0) return "neg";
return "";
}
function setStatus(msg, isErr) {
if (!elStatus) return;
elStatus.textContent = msg || "";
elStatus.className = "funds-status" + (isErr ? " err" : "");
}
function seriesToChartData(series) {
return (series || [])
.filter(function (p) {
return p && p.day && Number.isFinite(Number(p.total_usdt));
})
.map(function (p) {
return { time: String(p.day), value: Number(p.total_usdt) };
});
}
function destroyChart() {
if (chart) {
chart.remove();
chart = null;
lineSeries = null;
}
if (elChartHost) elChartHost.innerHTML = "";
}
function destroyFsChart() {
if (fsChart) {
fsChart.remove();
fsChart = null;
fsLineSeries = null;
}
if (elFsChartHost) elFsChartHost.innerHTML = "";
}
function chartPalette() {
const light = document.documentElement.getAttribute("data-theme") === "light";
return light
? { bg: "#eef4fa", text: "#4a6078", border: "#c5d4e4", line: "#006e9a", top: "#006e9a44" }
: { bg: "#060a14", text: "#6b8aa8", border: "#1a2840", line: "#00d4ff", top: "#00d4ff55" };
}
function createAreaChart(host) {
const p = chartPalette();
const c = LightweightCharts.createChart(host, {
layout: {
background: { color: p.bg },
textColor: p.text,
fontSize: 11,
},
grid: {
vertLines: { color: p.border, visible: true },
horzLines: { color: p.border, visible: true },
},
rightPriceScale: {
borderColor: p.border,
scaleMargins: { top: 0.08, bottom: 0.08 },
},
timeScale: {
borderColor: p.border,
timeVisible: true,
fixLeftEdge: true,
fixRightEdge: true,
},
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
handleScroll: { mouseWheel: true, pressedMouseMove: true },
handleScale: { axisPressedMouseMove: true, mouseWheel: true, pinch: true },
});
const s = c.addAreaSeries({
lineColor: p.line,
topColor: p.top || p.line + "44",
bottomColor: p.line + "08",
lineWidth: 2,
priceFormat: { type: "price", precision: 2, minMove: 0.01 },
});
function syncSize() {
if (!c || !host) return;
const w = Math.max(host.clientWidth || 0, 1);
const h = Math.max(host.clientHeight || 0, 200);
c.applyOptions({ width: w, height: h });
}
new ResizeObserver(function () {
syncSize();
}).observe(host);
syncSize();
return { chart: c, series: s };
}
function ensureChart() {
if (!elChartHost || !window.LightweightCharts) return;
if (chart) return;
const built = createAreaChart(elChartHost);
chart = built.chart;
lineSeries = built.series;
}
function ensureFsChart() {
if (!elFsChartHost || !window.LightweightCharts) return;
if (fsChart) return;
const built = createAreaChart(elFsChartHost);
fsChart = built.chart;
fsLineSeries = built.series;
}
function esc(s) {
return String(s || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/"/g, "&quot;");
}
function accountStatus(ac) {
if (!ac || !ac.monitored) return { text: "未监控", cls: "" };
if (ac.data_ok) return { text: "已监控", cls: "is-ok" };
return { text: "余额未齐", cls: "" };
}
function renderAccounts(accounts) {
if (!elAccounts) return;
if (!accounts || !accounts.length) {
elAccounts.innerHTML = '<p class="funds-empty">暂无账户配置</p>';
return;
}
elAccounts.innerHTML = accounts
.map(function (ac) {
const monitored = !!ac.monitored;
const offCls = monitored ? "" : " is-off";
const st = accountStatus(ac);
const clickable = monitored ? "" : ' disabled aria-disabled="true"';
const name = ac.name || ac.key || "—";
const total =
monitored && ac.data_ok ? fmt(ac.total_usdt, 2) + " U" : "—";
const funding =
monitored && ac.funding_usdt != null ? fmt(ac.funding_usdt, 2) + " U" : "—";
const trading =
monitored && ac.trading_usdt != null ? fmt(ac.trading_usdt, 2) + " U" : "—";
const optFunding =
monitored && ac.options_funding_usdt != null ? fmt(ac.options_funding_usdt, 2) + " U" : "";
const optTrading =
monitored && ac.options_trading_usdt != null ? fmt(ac.options_trading_usdt, 2) + " U" : "";
const optLine =
optFunding || optTrading
? '<div><span class="k">期权户</span><span class="v">' +
(optFunding || "—") +
" / " +
(optTrading || "—") +
"</span></div>"
: "";
const dd = ac.drawdown || {};
const ddU = dd.max_drawdown_u != null ? fmt(dd.max_drawdown_u, 2) + " U" : "—";
const ddPct = dd.max_drawdown_pct != null ? fmt(dd.max_drawdown_pct, 2) + "%" : "—";
const deltaCls = deltaClass(ac.day_delta_usdt);
const deltaText = monitored ? fmtDelta(ac.day_delta_usdt) : "—";
const periodCls = deltaClass(ac.period_delta_usdt);
const periodText = monitored ? fmtDelta(ac.period_delta_usdt) : "—";
return (
'<button type="button" class="funds-ac-card' +
offCls +
'" data-key="' +
esc(ac.key || "") +
'"' +
clickable +
' title="' +
esc(monitored ? "点击查看 " + name + " 资金曲线" : "未监控,不参与合计") +
'">' +
'<div class="funds-ac-head">' +
'<h3 class="funds-ac-name">' +
esc(name) +
"</h3>" +
'<span class="funds-ac-badge ' +
st.cls +
'">' +
st.text +
"</span>" +
"</div>" +
'<div class="funds-ac-total">' +
'<span class="k">总资金</span>' +
'<span class="v">' +
total +
"</span>" +
"</div>" +
'<div class="funds-ac-stats">' +
'<div><span class="k">资金户</span><span class="v">' +
funding +
"</span></div>" +
'<div><span class="k">交易户</span><span class="v">' +
trading +
"</span></div>" +
optLine +
'<div><span class="k">累计盈亏</span><span class="v ' +
periodCls +
'">' +
periodText +
"</span></div>" +
'<div><span class="k">较昨日</span><span class="v ' +
deltaCls +
'">' +
deltaText +
"</span></div>" +
'<div><span class="k">最大回撤</span><span class="v">' +
ddU +
" / " +
ddPct +
"</span></div>" +
"</div>" +
(monitored
? '<div class="funds-ac-foot">点击查看资金曲线</div>'
: "") +
"</button>"
);
})
.join("");
elAccounts.querySelectorAll(".funds-ac-card:not(.is-off)").forEach(function (btn) {
btn.addEventListener("click", function () {
openAccountFullscreen(btn.getAttribute("data-key"));
});
});
}
function findAccount(key) {
const accounts = (lastOverview && lastOverview.accounts) || [];
return accounts.find(function (ac) {
return String(ac.key || "") === String(key || "");
});
}
function closeAccountFullscreen() {
fsAccountKey = "";
destroyFsChart();
if (elFs) {
elFs.classList.add("hidden");
elFs.setAttribute("aria-hidden", "true");
}
document.body.classList.remove("funds-fullscreen-open");
}
function openAccountFullscreen(key) {
const ac = findAccount(key);
if (!ac || !ac.monitored) return;
fsAccountKey = String(key || "");
const dd = ac.drawdown || {};
const meta = lastOverview || {};
if (elFsTitle) elFsTitle.textContent = ac.name || ac.key || "—";
if (elFsSub) {
const parts = [
"资金户 + 交易户 + 期权户(USDC≈USDT,不含浮盈)",
"交易日 " + (meta.trading_day || "—"),
"自 " + (meta.history_start_day || "—") + " 起",
];
elFsSub.textContent = parts.join(" · ");
}
if (elFsTotal) {
elFsTotal.textContent =
ac.data_ok && ac.total_usdt != null ? fmt(ac.total_usdt, 2) + " U" : "—";
}
if (elFsFunding) {
elFsFunding.textContent =
ac.funding_usdt != null ? fmt(ac.funding_usdt, 2) + " U" : "—";
}
if (elFsTrading) {
elFsTrading.textContent =
ac.trading_usdt != null ? fmt(ac.trading_usdt, 2) + " U" : "—";
}
if (elFsDelta) {
elFsDelta.textContent = fmtDelta(ac.day_delta_usdt);
elFsDelta.className = "v " + deltaClass(ac.day_delta_usdt);
}
if (elFsDd) {
const ddU = dd.max_drawdown_u != null ? fmt(dd.max_drawdown_u, 2) + " U" : "—";
const ddPct = dd.max_drawdown_pct != null ? fmt(dd.max_drawdown_pct, 2) + "%" : "—";
elFsDd.textContent = ddU + " / " + ddPct;
}
if (elFs) {
elFs.classList.remove("hidden");
elFs.setAttribute("aria-hidden", "false");
document.body.classList.add("funds-fullscreen-open");
}
destroyFsChart();
const pts = seriesToChartData(ac.series || []);
if (pts.length) {
ensureFsChart();
if (fsLineSeries) {
fsLineSeries.setData(pts);
fsChart.timeScale().fitContent();
}
requestAnimationFrame(function () {
if (fsChart && elFsChartHost) {
fsChart.applyOptions({
width: elFsChartHost.clientWidth,
height: elFsChartHost.clientHeight,
});
fsChart.timeScale().fitContent();
}
});
} else if (elFsChartHost) {
elFsChartHost.innerHTML =
'<p class="funds-empty">暂无历史曲线,请保持监控板运行以积累快照</p>';
}
}
function renderDesc(data) {
const start = (data && data.history_start_day) || "—";
const keep = (data && data.keep_days) || 180;
const hour = data && data.reset_hour != null ? data.reset_hour : 8;
if (elDescBody) {
elDescBody.textContent =
"总资金 = 各监控户(永续资金账户 + 交易账户 + 期权账户,USDC 按 1:1 计入 USDT);自 " +
start +
" 起按北京时间 " +
hour +
":00 交易日切日快照,最多保留 " +
keep +
" 天.起算日由环境变量 HUB_FUND_HISTORY_START_DAY 配置.";
}
if (elChartSub) {
elChartSub.textContent = keep + " TRADING DAYS";
}
}
function renderOverview(data) {
lastOverview = data;
renderDesc(data);
const totals = data.totals || {};
const dd = totals.drawdown || {};
if (elTotal) {
elTotal.textContent =
totals.total_usdt != null ? fmt(totals.total_usdt, 2) + " U" : "—";
}
if (elDdU) elDdU.textContent = dd.max_drawdown_u != null ? fmt(dd.max_drawdown_u, 2) + " U" : "—";
if (elDdPct) {
elDdPct.textContent = dd.max_drawdown_pct != null ? fmt(dd.max_drawdown_pct, 2) + "%" : "—";
}
if (elDelta) {
elDelta.textContent = fmtDelta(totals.day_delta_usdt);
elDelta.className = "funds-stat-val " + deltaClass(totals.day_delta_usdt);
}
const periodCls = deltaClass(totals.period_delta_usdt);
if (elPeriod) {
elPeriod.textContent = fmtDelta(totals.period_delta_usdt);
elPeriod.className = "funds-stat-val " + periodCls;
}
if (elPeriodSub) {
const startDay = data.history_start_day || "—";
const pct = fmtPct(totals.period_delta_pct);
elPeriodSub.textContent =
pct !== "—"
? "自 " + startDay + " · " + pct
: "自 " + startDay + " 起相对起点";
}
if (elPeriodBanner) {
elPeriodBanner.textContent = fmtDelta(totals.period_delta_usdt);
elPeriodBanner.className = "funds-pnl-value " + periodCls;
}
if (elPeriodPct) {
elPeriodPct.textContent = fmtPct(totals.period_delta_pct);
elPeriodPct.className = "funds-pnl-pct " + periodCls;
}
if (elDayChip) {
elDayChip.textContent = fmtDelta(totals.day_delta_usdt);
elDayChip.className = "funds-pnl-side-val " + deltaClass(totals.day_delta_usdt);
}
if (elPnlBanner) {
elPnlBanner.className =
"funds-pnl-banner" + (periodCls ? " is-" + periodCls : "");
}
if (elMeta) {
const parts = [
"交易日 " + (data.trading_day || "—"),
"切日 " + (data.reset_hour != null ? data.reset_hour : 8) + ":00 北京",
"自 " + (data.history_start_day || "—") + " 起",
"最多 " + (data.keep_days || 180) + " 交易日",
];
if (data.updated_at) parts.push("刷新 " + data.updated_at);
if (totals.live_known_count != null) {
parts.push("合计含 " + totals.live_known_count + " 户");
}
elMeta.textContent = parts.join(" · ");
}
ensureChart();
if (lineSeries) {
const pts = seriesToChartData(totals.series || []);
if (pts.length) {
lineSeries.setData(pts);
chart.timeScale().fitContent();
} else {
lineSeries.setData([]);
}
}
renderAccounts(data.accounts || []);
// 分户卡片渲染后高度会变,补一次尺寸,避免 1080p 一屏布局下曲线被裁切
if (chart && elChartHost) {
requestAnimationFrame(function () {
if (!chart || !elChartHost) return;
chart.applyOptions({
width: Math.max(elChartHost.clientWidth || 0, 1),
height: Math.max(elChartHost.clientHeight || 0, 200),
});
chart.timeScale().fitContent();
});
}
if (fsAccountKey) {
const ac = findAccount(fsAccountKey);
if (ac && ac.monitored) openAccountFullscreen(fsAccountKey);
else closeAccountFullscreen();
}
}
async function load() {
if (loading) return;
loading = true;
setStatus("加载中…");
try {
const r = await fetch("/api/hub/fund-overview", { credentials: "same-origin" });
const j = await r.json();
if (!r.ok) {
setStatus(j.detail || j.msg || "加载失败", true);
return;
}
renderOverview(j);
setStatus("");
} catch (e) {
setStatus(String(e.message || e), true);
} finally {
loading = false;
}
}
function bind() {
if (elBtnRefresh) elBtnRefresh.addEventListener("click", load);
if (elFsBackdrop) elFsBackdrop.addEventListener("click", closeAccountFullscreen);
if (elFsClose) elFsClose.addEventListener("click", closeAccountFullscreen);
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape" && fsAccountKey) closeAccountFullscreen();
});
document.addEventListener("hub-theme-change", function () {
destroyChart();
destroyFsChart();
load();
});
}
function init() {
if (!page || page.classList.contains("hidden")) return;
if (!inited) {
bind();
inited = true;
}
load();
}
function destroy() {
closeAccountFullscreen();
destroyChart();
}
window.hubFundsPage = { init: init, destroy: destroy, reload: load };
})();
+125
View File
@@ -0,0 +1,125 @@
/**
* 使用说明:中控 docs/help MD 章节.
*/
(function () {
const page = document.getElementById("page-help");
if (!page) return;
const tocEl = document.getElementById("help-toc-nav");
const statusEl = document.getElementById("help-load-status");
const docBody = document.getElementById("help-doc-body");
const docSource = document.getElementById("help-doc-source");
let sectionsMeta = [];
let activeKey = "quickstart";
let cache = {};
let bound = false;
async function apiFetch(url) {
const r = await fetch(url, { credentials: "same-origin" });
const data = await r.json();
if (!r.ok || !data.ok) throw new Error((data && data.msg) || r.statusText || "请求失败");
return data;
}
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function sectionFromHash() {
const h = (window.location.hash || "").replace(/^#/, "").trim().toLowerCase();
if (!h) return null;
return sectionsMeta.some((s) => s.key === h) ? h : null;
}
function setHash(key) {
const next = `#${key}`;
if (window.location.hash !== next) {
history.replaceState(null, "", `/help${next}`);
}
}
function renderToc() {
if (!tocEl) return;
tocEl.innerHTML = sectionsMeta
.map(
(s) =>
`<button type="button" class="help-toc-item${s.key === activeKey ? " is-active" : ""}" data-key="${esc(s.key)}">${esc(s.label)}</button>`
)
.join("");
tocEl.querySelectorAll(".help-toc-item").forEach((btn) => {
btn.addEventListener("click", () => {
const key = btn.getAttribute("data-key");
if (!key || key === activeKey) return;
activeKey = key;
setHash(key);
renderToc();
void loadSection(key);
});
});
}
function renderSection(data) {
if (docBody) docBody.innerHTML = data.content_html || "";
if (docSource) {
docSource.textContent = data.md_source ? `来源: ${data.md_source}` : "";
}
if (statusEl) statusEl.textContent = "";
}
async function loadSection(key) {
if (cache[key]) {
renderSection(cache[key]);
return;
}
if (statusEl) statusEl.textContent = "加载中…";
try {
const data = await apiFetch(`/api/help/${encodeURIComponent(key)}`);
cache[key] = data;
renderSection(data);
} catch (err) {
if (statusEl) statusEl.textContent = "";
if (docBody) docBody.innerHTML = `<p class="muted">${esc(err.message || "加载失败")}</p>`;
}
}
async function loadMeta() {
const data = await apiFetch("/api/help/meta");
sectionsMeta = data.sections || [];
const fromHash = sectionFromHash();
if (fromHash) activeKey = fromHash;
else if (sectionsMeta.length && !sectionsMeta.some((s) => s.key === activeKey)) {
activeKey = sectionsMeta[0].key;
}
renderToc();
await loadSection(activeKey);
if (!sectionFromHash() && activeKey) setHash(activeKey);
}
function bindOnce() {
if (bound) return;
bound = true;
window.addEventListener("hashchange", () => {
const key = sectionFromHash();
if (!key || key === activeKey) return;
activeKey = key;
renderToc();
void loadSection(key);
});
}
window.hubHelpPage = {
init() {
bindOnce();
void loadMeta().catch((err) => {
if (statusEl) statusEl.textContent = "";
if (docBody) docBody.innerHTML = `<p class="muted">${esc(err.message || "加载失败")}</p>`;
});
},
destroy() {},
};
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#22d3ee"/>
<stop offset="100%" stop-color="#34d399"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="108" fill="#0c1019"/>
<rect x="36" y="36" width="440" height="440" rx="88" fill="#141b2d"/>
<rect x="36" y="36" width="440" height="440" rx="88" fill="none" stroke="url(#g)" stroke-width="12"/>
<path d="M120 320 L200 248 L280 272 L392 168" fill="none" stroke="url(#g)" stroke-width="20" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="392" cy="168" r="18" fill="#34d399"/>
<rect x="168" y="268" width="28" height="64" rx="6" fill="#f87171"/>
<line x1="182" y1="248" x2="182" y2="340" stroke="#f87171" stroke-width="10" stroke-linecap="round"/>
<rect x="268" y="220" width="28" height="96" rx="6" fill="#34d399"/>
<line x1="282" y1="200" x2="282" y2="340" stroke="#34d399" stroke-width="10" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,23 @@
{
"name": "复盘系统中控",
"short_name": "中控",
"description": "三所交易监控与行情中控",
"start_url": "/monitor",
"display": "standalone",
"background_color": "#0b0e18",
"theme_color": "#0b0e18",
"icons": [
{
"src": "/assets/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/assets/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
File diff suppressed because it is too large Load Diff
+165
View File
@@ -0,0 +1,165 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="utf-8" />
<script src="/assets/theme.js?v=20260604-hub-theme4"></script>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0b0e18" />
<meta name="apple-mobile-web-app-title" content="中控" />
<link rel="icon" href="/assets/icons/favicon.ico" sizes="32x32" />
<link rel="icon" href="/assets/icons/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/assets/icons/apple-touch-icon.png" />
<link rel="manifest" href="/assets/icons/manifest.webmanifest" />
<title>登录 · 复盘系统中控</title>
<link rel="stylesheet" href="/assets/app.css?v=20260604-hub-theme4" />
</head>
<body class="login-page">
<div class="login-bg" aria-hidden="true"></div>
<div class="login-theme-bar">
<div class="theme-toggle" role="group" aria-label="界面主题">
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/>
</svg>
</button>
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
</svg>
</button>
</div>
</div>
<div class="login-panel">
<div class="login-brand">
<span class="brand-mark"></span>
<div>
<div class="login-title">复盘系统中控</div>
<div class="login-sub">CRYPTO MONITOR · COMMAND</div>
</div>
</div>
<form id="login-form" class="login-form" autocomplete="off">
<label class="field">
<span>用户名</span>
<input type="text" name="username" id="login-username" required autocomplete="off" autocapitalize="off" spellcheck="false" />
</label>
<label class="field">
<span>密码</span>
<input type="password" name="password" id="login-password" required autocomplete="new-password" />
</label>
<button type="submit" class="primary login-submit" id="login-submit">进入系统</button>
<p id="login-err" class="login-err" hidden></p>
<p id="login-hint" class="login-foot" hidden></p>
</form>
</div>
<script>
(function () {
const form = document.getElementById("login-form");
const err = document.getElementById("login-err");
const hint = document.getElementById("login-hint");
const submitBtn = document.getElementById("login-submit");
const userInput = document.getElementById("login-username");
const params = new URLSearchParams(location.search);
const next = params.get("next") || "/monitor";
const inFrame = window.self !== window.top;
const isHttps = location.protocol === "https:";
if (inFrame) {
hint.hidden = false;
hint.textContent = isHttps
? "嵌入模式:登录成功后将自动写入会话."
: "嵌入模式需 HTTPS 中控;HTTP 时请用本地导航工具栏「中控登录」.";
}
function showErr(msg) {
err.textContent = msg;
err.hidden = false;
}
function gotoAfterLogin(token, dest) {
const target = dest.startsWith("/") ? dest : "/monitor";
if (inFrame) {
if (!token) {
showErr("登录响应缺少 session_token,请升级云端 hub 或使用本地导航「中控登录」.");
return;
}
if (!isHttps) {
showErr("跨站 iframe 登录需要 HTTPS 中控;请改用本地导航「中控登录」按钮.");
return;
}
const q = new URLSearchParams({ token, next: target });
const embedUrl = "/embed-auth?" + q.toString();
try {
window.parent.postMessage(
{
type: "hub:login-ok",
session_token: token,
next: target,
embed_auth_url: location.origin + embedUrl,
},
"*"
);
} catch (_) {}
submitBtn.textContent = "跳转中…";
submitBtn.disabled = true;
location.replace(embedUrl);
return;
}
location.href = target;
}
fetch("/api/auth/status")
.then((r) => r.json())
.then((s) => {
if (!s.required || s.logged_in) gotoAfterLogin(null, next);
})
.catch(() => {});
form.addEventListener("submit", async (e) => {
e.preventDefault();
err.hidden = true;
submitBtn.disabled = true;
const oldLabel = submitBtn.textContent;
submitBtn.textContent = "登录中…";
const username = userInput.value.trim();
const password = document.getElementById("login-password").value;
const headers = { "Content-Type": "application/json", Accept: "application/json" };
if (inFrame) headers["X-Hub-Embed"] = "1";
try {
const r = await fetch("/api/auth/login", {
method: "POST",
headers,
body: JSON.stringify({ username, password }),
});
let j = {};
try {
j = await r.json();
} catch (_) {
j = {};
}
if (r.ok && j.ok) {
gotoAfterLogin(j.session_token || null, next);
if (!inFrame) {
submitBtn.disabled = false;
submitBtn.textContent = oldLabel;
}
return;
}
if (r.status === 403) {
showErr("访问被拒绝(403):云端 hub 需设置 HUB_ALLOW_PUBLIC=true");
} else {
showErr(j.detail || j.msg || "用户名或密码错误 (" + r.status + ")");
}
} catch (ex) {
showErr("网络错误:" + ex);
}
submitBtn.disabled = false;
submitBtn.textContent = oldLabel;
});
})();
if (window.HubTheme && typeof HubTheme.initToggleUI === "function") {
HubTheme.initToggleUI();
}
</script>
</body>
</html>
+169
View File
@@ -0,0 +1,169 @@
/**
* 系统日志:三所 + 中控 PM2 stdout/stderr.
*/
(function () {
const page = document.getElementById("page-logs");
if (!page) return;
const tabsEl = document.getElementById("hub-logs-tabs");
const statusEl = document.getElementById("hub-logs-status");
const outEl = document.getElementById("hub-logs-out");
const errEl = document.getElementById("hub-logs-err");
const btnRefresh = document.getElementById("hub-logs-btn-refresh");
const btnPause = document.getElementById("hub-logs-btn-pause");
const POLL_MS = 4000;
let activeKey = "binance";
let tabsMeta = [];
let pollTimer = null;
let paused = false;
let loading = false;
let bound = false;
async function apiFetch(url) {
const r = await fetch(url, { credentials: "same-origin" });
const ct = (r.headers.get("content-type") || "").toLowerCase();
if (ct.includes("application/json")) {
const data = await r.json();
if (!r.ok) throw new Error((data && (data.msg || data.detail)) || r.statusText || "请求失败");
return data;
}
if (!r.ok) throw new Error(r.statusText || "请求失败");
return r;
}
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function wasScrolledToBottom(el) {
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight < 24;
}
function setPreText(el, text, stickBottom) {
if (!el) return;
const atBottom = stickBottom || wasScrolledToBottom(el);
el.textContent = text || "(暂无日志)";
if (atBottom) el.scrollTop = el.scrollHeight;
}
function setStatus(text, isErr) {
if (!statusEl) return;
statusEl.textContent = text || "";
statusEl.classList.toggle("is-err", !!isErr);
}
function renderTabs() {
if (!tabsEl) return;
tabsEl.innerHTML = tabsMeta
.map(
(t) =>
`<button type="button" class="hub-logs-tab${t.key === activeKey ? " is-active" : ""}" role="tab" aria-selected="${t.key === activeKey}" data-key="${esc(t.key)}">${esc(t.label)}</button>`
)
.join("");
tabsEl.querySelectorAll(".hub-logs-tab").forEach((btn) => {
btn.addEventListener("click", () => {
const key = btn.getAttribute("data-key");
if (!key || key === activeKey) return;
activeKey = key;
renderTabs();
void loadLogs(true);
});
});
}
async function loadMeta() {
const meta = await apiFetch("/api/system-logs/meta");
tabsMeta = Array.isArray(meta.targets) ? meta.targets : [];
if (tabsMeta.length && !tabsMeta.some((t) => t.key === activeKey)) {
activeKey = tabsMeta[0].key;
}
renderTabs();
}
async function loadLogs(force) {
if (loading && !force) return;
loading = true;
try {
const data = await apiFetch(`/api/system-logs/${encodeURIComponent(activeKey)}?lines=200`);
setPreText(outEl, data.out || "", true);
setPreText(errEl, data.err || "", true);
const ts = data.updated_at ? new Date(data.updated_at * 1000) : new Date();
const hh = String(ts.getHours()).padStart(2, "0");
const mm = String(ts.getMinutes()).padStart(2, "0");
const ss = String(ts.getSeconds()).padStart(2, "0");
const missing = [];
if (!data.out_exists) missing.push("实时");
if (!data.err_exists) missing.push("报错");
const hint = missing.length ? ` · ${missing.join("/")}日志文件暂无` : "";
setStatus(`已更新 ${hh}:${mm}:${ss}${hint}`, false);
} catch (e) {
setStatus(e.message || "加载失败", true);
} finally {
loading = false;
}
}
function startPoll() {
stopPoll();
if (paused) return;
pollTimer = window.setInterval(() => {
void loadLogs(false);
}, POLL_MS);
}
function stopPoll() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function bindControls() {
if (bound) return;
bound = true;
if (btnRefresh) {
btnRefresh.addEventListener("click", () => {
void loadLogs(true);
});
}
if (btnPause) {
btnPause.addEventListener("click", () => {
paused = !paused;
btnPause.textContent = paused ? "继续刷新" : "暂停刷新";
btnPause.classList.toggle("is-paused", paused);
if (paused) stopPoll();
else startPoll();
});
}
}
async function init() {
bindControls();
paused = false;
if (btnPause) {
btnPause.textContent = "暂停刷新";
btnPause.classList.remove("is-paused");
}
setStatus("加载中…", false);
try {
await loadMeta();
await loadLogs(true);
startPoll();
} catch (e) {
setStatus(e.message || "初始化失败", true);
}
}
function destroy() {
stopPoll();
setStatus("", false);
}
window.hubLogsPage = { init, destroy };
})();
+772
View File
@@ -0,0 +1,772 @@
/**
* 开仓计划:新建 / 进行中 / 历史 / 胜率统计
*/
(function () {
const page = document.getElementById("page-plan");
if (!page) return;
let meta = null;
let activePlans = [];
let archivedPlans = [];
let statsPeriod = "all";
let statsDim = "symbol";
let statsDateFrom = "";
let statsDateTo = "";
let editingPlanId = null;
let inited = false;
function $(id) {
return document.getElementById(id);
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function toast(msg, isErr) {
const el = $("toast");
if (!el) return;
el.textContent = msg;
el.className = isErr ? "err" : "ok";
clearTimeout(el._t);
el._t = setTimeout(function () {
el.className = "";
el.textContent = "";
}, 3200);
}
async function api(path, opts) {
const r = await fetch(path, Object.assign({ credentials: "same-origin" }, opts || {}));
let data = {};
try {
data = await r.json();
} catch (_e) {
data = {};
}
if (!r.ok) {
const detail = (data && data.detail) || r.statusText || "请求失败";
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
}
return data;
}
function todayIso() {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return y + "-" + m + "-" + day;
}
function exchangeLabel(key) {
const ex = (meta && meta.exchanges) || [];
const row = ex.find(function (e) {
return String(e.key) === String(key);
});
return (row && row.name) || key || "—";
}
function fmtPnl(v) {
if (v == null || v === "") return "";
const n = Number(v);
if (!Number.isFinite(n)) return String(v);
return (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
}
function fillSelect(el, options, valueKey, labelKey) {
if (!el) return;
el.innerHTML = "";
(options || []).forEach(function (opt) {
const o = document.createElement("option");
if (typeof opt === "string") {
o.value = opt;
o.textContent = opt;
} else {
o.value = opt[valueKey];
o.textContent = opt[labelKey];
}
el.appendChild(o);
});
}
function renderDirectionRadios(container, name, selected) {
if (!container || !meta) return;
container.innerHTML = "";
(meta.directions || []).forEach(function (d) {
const label = document.createElement("label");
label.className = "plan-radio-label";
const input = document.createElement("input");
input.type = "radio";
input.name = name;
input.value = d.value;
if (d.value === selected) input.checked = true;
label.appendChild(input);
label.appendChild(document.createTextNode(" " + d.label));
container.appendChild(label);
});
}
function bindMetaToCreateForm() {
fillSelect($("plan-create-exchange"), meta.exchanges, "key", "name");
fillSelect($("plan-create-type"), meta.plan_types, "value", "label");
fillSelect($("plan-create-trend-tf"), meta.trend_timeframes);
fillSelect($("plan-create-entry-tf"), meta.entry_timeframes);
renderDirectionRadios($("plan-create-direction"), "plan-direction", "long");
const dateEl = $("plan-create-date");
if (dateEl && !dateEl.value) dateEl.value = todayIso();
}
function planSummaryLine(p) {
return (
esc(p.symbol) +
" · " +
esc(exchangeLabel(p.exchange_key)) +
" · " +
esc(p.direction_label || p.direction) +
" · " +
esc(p.plan_type_label || p.plan_type)
);
}
function schemeOptionsHtml(selected) {
let html = '<option value="">请选择</option>';
(meta.entry_schemes || []).forEach(function (s) {
html +=
'<option value="' +
esc(s.value) +
'"' +
(selected === s.value ? " selected" : "") +
">" +
esc(s.label) +
"</option>";
});
return html;
}
function renderActiveList() {
const host = $("plan-active-list");
const cnt = $("plan-active-count");
if (!host) return;
if (cnt) cnt.textContent = activePlans.length ? activePlans.length + " 条" : "";
if (!activePlans.length) {
host.innerHTML = '<p class="plan-empty">暂无进行中的计划</p>';
return;
}
host.innerHTML = activePlans
.map(function (p) {
return (
'<article class="plan-active-card" data-id="' +
esc(p.id) +
'">' +
'<div class="plan-active-head">' +
'<div class="plan-active-title">' +
planSummaryLine(p) +
"</div>" +
'<div class="plan-active-actions">' +
'<button type="button" class="ghost plan-btn-edit" data-id="' +
esc(p.id) +
'">修改</button>' +
'<button type="button" class="ghost plan-btn-del" data-id="' +
esc(p.id) +
'">删除</button>' +
"</div></div>" +
'<div class="plan-active-meta">' +
esc(p.plan_date) +
" · 趋势 " +
esc(p.trend_timeframe) +
" / 入场 " +
esc(p.entry_timeframe) +
"</div>" +
'<div class="plan-active-levels">目标 ' +
esc(p.target_level || "—") +
" · 区间 " +
esc(p.current_range || "—") +
"</div>" +
(p.note ? '<div class="plan-active-note">' + esc(p.note) + "</div>" : "") +
'<div class="plan-scheme-row">' +
'<label class="plan-field plan-field-inline plan-field-scheme"><span>入场方案</span>' +
'<select class="plan-active-scheme" data-id="' +
esc(p.id) +
'">' +
schemeOptionsHtml(p.entry_scheme || "") +
"</select></label>" +
"</div>" +
'<div class="plan-close-row">' +
'<label class="plan-field plan-field-inline"><span>结果</span>' +
'<select class="plan-close-result" data-id="' +
esc(p.id) +
'"><option value="">—</option>' +
(meta.results || [])
.map(function (r) {
return (
'<option value="' +
esc(r.value) +
'"' +
(p.result === r.value ? " selected" : "") +
">" +
esc(r.label) +
"</option>"
);
})
.join("") +
"</select></label>" +
'<label class="plan-field plan-field-inline"><span>盈亏</span>' +
'<input class="plan-close-pnl" data-id="' +
esc(p.id) +
'" type="number" step="any" placeholder="U(可选)" value="' +
(p.pnl_amount != null ? esc(p.pnl_amount) : "") +
'" /></label>' +
'<button type="button" class="primary plan-btn-archive" data-id="' +
esc(p.id) +
'">填写结果并归档</button>' +
"</div></article>"
);
})
.join("");
}
function renderHistoryList() {
const host = $("plan-history-list");
const cnt = $("plan-history-count");
if (!host) return;
if (cnt) cnt.textContent = archivedPlans.length ? archivedPlans.length + " 条" : "";
if (!archivedPlans.length) {
host.innerHTML = '<p class="plan-empty">暂无历史计划</p>';
return;
}
host.innerHTML = archivedPlans
.map(function (p) {
const pnlTxt = fmtPnl(p.pnl_amount);
const resCls = p.result === "win" ? "plan-res-win" : "plan-res-loss";
return (
'<button type="button" class="plan-history-row" data-id="' +
esc(p.id) +
'">' +
'<span class="plan-history-date">' +
esc(p.plan_date) +
"</span>" +
'<span class="plan-history-main">' +
esc(p.symbol) +
" · " +
esc(exchangeLabel(p.exchange_key)) +
"</span>" +
'<span class="plan-history-scheme">' +
esc(p.entry_scheme_label || p.entry_scheme) +
"</span>" +
'<span class="plan-history-result ' +
resCls +
'">' +
esc(p.result_label || p.result) +
(pnlTxt ? " " + esc(pnlTxt) : "") +
"</span></button>"
);
})
.join("");
}
function renderStatsTable(stats) {
const host = $("plan-stats-table");
const labelEl = $("plan-stats-label");
if (labelEl) labelEl.textContent = (stats && stats.period_label) || "";
if (!host) return;
const items = (stats && stats.items) || [];
if (!items.length) {
host.innerHTML = '<p class="plan-empty">该范围内暂无已归档且有结果的计划</p>';
return;
}
const dimLabel =
stats.dimension === "trend_tf"
? "趋势周期"
: stats.dimension === "entry_scheme"
? "入场方案"
: "币种";
let rows = items
.map(function (it) {
return (
"<tr><td>" +
esc(it.label || it.key) +
"</td><td>" +
(it.total || 0) +
"</td><td>" +
(it.win_count || 0) +
"</td><td>" +
(it.loss_count || 0) +
"</td><td>" +
(it.win_rate != null ? it.win_rate + "%" : "—") +
"</td></tr>"
);
})
.join("");
host.innerHTML =
'<table class="plan-stats-table"><thead><tr>' +
"<th>" +
esc(dimLabel) +
"</th><th>计划数</th><th>盈利</th><th>亏损</th><th>胜率</th>" +
"</tr></thead><tbody>" +
rows +
"</tbody></table>";
}
function statsQuery() {
const q = new URLSearchParams();
q.set("dimension", statsDim);
q.set("period", statsPeriod);
if (statsPeriod === "range") {
if (statsDateFrom) q.set("date_from", statsDateFrom);
if (statsDateTo) q.set("date_to", statsDateTo);
}
return q.toString();
}
async function loadMeta() {
const data = await api("/api/entry-plans/meta");
meta = data;
bindMetaToCreateForm();
}
async function loadActive() {
const data = await api("/api/entry-plans?status=active");
activePlans = data.plans || [];
renderActiveList();
}
async function loadHistory() {
const data = await api("/api/entry-plans?status=archived");
archivedPlans = data.plans || [];
renderHistoryList();
}
async function loadStats() {
const data = await api("/api/entry-plans/stats?" + statsQuery());
renderStatsTable(data.stats || {});
}
async function refreshAll() {
await Promise.all([loadActive(), loadHistory(), loadStats()]);
}
function fmtRefreshTime() {
const d = new Date();
const h = String(d.getHours()).padStart(2, "0");
const m = String(d.getMinutes()).padStart(2, "0");
const s = String(d.getSeconds()).padStart(2, "0");
return h + ":" + m + ":" + s;
}
async function refreshPage() {
const btn = $("plan-btn-refresh");
const status = $("plan-refresh-status");
if (btn) btn.disabled = true;
if (status) status.textContent = "刷新中…";
try {
await loadMeta();
await refreshAll();
if (status) status.textContent = "已刷新 " + fmtRefreshTime();
} catch (e) {
toast(e.message || "刷新失败", true);
if (status) status.textContent = "刷新失败";
} finally {
if (btn) btn.disabled = false;
}
}
function readCreateForm() {
const dir = document.querySelector('input[name="plan-direction"]:checked');
return {
plan_date: ($("plan-create-date") && $("plan-create-date").value) || "",
exchange_key: ($("plan-create-exchange") && $("plan-create-exchange").value) || "",
symbol: ($("plan-create-symbol") && $("plan-create-symbol").value) || "",
plan_type: ($("plan-create-type") && $("plan-create-type").value) || "",
trend_timeframe: ($("plan-create-trend-tf") && $("plan-create-trend-tf").value) || "",
entry_timeframe: ($("plan-create-entry-tf") && $("plan-create-entry-tf").value) || "",
direction: (dir && dir.value) || "",
target_level: ($("plan-create-target") && $("plan-create-target").value) || "",
current_range: ($("plan-create-range") && $("plan-create-range").value) || "",
note: ($("plan-create-note") && $("plan-create-note").value) || "",
};
}
function resetCreateForm() {
const form = $("plan-create-form");
if (form) form.reset();
bindMetaToCreateForm();
if ($("plan-create-date")) $("plan-create-date").value = todayIso();
}
function openDetailModal(plan) {
const modal = $("plan-detail-modal");
const body = $("plan-detail-body");
const title = $("plan-detail-title");
if (!modal || !body || !plan) return;
if (title) title.textContent = plan.symbol + " · " + (plan.result_label || "计划");
const rows = [
["日期", plan.plan_date],
["交易所", exchangeLabel(plan.exchange_key)],
["币种", plan.symbol],
["类型", plan.plan_type_label],
["趋势周期", plan.trend_timeframe],
["入场周期", plan.entry_timeframe],
["方向", plan.direction_label],
["目标位", plan.target_level || "—"],
["当前区间", plan.current_range || "—"],
["入场方案", plan.entry_scheme_label],
["结果", plan.result_label || "—"],
["盈亏", fmtPnl(plan.pnl_amount) || "—"],
["备注", plan.note || "—"],
];
body.innerHTML = rows
.map(function (pair) {
return (
'<div class="plan-detail-row"><span class="plan-detail-k">' +
esc(pair[0]) +
'</span><span class="plan-detail-v">' +
esc(pair[1]) +
"</span></div>"
);
})
.join("");
modal.classList.remove("hidden");
modal.setAttribute("aria-hidden", "false");
}
function closeDetailModal() {
const modal = $("plan-detail-modal");
if (!modal) return;
modal.classList.add("hidden");
modal.setAttribute("aria-hidden", "true");
}
function buildEditFormHtml(p) {
const dirs = (meta.directions || [])
.map(function (d) {
return (
'<label class="plan-radio-label"><input type="radio" name="edit-direction" value="' +
esc(d.value) +
'"' +
(p.direction === d.value ? " checked" : "") +
" /> " +
esc(d.label) +
"</label>"
);
})
.join("");
function opts(list, key, valKey, labelKey) {
return (list || [])
.map(function (o) {
const v = typeof o === "string" ? o : o[valKey];
const lbl = typeof o === "string" ? o : o[labelKey];
return (
'<option value="' +
esc(v) +
'"' +
(String(p[key]) === String(v) ? " selected" : "") +
">" +
esc(lbl) +
"</option>"
);
})
.join("");
}
return (
'<div class="plan-form-grid">' +
'<label class="plan-field"><span>日期</span><input name="plan_date" type="date" value="' +
esc(p.plan_date) +
'" required /></label>' +
'<label class="plan-field"><span>交易所</span><select name="exchange_key" required>' +
opts(meta.exchanges, "exchange_key", "key", "name") +
"</select></label>" +
'<label class="plan-field"><span>币种</span><input name="symbol" type="text" value="' +
esc(p.symbol) +
'" required /></label>' +
'<label class="plan-field"><span>类型</span><select name="plan_type" required>' +
opts(meta.plan_types, "plan_type", "value", "label") +
"</select></label>" +
'<label class="plan-field"><span>趋势周期</span><select name="trend_timeframe" required>' +
opts(meta.trend_timeframes, "trend_timeframe") +
"</select></label>" +
'<label class="plan-field"><span>入场周期</span><select name="entry_timeframe" required>' +
opts(meta.entry_timeframes, "entry_timeframe") +
"</select></label>" +
'<label class="plan-field plan-field-full"><span>方向</span><span class="plan-radio-row">' +
dirs +
"</span></label>" +
'<label class="plan-field"><span>目标位</span><input name="target_level" type="text" value="' +
esc(p.target_level || "") +
'" /></label>' +
'<label class="plan-field"><span>当前区间</span><input name="current_range" type="text" value="' +
esc(p.current_range || "") +
'" /></label>' +
'<label class="plan-field plan-field-full"><span>入场方案</span><select name="entry_scheme" required>' +
opts(meta.entry_schemes, "entry_scheme", "value", "label") +
"</select></label>" +
'<label class="plan-field plan-field-full"><span>备注</span><textarea name="note" rows="2">' +
esc(p.note || "") +
"</textarea></label>" +
"</div>" +
'<div class="modal-actions"><button type="button" class="ghost" data-plan-edit-close>取消</button>' +
'<button type="submit" class="primary">保存修改</button></div>'
);
}
function openEditModal(plan) {
const modal = $("plan-edit-modal");
const form = $("plan-edit-form");
if (!modal || !form || !plan) return;
editingPlanId = plan.id;
form.innerHTML = buildEditFormHtml(plan);
modal.classList.remove("hidden");
modal.setAttribute("aria-hidden", "false");
}
function closeEditModal() {
const modal = $("plan-edit-modal");
if (!modal) return;
editingPlanId = null;
modal.classList.add("hidden");
modal.setAttribute("aria-hidden", "true");
}
function readEditForm(form) {
const fd = new FormData(form);
const dir = form.querySelector('input[name="edit-direction"]:checked');
return {
plan_date: fd.get("plan_date") || "",
exchange_key: fd.get("exchange_key") || "",
symbol: fd.get("symbol") || "",
plan_type: fd.get("plan_type") || "",
trend_timeframe: fd.get("trend_timeframe") || "",
entry_timeframe: fd.get("entry_timeframe") || "",
direction: (dir && dir.value) || "",
target_level: fd.get("target_level") || "",
current_range: fd.get("current_range") || "",
entry_scheme: fd.get("entry_scheme") || "",
note: fd.get("note") || "",
};
}
function bindEvents() {
const refreshBtn = $("plan-btn-refresh");
if (refreshBtn) {
refreshBtn.addEventListener("click", function () {
void refreshPage();
});
}
const createForm = $("plan-create-form");
if (createForm) {
createForm.addEventListener("submit", function (ev) {
ev.preventDefault();
api("/api/entry-plans", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(readCreateForm()),
})
.then(function () {
toast("计划已加入进行中");
resetCreateForm();
return refreshAll();
})
.catch(function (e) {
toast(e.message || "保存失败", true);
});
});
}
const activeList = $("plan-active-list");
if (activeList) {
activeList.addEventListener("click", function (ev) {
const t = ev.target;
if (!(t instanceof HTMLElement)) return;
const id = t.getAttribute("data-id");
if (!id) return;
if (t.classList.contains("plan-btn-del")) {
if (!window.confirm("确定删除该进行中的计划?")) return;
api("/api/entry-plans/" + id, { method: "DELETE" })
.then(function () {
toast("已删除");
return refreshAll();
})
.catch(function (e) {
toast(e.message || "删除失败", true);
});
return;
}
if (t.classList.contains("plan-btn-edit")) {
const plan = activePlans.find(function (p) {
return String(p.id) === String(id);
});
if (plan) openEditModal(plan);
return;
}
if (t.classList.contains("plan-btn-archive")) {
const card = t.closest(".plan-active-card");
const resultEl = card && card.querySelector('.plan-close-result[data-id="' + id + '"]');
const pnlEl = card && card.querySelector('.plan-close-pnl[data-id="' + id + '"]');
const schemeEl = card && card.querySelector('.plan-active-scheme[data-id="' + id + '"]');
const result = resultEl && resultEl.value;
if (!result) {
toast("请先选择结果(盈/亏)", true);
return;
}
const scheme = schemeEl && schemeEl.value;
if (!scheme) {
toast("请先选择入场方案(根据实际进场填写)", true);
return;
}
const payload = { result: result, entry_scheme: scheme };
const pnlRaw = pnlEl && pnlEl.value;
if (pnlRaw !== "" && pnlRaw != null) payload.pnl_amount = Number(pnlRaw);
api("/api/entry-plans/" + id, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
.then(function () {
toast("已归档");
return refreshAll();
})
.catch(function (e) {
toast(e.message || "归档失败", true);
});
}
});
activeList.addEventListener("change", function (ev) {
const t = ev.target;
if (!(t instanceof HTMLElement) || !t.classList.contains("plan-active-scheme")) return;
const id = t.getAttribute("data-id");
const scheme = t.value;
if (!id || !scheme) return;
api("/api/entry-plans/" + id, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entry_scheme: scheme }),
})
.then(function () {
toast("入场方案已保存");
return loadActive();
})
.catch(function (e) {
toast(e.message || "保存失败", true);
});
});
}
const historyList = $("plan-history-list");
if (historyList) {
historyList.addEventListener("click", function (ev) {
const row = ev.target.closest(".plan-history-row");
if (!row) return;
const id = row.getAttribute("data-id");
const plan = archivedPlans.find(function (p) {
return String(p.id) === String(id);
});
if (plan) openDetailModal(plan);
else {
api("/api/entry-plans/" + id).then(function (data) {
openDetailModal(data.plan);
});
}
});
}
document.querySelectorAll("[data-plan-modal-close]").forEach(function (el) {
el.addEventListener("click", closeDetailModal);
});
document.querySelectorAll("[data-plan-edit-close]").forEach(function (el) {
el.addEventListener("click", closeEditModal);
});
const editForm = $("plan-edit-form");
if (editForm) {
editForm.addEventListener("submit", function (ev) {
ev.preventDefault();
if (!editingPlanId) return;
api("/api/entry-plans/" + editingPlanId, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(readEditForm(editForm)),
})
.then(function () {
toast("已保存");
closeEditModal();
return refreshAll();
})
.catch(function (e) {
toast(e.message || "保存失败", true);
});
});
}
const periodTabs = $("plan-stats-period-tabs");
if (periodTabs) {
periodTabs.addEventListener("click", function (ev) {
const btn = ev.target.closest(".plan-period-btn");
if (!btn) return;
statsPeriod = btn.getAttribute("data-period") || "all";
periodTabs.querySelectorAll(".plan-period-btn").forEach(function (b) {
b.classList.toggle("is-active", b === btn);
});
const rangeWrap = $("plan-stats-range-wrap");
if (rangeWrap) rangeWrap.classList.toggle("hidden", statsPeriod !== "range");
loadStats().catch(function (e) {
toast(e.message || "统计加载失败", true);
});
});
}
const dimTabs = $("plan-stats-dim-tabs");
if (dimTabs) {
dimTabs.addEventListener("click", function (ev) {
const btn = ev.target.closest(".plan-dim-btn");
if (!btn) return;
statsDim = btn.getAttribute("data-dim") || "symbol";
dimTabs.querySelectorAll(".plan-dim-btn").forEach(function (b) {
b.classList.toggle("is-active", b === btn);
});
loadStats().catch(function (e) {
toast(e.message || "统计加载失败", true);
});
});
}
["plan-stats-date-from", "plan-stats-date-to"].forEach(function (id) {
const el = $(id);
if (!el) return;
el.addEventListener("change", function () {
statsDateFrom = ($("plan-stats-date-from") && $("plan-stats-date-from").value) || "";
statsDateTo = ($("plan-stats-date-to") && $("plan-stats-date-to").value) || "";
if (statsPeriod === "range") {
loadStats().catch(function (e) {
toast(e.message || "统计加载失败", true);
});
}
});
});
}
async function init() {
if (inited) {
await refreshPage();
return;
}
inited = true;
bindEvents();
try {
await loadMeta();
await refreshAll();
const status = $("plan-refresh-status");
if (status) status.textContent = "已刷新 " + fmtRefreshTime();
} catch (e) {
toast(e.message || "加载失败", true);
}
}
function destroy() {}
window.hubPlanPage = { init: init, refresh: refreshPage, destroy: destroy };
})();
+284
View File
@@ -0,0 +1,284 @@
/**
* 语录博客流:按交易日分组 · 截断展开 · 当日盈亏摘要 · AI 复盘跳转.
*/
(function () {
const page = document.getElementById("page-quotes");
if (!page) return;
const elFeed = document.getElementById("quotes-feed");
const elStatus = document.getElementById("quotes-status");
const elBtnRefresh = document.getElementById("quotes-btn-refresh");
const elLinkArchive = document.getElementById("quotes-link-archive");
const RECENT_LIMIT = 20;
const PREVIEW_LEN = 140;
const ARCHIVE_QUOTE_AI_KEY = "hub_archive_quote_ai";
let quotes = [];
let dayStats = {};
let expanded = {};
let inited = false;
let loading = false;
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function apiFetch(url, opts) {
return fetch(url, Object.assign({ credentials: "same-origin" }, opts || {}));
}
function setStatus(text) {
if (elStatus) elStatus.textContent = text || "";
}
function findQuote(id) {
return (
quotes.find(function (q) {
return String(q.id) === String(id);
}) || null
);
}
function fmtPnl(v) {
const n = Number(v);
if (!Number.isFinite(n)) return "—";
return (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
}
function pnlClass(v) {
const n = Number(v);
if (!Number.isFinite(n) || n === 0) return "";
return n > 0 ? "pnl-pos" : "pnl-neg";
}
function fmtWinRate(v) {
const n = Number(v);
if (!Number.isFinite(n)) return "—";
return n.toFixed(1) + "%";
}
function daySummaryHtml(day, st) {
if (!st) {
return '<span class="quotes-day-summary muted">当日统计加载中…</span>';
}
const openN = Number(st.open_count) || 0;
const pnl = st.pnl_total;
return (
'<span class="quotes-day-summary">' +
openN +
" 笔 · 盈亏 <span class=\"" +
pnlClass(pnl) +
'">' +
esc(fmtPnl(pnl)) +
"</span> · 胜率 " +
esc(fmtWinRate(st.win_rate)) +
"</span>"
);
}
function previewText(raw) {
const text = String(raw || "").trim();
if (text.length <= PREVIEW_LEN) return { text: text, truncated: false };
return { text: text.slice(0, PREVIEW_LEN).trim() + "…", truncated: true };
}
function groupByDay(rows) {
const map = {};
const order = [];
rows.forEach(function (q) {
const day = String(q.quote_date || "").slice(0, 10) || "—";
if (!map[day]) {
map[day] = [];
order.push(day);
}
map[day].push(q);
});
return { map: map, order: order };
}
function renderFeed() {
if (!elFeed) return;
if (!quotes.length) {
elFeed.innerHTML =
'<p class="quotes-empty">暂无复盘语录.可在「内照明心 → 复盘语录」中添加.</p>';
return;
}
const grouped = groupByDay(quotes);
elFeed.innerHTML = grouped.order
.map(function (day) {
const list = grouped.map[day] || [];
const cards = list
.map(function (q) {
const id = String(q.id);
const full = String(q.content || "").trim();
const isOpen = !!expanded[id];
const prev = previewText(full);
const showExpand = prev.truncated;
const body = isOpen || !showExpand ? full : prev.text;
return (
'<article class="quotes-card' +
(isOpen ? " is-expanded" : "") +
'" data-id="' +
esc(id) +
'">' +
'<div class="quotes-card-body">' +
esc(body) +
"</div>" +
'<div class="quotes-card-actions">' +
(showExpand
? '<button type="button" class="ghost quotes-expand-btn" data-id="' +
esc(id) +
'">' +
(isOpen ? "收起" : "展开") +
"</button>"
: "") +
'<button type="button" class="ghost quotes-ai-btn" data-id="' +
esc(id) +
'">AI 复盘</button>' +
"</div></article>"
);
})
.join("");
return (
'<section class="quotes-day-group" data-day="' +
esc(day) +
'">' +
'<header class="quotes-day-head">' +
'<h2 class="quotes-day-title">' +
esc(day) +
"</h2>" +
daySummaryHtml(day, dayStats[day]) +
"</header>" +
'<div class="quotes-day-cards">' +
cards +
"</div></section>"
);
})
.join("");
elFeed.querySelectorAll(".quotes-expand-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
const id = btn.getAttribute("data-id");
expanded[id] = !expanded[id];
renderFeed();
});
});
elFeed.querySelectorAll(".quotes-ai-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
startQuoteAiChat(btn.getAttribute("data-id"));
});
});
}
function startQuoteAiChat(quoteId) {
const q = findQuote(quoteId);
const content = q && String(q.content || "").trim();
if (!q || !content) {
setStatus("语录内容为空,无法发起 AI 对话");
return;
}
try {
sessionStorage.setItem(
ARCHIVE_QUOTE_AI_KEY,
JSON.stringify({
quote_date: q.quote_date || "",
content: content,
})
);
} catch (_) {
setStatus("无法保存跳转数据");
return;
}
if (typeof window.hubNavigateTo === "function") {
window.hubNavigateTo("/ai");
return;
}
location.href = "/ai";
}
async function loadDayStats(days) {
const uniq = [];
const seen = {};
(days || []).forEach(function (d) {
const day = String(d || "").slice(0, 10);
if (!day || day === "—" || seen[day]) return;
seen[day] = true;
uniq.push(day);
});
await Promise.all(
uniq.map(async function (day) {
if (dayStats[day]) return;
try {
const q = new URLSearchParams();
q.set("period", "today");
q.set("trading_day", day);
const r = await apiFetch("/api/archive/daily-trades?" + q.toString());
const j = await r.json();
if (r.ok) {
dayStats[day] = j.stats || { open_count: 0, pnl_total: 0, win_rate: null };
} else {
dayStats[day] = { open_count: 0, pnl_total: 0, win_rate: null };
}
} catch (_) {
dayStats[day] = { open_count: 0, pnl_total: 0, win_rate: null };
}
})
);
}
async function loadQuotes() {
if (loading) return;
loading = true;
setStatus("加载语录…");
try {
const r = await apiFetch("/api/archive/quotes");
const j = await r.json();
if (!r.ok) {
setStatus(j.detail || "加载失败");
return;
}
quotes = (j.quotes || []).slice(0, RECENT_LIMIT);
const days = quotes.map(function (q) {
return q.quote_date;
});
renderFeed();
await loadDayStats(days);
renderFeed();
setStatus("最近 " + quotes.length + " 条 · " + new Date().toLocaleTimeString());
} catch (e) {
setStatus(String(e && e.message ? e.message : e) || "加载失败");
} finally {
loading = false;
}
}
function bindEvents() {
if (elBtnRefresh) elBtnRefresh.addEventListener("click", loadQuotes);
if (elLinkArchive) {
elLinkArchive.addEventListener("click", function (ev) {
if (typeof window.hubNavigateTo === "function") {
ev.preventDefault();
window.hubNavigateTo("/archive");
}
});
}
}
async function init() {
if (!page || page.classList.contains("hidden")) return;
if (!inited) {
bindEvents();
inited = true;
}
await loadQuotes();
}
function destroy() {}
window.hubQuotesPage = { init: init, destroy: destroy };
})();
+182
View File
@@ -0,0 +1,182 @@
/**
* 策略说明:三所 MD + 开仓检查清单 JSON.
*/
(function () {
const page = document.getElementById("page-strategy");
if (!page) return;
const tabsEl = document.getElementById("strategy-tabs");
const statusEl = document.getElementById("strategy-load-status");
const docBody = document.getElementById("strategy-doc-body");
const docSource = document.getElementById("strategy-doc-source");
const docCard = page.querySelector(".strategy-doc-card");
const checklistCard = page.querySelector(".strategy-checklist-card");
const checklistTitle = document.getElementById("strategy-checklist-title");
const checklistBody = document.getElementById("strategy-checklist-body");
const footnotesEl = document.getElementById("strategy-checklist-footnotes");
const btnPrintDoc = document.getElementById("strategy-btn-print-doc");
const btnPrintChecklist = document.getElementById("strategy-btn-print-checklist");
const btnDownload = document.getElementById("strategy-btn-download");
let activeKey = "binance";
let tabsMeta = [];
let cache = {};
let bound = false;
let heightSyncRaf = 0;
async function apiFetch(url, opts) {
const r = await fetch(url, { credentials: "same-origin", ...(opts || {}) });
const ct = (r.headers.get("content-type") || "").toLowerCase();
if (ct.includes("application/json")) {
const data = await r.json();
if (!r.ok) throw new Error((data && data.msg) || r.statusText || "请求失败");
return data;
}
if (!r.ok) throw new Error(r.statusText || "请求失败");
return r;
}
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function syncDocCardHeight() {
if (!docCard || !checklistCard || window.matchMedia("(max-width: 960px)").matches) {
if (docCard) docCard.style.height = "";
return;
}
docCard.style.height = `${checklistCard.offsetHeight}px`;
}
function scheduleHeightSync() {
if (heightSyncRaf) cancelAnimationFrame(heightSyncRaf);
heightSyncRaf = requestAnimationFrame(() => {
heightSyncRaf = 0;
syncDocCardHeight();
});
}
function renderTabs() {
if (!tabsEl) return;
tabsEl.innerHTML = tabsMeta
.map(
(t) =>
`<button type="button" class="strategy-tab${t.key === activeKey ? " is-active" : ""}" role="tab" aria-selected="${t.key === activeKey}" data-key="${esc(t.key)}">${esc(t.label)}</button>`
)
.join("");
tabsEl.querySelectorAll(".strategy-tab").forEach((btn) => {
btn.addEventListener("click", () => {
const key = btn.getAttribute("data-key");
if (!key || key === activeKey) return;
activeKey = key;
renderTabs();
void loadExchange(key);
});
});
}
function renderChecklist(checklist) {
const cl = checklist || {};
const title = cl.title || "开仓检查清单";
if (checklistTitle) checklistTitle.textContent = title;
if (!checklistBody) return;
const groups = cl.groups || [];
if (!groups.length) {
checklistBody.innerHTML = '<p class="strategy-empty">暂无检查清单</p>';
} else {
checklistBody.innerHTML = groups
.map((grp) => {
const items = (grp.items || [])
.map((item) => `<li><span class="strategy-check-box" aria-hidden="true">☐</span>${esc(item)}</li>`)
.join("");
return `<div class="strategy-check-group"><h4>${esc(grp.title || "")}</h4><ul>${items}</ul></div>`;
})
.join("");
}
if (footnotesEl) {
const notes = cl.footnotes || [];
footnotesEl.innerHTML = notes.map((n) => `<li>${esc(n)}</li>`).join("");
footnotesEl.classList.toggle("hidden", !notes.length);
}
scheduleHeightSync();
}
function renderPayload(data) {
if (docBody) docBody.innerHTML = data.strategy_html || "";
if (docSource) {
const ver = data.version ? ` · ${data.version}` : "";
docSource.textContent = `文档:${data.md_source || ""}${ver}`;
}
renderChecklist(data.checklist);
scheduleHeightSync();
}
async function loadExchange(key) {
if (statusEl) statusEl.textContent = "加载中…";
try {
let data = cache[key];
if (!data) {
data = await apiFetch(`/api/strategy/${encodeURIComponent(key)}`);
cache[key] = data;
}
renderPayload(data);
if (statusEl) statusEl.textContent = "";
} catch (e) {
if (statusEl) statusEl.textContent = String(e);
if (docBody) docBody.innerHTML = "";
if (checklistBody) checklistBody.innerHTML = "";
scheduleHeightSync();
}
}
async function loadMeta() {
const meta = await apiFetch("/api/strategy/meta");
tabsMeta = meta.exchanges || [];
if (tabsMeta.length && !tabsMeta.some((t) => t.key === activeKey)) {
activeKey = tabsMeta[0].key;
}
renderTabs();
}
function printSection(mode) {
const part = mode === "checklist" ? "checklist" : "doc";
const url = `/api/strategy/${encodeURIComponent(activeKey)}/print?part=${encodeURIComponent(part)}`;
const w = window.open(url, "_blank", "noopener,noreferrer");
if (!w) {
if (statusEl) statusEl.textContent = "请允许弹出窗口以打开打印预览";
}
}
function bindActions() {
if (bound) return;
bound = true;
if (btnPrintDoc) btnPrintDoc.addEventListener("click", () => printSection("doc"));
if (btnPrintChecklist) btnPrintChecklist.addEventListener("click", () => printSection("checklist"));
if (btnDownload) {
btnDownload.addEventListener("click", () => {
window.location.href = `/api/strategy/${encodeURIComponent(activeKey)}/export`;
});
}
window.addEventListener("resize", scheduleHeightSync);
}
async function init() {
bindActions();
try {
await loadMeta();
await loadExchange(activeKey);
} catch (e) {
if (statusEl) statusEl.textContent = String(e);
}
}
function destroy() {
window.removeEventListener("resize", scheduleHeightSync);
}
window.hubStrategyPage = { init, destroy };
})();
+71
View File
@@ -0,0 +1,71 @@
/** 中控主题:暗色(默认)/ 亮色,localStorage hub-theme */
(function (global) {
const KEY = "hub-theme";
const META = { dark: "#0b0e18", light: "#d4dde8" };
function normalize(theme) {
return theme === "light" ? "light" : "dark";
}
function get() {
try {
return normalize(localStorage.getItem(KEY));
} catch (_) {
return "dark";
}
}
function broadcastThemeToInstances() {
const msg = { type: "hub-theme-sync", theme: get() };
document.querySelectorAll("iframe#instance-frame, iframe.instance-frame").forEach((frame) => {
try {
if (frame.contentWindow) frame.contentWindow.postMessage(msg, "*");
} catch (_) {}
});
}
function apply(theme) {
const t = normalize(theme);
const root = document.documentElement;
root.setAttribute("data-theme", t);
try {
localStorage.setItem(KEY, t);
} catch (_) {}
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute("content", META[t]);
root.style.colorScheme = t;
document.dispatchEvent(new CustomEvent("hub-theme-change", { detail: { theme: t } }));
broadcastThemeToInstances();
return t;
}
function toggle() {
return apply(get() === "dark" ? "light" : "dark");
}
function syncToggleUI(root) {
const scope = root || document;
scope.querySelectorAll(".theme-toggle-btn[data-theme-value]").forEach((btn) => {
const on = btn.getAttribute("data-theme-value") === get();
btn.classList.toggle("is-active", on);
btn.setAttribute("aria-pressed", on ? "true" : "false");
});
}
function initToggleUI(root) {
const scope = root || document;
syncToggleUI(scope);
scope.querySelectorAll(".theme-toggle-btn[data-theme-value]").forEach((btn) => {
if (btn.dataset.themeBound === "1") return;
btn.dataset.themeBound = "1";
btn.addEventListener("click", () => {
apply(btn.getAttribute("data-theme-value"));
syncToggleUI(scope);
});
});
document.addEventListener("hub-theme-change", () => syncToggleUI(scope));
}
apply(get());
global.HubTheme = { KEY, get, apply, toggle, syncToggleUI, initToggleUI };
})(typeof window !== "undefined" ? window : globalThis);
+194
View File
@@ -0,0 +1,194 @@
/**
* 时间平仓 + 整点强制清仓:表单开关 + 持仓/顶栏倒计时.
*/
(function (global) {
"use strict";
function pad2(n) {
return n < 10 ? "0" + n : String(n);
}
function formatCountdown(sec) {
const s = Math.max(0, parseInt(sec, 10) || 0);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const r = s % 60;
return pad2(h) + ":" + pad2(m) + ":" + pad2(r);
}
function isForceCloseActive(wrap) {
if (!wrap) return false;
const raw =
wrap.dataset.forceCloseActive ||
wrap.getAttribute("data-force-close-active") ||
"";
return raw === "1" || raw === "true";
}
function bindTimeCloseForm(checkboxId, selectId, wrapId) {
const cb = document.getElementById(checkboxId);
const sel = document.getElementById(selectId);
const wrap = wrapId ? document.getElementById(wrapId) : null;
if (!cb || !sel) return;
function sync() {
const on = !!cb.checked;
sel.disabled = false;
sel.tabIndex = 0;
if (wrap) wrap.classList.toggle("is-disabled", !on);
}
sel.addEventListener("mousedown", function (ev) {
ev.stopPropagation();
});
sel.addEventListener("click", function (ev) {
ev.stopPropagation();
});
cb.addEventListener("change", sync);
sync();
}
function paintCountdownEl(cd, rem, active) {
if (!cd) return;
if (active) {
cd.textContent = "执行中";
return;
}
cd.textContent = Number.isFinite(rem) ? formatCountdown(rem) : "--:--:--";
}
function paintOrderTimeClose(order) {
if (!order || order.id == null) return;
const wrap = document.getElementById("order-time-close-wrap-" + order.id);
const cd = document.getElementById("order-time-close-cd-" + order.id);
if (!wrap || !cd) return;
const enabled = !!(order.time_close_enabled || order.time_close_at_ms);
if (!enabled) {
wrap.style.display = "none";
return;
}
wrap.style.display = "";
const hours = order.time_close_hours;
const label = order.time_close_label || (hours ? "时间平仓 " + hours + "h" : "时间平仓");
const labelEl = wrap.querySelector(".pos-time-close-label");
if (labelEl) labelEl.textContent = label;
let rem =
order.time_close_remaining_sec != null
? Number(order.time_close_remaining_sec)
: null;
if ((rem == null || !Number.isFinite(rem)) && order.time_close_at_ms) {
rem = Math.max(0, Math.floor((Number(order.time_close_at_ms) - Date.now()) / 1000));
}
paintCountdownEl(cd, rem, false);
wrap.dataset.closeAtMs = order.time_close_at_ms ? String(order.time_close_at_ms) : "";
}
function paintOrderForceClose(order) {
if (!order || order.id == null) return;
const wrap = document.getElementById("order-force-close-wrap-" + order.id);
const cd = document.getElementById("order-force-close-cd-" + order.id);
if (!wrap || !cd) return;
const enabled = !!order.force_close_enabled;
if (!enabled) {
wrap.style.display = "none";
return;
}
wrap.style.display = "";
const label = order.force_close_label || "强制清仓";
const labelEl = wrap.querySelector(".pos-force-close-label");
if (labelEl) labelEl.textContent = label;
let rem =
order.force_close_remaining_sec != null
? Number(order.force_close_remaining_sec)
: null;
const atMs = order.force_close_at_ms;
if ((rem == null || !Number.isFinite(rem)) && atMs) {
rem = Math.max(0, Math.floor((Number(atMs) - Date.now()) / 1000));
}
const active = !!order.force_close_active;
paintCountdownEl(cd, rem, active);
wrap.dataset.forceCloseAtMs = atMs ? String(atMs) : "";
wrap.dataset.forceCloseActive = active ? "1" : "0";
}
function paintForceCloseHeader(state) {
const wrap = document.getElementById("force-close-header-badge");
if (!wrap) return;
if (!state || !state.enabled) {
wrap.style.display = "none";
return;
}
wrap.style.display = "";
const label = state.label || "强制清仓";
const labelPrefix = label + " 已开启 · ";
let prefixNode = wrap.querySelector(".force-close-header-prefix");
if (!prefixNode) {
wrap.textContent = "";
prefixNode = document.createElement("span");
prefixNode.className = "force-close-header-prefix";
prefixNode.textContent = labelPrefix;
wrap.appendChild(prefixNode);
const cd = document.createElement("span");
cd.className = "force-close-header-cd";
wrap.appendChild(cd);
} else {
prefixNode.textContent = labelPrefix;
}
const cd = wrap.querySelector(".force-close-header-cd");
let rem = state.remaining_sec != null ? Number(state.remaining_sec) : null;
if ((rem == null || !Number.isFinite(rem)) && state.next_at_ms) {
rem = Math.max(0, Math.floor((Number(state.next_at_ms) - Date.now()) / 1000));
}
paintCountdownEl(cd, rem, !!state.active);
wrap.dataset.forceCloseAtMs = state.next_at_ms ? String(state.next_at_ms) : "";
wrap.dataset.forceCloseActive = state.active ? "1" : "0";
}
function tickLocalCountdowns() {
document.querySelectorAll("[data-close-at-ms]").forEach(function (wrap) {
const closeAtRaw = wrap.dataset.closeAtMs || wrap.getAttribute("data-close-at-ms") || "";
const cd = wrap.querySelector(".pos-time-close-cd");
if (!cd) return;
const closeAt = Number(closeAtRaw);
if (!closeAt) return;
const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000));
cd.textContent = formatCountdown(rem);
});
document.querySelectorAll("[data-force-close-at-ms]").forEach(function (wrap) {
const closeAtRaw =
wrap.dataset.forceCloseAtMs || wrap.getAttribute("data-force-close-at-ms") || "";
const cd = wrap.querySelector(".pos-force-close-cd, .force-close-header-cd");
if (!cd) return;
const closeAt = Number(closeAtRaw);
if (!closeAt) return;
const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000));
paintCountdownEl(cd, rem, isForceCloseActive(wrap));
});
}
function paintOrders(orders) {
(orders || []).forEach(function (order) {
paintOrderTimeClose(order);
paintOrderForceClose(order);
});
}
function syncKeyTimeCloseVisibility(show) {
const wrap = document.getElementById("key-time-close-wrap");
if (!wrap) return;
wrap.style.display = show ? "inline-flex" : "none";
}
global.TimeCloseUI = {
bindTimeCloseForm: bindTimeCloseForm,
paintOrderTimeClose: paintOrderTimeClose,
paintOrderForceClose: paintOrderForceClose,
paintForceCloseHeader: paintForceCloseHeader,
paintOrders: paintOrders,
tickLocalCountdowns: tickLocalCountdowns,
syncKeyTimeCloseVisibility: syncKeyTimeCloseVisibility,
formatCountdown: formatCountdown,
};
if (!global.__timeCloseCountdownTimer) {
global.__timeCloseCountdownTimer = setInterval(tickLocalCountdowns, 1000);
}
})(typeof window !== "undefined" ? window : globalThis);