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>
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escAttr(s) {
|
||||
return esc(s).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\"/g, """);
|
||||
}
|
||||
|
||||
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 () {},
|
||||
};
|
||||
})();
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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();
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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 };
|
||||
})();
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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() {},
|
||||
};
|
||||
})();
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 181 B |
|
After Width: | Height: | Size: 162 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 497 B |
|
After Width: | Height: | Size: 5.9 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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 };
|
||||
})();
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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 };
|
||||
})();
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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 };
|
||||
})();
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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 };
|
||||
})();
|
||||
@@ -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);
|
||||
@@ -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);
|
||||