Initialize crypto_monitor_user (user edition) from monitor codebase.

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-17 16:18:13 +08:00
commit 53863559f4
608 changed files with 162764 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
/* 账户风控状态徽章 — 三所实例 + 中控共用;兼容 data-theme light/dark */
:root,
html[data-theme="dark"] {
--risk-normal-fg: #9cf0c4;
--risk-normal-bg: rgba(36, 140, 96, 0.16);
--risk-normal-border: rgba(72, 190, 130, 0.42);
--risk-normal-glow: rgba(72, 190, 130, 0.35);
--risk-1h-fg: #ffd27a;
--risk-1h-bg: rgba(210, 150, 40, 0.16);
--risk-1h-border: rgba(230, 170, 60, 0.45);
--risk-1h-glow: rgba(230, 170, 60, 0.32);
--risk-4h-fg: #ffab8a;
--risk-4h-bg: rgba(210, 90, 55, 0.16);
--risk-4h-border: rgba(230, 110, 70, 0.48);
--risk-4h-glow: rgba(230, 110, 70, 0.34);
--risk-daily-fg: #ff9ec4;
--risk-daily-bg: rgba(190, 55, 100, 0.18);
--risk-daily-border: rgba(210, 75, 120, 0.5);
--risk-daily-glow: rgba(210, 75, 120, 0.36);
--risk-position-fg: #8ec8ff;
--risk-position-bg: rgba(55, 120, 210, 0.18);
--risk-position-border: rgba(75, 145, 230, 0.48);
--risk-position-glow: rgba(75, 145, 230, 0.34);
--risk-badge-shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
}
html[data-theme="light"] {
--risk-normal-fg: #056b44;
--risk-normal-bg: rgba(10, 143, 92, 0.14);
--risk-normal-border: rgba(8, 122, 80, 0.38);
--risk-normal-glow: rgba(10, 143, 92, 0.22);
--risk-1h-fg: #8a5a00;
--risk-1h-bg: rgba(200, 140, 20, 0.14);
--risk-1h-border: rgba(170, 115, 10, 0.38);
--risk-1h-glow: rgba(200, 140, 20, 0.2);
--risk-4h-fg: #a83812;
--risk-4h-bg: rgba(210, 85, 35, 0.12);
--risk-4h-border: rgba(180, 65, 25, 0.36);
--risk-4h-glow: rgba(210, 85, 35, 0.2);
--risk-daily-fg: #9a1248;
--risk-daily-bg: rgba(180, 35, 80, 0.1);
--risk-daily-border: rgba(155, 28, 68, 0.34);
--risk-daily-glow: rgba(180, 35, 80, 0.18);
--risk-position-fg: #0b5cab;
--risk-position-bg: rgba(20, 100, 190, 0.12);
--risk-position-border: rgba(15, 85, 165, 0.36);
--risk-position-glow: rgba(20, 100, 190, 0.2);
--risk-badge-shadow: 0 1px 2px rgba(20, 50, 80, 0.1);
}
.risk-status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.03em;
line-height: 1.15;
padding: 5px 12px 5px 10px;
border-radius: 999px;
border: 1px solid var(--risk-border, transparent);
background: var(--risk-bg, transparent);
color: var(--risk-fg, inherit);
box-shadow: var(--risk-badge-shadow);
white-space: nowrap;
vertical-align: middle;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
/* 中控 iframe 内切页:避免徽章过渡动画造成 header 闪动 */
html[data-hub-linked="1"] .header-row .risk-status-badge {
transition: none;
}
.risk-status-badge::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
box-shadow: 0 0 0 1px color-mix(in srgb, currentColor 30%, transparent),
0 0 8px var(--risk-glow, currentColor);
opacity: 0.92;
}
.risk-status-normal {
--risk-fg: var(--risk-normal-fg);
--risk-bg: var(--risk-normal-bg);
--risk-border: var(--risk-normal-border);
--risk-glow: var(--risk-normal-glow);
}
.risk-status-freeze_1h {
--risk-fg: var(--risk-1h-fg);
--risk-bg: var(--risk-1h-bg);
--risk-border: var(--risk-1h-border);
--risk-glow: var(--risk-1h-glow);
}
.risk-status-freeze_4h {
--risk-fg: var(--risk-4h-fg);
--risk-bg: var(--risk-4h-bg);
--risk-border: var(--risk-4h-border);
--risk-glow: var(--risk-4h-glow);
}
.risk-status-freeze_daily {
--risk-fg: var(--risk-daily-fg);
--risk-bg: var(--risk-daily-bg);
--risk-border: var(--risk-daily-border);
--risk-glow: var(--risk-daily-glow);
}
.risk-status-freeze_position {
--risk-fg: var(--risk-position-fg);
--risk-bg: var(--risk-position-bg);
--risk-border: var(--risk-position-border);
--risk-glow: var(--risk-position-glow);
}
/* 实例页:与交易所标签并排 */
.header-row .risk-status-badge {
min-height: 28px;
}
/* 中控卡片标题内 */
.card-title .risk-status-badge,
.hub-tile-name .risk-status-badge {
font-size: 0.7rem;
padding: 3px 10px 3px 8px;
vertical-align: middle;
}
.card-title .risk-status-badge::before,
.hub-tile-name .risk-status-badge::before {
width: 6px;
height: 6px;
}
+120
View File
@@ -0,0 +1,120 @@
/**
* 账户风控徽章倒计时 — 三所实例 + 中控共用.
*/
(function (global) {
"use strict";
function formatRemaining(totalSec) {
const sec = Math.max(0, Math.floor(Number(totalSec) || 0));
if (sec <= 0) return "";
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`;
if (m > 0) return `${m}m ${String(s).padStart(2, "0")}s`;
return `${s}s`;
}
function baseLabel(riskStatus, el) {
if (riskStatus && riskStatus.status_label) return String(riskStatus.status_label);
if (el && el.dataset && el.dataset.statusLabel) return String(el.dataset.statusLabel);
return "正常";
}
function resolveFreezeUntilMs(riskStatus) {
if (!riskStatus) return null;
const sec = Number(riskStatus.freeze_remaining_sec);
if (Number.isFinite(sec) && sec > 0) {
return Date.now() + sec * 1000;
}
const until = Number(riskStatus.freeze_until_ms);
return Number.isFinite(until) && until > 0 ? until : null;
}
function badgeText(riskStatus) {
const label = baseLabel(riskStatus, null);
const until = resolveFreezeUntilMs(riskStatus);
if (!until || until <= Date.now()) return label;
const cd = formatRemaining((until - Date.now()) / 1000);
return cd ? `${label} · ${cd}` : label;
}
function setNormalBadge(el) {
el.className = "risk-status-badge risk-status-normal";
el.dataset.statusLabel = "正常";
el.textContent = "正常";
el.title = "";
if (el.dataset) delete el.dataset.freezeUntilMs;
}
function refreshElement(el) {
if (!el) return;
const label = baseLabel(null, el);
const until = Number(el.dataset && el.dataset.freezeUntilMs);
if (!Number.isFinite(until) || until <= Date.now()) {
if (el.dataset && el.dataset.freezeUntilMs) {
setNormalBadge(el);
} else {
el.textContent = label;
}
return;
}
const cd = formatRemaining((until - Date.now()) / 1000);
el.textContent = cd ? `${label} · ${cd}` : label;
}
function applyToElement(el, riskStatus) {
if (!el || !riskStatus) return;
const st = riskStatus.status || "normal";
el.className = "risk-status-badge risk-status-" + st;
el.dataset.statusLabel = baseLabel(riskStatus, el);
const until = resolveFreezeUntilMs(riskStatus);
if (until) {
el.dataset.freezeUntilMs = String(until);
} else if (el.dataset) {
delete el.dataset.freezeUntilMs;
}
el.textContent = badgeText(riskStatus);
el.title = riskStatus.reason || "";
}
function formatBadgeHtml(riskStatus, esc) {
if (!riskStatus || typeof riskStatus !== "object") return "";
const safe = typeof esc === "function" ? esc : (s) => String(s);
const st = riskStatus.status || "normal";
const label = safe(riskStatus.status_label || "正常");
const title = safe(riskStatus.reason || "");
const text = safe(badgeText(riskStatus));
const until = resolveFreezeUntilMs(riskStatus);
const untilAttr =
until != null
? ` data-freeze-until-ms="${safe(String(Math.floor(until)))}"`
: "";
return (
`<span class="risk-status-badge risk-status-${safe(st)}" role="status"` +
` title="${title}" data-status-label="${label}"${untilAttr}>${text}</span>`
);
}
function tickAll(root) {
const scope = root || document;
scope.querySelectorAll(".risk-status-badge[data-freeze-until-ms]").forEach(refreshElement);
}
let timer = null;
function startTicker() {
if (timer) return;
tickAll();
timer = setInterval(() => tickAll(), 1000);
}
global.AccountRiskBadge = {
formatRemaining,
badgeText,
refreshElement,
applyToElement,
formatBadgeHtml,
tickAll,
startTicker,
};
})(typeof window !== "undefined" ? window : globalThis);
+223
View File
@@ -0,0 +1,223 @@
/**
* AI 日复盘 / 周复盘:Markdown 子集渲染 + 五节大标题图标兜底
*/
(function (global) {
"use strict";
var SECTION_FIXES = [
{ re: /^\*\*1\.\s*(?!📊)总体盈亏结构\*\*/m, rep: "**1. 📊 总体盈亏结构**" },
{ re: /^\*\*2\.\s*(?!🧠)心态与执行\*\*/m, rep: "**2. 🧠 心态与执行**" },
{ re: /^\*\*3\.\s*(?!🏷️)行为标签\*\*/m, rep: "**3. 🏷️ 行为标签**" },
{ re: /^\*\*4\.\s*(?!✅)改进建议\*\*/m, rep: "**4. ✅ 改进建议**" },
{ re: /^\*\*5\.\s*(?!📈)图表(?:分析)?\*\*/m, rep: "**5. 📈 图表分析**" },
{ re: /^1\.\s*(?!📊)总体盈亏结构/m, rep: "**1. 📊 总体盈亏结构**" },
{ re: /^2\.\s*(?!🧠)心态与执行/m, rep: "**2. 🧠 心态与执行**" },
{ re: /^3\.\s*(?!🏷️)行为标签/m, rep: "**3. 🏷️ 行为标签**" },
{ re: /^4\.\s*(?!✅)改进建议/m, rep: "**4. ✅ 改进建议**" },
{ re: /^5\.\s*(?!📈)图表/m, rep: "**5. 📈 图表分析**" },
];
function escapeHtml(s) {
return String(s || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function parseInline(raw) {
var s = escapeHtml(raw);
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
return s;
}
function enhanceReviewHeadings(text) {
var out = String(text || "");
SECTION_FIXES.forEach(function (item) {
out = out.replace(item.re, item.rep);
});
if (/^【系统说明/m.test(out) && !/^️/m.test(out)) {
out = out.replace(/^【系统说明/gm, "️ 【系统说明");
}
if (/^原始记录:/m.test(out) && !/^📎/m.test(out)) {
out = out.replace(/^原始记录:/gm, "📎 **原始记录**");
}
return out;
}
function isNumberedListLine(trimmed) {
if (!trimmed) return false;
if (/^\d+\.\s+/.test(trimmed)) return true;
if (/^\*\*\d+\.\s*.+\*\*$/.test(trimmed)) return true;
return false;
}
/** 编号列表项之间的空行不拆段,避免每条都从 1 重新开始 */
function preprocessListBlanks(text) {
var lines = String(text || "").replace(/\r\n/g, "\n").split("\n");
var out = [];
for (var i = 0; i < lines.length; i++) {
var trimmed = lines[i].trim();
if (!trimmed) {
var prevTrim = out.length ? String(out[out.length - 1]).trim() : "";
var nextTrim = "";
for (var j = i + 1; j < lines.length; j++) {
var t = lines[j].trim();
if (t) {
nextTrim = t;
break;
}
}
if (isNumberedListLine(prevTrim) && isNumberedListLine(nextTrim)) {
continue;
}
}
out.push(lines[i]);
}
return out.join("\n");
}
function renderMarkdown(text) {
var src = enhanceReviewHeadings(preprocessListBlanks(text));
var lines = src.replace(/\r\n/g, "\n").split("\n");
var html = [];
var inUl = false;
var inOl = false;
function closeLists() {
if (inUl) {
html.push("</ul>");
inUl = false;
}
if (inOl) {
html.push("</ol>");
inOl = false;
}
}
lines.forEach(function (line) {
var trimmed = line.trim();
if (!trimmed) {
closeLists();
return;
}
var hm = trimmed.match(/^(#{1,3})\s+(.+)$/);
if (hm) {
closeLists();
var level = hm[1].length + 1;
if (level > 4) level = 4;
html.push("<h" + level + ">" + parseInline(hm[2]) + "</h" + level + ">");
return;
}
var ulm = trimmed.match(/^[-*]\s+(.+)$/);
if (ulm) {
if (!inUl) {
closeLists();
html.push("<ul>");
inUl = true;
}
html.push("<li>" + parseInline(ulm[1]) + "</li>");
return;
}
var boldOl = trimmed.match(/^\*\*(\d+)\.\s*(.+)\*\*$/);
if (boldOl) {
if (!inOl) {
closeLists();
html.push("<ol>");
inOl = true;
}
html.push("<li>" + parseInline(trimmed) + "</li>");
return;
}
var olm = trimmed.match(/^\d+\.\s+(.+)$/);
if (olm) {
if (!inOl) {
closeLists();
html.push("<ol>");
inOl = true;
}
html.push("<li>" + parseInline(olm[1]) + "</li>");
return;
}
closeLists();
if (/^📎\s*\*\*原始记录\*\*/.test(trimmed) || /^原始记录:/.test(trimmed)) {
html.push('<div class="md-raw-block-title">' + parseInline(trimmed) + "</div>");
return;
}
html.push("<p>" + parseInline(trimmed) + "</p>");
});
closeLists();
return html.join("\n");
}
var _genBusy = false;
function setGenerating(opts) {
opts = opts || {};
_genBusy = true;
var wrap = document.getElementById(opts.wrapId);
var el = document.getElementById(opts.elId);
var btn = opts.btnId ? document.getElementById(opts.btnId) : null;
if (wrap) wrap.style.display = "block";
if (el) {
el.classList.remove("ai-result-md");
el.classList.add("is-loading");
el.innerHTML = "";
el.innerText = opts.message || "生成复盘中,请稍候…";
}
if (btn) {
btn.disabled = true;
if (!btn.dataset.aiOrigText) btn.dataset.aiOrigText = btn.textContent;
btn.textContent = opts.btnLabel || "生成中…";
}
if (wrap && wrap.scrollIntoView) {
try {
wrap.scrollIntoView({ behavior: "smooth", block: "nearest" });
} catch (e) { /* ignore */ }
}
}
function clearGenerating(btnId) {
_genBusy = false;
var btn = btnId ? document.getElementById(btnId) : null;
if (btn) {
btn.disabled = false;
if (btn.dataset.aiOrigText) {
btn.textContent = btn.dataset.aiOrigText;
delete btn.dataset.aiOrigText;
}
}
}
function isGenerating() {
return _genBusy;
}
function setElementMarkdown(el, rawText) {
if (!el) return;
var raw = String(rawText || "");
el.dataset.markdownRaw = raw;
el.classList.remove("is-loading");
el.classList.add("ai-result-md");
el.innerHTML = renderMarkdown(raw);
}
function getElementMarkdown(el) {
if (!el) return "";
if (el.dataset && el.dataset.markdownRaw != null) {
return el.dataset.markdownRaw;
}
return el.innerText || "";
}
global.AiReviewRender = {
enhanceReviewHeadings: enhanceReviewHeadings,
renderMarkdown: renderMarkdown,
setElementMarkdown: setElementMarkdown,
getElementMarkdown: getElementMarkdown,
setGenerating: setGenerating,
clearGenerating: clearGenerating,
isGenerating: isGenerating,
};
})(typeof window !== "undefined" ? window : this);
+221
View File
@@ -0,0 +1,221 @@
/* 实盘/关键位放大页:与 instance_theme 联动,高对比 meta + 主题感知图表区 */
body.focus-page {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
padding: 14px;
margin: 0;
background: var(--focus-bg, #0b0d14);
color: var(--focus-fg, #eaeaea);
}
html[data-theme="light"] body.focus-page {
--focus-bg: #eef3f8;
--focus-fg: #142232;
--focus-card-bg: #fff;
--focus-card-border: #b8c8d8;
--focus-meta-bg: #fff;
--focus-meta-border: #9eb4c8;
--focus-meta-label: #2a4a66;
--focus-meta-value: #0a1628;
--focus-status: #4a6078;
--focus-chart-bg: #f0f4f9;
--focus-chart-border: #b8c8d8;
--focus-btn-bg: #fff;
--focus-btn-fg: #006e9a;
--focus-btn-border: rgba(0, 95, 140, 0.22);
--focus-input-bg: #fff;
--focus-input-fg: #142232;
--focus-input-border: #b8c8d8;
--focus-title: #0a1628;
--focus-pnl-up: #0a7a3d;
--focus-pnl-down: #c62828;
--focus-dir-short: #b71c1c;
--focus-dir-long: #0a7a3d;
}
html[data-theme="dark"] body.focus-page {
--focus-bg: #0b0d14;
--focus-fg: #eaeaea;
--focus-card-bg: #121726;
--focus-card-border: #2a3150;
--focus-meta-bg: #141b2f;
--focus-meta-border: #3d4f72;
--focus-meta-label: #c8d8f0;
--focus-meta-value: #f0f4ff;
--focus-status: #95a2c2;
--focus-chart-bg: #0f1320;
--focus-chart-border: #2a3150;
--focus-btn-bg: #151a2a;
--focus-btn-fg: #8fc8ff;
--focus-btn-border: #304164;
--focus-input-bg: #1a1a29;
--focus-input-fg: #fff;
--focus-input-border: #2e2e45;
--focus-title: #dbe4ff;
--focus-pnl-up: #3ddc84;
--focus-pnl-down: #ff7070;
--focus-dir-short: #ff8a80;
--focus-dir-long: #69f0ae;
}
body.focus-page * {
box-sizing: border-box;
}
.focus-page .container {
width: min(98vw, 1900px);
margin: 0 auto;
}
.focus-page .card {
background: var(--focus-card-bg);
border-radius: 10px;
padding: 12px;
border: 1px solid var(--focus-card-border);
margin-bottom: 12px;
}
.focus-page .row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.focus-page .btn {
padding: 7px 10px;
border-radius: 8px;
text-decoration: none;
border: 1px solid var(--focus-btn-border);
background: var(--focus-btn-bg);
color: var(--focus-btn-fg);
cursor: pointer;
}
.focus-page .btn:hover {
filter: brightness(1.06);
}
.focus-page select,
.focus-page input,
.focus-page button {
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--focus-input-border);
background: var(--focus-input-bg);
color: var(--focus-input-fg);
}
.focus-page .focus-title {
color: var(--focus-title);
font-weight: 700;
}
.focus-page .meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
margin-top: 10px;
}
.focus-page .meta-item {
background: var(--focus-meta-bg);
border: 1px solid var(--focus-meta-border);
border-radius: 8px;
padding: 10px 10px 9px;
}
.focus-page .meta-item .k {
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--focus-meta-label);
}
.focus-page .meta-item .v {
font-size: 1.02rem;
font-weight: 600;
margin-top: 5px;
word-break: break-all;
color: var(--focus-meta-value);
}
.focus-page .meta-item--emph {
border-width: 2px;
border-color: var(--focus-meta-label);
}
.focus-page .meta-item--emph .k {
font-size: 0.82rem;
font-weight: 700;
}
.focus-page .meta-item--emph .v {
font-size: 1.12rem;
font-weight: 800;
}
.focus-page .meta-item--pnl .v {
font-size: 1.14rem;
font-weight: 800;
letter-spacing: 0.01em;
}
.focus-page .meta-pnl-up {
color: var(--focus-pnl-up) !important;
}
.focus-page .meta-pnl-down {
color: var(--focus-pnl-down) !important;
}
.focus-page .meta-dir-long {
color: var(--focus-dir-long) !important;
}
.focus-page .meta-dir-short {
color: var(--focus-dir-short) !important;
}
.focus-page .status {
font-size: 0.84rem;
color: var(--focus-status);
}
.focus-page .status.err {
color: var(--focus-pnl-down);
}
.focus-page #chart-wrap {
height: 560px;
background: var(--focus-chart-bg);
border: 1px solid var(--focus-chart-border);
border-radius: 10px;
padding: 8px;
}
.focus-page #chart {
width: 100%;
height: 100%;
}
.focus-page .empty {
padding: 18px;
color: var(--focus-status);
}
.focus-page .exchange-tag {
font-size: 0.72rem;
font-weight: 600;
color: #b8f5d0;
background: #14241e;
border: 1px solid #2d6a4f;
padding: 4px 10px;
border-radius: 999px;
margin-left: 8px;
}
html[data-theme="light"] .focus-page .exchange-tag {
color: #0a5c38;
background: #e8f5ee;
border-color: #7bc9a0;
}
+401
View File
@@ -0,0 +1,401 @@
/**
* 实盘/关键位放大 K 线:交易所 tick 精度,主题感知图表,高对比 meta.
*/
(function (global) {
"use strict";
let activePriceTick = null;
function currentTheme() {
return document.documentElement.getAttribute("data-theme") === "light"
? "light"
: "dark";
}
function chartTheme(theme) {
if (theme === "light") {
return {
layout: { background: { color: "#f0f4f9" }, textColor: "#142232" },
grid: { vertLines: { color: "#d0dae4" }, horzLines: { color: "#d0dae4" } },
rightPriceScale: { borderColor: "#b8c8d8" },
timeScale: { borderColor: "#b8c8d8" },
candle: {
upColor: "#0a7a3d",
downColor: "#c62828",
wickUpColor: "#0a7a3d",
wickDownColor: "#c62828",
},
};
}
return {
layout: { background: { color: "#0f1320" }, textColor: "#d6deff" },
grid: { vertLines: { color: "#1e263d" }, horzLines: { color: "#1e263d" } },
rightPriceScale: { borderColor: "#2a3150" },
timeScale: { borderColor: "#2a3150" },
candle: {
upColor: "#4cd97f",
downColor: "#ff6666",
wickUpColor: "#4cd97f",
wickDownColor: "#ff6666",
},
};
}
const SAFE_PRICE_FORMAT = { type: "price", precision: 4, minMove: 0.0001 };
function decimalsFromTick(tick) {
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return null;
const minMove = Number(tick);
if (minMove >= 1) return 0;
const raw = String(minMove);
const sci = raw.match(/e-(\d+)/i);
if (sci) return Math.min(12, parseInt(sci[1], 10));
const fixed = minMove.toFixed(12);
const frac = fixed.split(".")[1] || "";
const trimmed = frac.replace(/0+$/, "");
if (trimmed.length) return Math.min(12, trimmed.length);
return Math.max(0, Math.min(12, Math.round(-Math.log10(minMove))));
}
function tickToPriceFormat(tick) {
try {
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) {
return { type: "price", precision: 2, minMove: 0.01 };
}
const minMove = Number(tick);
let prec = decimalsFromTick(minMove);
if (prec == null || prec < 0) prec = 4;
prec = Math.min(12, Math.max(0, Math.floor(prec)));
return { type: "price", precision: prec, minMove: minMove };
} catch (_) {
return SAFE_PRICE_FORMAT;
}
}
function roundToTick(v, tick) {
if (v == null || Number.isNaN(Number(v))) return v;
const n = Number(v);
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return n;
const t = Number(tick);
const rounded = Math.round(n / t) * t;
const dec = decimalsFromTick(t);
if (dec == null) return rounded;
return parseFloat(rounded.toFixed(dec));
}
function fmtPriceByTick(v, tick) {
if (v == null || Number.isNaN(Number(v))) return "-";
const n = Number(roundToTick(v, tick));
if (n === 0) return "0";
const dec = decimalsFromTick(tick);
if (dec != null) return n.toFixed(dec);
const av = Math.abs(n);
let d = 8;
if (av >= 10000) d = 2;
else if (av >= 100) d = 3;
else if (av >= 1) d = 4;
else if (av >= 0.01) d = 6;
const text = n.toFixed(d);
return text.includes(".") ? text.replace(/\.?0+$/, "") : text;
}
function setActivePriceTick(tick) {
activePriceTick =
tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0
? null
: Number(tick);
}
function formatSigned(v, digits) {
digits = digits === undefined ? 2 : digits;
if (v === null || typeof v === "undefined" || Number.isNaN(Number(v))) return "-";
const n = Number(v);
const sign = n > 0 ? "+" : "";
return sign + n.toFixed(digits);
}
function formatSignedPrice(v) {
if (v === null || typeof v === "undefined" || Number.isNaN(Number(v))) return "-";
const n = Number(v);
const body = fmtPriceByTick(Math.abs(n), activePriceTick);
if (body === "-") return "-";
return (n > 0 ? "+" : n < 0 ? "-" : "") + body;
}
function formatRrRatio(rr) {
if (rr === null || typeof rr === "undefined") return "-:1";
const n = Number(rr);
if (Number.isNaN(n)) return "-:1";
const body = Number.isInteger(n) ? String(n) : String(parseFloat(n.toFixed(2)));
return body + ":1";
}
function displayPrice(orderOrData, field, rawField) {
const dispKey = field + "_display";
if (orderOrData && orderOrData[dispKey] && orderOrData[dispKey] !== "-") {
return String(orderOrData[dispKey]);
}
const raw = orderOrData ? orderOrData[rawField || field] : null;
if (raw === null || typeof raw === "undefined" || Number.isNaN(Number(raw))) return "-";
return fmtPriceByTick(raw, activePriceTick);
}
function lineTitle(label, display) {
const d = display && display !== "-" ? display : "";
return d ? label + " " + d : label;
}
function paintOrderMeta(order) {
const symEl = document.getElementById("m-symbol");
const dirEl = document.getElementById("m-direction");
const pnlEl = document.getElementById("m-pnl");
if (symEl) symEl.textContent = order.symbol || "-";
if (dirEl) {
const isShort = order.direction === "short";
dirEl.textContent = isShort ? "做空" : "做多";
dirEl.className = "v " + (isShort ? "meta-dir-short" : "meta-dir-long");
}
const set = function (id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
};
set("m-entry", displayPrice(order, "trigger_price"));
set("m-sl", displayPrice(order, "stop_loss"));
set("m-tp", displayPrice(order, "take_profit"));
set("m-rr", formatRrRatio(order.rr_ratio));
set(
"m-breakeven",
order.breakeven_enabled === false || order.breakeven_enabled === 0 ? "关闭" : "开启"
);
set(
"m-price",
order.current_price_display ||
order.price_display ||
displayPrice(order, "current_price")
);
if (pnlEl) {
pnlEl.textContent =
formatSigned(order.float_pnl, 2) +
"U (" +
formatSigned(order.float_pct, 2) +
"%)";
pnlEl.className = "v";
const pnl = Number(order.float_pnl || 0);
if (pnl > 0) pnlEl.classList.add("meta-pnl-up");
else if (pnl < 0) pnlEl.classList.add("meta-pnl-down");
}
}
function paintKeyMeta(data) {
const key = data.key_monitor || null;
const symEl = document.getElementById("m-symbol");
if (symEl) symEl.textContent = data.symbol || "-";
const set = function (id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
};
set(
"m-price",
data.current_price_display || displayPrice(data, "current_price")
);
const dirEl = document.getElementById("m-direction");
if (!key) {
set("m-type", "未匹配到关键位");
set("m-direction", "-");
if (dirEl) dirEl.className = "v";
set("m-upper", "-");
set("m-lower", "-");
set("m-updiff", "-");
set("m-lowdiff", "-");
return;
}
set("m-type", key.monitor_type || "-");
if (dirEl) {
const isShort = key.direction === "short";
dirEl.textContent = isShort ? "做空" : "做多";
dirEl.className = "v " + (isShort ? "meta-dir-short" : "meta-dir-long");
}
set("m-upper", key.upper_display || displayPrice(key, "upper"));
set("m-lower", key.lower_display || displayPrice(key, "lower"));
if (activePriceTick != null) {
set(
"m-updiff",
formatSignedPrice(key.upper_diff) +
" (" +
formatSigned(key.upper_pct, 2) +
"%)"
);
set(
"m-lowdiff",
formatSignedPrice(key.lower_diff) +
" (" +
formatSigned(key.lower_pct, 2) +
"%)"
);
} else {
set(
"m-updiff",
formatSigned(key.upper_diff, 4) + " (" + formatSigned(key.upper_pct, 2) + "%)"
);
set(
"m-lowdiff",
formatSigned(key.lower_diff, 4) + " (" + formatSigned(key.lower_pct, 2) + "%)"
);
}
}
function applyPriceFormatToSeries(series, pf) {
if (!series || !series.applyOptions) return;
try {
series.applyOptions({ priceFormat: pf });
} catch (_) {
try {
series.applyOptions({ priceFormat: SAFE_PRICE_FORMAT });
} catch (_2) {}
}
}
function createFocusChart(host) {
if (!global.LightweightCharts) return null;
const th = chartTheme(currentTheme());
const chart = global.LightweightCharts.createChart(host, {
layout: th.layout,
grid: th.grid,
rightPriceScale: th.rightPriceScale,
timeScale: Object.assign({ timeVisible: true, secondsVisible: false }, th.timeScale),
crosshair: { mode: 0 },
localization: {
priceFormatter: function (p) {
return fmtPriceByTick(p, activePriceTick);
},
},
});
let candleSeries = null;
function applyChartPriceFormat() {
let pf = SAFE_PRICE_FORMAT;
try {
pf = tickToPriceFormat(activePriceTick);
} catch (_) {
pf = SAFE_PRICE_FORMAT;
}
applyPriceFormatToSeries(candleSeries, pf);
try {
chart.applyOptions({
localization: {
priceFormatter: function (p) {
return fmtPriceByTick(p, activePriceTick);
},
},
});
} catch (_) {}
}
function setPriceTick(tick) {
setActivePriceTick(tick);
applyChartPriceFormat();
}
const opts = Object.assign({ borderVisible: false }, th.candle);
if (typeof chart.addCandlestickSeries === "function") {
candleSeries = chart.addCandlestickSeries(opts);
} else if (
typeof chart.addSeries === "function" &&
global.LightweightCharts.CandlestickSeries
) {
candleSeries = chart.addSeries(global.LightweightCharts.CandlestickSeries, opts);
}
applyChartPriceFormat();
const priceLines = [];
function resetPriceLines() {
if (!candleSeries) return;
priceLines.forEach(function (line) {
try {
candleSeries.removePriceLine(line);
} catch (_) {}
});
priceLines.length = 0;
}
function addLine(price, title, color) {
if (!candleSeries || price === null || typeof price === "undefined") return;
const p = Number(roundToTick(price, activePriceTick));
if (Number.isNaN(p) || p <= 0) return;
priceLines.push(
candleSeries.createPriceLine({
price: p,
color: color,
lineWidth: 1,
lineStyle: 0,
axisLabelVisible: true,
title: title,
})
);
}
function applyTheme() {
const t = chartTheme(currentTheme());
chart.applyOptions({
layout: t.layout,
grid: t.grid,
rightPriceScale: t.rightPriceScale,
timeScale: t.timeScale,
localization: {
priceFormatter: function (p) {
return fmtPriceByTick(p, activePriceTick);
},
},
});
if (candleSeries && typeof candleSeries.applyOptions === "function") {
candleSeries.applyOptions(t.candle);
}
applyChartPriceFormat();
}
function resize() {
chart.applyOptions({ width: host.clientWidth, height: host.clientHeight });
}
global.addEventListener("resize", resize);
resize();
const obs = new MutationObserver(applyTheme);
obs.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
return {
chart: chart,
candleSeries: candleSeries,
resetPriceLines: resetPriceLines,
addLine: addLine,
applyTheme: applyTheme,
setPriceTick: setPriceTick,
ensureSeries: function () {
if (candleSeries) return true;
const t = chartTheme(currentTheme());
const o = Object.assign({ borderVisible: false }, t.candle);
if (typeof chart.addCandlestickSeries === "function") {
candleSeries = chart.addCandlestickSeries(o);
} else if (
typeof chart.addSeries === "function" &&
global.LightweightCharts.CandlestickSeries
) {
candleSeries = chart.addSeries(global.LightweightCharts.CandlestickSeries, o);
}
applyChartPriceFormat();
return !!candleSeries;
},
};
}
global.FocusChartPage = {
currentTheme: currentTheme,
chartTheme: chartTheme,
formatSigned: formatSigned,
formatRrRatio: formatRrRatio,
displayPrice: displayPrice,
lineTitle: lineTitle,
paintOrderMeta: paintOrderMeta,
paintKeyMeta: paintKeyMeta,
createFocusChart: createFocusChart,
setActivePriceTick: setActivePriceTick,
fmtPriceByTick: fmtPriceByTick,
};
})(typeof window !== "undefined" ? window : globalThis);
+80
View File
@@ -0,0 +1,80 @@
/**
* 表单提交防重复:网络慢时禁用按钮并显示「提交中」.
*/
(function (global) {
"use strict";
function submitButtons(form) {
if (!form) return [];
return Array.prototype.slice.call(
form.querySelectorAll('button[type="submit"], input[type="submit"]')
);
}
function lockForm(form, label) {
if (!form) return false;
if (form.dataset.submitGuard === "locked") return false;
form.dataset.submitGuard = "locked";
form.classList.add("is-form-submitting");
submitButtons(form).forEach(function (btn) {
if (btn.dataset.submitGuardOrig === undefined) {
btn.dataset.submitGuardOrig =
btn.tagName === "BUTTON" ? btn.textContent : btn.value;
}
btn.disabled = true;
if (label) {
if (btn.tagName === "BUTTON") btn.textContent = label;
else btn.value = label;
}
});
return true;
}
function unlockForm(form) {
if (!form) return;
delete form.dataset.submitGuard;
form.classList.remove("is-form-submitting");
submitButtons(form).forEach(function (btn) {
btn.disabled = false;
var orig = btn.dataset.submitGuardOrig;
if (orig !== undefined) {
if (btn.tagName === "BUTTON") btn.textContent = orig;
else btn.value = orig;
delete btn.dataset.submitGuardOrig;
}
});
}
function isLocked(form) {
return !!(form && form.dataset.submitGuard === "locked");
}
/** 已锁定时仅更新按钮文案(校验通过 → 真正提交前) */
function setSubmitLabel(form, label) {
if (!form || !label) return;
submitButtons(form).forEach(function (btn) {
if (btn.tagName === "BUTTON") btn.textContent = label;
else btn.value = label;
});
}
/** 已通过前端校验,发起最终 POST(页面将跳转) */
function nativeSubmitOnce(form, label) {
if (!form) return;
var text = label || "提交中…";
if (form.dataset.submitGuard === "locked") {
setSubmitLabel(form, text);
} else {
lockForm(form, text);
}
form.submit();
}
global.FormSubmitGuard = {
lock: lockForm,
unlock: unlockForm,
isLocked: isLocked,
setSubmitLabel: setSubmitLabel,
nativeSubmitOnce: nativeSubmitOnce,
};
})(typeof window !== "undefined" ? window : this);
File diff suppressed because it is too large Load Diff
+494
View File
@@ -0,0 +1,494 @@
/**
* 实例数据看板:拉 /api/instance/dashboard 渲染只读表格.
* 各区块无数据时不展示;有数据按表格展示.
*/
(function (global) {
const SECTION_ORDER = ["orders", "keys", "strategy", "options", "hedge_plan"];
let loading = false;
let timer = null;
function root() {
const active = document.querySelector('.embed-tab-pane.is-active-pane [data-inst-dashboard="1"]');
if (active) return active;
return document.getElementById("instance-dashboard");
}
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function fmtNum(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return escapeHtml(v);
return String(n);
}
function fmtPnl(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return "—";
const cls = n > 0 ? "pos-pnl-profit" : n < 0 ? "pos-pnl-loss" : "";
const sign = n > 0 ? "+" : "";
return '<span class="' + cls + '">' + sign + n.toFixed(2) + "U</span>";
}
function fmtPnlPlain(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return "—";
const cls = n > 0 ? "pos-pnl-profit" : n < 0 ? "pos-pnl-loss" : "";
return '<span class="' + cls + '">' + n.toFixed(2) + "</span>";
}
function dirCell(it) {
const d = String(it.direction || "").toLowerCase();
const label = it.direction_label || (d === "short" ? "做空" : d === "long" ? "做多" : "-");
const cls = d === "short" ? "inst-dash-dir-short" : d === "long" ? "inst-dash-dir-long" : "";
return '<td class="' + cls + '">' + escapeHtml(label) + "</td>";
}
function fmtExpiry(ms) {
const n = Number(ms);
if (!Number.isFinite(n) || n <= 0) return "—";
let fallback = "—";
try {
const d = new Date(n);
if (!Number.isNaN(d.getTime())) {
const pad = function (x) {
return String(x).padStart(2, "0");
};
fallback =
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
" " +
pad(d.getHours()) +
":" +
pad(d.getMinutes());
}
} catch (_) {}
return (
'<span class="opt-expiry-cd" data-opt-exp-ms="' +
escapeHtml(String(Math.floor(n))) +
'">' +
escapeHtml(fallback) +
"</span>"
);
}
function goTab(tab) {
if (!tab) return;
if (global.InstanceEmbed && typeof global.InstanceEmbed.loadTab === "function") {
global.InstanceEmbed.loadTab(tab);
return;
}
const pathMap = {
trade: "/trade",
key_monitor: "/key_monitor",
strategy: "/strategy",
options: "/options",
hedge_plan: "/hedge-plan",
};
const path = pathMap[tab] || "/" + tab;
location.href = path;
}
function tableWrap(headers, rowsHtml) {
return (
'<div class="inst-dash-table-wrap">' +
'<table class="inst-dash-table">' +
"<thead><tr>" +
headers
.map(function (h) {
return "<th>" + escapeHtml(h) + "</th>";
})
.join("") +
"</tr></thead>" +
"<tbody>" +
rowsHtml +
"</tbody></table></div>"
);
}
function rowClickAttrs(tab) {
return ' class="inst-dash-row" data-dash-tab="' + escapeHtml(tab || "") + '" role="link" tabindex="0"';
}
function mergeOrderLive(items, orderPrices) {
const map = {};
(orderPrices || []).forEach(function (p) {
if (p && p.id != null) map[String(p.id)] = p;
});
return (items || []).map(function (it) {
const live = map[String(it.id)] || {};
const mark =
live.exchange_mark_price != null
? live.exchange_mark_price
: live.price != null
? live.price
: it.mark_price;
const contracts =
live.contracts != null
? live.contracts
: live.order_amount != null
? live.order_amount
: it.contracts;
const entry =
live.avg_entry_price != null
? live.avg_entry_price
: it.entry;
return Object.assign({}, it, {
entry: entry,
mark_price: mark,
mark_display: live.price_display || null,
contracts: contracts,
tp_profit: live.reward_at_tp_usdt != null ? live.reward_at_tp_usdt : it.tp_profit,
float_pnl: live.float_pnl != null ? live.float_pnl : it.float_pnl,
});
});
}
function renderOrdersTable(items) {
const rows = items
.map(function (it) {
const sym = it.symbol || "-";
const mark =
it.mark_display != null && it.mark_display !== ""
? escapeHtml(it.mark_display)
: fmtNum(it.mark_price);
const tpProfit =
it.tp_profit != null && Number.isFinite(Number(it.tp_profit))
? '<span class="pos-tp-profit">' + Number(it.tp_profit).toFixed(2) + "U</span>"
: "—";
return (
"<tr" +
rowClickAttrs(it.tab || "trade") +
">" +
'<td class="td-symbol"><span class="inst-dash-sym-link">' +
escapeHtml(sym) +
"</span></td>" +
dirCell(it) +
"<td>" +
fmtNum(it.entry) +
"</td>" +
"<td>" +
mark +
"</td>" +
"<td>" +
fmtNum(it.contracts) +
"</td>" +
"<td>" +
tpProfit +
"</td>" +
"<td>" +
fmtPnlPlain(it.float_pnl) +
"</td>" +
"<td>—</td>" +
"</tr>"
);
})
.join("");
return tableWrap(
["合约", "方向", "开仓价", "标记价", "张数", "盈利金额", "浮盈", "操作"],
rows
);
}
function renderKeysTable(items) {
const rows = items
.map(function (it) {
return (
"<tr" +
rowClickAttrs(it.tab || "key_monitor") +
">" +
"<td>" +
escapeHtml(it.symbol || "-") +
"</td>" +
dirCell(it) +
"<td>" +
escapeHtml(it.subtitle || "—") +
"</td>" +
"<td>" +
fmtNum(it.upper) +
"</td>" +
"<td>" +
fmtNum(it.lower) +
"</td>" +
"</tr>"
);
})
.join("");
return tableWrap(["合约", "方向", "信号", "上沿", "下沿"], rows);
}
function renderStrategyTable(items) {
const rows = items
.map(function (it) {
const kindLabel = it.kind === "roll" ? "顺势加仓" : it.kind === "trend" ? "趋势回调" : "策略";
return (
"<tr" +
rowClickAttrs(it.tab || "strategy") +
">" +
"<td>" +
escapeHtml(kindLabel) +
"</td>" +
"<td>" +
escapeHtml(it.symbol || "-") +
"</td>" +
dirCell(it) +
"<td>" +
escapeHtml(it.status || "—") +
"</td>" +
"<td>" +
fmtNum(it.entry) +
"</td>" +
"</tr>"
);
})
.join("");
return tableWrap(["类型", "合约", "方向", "状态", "入场"], rows);
}
function renderOptionsTable(items) {
const rows = items
.map(function (it) {
const opt = it.opt_type_label ||
(String(it.opt_type || "").toUpperCase() === "C"
? "Call"
: String(it.opt_type || "").toUpperCase() === "P"
? "Put"
: it.opt_type || "—");
return (
"<tr" +
rowClickAttrs(it.tab || "options") +
">" +
"<td>" +
escapeHtml(it.inst_id || it.title || "-") +
"</td>" +
"<td>" +
escapeHtml(it.source_label || "纯期权") +
"</td>" +
"<td>" +
escapeHtml(opt) +
"</td>" +
"<td>" +
fmtNum(it.pos) +
"</td>" +
"<td>" +
fmtExpiry(it.exp_time_ms) +
"</td>" +
"<td>" +
escapeHtml(it.target_monitor || "—") +
"</td>" +
"<td>" +
fmtPnl(it.pnl) +
"</td>" +
"</tr>"
);
})
.join("");
return tableWrap(["合约", "来源", "类型", "张数", "到期时间", "目标监控", "盈亏"], rows);
}
function renderHedgeTable(items) {
const rows = items
.map(function (it) {
const stCls = it.status_active ? "inst-dash-status-active" : "";
const stText = it.status_label || (it.status_active ? "进行中" : it.status || "—");
return (
"<tr" +
rowClickAttrs(it.tab || "hedge_plan") +
">" +
"<td>#" +
escapeHtml(it.id != null ? it.id : "—") +
"</td>" +
"<td>" +
escapeHtml(it.underlying || "-") +
"</td>" +
"<td>" +
escapeHtml(it.plan_type_label || it.plan_type || "—") +
"</td>" +
'<td class="' +
stCls +
'">' +
escapeHtml(stText) +
"</td>" +
"<td>" +
escapeHtml(it.contracts_summary || it.subtitle || "—") +
"</td>" +
"</tr>"
);
})
.join("");
return tableWrap(["ID", "标的", "计划类型", "状态", "说明"], rows);
}
function renderTable(key, items) {
if (key === "orders") return renderOrdersTable(items);
if (key === "keys") return renderKeysTable(items);
if (key === "strategy") return renderStrategyTable(items);
if (key === "options") return renderOptionsTable(items);
if (key === "hedge_plan") return renderHedgeTable(items);
return "";
}
function sectionHasData(sec) {
if (!sec) return false;
const count = Number(sec.count);
if (Number.isFinite(count) && count > 0) return true;
return Array.isArray(sec.items) && sec.items.length > 0;
}
function renderSection(key, sec) {
if (!sectionHasData(sec)) return "";
const items = sec.items || [];
const count = Number(sec.count) || items.length;
return (
'<section class="inst-dash-section" data-dash-section="' +
escapeHtml(key) +
'">' +
'<div class="inst-dash-section-head">' +
"<h3>" +
escapeHtml(sec.title || key) +
' <span class="inst-dash-count">' +
count +
"</span></h3>" +
'<button type="button" class="btn-sm inst-dash-goto" data-dash-tab="' +
escapeHtml(sec.tab || "") +
'">打开</button>' +
"</div>" +
renderTable(key, items) +
"</section>"
);
}
function bindClicks(el) {
if (!el) return;
el.querySelectorAll("[data-dash-tab]").forEach(function (node) {
const handler = function () {
goTab(node.getAttribute("data-dash-tab"));
};
node.addEventListener("click", handler);
node.addEventListener("keydown", function (ev) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
handler();
}
});
});
}
async function load(opts) {
const el = root();
if (!el) return;
const status = el.querySelector("#inst-dash-status") || document.getElementById("inst-dash-status");
const sections = el.querySelector("#inst-dash-sections") || document.getElementById("inst-dash-sections");
const updated = el.querySelector("#inst-dash-updated") || document.getElementById("inst-dash-updated");
if (loading) return;
loading = true;
if (status && !(opts && opts.silent)) status.textContent = "加载中…";
try {
const [dashRes, priceRes] = await Promise.all([
fetch("/api/instance/dashboard", { credentials: "same-origin" }),
fetch("/api/price_snapshot", { credentials: "same-origin" }).catch(function () {
return null;
}),
]);
const data = await dashRes.json().catch(function () {
return {};
});
if (!dashRes.ok || !data.ok) {
throw new Error(data.msg || dashRes.statusText || "加载失败");
}
let orderPrices = [];
if (priceRes && priceRes.ok) {
try {
const snap = await priceRes.json();
orderPrices = snap.order_prices || [];
} catch (_) {}
}
if (data.orders && Array.isArray(data.orders.items)) {
data.orders.items = mergeOrderLive(data.orders.items, orderPrices);
data.orders.count = data.orders.items.length;
}
if (updated) updated.textContent = "更新 " + (data.updated_at || "—");
if (sections) {
const html = SECTION_ORDER.map(function (k) {
return renderSection(k, data[k]);
}).join("");
sections.innerHTML =
html || '<p class="inst-dash-empty muted">当前无活跃监控与持仓</p>';
bindClicks(sections);
if (global.OptionsExpiryCountdown) {
if (typeof global.OptionsExpiryCountdown.tick === "function") {
global.OptionsExpiryCountdown.tick(sections);
}
if (typeof global.OptionsExpiryCountdown.ensureTimer === "function") {
global.OptionsExpiryCountdown.ensureTimer();
}
}
}
if (status) status.textContent = "";
} catch (e) {
if (status) status.textContent = e.message || "加载失败";
} finally {
loading = false;
}
}
function stopAuto() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
function startAuto() {
stopAuto();
timer = setInterval(function () {
const el = root();
if (!el) return;
const pane = el.closest(".embed-tab-pane");
if (pane && !pane.classList.contains("is-active-pane")) return;
load({ silent: true });
}, 15000);
}
function init(force) {
const el = root();
if (!el) return;
if (!force && el.getAttribute("data-dash-booted") === "1") {
load({ silent: true });
startAuto();
return;
}
el.setAttribute("data-dash-booted", "1");
const btn = el.querySelector("#inst-dash-refresh") || document.getElementById("inst-dash-refresh");
if (btn && !btn.getAttribute("data-bound")) {
btn.setAttribute("data-bound", "1");
btn.addEventListener("click", function () {
load({});
});
}
load({});
startAuto();
}
function refreshSoft(opts) {
load(Object.assign({ silent: true }, opts || {}));
}
global.InstanceDashboard = {
init: init,
refreshSoft: refreshSoft,
load: load,
stopAuto: stopAuto,
};
})(window);
+525
View File
@@ -0,0 +1,525 @@
/**
* 中控 iframe 壳:顶栏/统计常驻,tab 内容走 /api/embed/page/<tab>.
* 各 tab 面板常驻 DOM,切换时 show/hide;脚本延后到首次激活,回访零请求.
*/
(function (global) {
const TAB_PATH = {
dashboard: "/dashboard",
key_monitor: "/key_monitor",
trade: "/trade",
strategy: "/strategy",
strategy_records: "/strategy/records",
options: "/options",
options_review: "/options/review",
hedge_plan: "/hedge-plan",
records: "/records",
stats: "/stats",
risk_policy: "/risk_policy",
env_config: "/env_config",
settings: "/settings",
};
let navToken = 0;
let loadingTab = false;
let pendingTabLoad = null;
const tabPanes = new Map();
const tabBooted = new Set();
/** 自带校验后 form.submit() 的表单,勿在捕获阶段再 fetch 一份(会双发 POST) */
const CUSTOM_SUBMIT_FORM_IDS = new Set(["add-order-form", "key-form", "roll-form"]);
function isEmbedShell() {
return document.body && document.body.getAttribute("data-embed-shell") === "1";
}
function getTab() {
try {
const t = new URLSearchParams(location.search).get("tab");
if (t) return t;
} catch (_) {}
return document.body.getAttribute("data-page") || "trade";
}
function listWindowQueryString() {
if (typeof global.listWindowQueryString === "function") {
return global.listWindowQueryString();
}
return "";
}
function pageRoot() {
return document.getElementById("embed-page-root");
}
function setNavActive(tab) {
document.querySelectorAll(".embed-top-nav [data-embed-tab]").forEach((a) => {
a.classList.toggle("active", a.getAttribute("data-embed-tab") === tab);
});
}
function pageNavAllowed(tab) {
if (global.InstanceSettingsPrefs && typeof global.InstanceSettingsPrefs.pageNavAllowed === "function") {
return global.InstanceSettingsPrefs.pageNavAllowed(tab);
}
return true;
}
function syncUrl(tab, replace) {
const q = new URLSearchParams(location.search);
q.set("tab", tab);
q.set("embed", "1");
const qs = q.toString();
const url = "/embed?" + qs;
if (replace) history.replaceState({ embedTab: tab }, "", url);
else history.pushState({ embedTab: tab }, "", url);
}
function notifyParentTabSwitch(tab) {
try {
window.parent.postMessage({ type: "instance-frame-navigating", embedShellTab: true, tab: tab }, "*");
} catch (_) {}
}
function runPageInit(tab, opts) {
const options = opts || {};
const revisit = !!options.revisit;
document.body.setAttribute("data-page", tab);
if (!revisit && typeof global.attachListWindowToExports === "function") {
global.attachListWindowToExports();
}
if (tab === "trade") {
if (!revisit && typeof global.refreshOrderDefaults === "function") global.refreshOrderDefaults();
if (!revisit && typeof global.initOrderEntryModelSelect === "function") {
const root = pageRoot() || document;
global.initOrderEntryModelSelect(root);
}
if (!revisit && global.ManualOrderRrPreview && typeof global.ManualOrderRrPreview.wire === "function") {
global.ManualOrderRrPreview.wire();
}
}
if (!revisit && tab === "key_monitor" && global.KeyMonitorForm && typeof global.KeyMonitorForm.init === "function") {
global.KeyMonitorForm.init();
}
if (tab === "dashboard" && global.InstanceDashboard && typeof global.InstanceDashboard.init === "function") {
global.InstanceDashboard.init(!!revisit);
}
if (!revisit && tab === "strategy" && typeof global.initStrategyRollForm === "function") {
global.initStrategyRollForm();
}
if (tab === "records") {
if (global.RecordsReviewPage && typeof global.RecordsReviewPage.init === "function") {
global.RecordsReviewPage.init({ refresh: !!revisit });
} else {
if (!revisit && typeof global.loadJournals === "function") global.loadJournals();
if (!revisit && typeof global.loadReviews === "function") global.loadReviews();
}
if (global.InstanceTheme && typeof global.InstanceTheme.initReviewEditModeSync === "function") {
global.InstanceTheme.initReviewEditModeSync();
} else if (typeof global.toggleReviewMode === "function") {
global.toggleReviewMode();
}
}
if (tab === "stats") {
if (typeof global.initStatsSegmentFromUrl === "function") global.initStatsSegmentFromUrl();
}
if (tab === "settings" || tab === "env_config") {
if (global.InstanceSettingsPrefs) {
if (typeof global.InstanceSettingsPrefs.bindEvents === "function") {
global.InstanceSettingsPrefs.bindEvents();
}
if (tab === "settings" && typeof global.InstanceSettingsPrefs.loadDisplayPrefsForm === "function") {
global.InstanceSettingsPrefs.loadDisplayPrefsForm();
}
if (tab === "env_config") {
if (typeof global.InstanceSettingsPrefs.loadEnvConfig === "function") {
global.InstanceSettingsPrefs.loadEnvConfig();
}
if (typeof global.InstanceSettingsPrefs.bindEnvTabs === "function") {
global.InstanceSettingsPrefs.bindEnvTabs();
}
}
}
}
if (!revisit) {
if (typeof global.refreshAccountSnapshot === "function") {
global.refreshAccountSnapshot({ silent: true });
}
if (typeof global.refreshPriceSnapshotConditional === "function") {
global.refreshPriceSnapshotConditional();
}
if (global.SymbolLivePrice && typeof global.SymbolLivePrice.init === "function") {
const root = pageRoot() || document;
global.SymbolLivePrice.init(root);
}
if (global.JournalUploadSlots && typeof global.JournalUploadSlots.init === "function") {
const root = pageRoot() || document;
global.JournalUploadSlots.init(root);
}
}
}
function runScripts(container) {
container.querySelectorAll("script").forEach((old) => {
const s = document.createElement("script");
if (old.src) s.src = old.src;
else s.textContent = old.textContent;
old.replaceWith(s);
});
}
function showPane(tab) {
tabPanes.forEach((pane, name) => {
const on = name === tab;
pane.hidden = !on;
pane.classList.toggle("is-active-pane", on);
});
}
function bootPaneScripts(tab) {
if (tabBooted.has(tab)) return;
const pane = tabPanes.get(tab);
if (!pane) return;
runScripts(pane);
tabBooted.add(tab);
}
function mountPane(tab, html) {
const root = pageRoot();
if (!root) return null;
const existing = tabPanes.get(tab);
if (existing) existing.remove();
const pane = document.createElement("div");
pane.className = "embed-tab-pane";
pane.setAttribute("data-embed-pane", tab);
pane.hidden = true;
const holder = document.createElement("div");
holder.innerHTML = html;
while (holder.firstChild) pane.appendChild(holder.firstChild);
root.appendChild(pane);
tabPanes.set(tab, pane);
return pane;
}
function initBootPane() {
const root = pageRoot();
if (!root || tabPanes.size > 0) return;
const tab = getTab();
if (root.querySelector("[data-embed-pane]")) return;
if (!root.childNodes.length) return;
const pane = document.createElement("div");
pane.className = "embed-tab-pane is-active-pane";
pane.setAttribute("data-embed-pane", tab);
Array.from(root.childNodes).forEach((node) => pane.appendChild(node));
root.appendChild(pane);
tabPanes.set(tab, pane);
tabBooted.add(tab);
showPane(tab);
}
function embedPageUrl(tab) {
const qs = listWindowQueryString();
let url = "/api/embed/page/" + encodeURIComponent(tab);
const parts = [];
if (qs) parts.push(qs);
parts.push("embed=1");
return url + "?" + parts.join("&");
}
async function fetchTabHtml(tab) {
const r = await fetch(embedPageUrl(tab), {
credentials: "same-origin",
headers: { "X-Instance-Soft-Nav": "1" },
});
const ct = (r.headers.get("content-type") || "").toLowerCase();
if (!ct.includes("application/json")) {
throw new Error("加载失败(HTTP " + r.status + ")");
}
const j = await r.json();
if (!j.ok || !j.html) throw new Error(j.msg || "加载失败");
return j.html;
}
function warmTabCache(tab) {
if (!tab || tabPanes.has(tab) || loadingTab) return;
fetchTabHtml(tab)
.then((html) => {
if (!tabPanes.has(tab)) mountPane(tab, html);
})
.catch(() => {});
}
function preloadAllTabs() {
const tabs = Object.keys(TAB_PATH);
const current = getTab();
const heavyLast = new Set(["options", "records", "stats"]);
const ordered = tabs.filter((t) => t !== current && !heavyLast.has(t))
.concat(tabs.filter((t) => heavyLast.has(t) && t !== current));
let idx = 0;
function step() {
if (idx >= ordered.length) return;
const tab = ordered[idx++];
if (tabPanes.has(tab)) {
step();
return;
}
fetchTabHtml(tab)
.then((html) => {
if (!tabPanes.has(tab)) mountPane(tab, html);
})
.catch(() => {})
.finally(() => {
setTimeout(step, heavyLast.has(tab) ? 400 : 180);
});
}
const ric = global.requestIdleCallback || function (fn) {
setTimeout(fn, 2000);
};
ric(step);
}
function clearTabCache() {
tabPanes.forEach((pane) => pane.remove());
tabPanes.clear();
tabBooted.clear();
}
function syncShellChrome(tab) {
const hideTopBar = tab === "settings" || tab === "risk_policy" || tab === "env_config";
document.querySelectorAll(".instance-top-bar").forEach((el) => {
el.hidden = hideTopBar;
});
}
function initPaneThemeToggle(tab) {
if (tab !== "settings") return;
const pane = tabPanes.get(tab);
if (!pane || !global.InstanceTheme) return;
if (typeof global.InstanceTheme.initToggleUI === "function") {
global.InstanceTheme.initToggleUI(pane);
}
if (typeof global.InstanceTheme.syncToggleUI === "function") {
global.InstanceTheme.syncToggleUI(pane);
}
}
function activateTab(tab, opts) {
const options = opts || {};
const revisit = !!options.revisit;
const firstBoot = !tabBooted.has(tab);
syncShellChrome(tab);
showPane(tab);
setNavActive(tab);
if (!options.skipUrl) syncUrl(tab, !!options.replace);
notifyParentTabSwitch(tab);
if (firstBoot) {
bootPaneScripts(tab);
initPaneThemeToggle(tab);
runPageInit(tab, { revisit: false });
return;
}
if (revisit) {
document.body.setAttribute("data-page", tab);
runPageInit(tab, { revisit: true });
return;
}
runPageInit(tab, { revisit: false });
}
async function loadTab(tab, opts) {
const options = opts || {};
if (!tab) return;
if (!pageNavAllowed(tab)) {
void loadTab("trade", { replace: true });
return;
}
if (tabPanes.has(tab) && !options.force) {
activateTab(tab, Object.assign({}, options, { revisit: true }));
return;
}
if (loadingTab) {
pendingTabLoad = { tab: tab, opts: options };
return;
}
const token = ++navToken;
loadingTab = true;
try {
const html = await fetchTabHtml(tab);
if (token !== navToken) return;
mountPane(tab, html);
activateTab(tab, options);
} catch (e) {
if (token === navToken) {
const flash = document.getElementById("embed-flash");
if (flash) {
flash.style.display = "";
flash.textContent = String(e && e.message ? e.message : e);
}
}
} finally {
if (token === navToken) loadingTab = false;
if (pendingTabLoad) {
const pending = pendingTabLoad;
pendingTabLoad = null;
if (pending.tab !== tab) void loadTab(pending.tab, pending.opts);
}
}
}
function reloadCurrentTab() {
const tab = getTab();
const pane = tabPanes.get(tab);
if (pane) pane.remove();
tabPanes.delete(tab);
tabBooted.delete(tab);
return loadTab(tab, { replace: true, skipUrl: true, force: true });
}
function postFormAndReload(form, label) {
if (!form) return Promise.resolve();
if (global.FormSubmitGuard) {
if (global.FormSubmitGuard.isLocked(form)) {
global.FormSubmitGuard.setSubmitLabel(form, label || "提交中…");
} else {
global.FormSubmitGuard.lock(form, label || "提交中…");
}
}
const fd = new FormData(form);
return fetch(form.action, {
method: form.method || "POST",
body: fd,
credentials: "same-origin",
redirect: "manual",
})
.then(() => reloadCurrentTab())
.catch(() => reloadCurrentTab());
}
function patchApplyListWindow() {
if (typeof global.applyListWindow !== "function") return;
global.applyListWindow = function embedApplyListWindow() {
clearTabCache();
const qs = listWindowQueryString();
const tab = getTab();
const q = new URLSearchParams(qs);
q.set("tab", tab);
q.set("embed", "1");
window.location.href = "/embed?" + q.toString();
};
}
function patchHardNavigations() {
const resubmitPaths =
/^\/(del_|delete_|add_|stop_|strategy\/|trend_|roll_|cancel_|place_)/;
document.addEventListener(
"click",
(ev) => {
if (!isEmbedShell()) return;
const a = ev.target.closest("a[href]");
if (!a || ev.defaultPrevented) return;
if (a.closest(".embed-top-nav")) return;
if (a.hasAttribute("download") || a.target === "_blank") return;
const raw = a.getAttribute("href");
if (!raw || raw.startsWith("#") || raw.startsWith("javascript:")) return;
let url;
try {
url = new URL(raw, location.href);
} catch (_) {
return;
}
if (url.origin !== location.origin) return;
if (url.pathname.startsWith("/export/") || url.pathname.startsWith("/order_focus") || url.pathname.startsWith("/key_focus")) {
return;
}
if (!resubmitPaths.test(url.pathname)) return;
ev.preventDefault();
fetch(url.pathname + url.search, { credentials: "same-origin", redirect: "manual" })
.then(() => reloadCurrentTab())
.catch(() => reloadCurrentTab());
},
false
);
document.addEventListener(
"submit",
(ev) => {
if (!isEmbedShell()) return;
const form = ev.target;
if (!(form instanceof HTMLFormElement)) return;
if (form.method && form.method.toUpperCase() === "GET") return;
if (CUSTOM_SUBMIT_FORM_IDS.has(form.id)) return;
ev.preventDefault();
const fd = new FormData(form);
fetch(form.action, {
method: form.method || "POST",
body: fd,
credentials: "same-origin",
redirect: "manual",
})
.then(() => reloadCurrentTab())
.catch(() => reloadCurrentTab());
},
true
);
}
function bindNav() {
document.querySelectorAll(".embed-top-nav [data-embed-tab]").forEach((a) => {
a.addEventListener("mouseenter", () => {
warmTabCache(a.getAttribute("data-embed-tab"));
});
a.addEventListener("click", (ev) => {
ev.preventDefault();
const tab = a.getAttribute("data-embed-tab");
if (!tab || tab === getTab()) return;
void loadTab(tab);
});
});
window.addEventListener("popstate", () => {
const tab = getTab();
void loadTab(tab, { replace: true, skipUrl: true });
});
}
function boot() {
if (!isEmbedShell()) return;
patchApplyListWindow();
patchHardNavigations();
initBootPane();
const bootTab = getTab();
if (!pageNavAllowed(bootTab)) {
void loadTab("trade", { replace: true });
return;
}
if (bootTab === "settings") {
initPaneThemeToggle("settings");
}
bindNav();
syncShellChrome(getTab());
runPageInit(getTab());
preloadAllTabs();
try {
window.parent.postMessage({ type: "instance-frame-ready" }, "*");
} catch (_) {}
}
global.InstanceEmbed = {
loadTab,
reloadCurrentTab,
getTab,
postFormAndReload,
clearTabCache,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})(typeof window !== "undefined" ? window : globalThis);
+113
View File
@@ -0,0 +1,113 @@
/**
* embed 壳:SSE 收到后台 tick 后拉 JSON 快照更新 DOM,切换 tab 不再重复请求 HTML.
*/
(function (global) {
let liveEventSource = null;
let liveReconnectTimer = null;
let localLiveVersion = -1;
let sseConnected = false;
let refreshTimer = null;
function isEmbedShell() {
return document.body && document.body.getAttribute("data-embed-shell") === "1";
}
function currentTab() {
if (global.InstanceEmbed && typeof global.InstanceEmbed.getTab === "function") {
return global.InstanceEmbed.getTab();
}
return document.body.getAttribute("data-page") || "trade";
}
function refreshTabData(tab, opts) {
const options = opts || {};
if (typeof global.refreshAccountSnapshot === "function") {
global.refreshAccountSnapshot(options);
}
if (typeof global.refreshPriceSnapshotConditional === "function") {
global.refreshPriceSnapshotConditional();
}
if (tab === "options" && global.OptionsPanelLive && typeof global.OptionsPanelLive.refreshSoft === "function") {
global.OptionsPanelLive.refreshSoft(options);
}
if (tab === "dashboard" && global.InstanceDashboard && typeof global.InstanceDashboard.refreshSoft === "function") {
global.InstanceDashboard.refreshSoft(options);
}
}
function scheduleRefresh(opts) {
if (refreshTimer) return;
const options = opts || {};
refreshTimer = setTimeout(function () {
refreshTimer = null;
if (document.hidden) return;
refreshTabData(currentTab(), { silent: true, force: !!options.force });
}, 80);
}
function onLiveEvent(data) {
const reason = data && data.reason;
const ver = Number(data && data.live_version) || 0;
if (!ver) return;
if (reason === "connect") {
localLiveVersion = ver;
scheduleRefresh();
return;
}
if (ver === localLiveVersion) return;
localLiveVersion = ver;
scheduleRefresh({ force: reason === "balance" });
}
function closeLiveStream() {
if (liveEventSource) {
liveEventSource.close();
liveEventSource = null;
}
if (liveReconnectTimer) {
clearTimeout(liveReconnectTimer);
liveReconnectTimer = null;
}
sseConnected = false;
}
function connectLiveStream() {
if (!isEmbedShell()) return;
closeLiveStream();
liveEventSource = new EventSource("/api/instance/live/stream");
liveEventSource.addEventListener("live", function (ev) {
try {
onLiveEvent(JSON.parse(ev.data || "{}"));
} catch (_) {}
});
liveEventSource.onopen = function () {
sseConnected = true;
};
liveEventSource.onerror = function () {
sseConnected = false;
closeLiveStream();
liveReconnectTimer = setTimeout(function () {
connectLiveStream();
}, 8000);
};
}
function startLive() {
if (!isEmbedShell()) return;
connectLiveStream();
}
global.InstanceLive = {
start: startLive,
refreshTabData: refreshTabData,
isConnected: function () {
return sseConnected;
},
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", startLive);
} else {
startLive();
}
})(typeof window !== "undefined" ? window : globalThis);
+294
View File
@@ -0,0 +1,294 @@
.order-trade-style-hint{font-size:.78rem;color:#8fc8ff;margin-left:4px;white-space:nowrap}
.order-entry-model-row{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
.order-entry-model-row select.order-entry-category{min-width:4.8em;max-width:6.5em}
.order-entry-model-row select.order-entry-model-sub{min-width:7em;max-width:10rem}
.order-leverage-hint{font-size:.78rem;color:#cfd3ef;white-space:nowrap;align-self:center}
body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;background:#0b0d14;color:#eaeaea;padding:14px 20px}
.container{width:100%;max-width:min(1440px,94vw);margin:0 auto;padding:0 clamp(8px,1.5vw,20px)}
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
.header-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center}
.top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px}
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
.stat-box{display:grid;grid-template-columns:repeat(auto-fit,minmax(148px,1fr));gap:12px;margin-bottom:16px;align-items:stretch}
.stat-item{min-width:0;min-height:76px;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:6px;background:#151a2a;padding:12px 10px;border-radius:10px;text-align:center;border:1px solid #2a3152}
.stat-item .label{font-size:.8rem;color:#aaa;line-height:1.25;max-width:100%}
.stat-item .value{font-size:1.25rem;font-weight:600;color:#fff;line-height:1.3;min-height:1.35em;display:flex;align-items:center;justify-content:center}
.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
.card{background:#121726;border-radius:10px;padding:12px;border:1px solid #2a3150}
.full{grid-column:1/-1}
.card h2{font-size:1rem;margin-bottom:10px;color:#d4d9ff}
.form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center}
.form-row > input:not([type=checkbox]):not([type=radio]),.form-row > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem}
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
.order-plan-preview{display:flex;gap:18px;flex-wrap:wrap;align-items:center;margin:4px 0 10px;padding:10px 12px;background:#151a28;border:1px solid #2a3150;border-radius:8px;font-size:.85rem}
.order-preview-risk{color:#ff6b6b}
.order-preview-risk strong{color:#ff8f8f;font-weight:600}
.order-preview-profit{color:#4cd97f}
.order-preview-profit strong{color:#6ee7a0;font-weight:600}
.order-preview-rr{color:#cfd3ef}
.order-preview-rr strong{font-weight:600;color:#dbe4ff}
.order-preview-rr.order-preview-rr-low strong{color:#ff8f8f}
.order-preview-rr.order-preview-rr-ok strong{color:#8fc8ff}
.form-row > button,.form-row > label{flex:0 0 auto}
.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
/* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */
.journal-card .form-grid{gap:10px}
.journal-card .form-grid > input,
.journal-card .form-grid > select{
min-width:0;
width:100%;
max-width:100%;
box-sizing:border-box;
}
.journal-card #journal-form textarea[name="note"]{
display:block;width:100%;max-width:100%;box-sizing:border-box;margin-top:8px;
}
input,select,button,textarea{padding:8px 10px;border-radius:8px;border:1px solid #2e2e45;background:#1a1a29;color:#fff;font-size:.88rem;outline:none}
button{background:linear-gradient(90deg,#4285f4,#7b42ff);border:none;cursor:pointer}
.list{display:flex;flex-direction:column;gap:8px;margin-top:8px;max-height:240px;overflow:auto}
.list-item{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:9px;background:#1a2034;border:1px solid #2a3150;border-radius:8px}
.btn-del{padding:5px 9px;background:#2f2134;color:#ff7b7b;border-radius:8px;text-decoration:none;font-size:.8rem}
.rule-tip{font-size:.8rem;color:#95a2c2;margin-bottom:8px}
table{width:100%;border-collapse:collapse}
th,td{padding:8px;text-align:left;border-bottom:1px solid #25253b;font-size:.85rem}
th{color:#a9a9ff}
.badge{padding:2px 6px;border-radius:6px;font-size:.72rem}
.profit{background:#1e332f;color:#4cd97f}
.loss{background:#331e24;color:#ff6666}
.miss{background:#29241e;color:#eac147}
.direction{background:#1e2533;color:#4cc2ff}
.direction-long{background:#1e332f;color:#4cd97f}
.direction-short{background:#331e24;color:#ff6666}
.pnl-profit{color:#4cd97f;font-weight:600}
.pnl-loss{color:#ff6666;font-weight:600}
.flash{padding:10px;background:#1e2533;color:#4cc2ff;border-radius:10px;margin-bottom:12px;text-align:center;border:1px solid #304164}
form.is-form-submitting{opacity:.88;pointer-events:none}
form.is-form-submitting button[type=submit],form.is-form-submitting input[type=submit]{cursor:wait}
.ai-result{background:#1a1a29;border:1px solid #2e2e45;border-radius:8px;padding:10px;white-space:pre-wrap;max-height:220px;overflow:auto;font-size:.84rem;line-height:1.45;margin-top:8px}
.ai-result.ai-result-md,.detail-modal .panel-body.md-review{white-space:normal}
.ai-result-md p,.detail-modal .panel-body.md-review p{margin:6px 0;color:#dde2ff}
.ai-result-md ul,.ai-result-md ol,.detail-modal .panel-body.md-review ul,.detail-modal .panel-body.md-review ol{margin:6px 0 8px 1.25em;padding:0}
.ai-result-md li,.detail-modal .panel-body.md-review li{margin:5px 0;line-height:1.5}
.ai-result-md strong,.detail-modal .panel-body.md-review strong{color:#f0f3ff;font-weight:600}
.ai-result-md h2,.detail-modal .panel-body.md-review h2{font-size:1.02rem;color:#b8c8ff;margin:14px 0 8px;padding-bottom:4px;border-bottom:1px solid #2e2e45}
.ai-result-md h3,.detail-modal .panel-body.md-review h3{font-size:.92rem;color:#c9d4ff;margin:10px 0 6px}
.ai-result-md code,.detail-modal .panel-body.md-review code{background:#252538;padding:1px 4px;border-radius:4px;font-size:.82em}
.ai-result-md .md-raw-block-title,.detail-modal .panel-body.md-review .md-raw-block-title{margin-top:14px;padding-top:10px;border-top:1px dashed #3a3a55;color:#a8b0d8;font-weight:600}
.price-up{color:#4cd97f}
.price-down{color:#ff6666}
.price-flat{color:#cfd3ef}
.panel-list{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.panel-item{background:#141423;border:1px solid #24243b;border-radius:10px;padding:10px;max-height:260px;overflow:auto}
.entry{border-bottom:1px solid #2b2b43;padding:8px 0}
.entry:last-child{border-bottom:none}
.table-del{padding:4px 8px;background:#2f2134;color:#ff7b7b;border:none;border-radius:6px;cursor:pointer;font-size:.78rem}
.mood-grid{display:flex;gap:10px;flex-wrap:wrap;font-size:.82rem;color:#d7d7ea}
.mood-grid label{display:flex;align-items:center;gap:3px}
.screenshot{width:100px;border-radius:6px;cursor:pointer;margin-top:6px}
.modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:1210}
.modal img{max-width:90%;max-height:90%;border-radius:8px}
.detail-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:1200;padding:20px}
.detail-modal .panel{width:min(92vw,980px);max-height:88vh;overflow:auto;background:#121726;border:1px solid #2a3150;border-radius:10px;padding:14px}
.detail-modal .panel-head{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:10px}
.detail-modal .panel-title{font-size:1rem;color:#dbe4ff}
.detail-modal .panel-close{padding:6px 10px;background:#2f2134;color:#ffb2b2;border:none;border-radius:8px;cursor:pointer}
.detail-modal .panel-body{white-space:pre-wrap;line-height:1.5;font-size:.86rem;color:#e5e9ff}
.detail-modal .panel-image{margin-top:10px;max-width:min(100%,680px);border-radius:8px;cursor:pointer;border:1px solid #2a3150}
.detail-modal .panel-actions{display:flex;gap:8px;align-items:center;flex-shrink:0}
.detail-modal .panel-fs{padding:6px 10px;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;cursor:pointer;font-size:.82rem}
.detail-modal.fullscreen{padding:10px}
.detail-modal.fullscreen .panel{width:100%;height:100%;max-width:none;max-height:none;display:flex;flex-direction:column;overflow:hidden}
.detail-modal.fullscreen .panel-body{flex:1;overflow:auto;min-height:0;font-size:.9rem}
.ai-result-wrap{margin-top:8px}
.ai-result-toolbar{display:flex;gap:8px;margin-top:6px}
.ai-result-toolbar .btn-fs{padding:4px 10px;font-size:.78rem;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:6px;cursor:pointer}
.table-wrap{overflow-x:auto}
.dual-panel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:stretch}
.dual-panel-grid .card{height:100%;display:flex;flex-direction:column}
.panel-scroll{flex:1;min-height:280px;max-height:420px;overflow:auto}
.records-card{grid-column:1/-1}
.review-card{grid-column:1/-1}
.review-card-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap}
.review-card-head h2{margin:0}
.review-card-fs-btn{padding:6px 12px;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;cursor:pointer;font-size:.82rem;white-space:nowrap}
.review-card-fs-btn:hover{filter:brightness(1.08)}
body.review-card-fullscreen-open{overflow:hidden}
.review-card.is-fullscreen{
position:fixed;inset:12px;z-index:1100;margin:0;
width:auto !important;max-width:none;height:auto;
overflow:auto;display:flex;flex-direction:column;
box-shadow:0 12px 48px rgba(0,0,0,.55);
}
.review-card.is-fullscreen .panel-list{flex:1;min-height:320px}
.review-card.is-fullscreen .panel-item{max-height:none;height:auto;min-height:280px}
.review-card.is-fullscreen .ai-result{max-height:min(36vh, 320px)}
@media (max-width: 1200px){
.stat-box{grid-template-columns:repeat(auto-fill,minmax(140px,1fr))}
}
@media (min-width: 1440px){
.panel-scroll,.pos-list{max-height:420px}
.records-card .table-wrap{max-height:620px;overflow:auto}
}
@media (min-width: 2200px){
.container{max-width:min(1720px,90vw)}
}
@media (min-width: 2560px){
.container{max-width:min(1860px,88vw)}
.dual-panel-grid{gap:18px}
}
@media (min-width: 3000px){
.container{max-width:min(1980px,86vw)}
.pos-grid{grid-template-columns:repeat(4,minmax(0,1fr))}
}
@media (max-width: 1100px){
.grid{grid-template-columns:1fr}
.dual-panel-grid{grid-template-columns:1fr}
.records-card,.review-card{grid-column:auto}
.panel-list{grid-template-columns:1fr}
}
@media (max-width: 960px){
body{padding:10px}
.form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
.stat-box{grid-template-columns:repeat(2,minmax(0,1fr))}
}
.stats-detail{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px;margin-top:10px}
.stats-detail .stat-item{min-width:0;min-height:0;display:block;text-align:left;padding:10px 12px;align-items:stretch;gap:4px}
.stats-detail .stat-item .value{min-height:0;display:block;font-size:1.05rem}
.stats-detail .stat-item .label{font-size:.75rem}
.stats-detail .stat-item .value{font-size:1.05rem;word-break:break-all}
.export-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px;font-size:.85rem}
.export-bar a{color:#8fc8ff;text-decoration:none;padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a}
.export-bar a:hover{background:#1f2740}
.list-window-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px;padding:10px 12px;background:#151a2a;border:1px solid #304164;border-radius:10px;font-size:.82rem}
.list-window-bar label{color:#9aa;display:flex;align-items:center;gap:6px}
.stats-segment-block{margin-top:20px;padding-top:14px;border-top:1px solid #3a4468}
.stats-segment-block h2{font-size:1.05rem;color:#dbe4ff;margin-bottom:8px}
.stats-period-tabs{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px;position:relative;z-index:2}
.stats-period-tab{background:#151a2a;color:#9aa3bf;border:1px solid #304164;border-radius:8px;padding:7px 14px;font-size:.84rem;cursor:pointer;transition:background .15s,border-color .15s,color .15s}
.stats-period-tab:hover{background:#1c2438;color:#cfd3ef}
.stats-period-tab.active{background:#1f3a5a;color:#8fc8ff;border-color:#3d5f8a;font-weight:600}
.stats-period-pane[hidden]{display:none!important}
.stats-period-range{font-size:.78rem;color:#8892b0;margin-bottom:12px;line-height:1.45}
.inst-stats-viz{display:flex;flex-direction:column;gap:14px;margin-bottom:14px}
.inst-stats-kpis{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}
.inst-stats-kpi{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;padding:12px 10px;background:#151a2a;border:1px solid #2a3152;border-radius:10px;text-align:center;min-height:88px}
.inst-stats-kpi-val{font-size:1.15rem;font-weight:700;font-variant-numeric:tabular-nums;line-height:1.2}
.inst-stats-kpi-lbl{font-size:.72rem;color:#8892b0;line-height:1.3}
.inst-stats-ring{--win-pct:0;width:56px;height:56px;border-radius:50%;background:conic-gradient(#4cd97f 0 calc(var(--win-pct) * 1%),#ff6b6b calc(var(--win-pct) * 1%) 100%);display:flex;align-items:center;justify-content:center;position:relative}
.inst-stats-ring::before{content:"";position:absolute;inset:7px;border-radius:50%;background:#151a2a}
.inst-stats-ring-label{position:relative;z-index:1;font-size:.78rem;font-weight:700;font-variant-numeric:tabular-nums}
.inst-stats-block{padding:12px;background:#141923;border:1px solid #2a3150;border-radius:10px}
.inst-stats-block-title{font-size:.72rem;color:#8892b0;margin-bottom:8px}
.inst-stats-stacked-bar{display:flex;height:10px;border-radius:6px;overflow:hidden;background:#1e2438}
.inst-stats-stacked-fill{height:100%;min-width:0;transition:width .2s ease}
.inst-stats-stacked-fill--profit{background:#4cd97f}
.inst-stats-stacked-fill--loss{background:#ff6b6b}
.inst-stats-bar-labels{display:flex;justify-content:space-between;gap:10px;margin-top:8px;font-size:.76rem;font-variant-numeric:tabular-nums}
.inst-stats-risk-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 12px}
.inst-stats-risk-item{display:flex;flex-direction:column;gap:3px;min-width:0}
.inst-stats-risk-item .k{font-size:.7rem;color:#8892b0}
.inst-stats-risk-item .v{font-size:.84rem;font-weight:600;font-variant-numeric:tabular-nums;color:#e8ecf4;word-break:break-word}
.inst-stats-empty{margin:0;padding:18px;text-align:center;color:#8892b0;font-size:.85rem;background:#141923;border:1px dashed #2a3348;border-radius:10px}
.inst-stats-details{margin-top:4px}
.inst-stats-details>summary{cursor:pointer;font-size:.84rem;color:#9aa3bf;padding:8px 0;user-select:none;list-style-position:inside}
.inst-stats-details>summary::-webkit-details-marker{color:#6d7689}
.inst-stats-details[open]>summary{margin-bottom:6px;color:#cfd3ef}
@media (max-width:640px){.inst-stats-kpis{grid-template-columns:1fr}.inst-stats-risk-grid{grid-template-columns:1fr}}
.key-history{margin-top:12px;padding-top:10px;border-top:1px solid #2a3150}
.key-history h3{font-size:.88rem;color:#b8c4ff;margin-bottom:6px}
.key-history .sub{font-size:.72rem;color:#8892b0;margin-bottom:6px}
.key-history .list{max-height:200px}
.pos-section{margin-top:12px}
.pos-section-title{font-size:.82rem;color:#8892b0;margin-bottom:8px;font-weight:500}
.pos-list{display:flex;flex-direction:column;gap:10px;max-height:280px;overflow:auto}
.dual-panel-grid .pos-list-live{max-height:none;overflow:visible;flex:1 1 auto}
.dual-panel-grid .panel-scroll.pos-list-live{max-height:none;overflow:visible}
.pos-card{background:#141923;border:1px solid #2a3348;border-radius:10px;padding:12px 14px}
.pos-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px}
.pos-meta{font-size:.74rem;color:#8b95a8;line-height:1.45;margin-bottom:12px;display:flex;flex-wrap:wrap;align-items:center;gap:4px 0}
.pos-meta-item{display:inline-flex;align-items:center}
.pos-meta-item:not(:last-child)::after{content:'|';margin:0 8px;color:#3d4659}
.pos-meta-on{color:#6eb5ff}
.pos-meta-off{color:#7d8799}
.pos-breakeven-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:6px;font-size:.72rem;font-weight:600;background:#1a3d2e;color:#4cd97f}
.pos-card-symbol{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}
.pos-card-symbol strong{font-size:.95rem;color:#fff;font-weight:600}
.pos-side-badge{padding:3px 8px;border-radius:6px;font-size:.72rem;font-weight:500;line-height:1.2}
.pos-side-long{background:#253a6e;color:#6eb5ff}
.pos-side-short{background:#4a2230;color:#ff8a8a}
.pos-head-actions{display:flex;align-items:center;gap:6px;flex-shrink:0}
.pos-entrust-btn{padding:6px 12px;background:#2a4a7a;color:#8fc8ff;border:none;border-radius:8px;font-size:.82rem;font-weight:500;cursor:pointer;white-space:nowrap}
.pos-entrust-btn:hover{background:#355d96}
.pos-close-btn{padding:6px 14px;background:#c45454;color:#fff;border-radius:8px;text-decoration:none;font-size:.82rem;font-weight:500;flex-shrink:0;white-space:nowrap;border:none;cursor:pointer;display:inline-block}
.pos-close-btn:hover{background:#d66565;color:#fff}
.pos-ex-orders{margin-top:10px;padding-top:10px;border-top:1px dashed #2a3348}
.pos-ex-orders-title{font-size:.74rem;color:#7d8799;margin-bottom:6px}
.pos-ex-order-row{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:.78rem;color:#c5cce0;margin-top:5px}
.pos-ex-order-main{flex:1;min-width:0;line-height:1.35}
.pos-ex-cancel-btn{padding:3px 10px;background:#3a3048;color:#d4b8ff;border:none;border-radius:6px;font-size:.74rem;cursor:pointer;flex-shrink:0}
.pos-ex-cancel-btn:disabled{opacity:.4;cursor:not-allowed}
.tpsl-modal-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9000;align-items:center;justify-content:center;padding:16px}
.tpsl-modal-backdrop.open{display:flex}
.tpsl-modal{background:#1a2030;border:1px solid #3a4a66;border-radius:12px;padding:16px 18px;width:min(440px,100%);max-height:90vh;overflow:auto}
.tpsl-modal h3{margin:0 0 12px;font-size:1rem;color:#fff}
.tpsl-modal .form-row{margin-bottom:10px}
.tpsl-modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
.tpsl-modal-actions button{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem}
.tpsl-modal-submit{background:#2d6a4f;color:#fff}
.tpsl-modal-cancel{background:#3a3f52;color:#ddd}
.review-entry-reason-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9100;align-items:center;justify-content:center;padding:16px}
.review-entry-reason-backdrop.open{display:flex}
.review-entry-reason-modal{background:#1a2030;border:1px solid #3a4a66;border-radius:12px;padding:16px 18px;width:min(480px,100%);max-height:90vh;overflow:auto}
.review-entry-reason-modal h3{margin:0 0 8px;font-size:1rem;color:#fff}
.review-entry-reason-hint{margin:0 0 12px;font-size:.82rem;color:#9aa3c7;line-height:1.45}
.review-entry-reason-select{width:100%;padding:8px 10px;border-radius:8px;border:1px solid #3a4a66;background:#121726;color:#e8ecff;font-size:.9rem}
.review-entry-reason-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
.review-entry-reason-actions button{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem}
.review-entry-reason-ok{background:#2d6a4f;color:#fff}
.review-entry-reason-cancel{background:#3a3f52;color:#ddd}
.pos-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px 14px;margin-bottom:12px}
.pos-cell{display:flex;flex-direction:column;gap:4px;min-width:0}
.pos-label{font-size:.72rem;color:#7d8799}
.pos-value{font-size:.88rem;color:#e8ecf4;font-weight:500;line-height:1.25}
.pos-val-dash{opacity:.75;color:#8b95a8}
.pos-value.price-up{color:#4cd97f}
.pos-value.price-down{color:#ff6666}
.pos-value.price-flat{color:#e8ecf4}
.pos-footer{display:flex;flex-wrap:wrap;gap:14px 18px;font-size:.75rem;color:#6d7689}
.pos-empty{padding:18px;text-align:center;color:#8892b0;font-size:.85rem;background:#141923;border:1px dashed #2a3348;border-radius:10px}
@media (max-width:520px){.pos-grid{grid-template-columns:repeat(2,1fr)}}
.stats-card{grid-column:1/-1;margin-top:14px}
.stats-card .stats-toggle{background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;padding:6px 10px;cursor:pointer}
.stats-card.collapsed .stats-content{display:none}
.stats-period-block{margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid #2a3150}
.stats-period-block:last-child{border-bottom:none;margin-bottom:0;padding-bottom:0}
.stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px}
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
#embed-page-root{min-height:120px;position:relative}
.embed-tab-pane[hidden]{display:none!important}
.inst-dash-card{grid-column:1/-1}
.inst-dash-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:8px}
.inst-dash-desc{margin:0;font-size:.82rem}
.inst-dash-head-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.inst-dash-status{min-height:1.2em;margin:0 0 10px}
.inst-dash-sections{display:flex;flex-direction:column;gap:14px}
.inst-dash-section{padding:12px;background:#141923;border:1px solid #2a3150;border-radius:10px}
.inst-dash-section-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px}
.inst-dash-section-head h3{margin:0;font-size:.95rem;color:#dbe4ff}
.inst-dash-count{display:inline-block;min-width:1.4em;padding:1px 7px;margin-left:4px;border-radius:999px;background:#1f3a5a;color:#8fc8ff;font-size:.75rem;font-weight:600}
.inst-dash-empty{margin:0;padding:14px;text-align:center;border:1px dashed #2a3348;border-radius:8px;font-size:.84rem}
.inst-dash-table-wrap{overflow:auto;border:1px solid #2a3150;border-radius:8px}
.inst-dash-table{width:100%;border-collapse:collapse;font-size:.84rem}
.inst-dash-table th,.inst-dash-table td{padding:8px 10px;text-align:left;border-bottom:1px solid #25253b;white-space:nowrap}
.inst-dash-table th{color:#a9a9ff;background:#151a2a;font-weight:600}
.inst-dash-table tbody tr:last-child td{border-bottom:none}
.inst-dash-table tbody tr.inst-dash-row{cursor:pointer}
.inst-dash-table tbody tr.inst-dash-row:hover{background:#1e2740}
.inst-dash-sym-link{color:#8fc8ff;text-decoration:underline}
.inst-dash-dir-long{color:#4cd97f;font-weight:600}
.inst-dash-dir-short{color:#ff6666;font-weight:600}
.inst-dash-status-active{color:#4cd97f;font-weight:600}
.inst-dash-table .pos-tp-profit{color:#cfd3ef}
@@ -0,0 +1,74 @@
/**
* 手机端:交易记录 / 复盘记录紧凑列表(币种 · 方向 · 盈亏),点击展开详情.
*/
(function (global) {
"use strict";
var resizeTimer = null;
function refreshTradeRecords() {
var UI = global.InstanceUI;
if (!UI) return;
var card = document.querySelector(".records-card");
if (!card) return;
var tableWrap = card.querySelector(".table-wrap");
var table = tableWrap && tableWrap.querySelector("table");
if (!table) return;
var listEl = card.querySelector(".mobile-record-list");
var mobile = UI.isMobileCompactRecords();
if (!mobile) {
if (listEl) listEl.remove();
return;
}
if (!listEl) {
listEl = document.createElement("div");
listEl.className = "mobile-record-list";
tableWrap.parentNode.insertBefore(listEl, tableWrap);
}
var rows = table.querySelectorAll('tr[id^="trade-row-"]');
listEl.innerHTML = rows.length
? Array.prototype.map
.call(rows, function (tr) {
return UI.renderMobileTradeRow(tr);
})
.join("")
: '<div class="journal-empty-msg">暂无交易记录</div>';
listEl.querySelectorAll(".mobile-record-row").forEach(function (btn) {
btn.addEventListener("click", function () {
var rowId = btn.getAttribute("data-row-id");
var tr = rowId && document.getElementById(rowId);
if (tr) UI.openTradeRecordDetailModal(tr);
});
});
}
function onResize() {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
refreshTradeRecords();
if (typeof global.loadJournals === "function" && document.getElementById("journal-list")) {
global.loadJournals();
}
}, 180);
}
function init() {
refreshTradeRecords();
global.addEventListener("resize", onResize);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
global.InstanceRecordsMobile = {
refresh: refreshTradeRecords,
};
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,490 @@
/**
* 实例:导航显示,env 配置,改密,PM2 重启.
*/
(function (global) {
const DISPLAY = () => global.__INSTANCE_DISPLAY__ || {};
function setStatus(el, text, isErr) {
if (!el) return;
el.textContent = text || "";
el.classList.toggle("err", !!isErr);
}
async function fetchJson(url, opts) {
const res = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {}));
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.msg || res.statusText || "请求失败");
}
return data;
}
/** 默认关闭的导航开关:缺失时按 false,不能用 !== false */
const NAV_DEFAULT_OFF = { show_nav_dashboard: true };
function navPrefShow(display, key) {
if (!key) return true;
if (NAV_DEFAULT_OFF[key]) return display[key] === true;
return display[key] !== false;
}
function applyDisplayToNav(display) {
const map = {
dashboard: "show_nav_dashboard",
strategy: "show_nav_strategy",
strategy_records: "show_nav_strategy_records",
records: "show_nav_records",
stats: "show_nav_stats",
options: "show_nav_options",
"options-review": "show_nav_options_review",
options_review: "show_nav_options_review",
"hedge-plan": "show_nav_hedge_plan",
hedge_plan: "show_nav_hedge_plan",
risk_policy: "show_nav_risk_policy",
env_config: "show_nav_env_config",
};
document.querySelectorAll(".embed-top-nav [data-embed-tab], .top-nav a[href^='/']").forEach((a) => {
const tab = a.getAttribute("data-embed-tab") || (a.getAttribute("href") || "").replace(/^\//, "").split("?")[0];
const key = map[tab];
if (!key) return;
const show = navPrefShow(display, key);
a.classList.toggle("nav-hidden", !show);
a.style.display = show ? "" : "none";
});
global.__INSTANCE_DISPLAY__ = display;
}
function pageNavAllowed(tab) {
const d = DISPLAY();
const map = {
dashboard: "show_nav_dashboard",
strategy: "show_nav_strategy",
strategy_records: "show_nav_strategy_records",
records: "show_nav_records",
stats: "show_nav_stats",
options: "show_nav_options",
"options-review": "show_nav_options_review",
options_review: "show_nav_options_review",
"hedge-plan": "show_nav_hedge_plan",
hedge_plan: "show_nav_hedge_plan",
risk_policy: "show_nav_risk_policy",
env_config: "show_nav_env_config",
};
const key = map[tab];
if (!key) return true;
return navPrefShow(d, key);
}
function displayPrefsRoot() {
const settingsPane = document.querySelector('.embed-tab-pane[data-embed-pane="settings"]');
if (settingsPane) {
const inSettings = settingsPane.querySelector("#display-prefs-form");
if (inSettings) return inSettings;
}
const pane = document.querySelector(".embed-tab-pane.is-active-pane");
if (pane) {
const inPane = pane.querySelector("#display-prefs-form");
if (inPane) return inPane;
}
return document.getElementById("display-prefs-form");
}
function displayPrefsStatusEl() {
const card = document.getElementById("display-prefs-card");
if (card) {
const el = card.querySelector("#display-prefs-status");
if (el) return el;
}
return document.getElementById("display-prefs-status");
}
function envConfigRoot() {
const activePane = document.querySelector(".embed-tab-pane.is-active-pane");
if (activePane) {
return activePane.querySelector(".env-config-page");
}
return document.querySelector(".env-config-page");
}
function bindEnvTabs() {
/* Tab 切换由 CSS radio+label 实现 */
}
async function loadDisplayPrefsForm(force) {
const root = displayPrefsRoot();
if (!root) return;
if (!force && root.getAttribute("data-prefs-ssr") === "1" && root.querySelector("[data-pref-key]")) {
return;
}
return loadDisplayPrefsFormIn(root);
}
async function loadDisplayPrefsFormIn(root) {
try {
const data = await fetchJson("/api/settings/display");
const display = data.display || {};
const meta = data.meta || [];
root.innerHTML = "";
meta.forEach((group) => {
const section = document.createElement("div");
section.className = "display-prefs-group";
const title = document.createElement("h3");
title.className = "settings-subcard-title";
title.textContent = group.group;
section.appendChild(title);
const grid = document.createElement("div");
grid.className = "display-prefs-checks";
(group.entries || []).forEach((item) => {
const label = document.createElement("label");
label.className = "chk-label";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.dataset.prefKey = item.key;
cb.checked = NAV_DEFAULT_OFF[item.key]
? display[item.key] === true
: display[item.key] !== false;
label.appendChild(cb);
label.appendChild(document.createTextNode(" " + item.label));
grid.appendChild(label);
});
section.appendChild(grid);
root.appendChild(section);
});
root.setAttribute("data-prefs-ssr", "1");
} catch (e) {
root.innerHTML = '<span class="err">' + (e.message || "加载失败") + "</span>";
}
}
async function saveDisplayPrefs() {
const status = displayPrefsStatusEl();
const root = displayPrefsRoot();
if (!root) {
setStatus(status, "未找到导航设置表单", true);
return;
}
const display = {};
root.querySelectorAll("input[data-pref-key]").forEach((cb) => {
display[cb.dataset.prefKey] = !!cb.checked;
});
try {
const data = await fetchJson("/api/settings/display", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ display }),
});
applyDisplayToNav(data.display || display);
setStatus(status, "已保存,导航已更新");
} catch (e) {
setStatus(status, e.message || "保存失败", true);
}
}
let envSchemaGroups = [];
function renderEnvFieldRow(field) {
const row = document.createElement("div");
row.className = "env-field-row" + (field.restart_required ? " env-field-row--restart" : "");
const label = document.createElement("label");
label.className = "env-field-label";
label.htmlFor = "env-f-" + field.key;
label.textContent = field.label || field.key;
if (field.restart_required) {
const mark = document.createElement("span");
mark.className = "env-restart-mark";
mark.title = "需重启";
mark.textContent = "*";
label.appendChild(mark);
}
row.appendChild(label);
if (field.note) {
const note = document.createElement("div");
note.className = "env-field-note muted";
note.textContent = field.note;
row.appendChild(note);
}
let input;
if (field.type === "bool") {
input = document.createElement("select");
input.id = "env-f-" + field.key;
[["true", "开启"], ["false", "关闭"]].forEach(([v, text]) => {
const o = document.createElement("option");
o.value = v;
o.textContent = text;
input.appendChild(o);
});
const cur = (field.current || field.default || "false").toLowerCase();
input.value = cur === "true" || cur === "1" ? "true" : "false";
} else {
input = document.createElement("input");
input.id = "env-f-" + field.key;
input.type = "password";
// 防止浏览器把登录密码自动填进 API Key/Secret(保存对冲开关时曾误写入密钥)
input.autocomplete = "new-password";
input.setAttribute("data-lpignore", "true");
input.setAttribute("data-1p-ignore", "true");
input.setAttribute("data-form-type", "other");
input.readOnly = true;
input.addEventListener("focus", function () {
input.readOnly = false;
});
if (field.sensitive) {
input.dataset.envSensitive = "1";
input.dataset.envDirty = "0";
input.addEventListener("input", function () {
input.dataset.envDirty = "1";
});
if (field.has_value) {
const cur = document.createElement("div");
cur.className = "env-sensitive-current muted";
const labelSpan = document.createElement("span");
labelSpan.textContent = "已配置 ";
const masked = document.createElement("span");
masked.className = "env-masked-value";
masked.textContent = field.masked || "";
cur.appendChild(labelSpan);
cur.appendChild(masked);
row.appendChild(cur);
}
input.placeholder = field.has_value ? "修改时填写新值,留空不修改" : "请输入";
} else {
input.type = "text";
input.autocomplete = "off";
input.value = field.current || field.default || "";
}
}
input.dataset.envKey = field.key;
input.className = "env-field-input";
row.appendChild(input);
return row;
}
function renderEnvConfigBody(groups) {
const body = document.createElement("div");
body.className = "env-config-body card";
body.id = "env-config-body";
body.setAttribute("data-env-ssr", "1");
groups.forEach((_group, idx) => {
const radio = document.createElement("input");
radio.type = "radio";
radio.name = "env-section";
radio.id = "env-sec-" + idx;
radio.className = "env-tab-radio";
if (idx === 0) radio.checked = true;
body.appendChild(radio);
});
const tabBar = document.createElement("div");
tabBar.className = "env-config-tabs";
tabBar.setAttribute("role", "tablist");
const panelsWrap = document.createElement("div");
panelsWrap.className = "env-config-panels";
panelsWrap.id = "env-config-grid";
groups.forEach((group, idx) => {
const label = document.createElement("label");
label.className = "env-tab-btn";
label.htmlFor = "env-sec-" + idx;
label.setAttribute("role", "tab");
label.textContent = group.title || "其他";
tabBar.appendChild(label);
const panel = document.createElement("section");
panel.className = "env-panel env-panel--" + idx;
panel.setAttribute("role", "tabpanel");
if (group.has_restart) {
const hint = document.createElement("p");
hint.className = "env-panel-hint";
hint.textContent = "本组含需重启项,修改后请点「保存并重启」.";
panel.appendChild(hint);
}
const grid = document.createElement("div");
grid.className = "env-form-grid";
(group.fields || []).forEach((field) => grid.appendChild(renderEnvFieldRow(field)));
panel.appendChild(grid);
panelsWrap.appendChild(panel);
});
body.appendChild(tabBar);
body.appendChild(panelsWrap);
return body;
}
async function loadEnvConfig(force) {
const root = envConfigRoot();
const body = root && root.querySelector("#env-config-body");
if (!force && body && body.getAttribute("data-env-ssr") === "1" && body.querySelector("[data-env-key]")) {
return;
}
return loadEnvConfigIn(root);
}
async function loadEnvConfigIn(root) {
const page = root || envConfigRoot() || document.querySelector(".env-config-page");
if (!page) return;
const loading = document.createElement("div");
loading.className = "env-config-loading-wrap card";
loading.id = "env-config-body";
loading.innerHTML = '<div class="env-config-loading muted">加载配置中…</div>';
const oldBody = page.querySelector("#env-config-body");
const oldGrid = page.querySelector("#env-config-grid.env-config-loading-wrap");
if (oldBody) oldBody.replaceWith(loading);
else if (oldGrid) oldGrid.replaceWith(loading);
try {
const data = await fetchJson("/api/settings/env");
envSchemaGroups = data.groups || [];
loading.replaceWith(renderEnvConfigBody(envSchemaGroups));
} catch (e) {
loading.innerHTML = '<span class="err">' + (e.message || "加载失败") + "</span>";
}
}
function collectEnvValues() {
const root = envConfigRoot();
const values = {};
const scope = root || document;
scope.querySelectorAll(".env-field-input[data-env-key]").forEach((el) => {
if (el.dataset.envSensitive === "1" && el.dataset.envDirty !== "1") {
// 未改动过的敏感项不提交,避免浏览器自动填充覆盖已有密钥
return;
}
values[el.dataset.envKey] = el.value;
});
return values;
}
async function saveEnvConfig(restartAfter) {
const status = document.getElementById("env-config-status");
setStatus(status, "保存中…");
try {
const data = await fetchJson("/api/settings/env", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ values: collectEnvValues() }),
});
const needRestart = restartAfter || data.restart_required;
if (needRestart) {
setStatus(status, "已保存,正在重启实例…");
await restartInstance();
setStatus(status, "保存并重启完成");
await loadEnvConfig();
} else {
setStatus(status, "已保存(即时生效项已应用)");
await loadEnvConfig();
}
} catch (e) {
setStatus(status, e.message || "保存失败", true);
}
}
async function restartInstance() {
try {
await fetchJson("/api/admin/restart", { method: "POST" });
} catch (_) {
// 重启会中断当前 HTTP 连接;只要后续 health 恢复即视为成功.
}
const deadline = Date.now() + 90000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 2000));
try {
const h = await fetch("/api/admin/health", { credentials: "same-origin" });
if (h.ok) return;
} catch (_) {}
}
throw new Error("重启后服务未在预期时间内恢复");
}
async function savePassword() {
const status = document.getElementById("pwd-save-status");
const body = {
old_password: (document.getElementById("pwd-old") || {}).value || "",
new_username: (document.getElementById("pwd-new-username") || {}).value || "",
new_password: (document.getElementById("pwd-new") || {}).value || "",
confirm_password: (document.getElementById("pwd-confirm") || {}).value || "",
};
try {
const data = await fetchJson("/api/settings/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (data.restart_required) {
setStatus(status, "密码已保存,正在重启…");
await restartInstance();
setStatus(status, "密码已更新,请用新密码登录");
} else {
setStatus(status, "密码已更新");
}
} catch (e) {
setStatus(status, e.message || "保存失败", true);
}
}
function installDelegatedHandlers() {
if (document.documentElement.dataset.prefsDelegateBound === "1") return;
document.documentElement.dataset.prefsDelegateBound = "1";
document.addEventListener("click", (ev) => {
const target = ev.target;
if (!(target instanceof Element)) return;
if (target.closest("#display-prefs-save")) {
ev.preventDefault();
void saveDisplayPrefs();
return;
}
if (target.closest("#env-config-save")) {
ev.preventDefault();
void saveEnvConfig(false);
return;
}
if (target.closest("#env-config-save-restart")) {
ev.preventDefault();
void saveEnvConfig(true);
return;
}
if (target.closest("#env-config-reload")) {
ev.preventDefault();
void loadEnvConfig(true);
return;
}
if (target.closest("#pwd-save-btn")) {
ev.preventDefault();
void savePassword();
}
});
}
function bindClickOnce(id, handler) {
const el = document.getElementById(id);
if (!el || el.dataset.bound === "1") return;
el.dataset.bound = "1";
el.addEventListener("click", handler);
}
function bindEvents() {
bindClickOnce("display-prefs-save", saveDisplayPrefs);
bindClickOnce("env-config-save", () => saveEnvConfig(false));
bindClickOnce("env-config-save-restart", () => saveEnvConfig(true));
bindClickOnce("env-config-reload", () => loadEnvConfig(true));
bindClickOnce("pwd-save-btn", savePassword);
}
function initPage() {
installDelegatedHandlers();
bindEvents();
loadDisplayPrefsForm(false);
loadEnvConfig(false);
if (global.__INSTANCE_DISPLAY__) applyDisplayToNav(global.__INSTANCE_DISPLAY__);
}
global.InstanceSettingsPrefs = {
pageNavAllowed,
applyDisplayToNav,
loadDisplayPrefsForm,
loadEnvConfig,
bindEnvTabs,
bindEvents,
restartInstance,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initPage);
} else {
initPage();
}
})(typeof window !== "undefined" ? window : globalThis);
+117
View File
@@ -0,0 +1,117 @@
(function (global) {
"use strict";
var PERIODS = ["day", "week", "month"];
function statsSegmentSelect() {
return document.getElementById("stats-segment-select");
}
function panelFromTrigger(triggerEl) {
if (triggerEl && triggerEl.closest) {
var fromBtn = triggerEl.closest(".stats-segment-panel");
if (fromBtn) return fromBtn;
}
return null;
}
function activeSegmentPanel(triggerEl) {
var panel = panelFromTrigger(triggerEl);
if (panel) return panel;
var sel = statsSegmentSelect();
if (!sel) return null;
var key = sel.value;
return document.querySelector(
'.stats-segment-panel[data-stats-segment="' + key + '"]'
);
}
function replaceStatsUrl(params) {
var q = new URLSearchParams(global.location.search);
Object.keys(params).forEach(function (k) {
if (params[k] == null || params[k] === "") q.delete(k);
else q.set(k, params[k]);
});
var qs = q.toString();
global.history.replaceState(
null,
"",
qs ? global.location.pathname + "?" + qs : global.location.pathname
);
}
function switchStatsPeriod(periodKey, triggerEl) {
var panel = activeSegmentPanel(triggerEl);
if (!panel) return;
var key = PERIODS.indexOf(periodKey) >= 0 ? periodKey : "day";
panel.querySelectorAll(".stats-period-pane").forEach(function (pane) {
var match = pane.getAttribute("data-stats-period") === key;
if (match) pane.removeAttribute("hidden");
else pane.setAttribute("hidden", "");
});
panel.querySelectorAll(".stats-period-tab").forEach(function (btn) {
var on = btn.getAttribute("data-stats-period") === key;
btn.classList.toggle("active", on);
btn.setAttribute("aria-selected", on ? "true" : "false");
});
replaceStatsUrl({ stats_period: key });
}
function switchStatsSegment() {
var sel = statsSegmentSelect();
if (!sel) return;
var key = sel.value;
document.querySelectorAll(".stats-segment-panel").forEach(function (p) {
p.style.display =
p.getAttribute("data-stats-segment") === key ? "block" : "none";
});
replaceStatsUrl({ stats_segment: key });
var period =
new URLSearchParams(global.location.search).get("stats_period") || "day";
switchStatsPeriod(period);
}
function ensurePeriodTabDelegation() {
if (global.__instanceStatsTabsDelegated) return;
global.__instanceStatsTabsDelegated = true;
document.addEventListener(
"click",
function (e) {
var btn =
e.target && e.target.closest
? e.target.closest(".stats-period-tab")
: null;
if (!btn) return;
var card = document.getElementById("stats-card");
if (!card || !card.contains(btn)) return;
switchStatsPeriod(btn.getAttribute("data-stats-period") || "day", btn);
},
true
);
}
function initStatsFromUrl() {
var sel = statsSegmentSelect();
if (!sel) return;
ensurePeriodTabDelegation();
var url = new URLSearchParams(global.location.search);
var segKey = url.get("stats_segment");
if (
segKey &&
sel.querySelector('option[value="' + segKey.replace(/"/g, "") + '"]')
) {
sel.value = segKey;
}
switchStatsSegment();
var period = url.get("stats_period") || "day";
if (PERIODS.indexOf(period) < 0) period = "day";
switchStatsPeriod(period);
}
ensurePeriodTabDelegation();
global.switchStatsSegment = switchStatsSegment;
global.switchStatsPeriod = switchStatsPeriod;
global.initStatsFromUrl = initStatsFromUrl;
global.initStatsSegmentFromUrl = initStatsFromUrl;
})(window);
File diff suppressed because it is too large Load Diff
+570
View File
@@ -0,0 +1,570 @@
/**
* 三所实例主题:默认暗色;单独登录用 instance-theme;中控 iframe/SSO 随 hub-theme 联动.
*/
(function (global) {
const STANDALONE_KEY = "instance-theme";
const HUB_LINKED_THEME_KEY = "hub-linked-theme";
const META = { dark: "#0b0d14", light: "#c8d4de" };
function normalize(theme) {
return theme === "light" ? "light" : "dark";
}
function isHubLinked() {
try {
if (window.self !== window.top) return true;
} catch (_) {
return true;
}
return false;
}
function themeFromUrl() {
try {
const t = new URLSearchParams(location.search).get("hub_theme");
if (t === "light" || t === "dark") return t;
} catch (_) {}
return null;
}
function readLinkedThemeStorage() {
try {
const t = sessionStorage.getItem(HUB_LINKED_THEME_KEY);
if (t === "light" || t === "dark") return t;
} catch (_) {}
return null;
}
function writeLinkedThemeStorage(theme) {
if (!isHubLinked()) return;
try {
sessionStorage.setItem(HUB_LINKED_THEME_KEY, normalize(theme));
} catch (_) {}
}
function getStandalone() {
try {
return normalize(localStorage.getItem(STANDALONE_KEY));
} catch (_) {
return "dark";
}
}
function setStandalone(theme) {
try {
localStorage.setItem(STANDALONE_KEY, normalize(theme));
} catch (_) {}
}
let _linkedTheme = null;
let _appliedTheme = null;
function get() {
if (isHubLinked()) {
return themeFromUrl() || _linkedTheme || readLinkedThemeStorage() || "dark";
}
return getStandalone();
}
/** 模板内联暗色 → 亮色(切换时重写 style 属性) */
const INLINE_HEX_LIGHT = {
"#cfd3ef": "#1a2838",
"#8892b0": "#4a6078",
"#9aa3c4": "#4a6078",
"#8b95a8": "#4a6078",
"#8b95b8": "#4a6078",
"#6a7598": "#4a6078",
"#7d8799": "#4a6078",
"#6d7689": "#4a6078",
"#dbe4ff": "#142232",
"#f0f2ff": "#142232",
"#e8ecf4": "#142232",
"#c5cce0": "#4a6078",
"#b8c4ff": "#142232",
"#8fc8ff": "#006e9a",
"#6ab8ff": "#006e9a",
"#6eb5ff": "#006e9a",
"#101522": "#ffffff",
"#121726": "#ffffff",
"#141423": "#ffffff",
"#24243b": "#b8c8d8",
"#252a45": "#b8c8d8",
"#252538": "#eef3f8",
"#1a1a29": "#f6f9fc",
"#2e2e45": "#b8c8d8",
"#2b2b43": "#d0dae4",
"#151a2a": "#eef3f8",
"#141a2a": "#ffffff",
"#141923": "#ffffff",
"#141a2e": "#ffffff",
"#0f1424": "#f6f9fc",
"#0f1420": "#f6f9fc",
"#0f1117": "#d8e2ec",
"#1a2034": "#eef3f8",
"#1a2030": "#ffffff",
"#1f3a5a": "#e8eef5",
"#2f2f44": "#dde5ec",
"#2a3f6c": "rgba(0,110,154,0.14)",
"#304164": "rgba(0,95,140,0.22)",
"#2a3150": "#b8c8d8",
"#2a3152": "#b8c8d8",
"#3a5a8a": "rgba(0,95,140,0.35)",
"#2a3348": "#b8c8d8",
"#243050": "rgba(0,75,115,0.16)",
"#2a3558": "#d0dae4",
"#3a4468": "#c8d4e0",
"#3a4a66": "#b8c8d8",
"#3a3f52": "#dde5ec",
"#3d4659": "#b8c8d8",
"#1f2740": "#eef3f8",
"#1f2a44": "rgba(0,110,154,0.1)",
"#1f4a3a": "#e8f5ef",
"#2a4a7a": "#e8eef5",
"#3a3048": "#eef3f8",
"#d4b8ff": "#5b4fc7",
"#e6e8ef": "#1a2838",
};
function remapInlineStyle(style, theme) {
if (!style) return style;
if (theme !== "light") return style;
const hadSecondaryBtnBg = /#1f3a5a/i.test(style);
let out = style;
for (const [from, to] of Object.entries(INLINE_HEX_LIGHT)) {
out = out.replace(new RegExp(from.replace("#", "\\#"), "gi"), to);
}
if (hadSecondaryBtnBg && !/color\s*:/i.test(style)) {
out = `${out.replace(/;+\s*$/, "")};color:#006e9a`;
}
return out;
}
function syncInlineStyles(theme, root) {
const scope = root || document;
scope.querySelectorAll("[style]").forEach((el) => {
const raw = el.getAttribute("style");
if (!raw) return;
if (!el.dataset.instStyleBase) {
el.dataset.instStyleBase = raw;
}
const base = el.dataset.instStyleBase;
el.setAttribute("style", theme === "light" ? remapInlineStyle(base, "light") : base);
});
}
function mergeHubQueryIntoHref(href, theme) {
if (!href || href.startsWith("#") || href.startsWith("javascript:")) return href;
try {
const u = new URL(href, location.origin);
if (u.origin !== location.origin) return href;
if (isHubLinked()) {
u.searchParams.set("embed", "1");
if (theme === "light" || theme === "dark") {
u.searchParams.set("hub_theme", theme);
}
}
return u.pathname + u.search + u.hash;
} catch (_) {
return href;
}
}
function patchHubNavLinks(theme) {
if (!isHubLinked()) return;
const t = normalize(theme || get());
document
.querySelectorAll(".top-nav a[href], .strategy-subnav a[href]")
.forEach((a) => {
const href = a.getAttribute("href");
if (!href) return;
const next = mergeHubQueryIntoHref(href, t);
if (next !== href) a.setAttribute("href", next);
});
}
function apply(theme, opts) {
const options = opts || {};
const linked = isHubLinked();
const t = normalize(theme);
const root = document.documentElement;
const unchanged =
!options.force &&
_appliedTheme === t &&
root.getAttribute("data-theme") === t;
if (unchanged) {
return t;
}
_appliedTheme = t;
if (linked) {
_linkedTheme = t;
writeLinkedThemeStorage(t);
root.setAttribute("data-hub-linked", "1");
} else {
root.removeAttribute("data-hub-linked");
}
if (!linked && !options.skipStore) {
setStandalone(t);
}
root.setAttribute("data-theme", t);
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute("content", META[t]);
root.style.colorScheme = t;
if (document.body) {
syncInlineStyles(t);
patchHubNavLinks(t);
} else {
document.addEventListener(
"DOMContentLoaded",
function onDom() {
syncInlineStyles(t);
patchHubNavLinks(t);
},
{ once: true }
);
}
syncToggleUI();
document.dispatchEvent(
new CustomEvent("instance-theme-change", { detail: { theme: t, hubLinked: linked } })
);
return t;
}
function syncToggleUI(root) {
const scope = root || document;
const linked = isHubLinked();
const toggle = scope.querySelector(".instance-theme-toggle");
if (toggle) {
toggle.classList.toggle("is-hub-linked", linked);
toggle.setAttribute("aria-hidden", linked ? "true" : "false");
}
if (linked) return;
scope.querySelectorAll(".theme-toggle-btn[data-theme-value]").forEach((btn) => {
const on = btn.getAttribute("data-theme-value") === getStandalone();
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", () => {
if (isHubLinked()) return;
apply(btn.getAttribute("data-theme-value"));
});
});
}
function initMobileTopNav() {
const mq = window.matchMedia("(max-width: 720px)");
function scrollActiveTab(nav) {
const active = nav.querySelector("a.active");
if (!active) return;
requestAnimationFrame(() => {
try {
active.scrollIntoView({ inline: "center", block: "nearest", behavior: "instant" });
} catch (_) {
active.scrollIntoView(false);
}
});
}
function apply() {
if (!mq.matches) return;
document.querySelectorAll(".top-nav").forEach(scrollActiveTab);
}
apply();
mq.addEventListener("change", apply);
window.addEventListener("resize", apply);
window.addEventListener("orientationchange", apply);
}
function initFromHubMessage(data) {
if (!data || data.type !== "hub-theme-sync") return;
if (!isHubLinked()) return;
apply(data.theme, { skipStore: true });
}
/** 交易记录页:核对开关与按钮 disabled 保持同步(含 iframe 软导航后动态挂载的 toggle) */
function syncReviewEditButtons() {
const toggle = document.getElementById("review-mode-toggle");
if (!toggle) return;
const on = !!toggle.checked;
document.querySelectorAll(".review-edit-btn").forEach((btn) => {
btn.disabled = !on;
});
}
function initReviewEditModeSync() {
if (!global.__instReviewModeBound) {
global.__instReviewModeBound = true;
const onToggle = () => {
if (typeof global.toggleReviewMode === "function") global.toggleReviewMode();
else syncReviewEditButtons();
};
document.addEventListener("change", (ev) => {
if (ev.target && ev.target.id === "review-mode-toggle") onToggle();
});
document.addEventListener("input", (ev) => {
if (ev.target && ev.target.id === "review-mode-toggle") onToggle();
});
}
const run = () => {
if (typeof global.toggleReviewMode === "function") global.toggleReviewMode();
else syncReviewEditButtons();
};
run();
requestAnimationFrame(run);
setTimeout(run, 0);
if (!global.__instReviewModePageshowBound) {
global.__instReviewModePageshowBound = true;
window.addEventListener("pageshow", run);
}
}
function notifyParentFrameNavStart() {
if (!isHubLinked()) return;
try {
window.parent.postMessage({ type: "instance-frame-navigating", theme: get() }, "*");
} catch (_) {}
}
function notifyParentFrameReady() {
if (!isHubLinked()) return;
dismissNavOverlay();
try {
window.parent.postMessage({ type: "instance-frame-ready", theme: get() }, "*");
} catch (_) {}
}
function ensureNavOverlay() {
const t = normalize(get());
const bg = META[t];
let el = document.getElementById("inst-nav-overlay");
if (!el) {
el = document.createElement("div");
el.id = "inst-nav-overlay";
el.setAttribute("aria-hidden", "true");
(document.body || document.documentElement).appendChild(el);
}
el.style.cssText =
"position:fixed;inset:0;z-index:2147483646;background:" +
bg +
";opacity:1;pointer-events:auto;transition:opacity 80ms ease;";
return el;
}
function dismissNavOverlay() {
const el = document.getElementById("inst-nav-overlay");
if (!el) return;
el.style.opacity = "0";
window.setTimeout(() => {
try {
el.remove();
} catch (_) {}
}, 90);
}
function injectNavOverlayIntoHtml(html, theme) {
const t = normalize(theme || get());
const bg = META[t];
let out = html || "";
const guard =
'<style id="inst-nav-guard">html,body{background:' +
bg +
"!important;color-scheme:" +
t +
';}</style>';
if (out.includes("</head>")) {
out = out.replace("</head>", guard + "</head>");
} else {
out = guard + out;
}
out = out.replace(/<html([^>]*)>/i, (m, attrs) => {
if (/data-theme=/i.test(attrs)) {
return m.replace(/data-theme="[^"]*"/i, 'data-theme="' + t + '"');
}
return "<html" + attrs + ' data-theme="' + t + '">';
});
const overlay =
'<div id="inst-nav-overlay" aria-hidden="true" style="position:fixed;inset:0;z-index:2147483646;background:' +
bg +
';opacity:1;pointer-events:auto"></div>';
if (/<body[^>]*>/i.test(out)) {
out = out.replace(/<body([^>]*)>/i, "<body$1>" + overlay);
}
return out;
}
/** 中控 iframe:fetch 换页 + 页内遮罩,避免整页卸载与中控侧长时间空白. */
function initHubEmbedInFrameNav() {
if (!isHubLinked()) return;
if (document.body && document.body.getAttribute("data-embed-shell") === "1") return;
let navToken = 0;
function isSoftNavLink(a) {
if (!a || !a.getAttribute) return false;
if (a.hasAttribute("download") || a.target === "_blank") return false;
return !!a.closest(".top-nav, .strategy-subnav");
}
function softNavFetch(href) {
return fetch(href, {
credentials: "same-origin",
headers: { "X-Instance-Soft-Nav": "1" },
});
}
async function navigateInFrame(href, opts) {
const token = ++navToken;
notifyParentFrameNavStart();
ensureNavOverlay();
try {
const r = await softNavFetch(href);
if (token !== navToken) return;
if (!r.ok) {
location.assign(href);
return;
}
let html = await r.text();
if (token !== navToken) return;
html = injectNavOverlayIntoHtml(html, get());
let path = href;
try {
const u = new URL(href, location.href);
path = u.pathname + u.search + u.hash;
} catch (_) {}
if (opts && opts.replace) history.replaceState(null, "", path);
else history.pushState(null, "", path);
document.open();
document.write(html);
document.close();
} catch (_) {
if (token === navToken) location.assign(href);
}
}
document.addEventListener(
"click",
(ev) => {
const a = ev.target.closest("a[href]");
if (!a || !isSoftNavLink(a) || ev.defaultPrevented) return;
if (ev.button !== 0 || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return;
const rawHref = a.getAttribute("href");
if (!rawHref || rawHref.startsWith("#") || rawHref.startsWith("javascript:")) return;
let target;
try {
target = new URL(rawHref, location.href);
} catch (_) {
return;
}
if (target.origin !== location.origin) return;
const nextHref = target.pathname + target.search + target.hash;
if (target.pathname === location.pathname && target.search === location.search) return;
ev.preventDefault();
void navigateInFrame(nextHref);
},
true
);
window.addEventListener("popstate", () => {
void navigateInFrame(location.pathname + location.search + location.hash, { replace: true });
});
}
function purgeLegacySoftNavCache() {
try {
for (let i = localStorage.length - 1; i >= 0; i -= 1) {
const key = localStorage.key(i);
if (!key) continue;
if (
key.startsWith("inst-pc:") ||
key === "inst-page-cache-index" ||
key === "inst-page-cache-days"
) {
localStorage.removeItem(key);
}
}
sessionStorage.removeItem("inst-soft-nav");
sessionStorage.removeItem("inst-cache-revalidate");
} catch (_) {}
}
function boot() {
purgeLegacySoftNavCache();
if (isHubLinked()) {
apply(get(), { skipStore: true });
window.addEventListener("message", (ev) => initFromHubMessage(ev.data));
initHubEmbedInFrameNav();
try {
window.parent.postMessage({ type: "instance-theme-ready" }, "*");
} catch (_) {}
} else {
apply(getStandalone());
}
function observeDynamicLists() {
["journal-list", "review-list"].forEach((id) => {
const el = document.getElementById(id);
if (!el || el.dataset.instThemeObserved === "1") return;
el.dataset.instThemeObserved = "1";
new MutationObserver(() => {
syncInlineStyles(get());
patchHubNavLinks(get());
}).observe(el, {
childList: true,
subtree: true,
});
});
}
const onReady = () => {
initToggleUI();
initMobileTopNav();
initReviewEditModeSync();
syncInlineStyles(get());
patchHubNavLinks(get());
observeDynamicLists();
if (isHubLinked()) {
requestAnimationFrame(() => {
requestAnimationFrame(() => notifyParentFrameReady());
});
}
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", onReady);
} else {
onReady();
}
document.addEventListener("instance-theme-change", (ev) => {
const t = ev.detail && ev.detail.theme;
if (t) {
syncInlineStyles(t);
patchHubNavLinks(t);
}
});
}
boot();
global.InstanceTheme = {
STANDALONE_KEY,
HUB_LINKED_THEME_KEY,
isHubLinked,
get,
apply,
initToggleUI,
syncToggleUI,
syncInlineStyles,
patchHubNavLinks,
mergeHubQueryIntoHref,
syncReviewEditButtons,
initReviewEditModeSync,
};
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,54 @@
/* 紧接 instance_theme.js 之后加载,避免亮色下先闪暗色底 */
html {
background: #0b0d14;
color-scheme: dark;
}
html[data-theme="light"] {
background: #c8d4de;
color-scheme: light;
}
html[data-theme="light"] body {
background: #c8d4de !important;
color: #142232 !important;
}
.review-edit-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
html[data-theme="light"] .header h1 {
color: #142232 !important;
}
html[data-theme="light"] .top-nav a,
html[data-theme="light"] .embed-top-nav a,
html[data-theme="light"] .strategy-subnav a {
background: #fff !important;
color: #006e9a !important;
border-color: rgba(0, 95, 140, 0.22) !important;
}
html[data-theme="light"] .top-nav a:hover,
html[data-theme="light"] .embed-top-nav a:hover,
html[data-theme="light"] .strategy-subnav a:hover {
background: rgba(0, 110, 154, 0.1) !important;
color: #004d6e !important;
}
html[data-theme="light"] .top-nav a.active,
html[data-theme="light"] .embed-top-nav a.active,
html[data-theme="light"] .strategy-subnav a.active {
background: rgba(0, 110, 154, 0.12) !important;
color: #004d6e !important;
border: 1px solid rgba(0, 95, 140, 0.28) !important;
font-weight: 600;
}
html[data-theme="light"] .card,
html[data-theme="light"] .stat-item {
background: #fff !important;
border-color: #b8c8d8 !important;
}
+456
View File
@@ -0,0 +1,456 @@
/**
* 三所实例共用 UI:复盘详情,盈亏着色等.
*/
(function (global) {
"use strict";
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function pnlClassFromValue(val) {
const n = Number(String(val == null ? "" : val).replace(/[^\d.-]/g, ""));
if (!Number.isFinite(n) || n === 0) return "";
return n > 0 ? "pnl-profit" : "pnl-loss";
}
function formatPnlSpan(val, suffix) {
const sfx = suffix == null ? "U" : suffix;
const cls = pnlClassFromValue(val);
const text = escapeHtml(val == null || val === "" ? "-" : val) + sfx;
return cls ? `<span class="${cls}">${text}</span>` : text;
}
function buildJournalDetailHtml(o, formatExitLine) {
const moodTags =
Array.isArray(o.mood_issues) && o.mood_issues.length
? o.mood_issues.join(",")
: o.mood_issues || "无";
const exitText =
typeof formatExitLine === "function" ? formatExitLine(o) : o.exit_reason || "无";
const lines = [
`币种/周期:${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "-")}`,
`方向:${escapeHtml((function(){ const d = inferJournalDirection(o); return d ? d.text : "-"; })())}`,
`开仓时间:${escapeHtml(o.open_datetime || "-")}`,
`平仓时间:${escapeHtml(o.close_datetime || "-")}`,
`持仓时长:${escapeHtml(o.hold_duration || "-")}`,
`盈亏:${formatPnlSpan(o.pnl)}`,
`下单类型:${escapeHtml(o.order_type || "无")}`,
`开仓类型:${escapeHtml(o.entry_reason || "无")}`,
`平仓/离场:${escapeHtml(exitText)}`,
`预期RR:${escapeHtml(o.expect_rr || "-")}`,
`实际RR:${escapeHtml(o.real_rr || "-")}`,
`保本后盯盘:${escapeHtml(o.post_breakeven_stare || "-")}`,
`心态标签:${escapeHtml(moodTags)}`,
`备注:${escapeHtml(o.note || "无")}`,
];
return lines.join("<br>");
}
function resolveJournalImages(o) {
if (Array.isArray(o.images) && o.images.length) return o.images;
if (o.image) return [{ tf: "", file: o.image }];
return [];
}
function setJournalDetailImages(o) {
const grid = document.getElementById("detailImages");
const legacyImg = document.getElementById("detailImage");
const images = resolveJournalImages(o || {});
if (grid) {
if (!images.length) {
grid.innerHTML = "";
grid.style.display = "none";
} else {
grid.innerHTML = images
.map(function (img) {
const tf = String(img.tf || "").trim();
const file = String(img.file || "").trim();
if (!file) return "";
const label = tf ? escapeHtml(tf) : "截图";
const src = "/static/images/" + encodeURIComponent(file).replace(/%2F/g, "/");
return (
'<div class="journal-detail-img-cell">' +
'<span class="journal-detail-img-label">' +
label +
"</span>" +
'<img class="journal-detail-img-thumb" src="' +
src +
'" alt="' +
label +
'" onclick="showImage(this.src)">' +
"</div>"
);
})
.join("");
grid.style.display = "grid";
}
if (legacyImg) {
legacyImg.src = "";
legacyImg.style.display = "none";
}
return;
}
if (legacyImg) {
if (images.length === 1) {
legacyImg.src = "/static/images/" + images[0].file;
legacyImg.style.display = "block";
} else {
legacyImg.src = "";
legacyImg.style.display = "none";
}
}
}
function clearJournalDetailImages() {
const grid = document.getElementById("detailImages");
if (grid) {
grid.innerHTML = "";
grid.style.display = "none";
}
const legacyImg = document.getElementById("detailImage");
if (legacyImg) {
legacyImg.src = "";
legacyImg.style.display = "none";
}
}
function setJournalDetailBody(o, formatExitLine) {
const body = document.getElementById("detailBody");
if (!body) return;
body.classList.remove("md-review", "trade-record-detail-wrap");
body.classList.add("journal-detail-meta");
body.innerHTML = buildJournalDetailHtml(o, formatExitLine);
}
function openJournalDetailModal(id, journalCache, formatExitLine) {
const o = journalCache && journalCache[id];
if (!o) return;
const titleEl = document.getElementById("detailTitle");
if (titleEl) {
titleEl.innerText = `交易复盘详情|${o.coin || "-"} ${o.tf || "-"}`;
}
setJournalDetailBody(o, formatExitLine);
clearDetailActions();
setJournalDetailImages(o);
if (typeof setDetailModalFullscreen === "function") {
setDetailModalFullscreen(false);
}
const modal = document.getElementById("detailModal");
if (modal) modal.style.display = "flex";
}
function isMobileCompactRecords() {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia("(max-width: 720px)").matches;
}
function inferJournalDirection(o) {
const hint = String((o && (o.direction_hint || o.direction)) || "").toLowerCase();
if (hint === "long" || hint === "buy" || hint === "多") {
return { text: "做多", cls: "direction-long" };
}
if (hint === "short" || hint === "sell" || hint === "空") {
return { text: "做空", cls: "direction-short" };
}
const text = String((o && (o.entry_reason || o.note)) || "");
if (/做空|空头|short/i.test(text)) {
return { text: "做空", cls: "direction-short" };
}
if (/做多|多头|long/i.test(text)) {
return { text: "做多", cls: "direction-long" };
}
return null;
}
function renderJournalListHtml(data) {
if (!data || !data.length) return "";
const mobile = isMobileCompactRecords();
if (mobile) {
return data
.map(function (o) {
const dir = inferJournalDirection(o);
const pnlCls = pnlClassFromValue(o.pnl);
const dirHtml = dir
? `<span class="badge ${dir.cls}">${escapeHtml(dir.text)}</span>`
: `<span class="mrr-muted">-</span>`;
const id = escapeHtml(o.id);
return `<div class="mobile-record-row-wrap">
<button type="button" class="mobile-record-row" onclick="openJournalDetail('${id}')">
<span class="mrr-symbol">${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "")}</span>
<span class="mrr-dir">${dirHtml}</span>
<span class="mrr-pnl ${pnlCls}">${escapeHtml(o.pnl == null || o.pnl === "" ? "-" : o.pnl)}U</span>
</button>
<button type="button" class="mobile-record-del" title="删除" onclick="deleteJournal('${id}')">×</button>
</div>`;
})
.join("");
}
const rows = data
.map(function (o) {
const moodTags = Array.isArray(o.mood_issues)
? o.mood_issues.join(",")
: o.mood_issues || "";
const mood = moodTags || "无";
const id = escapeHtml(o.id);
const pnlCls = pnlClassFromValue(o.pnl);
const pnlTxt =
o.pnl == null || o.pnl === "" ? "-" : String(o.pnl);
const dir = inferJournalDirection(o);
const dirHtml = dir
? `<span class="badge ${dir.cls}">${escapeHtml(dir.text)}</span>`
: "-";
return `<tr id="journal-row-${id}">
<td>${escapeHtml(o.coin || "-")}</td>
<td>${escapeHtml(o.tf || "-")}</td>
<td>${dirHtml}</td>
<td>${escapeHtml(o.order_type || "-")}</td>
<td>${escapeHtml(o.entry_reason || "-")}</td>
<td><span class="${pnlCls}">${escapeHtml(pnlTxt)}</span></td>
<td>${escapeHtml((o.open_datetime || "-").toString().slice(0, 16))}</td>
<td>${escapeHtml((o.close_datetime || "-").toString().slice(0, 16))}</td>
<td>${escapeHtml(o.hold_duration || "-")}</td>
<td>${escapeHtml(mood)}</td>
<td>
<button type="button" class="table-del" style="background:#1f3a5a;color:#8fc8ff;margin-right:6px" onclick="openJournalDetail('${id}')">查看详情</button>
<button type="button" class="table-del" onclick="deleteJournal('${id}')">删除</button>
</td>
</tr>`;
})
.join("");
return `<div class="table-wrap"><table class="rr-journals-table">
<thead><tr>
<th>品种</th><th>周期</th><th>方向</th><th>下单类型</th><th>开仓类型</th>
<th>盈亏U</th><th>开仓时间</th><th>平仓时间</th><th>持仓</th><th>心态标签</th><th>操作</th>
</tr></thead>
<tbody>${rows}</tbody>
</table></div>`;
}
function parseTradeRecordRow(tr) {
const cells = tr.querySelectorAll("td");
if (cells.length < 15) return null;
const dirBadge = cells[3].querySelector(".badge");
return {
rowId: tr.id,
symbol: cells[0].textContent.trim(),
type: cells[1].textContent.trim(),
entryReason: cells[2].textContent.trim(),
directionHtml: (dirBadge ? dirBadge.outerHTML : cells[3].innerHTML).trim(),
directionText: cells[3].textContent.trim(),
trigger: cells[4].textContent.trim(),
stopLoss: cells[5].textContent.trim(),
takeProfit: cells[6].textContent.trim(),
margin: cells[7].textContent.trim(),
leverage: cells[8].textContent.trim(),
holdMinutes: cells[9].textContent.trim(),
openedAt: cells[10].textContent.trim(),
closedAt: cells[11].textContent.trim(),
pnlHtml: cells[12].innerHTML.trim(),
pnlText: cells[12].textContent.trim(),
resultHtml: cells[13].innerHTML.trim(),
resultText: cells[13].textContent.trim(),
actionsHtml: cells[14].innerHTML,
};
}
function renderMobileTradeRow(tr) {
const row = parseTradeRecordRow(tr);
if (!row) return "";
const pnlCls = pnlClassFromValue(row.pnlText);
return `<button type="button" class="mobile-record-row" data-row-id="${escapeHtml(row.rowId)}">
<span class="mrr-symbol">${escapeHtml(row.symbol)}</span>
<span class="mrr-dir">${row.directionHtml}</span>
<span class="mrr-pnl ${pnlCls}">${escapeHtml(row.pnlText || "-")}</span>
</button>`;
}
function tradeDetailRow(label, valueHtml) {
return `<div class="trd-row"><span class="trd-label">${escapeHtml(label)}</span><span class="trd-value">${valueHtml}</span></div>`;
}
function buildTradeRecordDetailHtml(row) {
return `<div class="trade-record-detail">${
tradeDetailRow("品种", escapeHtml(row.symbol)) +
tradeDetailRow("下单类型", escapeHtml(row.type)) +
tradeDetailRow("开仓类型", escapeHtml(row.entryReason || "-")) +
tradeDetailRow("方向", row.directionHtml) +
tradeDetailRow("成交价", escapeHtml(row.trigger)) +
tradeDetailRow("止损(开仓)", escapeHtml(row.stopLoss)) +
tradeDetailRow("止盈", escapeHtml(row.takeProfit)) +
tradeDetailRow("基数", escapeHtml(row.margin)) +
tradeDetailRow("杠杆", escapeHtml(row.leverage)) +
tradeDetailRow("持仓分钟", escapeHtml(row.holdMinutes)) +
tradeDetailRow("开仓时间", escapeHtml(row.openedAt)) +
tradeDetailRow("平仓时间", escapeHtml(row.closedAt)) +
tradeDetailRow("盈亏U", row.pnlHtml) +
tradeDetailRow("结果", row.resultHtml)
}</div>`;
}
function clearDetailActions() {
const el = document.getElementById("detailActions");
if (el) {
el.innerHTML = "";
el.style.display = "none";
}
}
function setDetailActionsHtml(html) {
let el = document.getElementById("detailActions");
if (!el) {
const panel = document.querySelector("#detailModal .panel");
if (!panel) return;
el = document.createElement("div");
el.id = "detailActions";
el.className = "detail-actions";
const body = document.getElementById("detailBody");
if (body && body.parentNode === panel) {
panel.insertBefore(el, body.nextSibling);
} else {
panel.appendChild(el);
}
}
el.innerHTML = html || "";
el.style.display = html ? "flex" : "none";
}
function promptReviewEntryReason(options, currentValue) {
const opts = Array.isArray(options) ? options : [];
const cur = String(currentValue == null ? "" : currentValue).trim();
return new Promise(function (resolve) {
const backdrop = document.createElement("div");
backdrop.className = "review-entry-reason-backdrop open";
const modal = document.createElement("div");
modal.className = "review-entry-reason-modal";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
const title = document.createElement("h3");
title.textContent = "开仓类型";
modal.appendChild(title);
const hint = document.createElement("p");
hint.className = "review-entry-reason-hint";
hint.textContent = "请选择下拉选项之一;选「不改该项」则保留原值.";
modal.appendChild(hint);
const select = document.createElement("select");
select.className = "review-entry-reason-select";
const emptyOpt = document.createElement("option");
emptyOpt.value = "";
emptyOpt.textContent = "(不改该项)";
select.appendChild(emptyOpt);
const seen = new Set([""]);
if (cur && opts.indexOf(cur) < 0) {
const curOpt = document.createElement("option");
curOpt.value = cur;
curOpt.textContent = cur + "(当前)";
select.appendChild(curOpt);
seen.add(cur);
}
opts.forEach(function (opt) {
const v = String(opt || "").trim();
if (!v || seen.has(v)) return;
const o = document.createElement("option");
o.value = v;
o.textContent = v;
select.appendChild(o);
seen.add(v);
});
if (cur) select.value = cur;
modal.appendChild(select);
const actions = document.createElement("div");
actions.className = "review-entry-reason-actions";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "review-entry-reason-cancel";
cancelBtn.textContent = "取消";
const okBtn = document.createElement("button");
okBtn.type = "button";
okBtn.className = "review-entry-reason-ok";
okBtn.textContent = "确定";
actions.appendChild(cancelBtn);
actions.appendChild(okBtn);
modal.appendChild(actions);
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
function cleanup(result) {
document.removeEventListener("keydown", onKey);
backdrop.remove();
resolve(result);
}
function onKey(ev) {
if (ev.key === "Escape") cleanup(null);
}
cancelBtn.addEventListener("click", function () {
cleanup(null);
});
backdrop.addEventListener("click", function (ev) {
if (ev.target === backdrop) cleanup(null);
});
okBtn.addEventListener("click", function () {
cleanup(select.value);
});
document.addEventListener("keydown", onKey);
select.focus();
});
}
function openTradeRecordDetailModal(tr) {
const row = parseTradeRecordRow(tr);
if (!row) return;
const titleEl = document.getElementById("detailTitle");
if (titleEl) {
titleEl.innerText = `交易记录|${row.symbol}`;
}
const body = document.getElementById("detailBody");
if (body) {
body.classList.remove("md-review", "journal-detail-meta");
body.classList.add("trade-record-detail-wrap");
body.innerHTML = buildTradeRecordDetailHtml(row);
}
setDetailActionsHtml(
`<div class="detail-actions-inner">${row.actionsHtml}</div>`
);
const imgEl = document.getElementById("detailImage");
if (imgEl) {
imgEl.src = "";
imgEl.style.display = "none";
}
if (typeof setDetailModalFullscreen === "function") {
setDetailModalFullscreen(false);
}
const modal = document.getElementById("detailModal");
if (modal) modal.style.display = "flex";
}
global.InstanceUI = {
escapeHtml: escapeHtml,
pnlClassFromValue: pnlClassFromValue,
formatPnlSpan: formatPnlSpan,
buildJournalDetailHtml: buildJournalDetailHtml,
setJournalDetailBody: setJournalDetailBody,
openJournalDetailModal: openJournalDetailModal,
isMobileCompactRecords: isMobileCompactRecords,
inferJournalDirection: inferJournalDirection,
renderJournalListHtml: renderJournalListHtml,
parseTradeRecordRow: parseTradeRecordRow,
renderMobileTradeRow: renderMobileTradeRow,
buildTradeRecordDetailHtml: buildTradeRecordDetailHtml,
openTradeRecordDetailModal: openTradeRecordDetailModal,
clearDetailActions: clearDetailActions,
clearJournalDetailImages: clearJournalDetailImages,
setJournalDetailImages: setJournalDetailImages,
promptReviewEntryReason: promptReviewEntryReason,
};
})(typeof window !== "undefined" ? window : globalThis);
+145
View File
@@ -0,0 +1,145 @@
/**
* 复盘表单:四周期截图即时上传与状态展示.
*/
(function (global) {
"use strict";
function newDraftId() {
if (global.crypto && typeof global.crypto.randomUUID === "function") {
return global.crypto.randomUUID().replace(/-/g, "");
}
var s = "";
for (var i = 0; i < 32; i++) {
s += Math.floor(Math.random() * 16).toString(16);
}
return s;
}
function ensureDraftId(root) {
var scope = root || document;
var el = scope.querySelector("#journal-draft-id");
if (!el) return "";
if (!el.value) {
el.value = newDraftId();
}
return el.value;
}
function rowParts(input) {
var row = input.closest(".journal-upload-row");
if (!row) return {};
return {
row: row,
status: row.querySelector(".journal-upload-status"),
hidden: row.querySelector(".journal-upload-hidden-file"),
};
}
function setStatus(statusEl, text, kind) {
if (!statusEl) return;
statusEl.textContent = text || "";
statusEl.classList.remove(
"journal-upload-status--pending",
"journal-upload-status--ok",
"journal-upload-status--err"
);
if (kind) {
statusEl.classList.add("journal-upload-status--" + kind);
}
}
function uploadSlotFile(input, file) {
var parts = rowParts(input);
var draftId = ensureDraftId(input.form || document);
if (!draftId || !file) {
setStatus(parts.status, "上传失败", "err");
return;
}
setStatus(parts.status, "上传中…", "pending");
if (parts.hidden) {
parts.hidden.value = "";
}
var fd = new FormData();
fd.append("journal_draft_id", draftId);
fd.append("tf", input.getAttribute("data-tf") || "");
fd.append("file", file);
fetch("/api/journal_upload_slot", { method: "POST", body: fd, credentials: "same-origin" })
.then(function (res) {
return res.json().then(function (data) {
return { ok: res.ok, data: data };
});
})
.then(function (result) {
if (!result.ok || !result.data || !result.data.ok) {
throw new Error(
(result.data && result.data.error) || "upload failed"
);
}
var fname = String(result.data.file || "").trim();
if (parts.hidden) {
parts.hidden.value = fname;
}
input.value = "";
setStatus(parts.status, "上传成功 " + fname, "ok");
})
.catch(function () {
if (parts.hidden) {
parts.hidden.value = "";
}
setStatus(parts.status, "上传失败", "err");
});
}
function bindInput(input) {
if (!input || input.dataset.journalSlotBound === "1") return;
input.dataset.journalSlotBound = "1";
input.addEventListener("change", function () {
var file = input.files && input.files[0];
if (!file) {
var parts = rowParts(input);
if (parts.hidden) {
parts.hidden.value = "";
}
setStatus(parts.status, "", "");
return;
}
uploadSlotFile(input, file);
});
}
function resetSlots(root) {
var scope = root || document;
var draftEl = scope.querySelector("#journal-draft-id");
if (draftEl) {
draftEl.value = newDraftId();
}
scope.querySelectorAll(".journal-upload-hidden-file").forEach(function (el) {
el.value = "";
});
scope.querySelectorAll(".journal-upload-slot-input").forEach(function (el) {
el.value = "";
});
scope.querySelectorAll(".journal-upload-status").forEach(function (el) {
setStatus(el, "", "");
});
}
function init(root) {
var scope = root || document;
ensureDraftId(scope);
scope.querySelectorAll(".journal-upload-slot-input").forEach(bindInput);
}
global.JournalUploadSlots = { init: init, reset: resetSlots };
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
init(document);
});
} else {
init(document);
}
})(typeof window !== "undefined" ? window : globalThis);
+160
View File
@@ -0,0 +1,160 @@
/**
* 关键位监控添加表单:类型切换显隐,成交量排名校验(三所实例共用).
*/
(function (global) {
const RS_TYPES = new Set([
"关键支撑阻力",
"关键阻力位",
"关键支撑位",
]);
function syncKeyMonitorFormFields() {
const typeEl = document.querySelector('#key-form [name="type"]');
const dirEl = document.getElementById("key-direction");
const modeEl = document.getElementById("key-sl-tp-mode");
const manualTp = document.getElementById("key-manual-tp");
const beWrap = document.getElementById("key-breakeven-wrap");
if (!typeEl) return;
const t = (typeEl.value || "").trim();
const autoTypes = new Set(["箱体突破", "收敛突破"]);
const fibTypes = new Set(["斐波回调0.618", "斐波回调0.786"]);
const fbTypes = new Set(["假突破"]);
const teTypes = new Set(["回调触价开仓", "突破触价开仓", "触价开仓"]);
const showAuto = autoTypes.has(t);
const showFb = fbTypes.has(t);
const showTe = teTypes.has(t);
const showBe = showAuto || fibTypes.has(t) || showFb || showTe;
const showDir = !RS_TYPES.has(t);
const upperEl = document.getElementById("key-upper");
const lowerEl = document.getElementById("key-lower");
const fbPriceEl = document.getElementById("key-fb-price");
const teEntryEl = document.getElementById("key-trigger-entry");
const teSlEl = document.getElementById("key-trigger-sl");
const teTpEl = document.getElementById("key-trigger-tp");
if (dirEl) {
dirEl.style.display = showDir ? "" : "none";
dirEl.required = showDir;
if (!showDir) dirEl.value = "";
}
if (modeEl) modeEl.style.display = showAuto ? "" : "none";
if (manualTp) {
const trend = showAuto && modeEl && modeEl.value === "trend_manual";
manualTp.style.display = trend ? "" : "none";
manualTp.required = !!trend;
}
if (beWrap) beWrap.style.display = showBe ? "inline-flex" : "none";
if (global.TimeCloseUI) global.TimeCloseUI.syncKeyTimeCloseVisibility(showBe);
const hideBounds = showFb || showTe;
if (upperEl) {
upperEl.style.display = hideBounds ? "none" : "";
upperEl.required = !hideBounds;
if (hideBounds) upperEl.value = "";
}
if (lowerEl) {
lowerEl.style.display = hideBounds ? "none" : "";
lowerEl.required = !hideBounds;
if (hideBounds) lowerEl.value = "";
}
if (fbPriceEl) {
fbPriceEl.style.display = showFb ? "" : "none";
fbPriceEl.required = showFb;
if (!showFb) fbPriceEl.value = "";
fbPriceEl.placeholder =
dirEl && dirEl.value === "short"
? "高点(阻力)"
: dirEl && dirEl.value === "long"
? "低点(支撑)"
: "做空填高点/做多填低点";
}
[teEntryEl, teSlEl, teTpEl].forEach((el) => {
if (!el) return;
el.style.display = showTe ? "" : "none";
el.required = showTe;
if (!showTe) el.value = "";
});
}
function submitKeyForm(keyForm, label) {
if (
document.body &&
document.body.getAttribute("data-embed-shell") === "1" &&
global.InstanceEmbed &&
typeof global.InstanceEmbed.postFormAndReload === "function"
) {
global.InstanceEmbed.postFormAndReload(keyForm, label || "提交中…");
return;
}
if (global.FormSubmitGuard) global.FormSubmitGuard.nativeSubmitOnce(keyForm, label || "提交中…");
else keyForm.submit();
}
function bindKeyMonitorForm() {
const keyForm = document.getElementById("key-form");
const keyTypeSel = document.querySelector('#key-form [name="type"]');
const keyModeSel = document.getElementById("key-sl-tp-mode");
const keyDirSel = document.getElementById("key-direction");
if (keyTypeSel) keyTypeSel.addEventListener("change", syncKeyMonitorFormFields);
if (keyModeSel) keyModeSel.addEventListener("change", syncKeyMonitorFormFields);
if (keyDirSel) keyDirSel.addEventListener("change", syncKeyMonitorFormFields);
syncKeyMonitorFormFields();
if (global.TimeCloseUI) {
global.TimeCloseUI.bindTimeCloseForm(
"key-time-close-cb",
"key-time-close-hours",
"key-time-close-wrap"
);
}
if (!keyForm || keyForm.dataset.keyFormBound === "1") return;
keyForm.dataset.keyFormBound = "1";
keyForm.addEventListener("submit", (e) => {
e.preventDefault();
if (global.FormSubmitGuard && global.FormSubmitGuard.isLocked(keyForm)) return;
const symbolEl = keyForm.querySelector('[name="symbol"]');
const symbol = (symbolEl ? symbolEl.value : "").trim();
if (!symbol) {
alert("请先输入交易对");
return;
}
const typeVal = (keyForm.querySelector('[name="type"]') || {}).value || "";
if (typeVal === "假突破") {
submitKeyForm(keyForm, "提交中…");
return;
}
if (global.FormSubmitGuard) global.FormSubmitGuard.lock(keyForm, "校验排名中…");
fetch(`/api/symbol_liquidity_rank?symbol=${encodeURIComponent(symbol)}`)
.then((r) => r.json().then((d) => ({ status: r.status, data: d })))
.then(({ status, data }) => {
if (status >= 400 || !data.ok) {
alert((data && data.msg) || "日成交量排名读取失败");
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
return;
}
const rankMax = data.rank_max || 30;
const inTop = data.in_top != null ? data.in_top : data.in_top30;
if (data.rank == null || !inTop) {
alert(
`${data.symbol} 当前日成交量排名 ${data.rank == null ? "—" : data.rank}/${data.total},不在前${rankMax},已拦截.`
);
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
return;
}
submitKeyForm(keyForm, "提交中…");
})
.catch(() => {
alert("日成交量排名检查失败,请稍后重试");
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
});
});
}
global.KeyMonitorForm = {
syncFields: syncKeyMonitorFormFields,
init: bindKeyMonitorForm,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", bindKeyMonitorForm);
} else {
bindKeyMonitorForm();
}
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,340 @@
/**
* 实盘下单:填完币种与止盈止损后,在表单下方显示预估风险 / 预估盈利 / 预估盈亏比.
* 以损定仓:风险 = 当前交易基数 × risk%.
* 全仓杠杆:风险 = 可用保证金×缓冲 × 杠杆 × |SL-入场|/入场(与开仓 calc_risk_amount_from_plan 一致).
*/
(function (global) {
"use strict";
let debounceMs = 400;
let minRr = 1.5;
let debounceTimer = null;
let fetchSeq = 0;
function $(id) {
return document.getElementById(id);
}
function num(v) {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function formatRr(rr) {
if (rr === null || typeof rr === "undefined") return "—";
const n = Number(rr);
if (!Number.isFinite(n)) return "—";
const body = Number.isInteger(n) ? String(n) : String(parseFloat(n.toFixed(2)));
return body + ":1";
}
function formatU(v) {
if (v === null || typeof v === "undefined" || !Number.isFinite(Number(v))) return "—";
return Number(v).toFixed(2) + "U";
}
function setMetric(el, label, valueText) {
if (!el) return;
el.innerHTML = label + ":<strong>" + valueText + "</strong>";
}
function sizingMode() {
return (document.body && document.body.getAttribute("data-position-sizing-mode")) || "risk";
}
function isFullMarginMode() {
return sizingMode() === "full_margin";
}
function fullMarginBuffer() {
const n = Number(document.body && document.body.getAttribute("data-full-margin-buffer"));
return Number.isFinite(n) && n > 0 ? n : 0.9;
}
function leverageForSymbol(sym) {
const u = (sym || "").trim().toUpperCase();
const btc = Number(document.body && document.body.getAttribute("data-btc-leverage"));
const alt = Number(document.body && document.body.getAttribute("data-alt-leverage"));
if (u.startsWith("BTC") || u.startsWith("ETH")) {
return Number.isFinite(btc) && btc > 0 ? btc : 10;
}
return Number.isFinite(alt) && alt > 0 ? alt : 5;
}
function riskPercent() {
const form = $("add-order-form");
const raw =
(form && form.getAttribute("data-risk-percent")) ||
(document.body && document.body.getAttribute("data-risk-percent")) ||
"";
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : 1;
}
function calcRiskFraction(direction, entry, sl) {
const e = num(entry);
const s = num(sl);
if (e === null || s === null || e <= 0 || s <= 0) return null;
let risk = 0;
if (direction === "short") {
risk = s - e;
} else {
risk = e - s;
}
if (risk <= 0) return null;
return risk / e;
}
function calcRr(direction, entry, sl, tp) {
const e = num(entry);
const s = num(sl);
const t = num(tp);
if (e === null || s === null || t === null) return null;
if (direction === "short") {
if (s <= e || t >= e) return null;
return (e - t) / (s - e);
}
if (s >= e || t <= e) return null;
return (t - e) / (e - s);
}
function calcRrFromPct(slPct, tpPct) {
const sl = num(slPct);
const tp = num(tpPct);
if (sl === null || tp === null || sl <= 0 || tp <= 0) return null;
return tp / sl;
}
function calcTpFromFixedRr(direction, entry, sl, rr) {
const e = num(entry);
const s = num(sl);
const r = num(rr);
if (e === null || s === null || r === null || r <= 0) return null;
if (direction === "short") {
if (s <= e) return null;
return e - (s - e) * r;
}
if (s >= e) return null;
return e + (e - s) * r;
}
function resolveSlPrice(mode, direction, entry) {
if (mode === "pct") {
const slPct = num($("order-sl-pct") && $("order-sl-pct").value);
if (slPct === null || slPct <= 0) return null;
if (direction === "short") return entry * (1 + slPct / 100);
return entry * (1 - slPct / 100);
}
return num($("order-sl") && $("order-sl").value);
}
function currentMode() {
return ($("sltp-mode") && $("sltp-mode").value) || "fixed_rr";
}
function currentDirection() {
return ($("order-direction") && $("order-direction").value) || "long";
}
function currentSymbol() {
return (($("order-symbol") && $("order-symbol").value) || "").trim();
}
function inputsComplete(m) {
const dir = currentDirection();
if (!currentSymbol() || !dir) return false;
if (m === "pct") {
const sl = num($("order-sl-pct") && $("order-sl-pct").value);
const tp = num($("order-tp-pct") && $("order-tp-pct").value);
return sl !== null && tp !== null && sl > 0 && tp > 0;
}
if (m === "fixed_rr") {
const sl = num($("order-sl") && $("order-sl").value);
const rr = num($("order-fixed-rr") && $("order-fixed-rr").value);
return sl !== null && rr !== null && sl > 0 && rr > 0;
}
const sl = num($("order-sl") && $("order-sl").value);
const tp = num($("order-tp") && $("order-tp").value);
return sl !== null && tp !== null && sl > 0 && tp > 0;
}
function paintEmpty() {
setMetric($("order-risk-preview"), "预估风险", "—");
setMetric($("order-profit-preview"), "预估盈利", "—");
setMetric($("order-rr-preview"), "预估盈亏比", "—");
}
function paintLoading() {
setMetric($("order-risk-preview"), "预估风险", "计算中…");
setMetric($("order-profit-preview"), "预估盈利", "计算中…");
setMetric($("order-rr-preview"), "预估盈亏比", "计算中…");
}
function paintFail(kind) {
const msg = kind === "fetch_fail" ? "取价失败" : "无效";
setMetric($("order-risk-preview"), "预估风险", msg);
setMetric($("order-profit-preview"), "预估盈利", msg);
setMetric($("order-rr-preview"), "预估盈亏比", msg);
}
function paintOk(riskU, profitU, rr) {
setMetric($("order-risk-preview"), "预估风险", formatU(riskU));
setMetric($("order-profit-preview"), "预估盈利", formatU(profitU));
const rrEl = $("order-rr-preview");
const rrText = formatRr(rr);
setMetric(rrEl, "预估盈亏比", rrText);
if (rrEl && rr !== null && Number.isFinite(Number(rr))) {
rrEl.classList.toggle("order-preview-rr-low", Number(rr) < minRr);
rrEl.classList.toggle("order-preview-rr-ok", Number(rr) >= minRr);
}
}
function plannedRiskFromRiskMode(capital) {
const cap = num(capital);
if (cap === null || cap <= 0) return null;
return Math.round((cap * riskPercent()) / 100 * 100) / 100;
}
function plannedRiskFromFullMargin(availableUsdt, symbol, direction, entry, sl) {
const avail = num(availableUsdt);
if (avail === null || avail <= 0) return null;
const slPx = num(sl);
const entryPx = num(entry);
if (slPx === null || entryPx === null) return null;
const rf = calcRiskFraction(direction, entryPx, slPx);
if (rf === null) return null;
const margin = Math.round(avail * fullMarginBuffer() * 100) / 100;
const lev = leverageForSymbol(symbol);
return Math.round(margin * lev * rf * 100) / 100;
}
function resolvePreviewRr(m, dir, entry) {
if (m === "pct") {
return calcRrFromPct(
$("order-sl-pct") && $("order-sl-pct").value,
$("order-tp-pct") && $("order-tp-pct").value
);
}
const sl = num($("order-sl") && $("order-sl").value);
if (m === "fixed_rr") {
const fixed = num($("order-fixed-rr") && $("order-fixed-rr").value);
if (fixed !== null && fixed > 0) return fixed;
const tp = calcTpFromFixedRr(dir, entry, sl, fixed);
return calcRr(dir, entry, sl, tp);
}
const tp = num($("order-tp") && $("order-tp").value);
return calcRr(dir, entry, sl, tp);
}
function refreshNow() {
if (!$("order-plan-preview")) return;
const m = currentMode();
if (!inputsComplete(m)) {
paintEmpty();
return;
}
const sym = currentSymbol();
const dir = currentDirection();
const seq = ++fetchSeq;
paintLoading();
const defaultsP = fetch(
"/api/order_defaults?symbol=" +
encodeURIComponent(sym) +
"&direction=" +
encodeURIComponent(dir)
).then(function (r) {
return r.json();
});
const capitalP = fetch("/api/account_snapshot").then(function (r) {
return r.json();
});
Promise.all([defaultsP, capitalP])
.then(function (results) {
if (seq !== fetchSeq) return;
const data = results[0];
const account = results[1] || {};
if (!data.ok) {
paintFail("fetch_fail");
return;
}
const entry = num(data.last_price != null ? data.last_price : data.price);
if (entry === null) {
paintFail("fetch_fail");
return;
}
const rr = resolvePreviewRr(m, dir, entry);
if (rr === null) {
paintFail("invalid");
return;
}
let riskU = null;
if (isFullMarginMode()) {
const slPx = resolveSlPrice(m, dir, entry);
const avail =
data.available_trading_usdt != null
? data.available_trading_usdt
: account.available_trading_usdt;
riskU = plannedRiskFromFullMargin(avail, sym, dir, entry, slPx);
} else {
riskU = plannedRiskFromRiskMode(account.current_capital);
}
if (riskU === null) {
paintFail("fetch_fail");
return;
}
const profitU = Math.round(riskU * rr * 100) / 100;
paintOk(riskU, profitU, rr);
})
.catch(function () {
if (seq !== fetchSeq) return;
paintFail("fetch_fail");
});
}
function schedule() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(refreshNow, debounceMs);
}
function wire(opts) {
opts = opts || {};
if (opts.minRr != null && Number.isFinite(Number(opts.minRr))) {
minRr = Number(opts.minRr);
}
if (opts.debounceMs != null && Number.isFinite(Number(opts.debounceMs))) {
debounceMs = Number(opts.debounceMs);
}
[
"order-symbol",
"order-direction",
"sltp-mode",
"order-sl",
"order-tp",
"order-sl-pct",
"order-tp-pct",
"order-fixed-rr",
"order-leverage",
].forEach(function (id) {
const el = $(id);
if (!el || el._rrPreviewBound) return;
el._rrPreviewBound = true;
el.addEventListener("input", schedule);
el.addEventListener("change", schedule);
});
schedule();
}
global.ManualOrderRrPreview = {
wire: wire,
schedule: schedule,
refresh: refreshNow,
calcRr: calcRr,
calcRrFromPct: calcRrFromPct,
calcRiskFraction: calcRiskFraction,
formatRr: formatRr,
};
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,58 @@
/**
* 期权到期倒计时(实例期权页 + 中控监控/看板共用)
*/
(function (global) {
function normalizeExpMs(v) {
if (v == null || v === "") return null;
var n = Number(v);
if (!Number.isFinite(n) || n <= 0) return null;
if (n < 1e12) n *= 1000;
return n;
}
function formatCountdown(expMs, nowMs) {
var ms = normalizeExpMs(expMs);
if (ms == null) return "—";
var now = nowMs != null ? nowMs : Date.now();
var rem = Math.max(0, Math.floor((ms - now) / 1000));
if (rem <= 0) return "已到期";
var d = Math.floor(rem / 86400);
var h = Math.floor((rem % 86400) / 3600);
var m = Math.floor((rem % 3600) / 60);
var s = rem % 60;
var pad = function (x) {
return String(x).padStart(2, "0");
};
if (d > 0) return d + "天 " + pad(h) + ":" + pad(m) + ":" + pad(s);
return pad(h) + ":" + pad(m) + ":" + pad(s);
}
function tick(root) {
var scope = root && root.querySelectorAll ? root : document;
var now = Date.now();
scope.querySelectorAll("[data-opt-exp-ms]").forEach(function (el) {
var exp = el.getAttribute("data-opt-exp-ms");
var text = formatCountdown(exp, now);
el.textContent = text;
var expMs = normalizeExpMs(exp);
el.classList.toggle("opt-expiry-cd--urgent", expMs != null && expMs - now > 0 && expMs - now < 3600000);
el.classList.toggle("opt-expiry-cd--expired", text === "已到期");
});
}
var timer = null;
function ensureTimer() {
tick();
if (timer) return;
timer = setInterval(function () {
tick();
}, 1000);
}
global.OptionsExpiryCountdown = {
normalizeExpMs: normalizeExpMs,
format: formatCountdown,
tick: tick,
ensureTimer: ensureTimer,
};
})(typeof window !== "undefined" ? window : globalThis);
File diff suppressed because it is too large Load Diff
+232
View File
@@ -0,0 +1,232 @@
(function (global) {
"use strict";
function fmt(v, d) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
return Number(v).toFixed(d == null ? 2 : d);
}
function fmtDisplay(v, fallback) {
if (v !== null && v !== undefined && String(v).trim() !== "") return String(v);
if (fallback !== undefined) return fmtDisplay(fallback);
return "—";
}
function fmtOptionPx(v, tickSz) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
const n = Number(v);
const tick = Number(tickSz);
if (!tickSz || Number.isNaN(tick) || tick <= 0) {
let s = n.toFixed(4).replace(/\.?0+$/, "");
return s || "0";
}
let decimals = 0;
if (tick < 1) decimals = Math.max(0, -Math.round(Math.log10(tick)));
else if (String(tick).indexOf(".") >= 0) decimals = String(tick).split(".")[1].length;
let s = n.toFixed(decimals);
// 仅裁小数尾零;整数 tick(BTC=5)时绝不能把 1370 裁成 137
if (decimals > 0) s = s.replace(/\.?0+$/, "");
return s || "0";
}
function fmtUsdc(v) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
return Number(v).toFixed(2);
}
function optTypeLabel(t) {
return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call";
}
function pnlCls(upl, hub) {
if (upl > 0) return hub ? "pnl-pos" : "pos-pnl-profit";
if (upl < 0) return hub ? "pnl-neg" : "pos-pnl-loss";
return "";
}
function fmtPxSz(px, sz, tickSz) {
if (px === null || px === undefined || Number.isNaN(Number(px))) return "—";
let price = fmtOptionPx(px, tickSz);
if (sz === null || sz === undefined || sz === "" || Number.isNaN(Number(sz))) return price;
const s = Number(sz);
const size = Math.abs(s - Math.round(s)) < 1e-9 ? String(Math.round(s)) : String(s);
return price + "/" + size;
}
/** 买盘深度:价格/流动性;仅展示平仓所需档位(买一不够才出买二…). */
function fmtCloseLevels(preview, tickSz) {
if (preview && preview.bid_invalid) {
return "暂无有效买盘";
}
const levels = ((preview && preview.levels) || []).slice(0, 5);
if (!levels.length) return "—";
return levels.map(function (x, idx) {
const levelNo = x.level != null ? x.level : idx + 1;
const liq = x.available_sheets != null ? x.available_sheets : x.sz;
return "买" + levelNo + " " + fmtPxSz(x.px, liq, tickSz);
}).join(" · ");
}
function closeGateHint(preview) {
if (!preview) return "";
if (preview.bid_invalid || preview.manual_close_blocked) {
return preview.bid_invalid_reason || "当前买一无效,禁止买一平仓";
}
const gate = preview.close_gate || {};
if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) {
return "目标门控: " + (preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟");
}
return "";
}
function netPnlFromPos(p) {
const preview = (p && p.close_preview) || {};
if (preview.estimated_pnl != null && !Number.isNaN(Number(preview.estimated_pnl))) {
return Number(preview.estimated_pnl);
}
const recv = Number(preview.total_received);
const prem = Number(p && p.premium_paid);
if (preview.total_received != null && !Number.isNaN(recv) && !Number.isNaN(prem)) {
return recv - prem;
}
return null;
}
function netRoiFromPos(p, net) {
const preview = (p && p.close_preview) || {};
if (preview.estimated_pnl_ratio_pct != null && !Number.isNaN(Number(preview.estimated_pnl_ratio_pct))) {
return Number(preview.estimated_pnl_ratio_pct);
}
const prem = Number(p && p.premium_paid);
if (net == null || Number.isNaN(prem) || prem <= 0) return null;
return (net / prem) * 100;
}
function fmtClosePreview(preview, premiumPaid, hub) {
if (!preview || preview.total_received == null) return "—";
const recvTxt = fmtUsdc(preview.total_received);
let cls = "";
const prem = Number(premiumPaid);
const recv = Number(preview.total_received);
if (!Number.isNaN(prem) && !Number.isNaN(recv)) {
if (recv > prem) cls = " " + pnlCls(1, hub);
else if (recv < prem) cls = " " + pnlCls(-1, hub);
}
return '<span class="opt-close-value' + cls + '">' + recvTxt + " USDC</span>";
}
function expiryCdHtml(expMs) {
const ms = expMs != null && expMs !== "" ? String(expMs) : "";
if (!ms) return "—";
return '<span class="opt-expiry-cd" data-opt-exp-ms="' + ms + '">—</span>';
}
function renderCardInner(p, opts) {
opts = opts || {};
const hub = !!opts.hub;
const readOnly = !!opts.readOnly;
const net = netPnlFromPos(p);
const roi = netRoiFromPos(p, net);
const uplCls = pnlCls(net, hub);
const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long";
const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time;
const expAttr = expMs != null && expMs !== "" ? String(expMs) : "";
const closePreview = p.close_preview || {};
const tickSz = p.tick_sz;
const premTxt = fmtDisplay(p.premium_paid_fmt, p.premium_paid != null ? fmtUsdc(p.premium_paid) : null);
const avgTxt = p.avg_px != null ? fmtOptionPx(p.avg_px, tickSz) : fmtDisplay(p.avg_px_fmt);
const markTxt = p.mark_px != null ? fmtOptionPx(p.mark_px, tickSz) : fmtDisplay(p.mark_px_fmt);
let headActions = "";
if (!readOnly) {
const closeSheets = p.avail_pos != null && Number(p.avail_pos) > 0 ? p.avail_pos : p.pos;
headActions =
'<div class="pos-head-actions">' +
'<button type="button" class="btn-primary opt-close-btn" data-inst="' + (p.inst_id || "") + '" data-sheets="' + closeSheets + '">买一平仓</button>' +
"</div>";
}
return (
'<div class="pos-card-head">' +
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + "</strong>" +
'<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span></div>" +
headActions +
"</div>" +
'<div class="pos-meta">' +
'<span class="pos-meta-item">行权价: ' + fmt(p.strike, 0) + "</span>" +
'<span class="pos-meta-item">张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "</span>" +
(expAttr
? '<span class="pos-meta-item">到期倒计时: ' + expiryCdHtml(expAttr) + "</span>"
: "") +
"</div>" +
'<div class="pos-grid">' +
'<div class="pos-cell"><span class="pos-label">权利金</span><span class="pos-value">' + premTxt + " USDC</span></div>" +
'<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + avgTxt + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + markTxt + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">指数价</span><span class="pos-value">' + fmt(p.idx_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">到期平衡</span><span class="pos-value">' + fmt(p.expiry_be_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">平掉回本</span><span class="pos-value">' + fmt(p.close_be_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' +
(closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
(closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>" +
'<div class="pos-cell opt-pos-cell--depth"><span class="pos-label">买盘深度</span><span class="pos-value opt-bid-plain">' + fmtCloseLevels(closePreview, tickSz) + "</span></div>" +
'<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘回收</span><span class="pos-value">' +
(closePreview.bid_invalid
? '<span class="muted">暂无有效买盘</span>'
: fmtClosePreview(closePreview, p.premium_paid, hub)) + "</span></div>" +
"</div>" +
(function () {
const hint = closeGateHint(closePreview);
return hint ? '<div class="muted opt-bid-invalid-hint">' + hint + "</div>" : "";
})() +
(p.target_index != null
? (function () {
const eth = p.eth_amount != null ? Number(p.eth_amount)
: (Number(p.pos) > 0 ? Number(p.pos) * Number(p.ct_mult || 0.01) : null);
const strike = Number(p.strike);
const tgt = Number(p.target_index);
const prem = Number(p.premium_paid);
let profit = null;
let value = null;
if (Number.isFinite(tgt) && Number.isFinite(strike) && eth > 0) {
const o = String(p.opt_type || "").toUpperCase();
const intrinsic = o === "C" ? Math.max(0, tgt - strike) : o === "P" ? Math.max(0, strike - tgt) : null;
if (intrinsic != null) {
value = Math.round(intrinsic * eth * 100) / 100;
if (Number.isFinite(prem)) profit = Math.round((value - prem) * 100) / 100;
}
}
const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC");
const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : "";
const hedgeTarget = p.hedge_plan_target || null;
const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan";
return (
'<div class="opt-target-row opt-target-row--ro' + (managed ? " opt-target-row--managed" : "") + '">' +
'<span class="opt-target-row-label">' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "</span>" +
'<span class="pos-value">目标 ' + fmt(p.target_index, 1) + "</span>" +
'<span class="pos-value">价值 ' + (value == null ? "—" : fmtUsdc(value) + " USDC") + "</span>" +
'<span class="pos-value' + profitCls + '">预估盈利 ' + profitTxt + "</span>" +
'<span class="muted opt-target-row-hint">' +
(managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") +
"</span></div>"
);
})()
: "")
);
}
function renderCard(p, opts) {
opts = opts || {};
const hub = !!opts.hub;
const extraCls = hub ? " hub-pos-card hub-opt-pos-card" : " opt-pos-card";
return (
'<div class="pos-card' + extraCls + '" data-inst="' + (p.inst_id || "") + '">' +
renderCardInner(p, opts) +
"</div>"
);
}
global.OptionsPositionCards = {
renderCardInner: renderCardInner,
renderCard: renderCard,
};
})(typeof window !== "undefined" ? window : globalThis);
File diff suppressed because it is too large Load Diff
+345
View File
@@ -0,0 +1,345 @@
(function () {
"use strict";
const root = document.getElementById("options-settings-root");
if (!root) return;
const SWAP_BTNS = ["opt-set-swap-btn", "opt-set-swap-all-btn"];
const INT_BTNS = ["opt-set-int-btn", "opt-set-int-all-btn"];
const CROSS_BTNS = ["opt-set-cross-btn", "opt-set-cross-all-btn"];
async function apiJson(url, opts) {
const r = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {}));
return r.json();
}
function refreshFundsAfterMutation() {
if (typeof refreshAccountSnapshot !== "function") return;
refreshAccountSnapshot({ force: true });
setTimeout(function () {
refreshAccountSnapshot({ force: true, silent: true });
}, 1500);
}
function setMsg(id, text, isErr) {
const el = document.getElementById(id);
if (!el) return;
el.textContent = text || "";
el.classList.toggle("opt-error", !!isErr);
el.classList.toggle("opt-success", !!text && !isErr);
}
function fmtAmt(amount, ccy) {
return `${Number(amount).toFixed(2)} ${ccy}`;
}
function accountLabel(acct) {
return acct === "trading" ? "交易账户" : "资金账户";
}
function swapDirLabel(dir) {
return dir === "usdc_to_usdt" ? "USDC → USDT" : "USDT → USDC";
}
function confirmOk(message) {
return window.confirm(message);
}
function setButtonsBusy(btnIds, busy, busyText) {
btnIds.forEach(function (id) {
const btn = document.getElementById(id);
if (!btn) return;
if (busy) {
if (!btn.dataset.origText) btn.dataset.origText = btn.textContent;
btn.disabled = true;
if (busyText) btn.textContent = busyText;
} else {
btn.disabled = false;
if (btn.dataset.origText) {
btn.textContent = btn.dataset.origText;
delete btn.dataset.origText;
}
}
});
const amountIds = {
"opt-set-swap-btn": "opt-set-swap-amount",
"opt-set-swap-all-btn": "opt-set-swap-amount",
"opt-set-int-btn": "opt-set-int-amount",
"opt-set-int-all-btn": "opt-set-int-amount",
"opt-set-cross-btn": "opt-set-cross-amount",
"opt-set-cross-all-btn": "opt-set-cross-amount",
};
btnIds.forEach(function (id) {
const input = document.getElementById(amountIds[id]);
if (input) input.disabled = busy;
});
}
function roundAvail(v) {
const n = Number(v);
if (!Number.isFinite(n) || n <= 0) return null;
return Math.round(n * 100) / 100;
}
async function loadBalances(force, scope) {
const parts = [];
if (force) parts.push("force=1");
if (scope && scope !== "main") parts.push("scope=" + encodeURIComponent(scope));
const q = parts.length ? "?" + parts.join("&") : "";
const d = await apiJson("/api/options/balances" + q);
if (!d.ok) throw new Error(d.msg || "余额拉取失败");
return d;
}
function pickBalance(bal, account, ccy) {
const acct = account === "trading" ? "trading" : "funding";
const c = String(ccy || "").toLowerCase();
const availKey = acct + "_" + c + "_avail";
const totalKey = acct + "_" + c;
return roundAvail(bal[availKey] != null ? bal[availKey] : bal[totalKey]);
}
async function resolveSwapMaxAmount(dir) {
const bal = await loadBalances(true, "main");
const ccy = dir === "usdc_to_usdt" ? "USDC" : "USDT";
// 币种兑换走资金账户现货;统一账户下 USDT 有时在交易户,市价单仍可能成交
let amount = pickBalance(bal, "funding", ccy);
let source = "funding";
if (!amount && ccy === "USDT") {
const tradingAmt = pickBalance(bal, "trading", ccy);
if (tradingAmt) {
amount = tradingAmt;
source = "trading";
}
}
return { amount, bal, ccy, source };
}
async function resolveMaxAmount(account, ccy, scope) {
const bal = await loadBalances(true, scope || "main");
return pickBalance(bal, account, ccy);
}
async function submitSwap(amount) {
setButtonsBusy(SWAP_BTNS, true, "兑换中…");
setMsg("opt-set-swap-msg", "兑换中,市价成交可能有延时…", false);
try {
const d = await apiJson("/api/options/spot/swap", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
direction: document.getElementById("opt-set-swap-dir").value,
amount: amount,
}),
});
if (d.ok) {
setMsg("opt-set-swap-msg", "兑换成功", false);
refreshFundsAfterMutation();
} else {
setMsg("opt-set-swap-msg", "兑换失败:" + (d.msg || "未知错误"), true);
}
return d;
} catch (e) {
setMsg("opt-set-swap-msg", "兑换失败:" + (e.message || "网络错误"), true);
return { ok: false };
} finally {
setButtonsBusy(SWAP_BTNS, false);
}
}
const swapBtn = document.getElementById("opt-set-swap-btn");
if (swapBtn) {
swapBtn.addEventListener("click", async function () {
const amount = parseFloat(document.getElementById("opt-set-swap-amount").value);
if (!amount || amount <= 0) {
setMsg("opt-set-swap-msg", "请输入有效数量", true);
return;
}
await submitSwap(amount);
});
}
const swapAllBtn = document.getElementById("opt-set-swap-all-btn");
if (swapAllBtn) {
swapAllBtn.addEventListener("click", async function () {
try {
const dir = document.getElementById("opt-set-swap-dir").value;
const { amount, bal, ccy, source } = await resolveSwapMaxAmount(dir);
if (!amount) {
const fu = bal.funding_usdt_avail != null ? bal.funding_usdt_avail : bal.funding_usdt;
const tu = bal.trading_usdt_avail != null ? bal.trading_usdt_avail : bal.trading_usdt;
const fc = bal.funding_usdc_avail != null ? bal.funding_usdc_avail : bal.funding_usdc;
setMsg(
"opt-set-swap-msg",
"资金账户可用 " +
ccy +
" 不足(资金户 USDT:" +
(fu != null ? fu : "—") +
" USDC:" +
(fc != null ? fc : "—") +
"; 交易户 USDT:" +
(tu != null ? tu : "—") +
")",
true
);
return;
}
const srcLabel = source === "trading" ? "交易账户" : "资金账户";
const msg =
"确认全部兑换?\n\n" +
"方向:" + swapDirLabel(dir) + "\n" +
"金额:" + fmtAmt(amount, ccy) + "\n" +
"来源:" + srcLabel + "\n\n" +
"将按该账户可用余额发起市价兑换(可能有延时)。请确认。";
if (!confirmOk(msg)) return;
document.getElementById("opt-set-swap-amount").value = String(amount);
await submitSwap(amount);
} catch (e) {
setMsg("opt-set-swap-msg", "兑换失败:" + (e.message || "余额拉取失败"), true);
}
});
}
async function submitInternalTransfer(amount) {
setButtonsBusy(INT_BTNS, true, "划转中…");
setMsg("opt-set-int-msg", "划转中…", false);
try {
const d = await apiJson("/api/options/transfer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ccy: document.getElementById("opt-set-int-ccy").value,
from: document.getElementById("opt-set-int-from").value,
to: document.getElementById("opt-set-int-to").value,
amount: amount,
}),
});
if (d.ok) {
setMsg("opt-set-int-msg", "划转成功", false);
refreshFundsAfterMutation();
} else {
setMsg("opt-set-int-msg", "划转失败:" + (d.msg || "未知错误"), true);
}
return d;
} catch (e) {
setMsg("opt-set-int-msg", "划转失败:" + (e.message || "网络错误"), true);
return { ok: false };
} finally {
setButtonsBusy(INT_BTNS, false);
}
}
const intBtn = document.getElementById("opt-set-int-btn");
if (intBtn) {
intBtn.addEventListener("click", async function () {
const amount = parseFloat(document.getElementById("opt-set-int-amount").value);
if (!amount || amount <= 0) {
setMsg("opt-set-int-msg", "请输入有效数量", true);
return;
}
await submitInternalTransfer(amount);
});
}
const intAllBtn = document.getElementById("opt-set-int-all-btn");
if (intAllBtn) {
intAllBtn.addEventListener("click", async function () {
try {
const ccy = document.getElementById("opt-set-int-ccy").value;
const from = document.getElementById("opt-set-int-from").value;
const to = document.getElementById("opt-set-int-to").value;
const amount = await resolveMaxAmount(from, ccy, "main");
if (!amount) {
setMsg("opt-set-int-msg", "划出账户可用余额不足", true);
return;
}
const msg =
"确认全部划转?\n\n" +
"币种:" + ccy + "\n" +
"划出:" + accountLabel(from) + "\n" +
"划入:" + accountLabel(to) + "\n" +
"金额:" + fmtAmt(amount, ccy) + "\n\n" +
"将划转该账户全部可用余额。";
if (!confirmOk(msg)) return;
document.getElementById("opt-set-int-amount").value = String(amount);
await submitInternalTransfer(amount);
} catch (e) {
setMsg("opt-set-int-msg", "划转失败:" + (e.message || "余额拉取失败"), true);
}
});
}
async function submitCrossTransfer(amount) {
setButtonsBusy(CROSS_BTNS, true, "划转中…");
setMsg("opt-set-cross-msg", "划转中…", false);
try {
const d = await apiJson("/api/options/cross-transfer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ccy: document.getElementById("opt-set-cross-ccy").value,
amount: amount,
from_account: document.getElementById("opt-set-cross-from").value,
to_account: document.getElementById("opt-set-cross-to").value,
direction: document.getElementById("opt-set-cross-dir").value,
}),
});
if (d.ok) {
setMsg("opt-set-cross-msg", "划转成功", false);
refreshFundsAfterMutation();
} else {
setMsg("opt-set-cross-msg", "划转失败:" + (d.msg || "未知错误"), true);
}
return d;
} catch (e) {
setMsg("opt-set-cross-msg", "划转失败:" + (e.message || "网络错误"), true);
return { ok: false };
} finally {
setButtonsBusy(CROSS_BTNS, false);
}
}
const crossBtn = document.getElementById("opt-set-cross-btn");
if (crossBtn) {
crossBtn.addEventListener("click", async function () {
const amount = parseFloat(document.getElementById("opt-set-cross-amount").value);
if (!amount || amount <= 0) {
setMsg("opt-set-cross-msg", "请输入有效数量", true);
return;
}
await submitCrossTransfer(amount);
});
}
const crossAllBtn = document.getElementById("opt-set-cross-all-btn");
if (crossAllBtn) {
crossAllBtn.addEventListener("click", async function () {
try {
const ccy = document.getElementById("opt-set-cross-ccy").value;
const from = document.getElementById("opt-set-cross-from").value;
const to = document.getElementById("opt-set-cross-to").value;
const direction = document.getElementById("opt-set-cross-dir").value;
const scope = direction === "sub_to_main" ? "sub" : "main";
const sideLabel = direction === "sub_to_main" ? "子账户" : "主账户";
const amount = await resolveMaxAmount(from, ccy, scope);
if (!amount) {
setMsg("opt-set-cross-msg", sideLabel + "划出账户可用余额不足", true);
return;
}
const msg =
"确认全部划转?\n\n" +
"方向:" + (direction === "main_to_sub" ? "主 → 子" : "子 → 主") + "\n" +
"币种:" + ccy + "\n" +
"划出:" + sideLabel + " · " + accountLabel(from) + "\n" +
"划入:" + (direction === "main_to_sub" ? "子账户" : "主账户") + " · " + accountLabel(to) + "\n" +
"金额:" + fmtAmt(amount, ccy) + "\n\n" +
"将划转该账户全部可用余额。";
if (!confirmOk(msg)) return;
document.getElementById("opt-set-cross-amount").value = String(amount);
await submitCrossTransfer(amount);
} catch (e) {
setMsg("opt-set-cross-msg", "划转失败:" + (e.message || "余额拉取失败"), true);
}
});
}
})();
+214
View File
@@ -0,0 +1,214 @@
(function (global) {
var delegated = false;
function queryInScope(scope, id) {
if (scope && scope.querySelector) return scope.querySelector("#" + id);
return document.getElementById(id);
}
function categoriesData() {
return global.ORDER_ENTRY_MODEL_CATEGORIES || [];
}
function codeToCategoryMap() {
return global.ORDER_ENTRY_MODEL_CODE_TO_CATEGORY || {};
}
function tradeStyleForCode(code, modelSel) {
if (modelSel && code) {
var opt = modelSel.querySelector('option[value="' + code.replace(/"/g, '\\"') + '"]');
if (opt) {
var ds = opt.getAttribute("data-trade-style");
if (ds === "swing" || ds === "trend") return ds;
}
}
var map = global.ORDER_ENTRY_MODEL_TRADE_STYLE || {};
return map[code] || "trend";
}
function findOption(catKey, code) {
var cats = categoriesData();
for (var i = 0; i < cats.length; i++) {
if (cats[i].key !== catKey) continue;
var opts = cats[i].options || [];
for (var j = 0; j < opts.length; j++) {
if (opts[j].code === code) return opts[j];
}
}
return null;
}
function filterDomSubOptions(modelSel, catKey, preserveCode) {
var tagged = modelSel.querySelectorAll("option[data-entry-category]");
if (!tagged.length) return false;
var any = false;
for (var i = 0; i < tagged.length; i++) {
var opt = tagged[i];
var show = !!catKey && opt.getAttribute("data-entry-category") === catKey;
opt.hidden = !show;
opt.disabled = !show;
if (show) any = true;
}
modelSel.disabled = !any;
if (!any) {
modelSel.value = "";
return true;
}
var pick = preserveCode || "";
if (pick) {
var picked = modelSel.querySelector('option[value="' + pick.replace(/"/g, '\\"') + '"]:not([disabled])');
if (picked) {
modelSel.value = pick;
return true;
}
}
var visible = [];
for (var k = 0; k < tagged.length; k++) {
if (!tagged[k].disabled) visible.push(tagged[k]);
}
if (visible.length === 1) modelSel.value = visible[0].value;
else modelSel.value = "";
return true;
}
function rebuildFromCategories(modelSel, catKey, preserveCode) {
var cats = categoriesData();
var cat = null;
for (var i = 0; i < cats.length; i++) {
if (cats[i].key === catKey) {
cat = cats[i];
break;
}
}
modelSel.innerHTML = "";
var placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = "类型";
modelSel.appendChild(placeholder);
if (!cat || !cat.options || !cat.options.length) {
modelSel.disabled = true;
modelSel.value = "";
return;
}
modelSel.disabled = false;
var pick = preserveCode || "";
for (var k = 0; k < cat.options.length; k++) {
var o = cat.options[k];
var opt = document.createElement("option");
opt.value = o.code;
opt.textContent = o.label;
if (o.trade_style) opt.setAttribute("data-trade-style", o.trade_style);
+611
View File
@@ -0,0 +1,611 @@
/**
* 三所 /records:交易记录分页 + 复盘表单显隐 + 复盘/AI 列表分页(soft,每页5).
*/
(function (global) {
"use strict";
var PAGE_SIZE = 5;
var tradesPage = 0;
var tradesPages = 1;
var journalsAll = [];
var journalsPage = 0;
var journalsPages = 1;
var reviewsAll = [];
var reviewsPage = 0;
var reviewsPages = 1;
var tradesCache = {};
var booted = false;
function $(id) {
return document.getElementById(id);
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function listQs() {
if (typeof global.listWindowQueryString === "function") {
return global.listWindowQueryString() || "";
}
return "";
}
function fmtNum(v, digits) {
if (v == null || v === "") return "—";
var n = Number(v);
if (!Number.isFinite(n)) return esc(v);
return n.toFixed(digits == null ? 2 : digits);
}
function fmtTime(s) {
if (!s) return "—";
return esc(String(s).slice(0, 16));
}
function resultBadge(result) {
var er = String(result || "").trim();
if (["止盈", "保本止盈", "移动止盈"].indexOf(er) >= 0) {
return '<span class="badge profit">' + esc(er) + "</span>";
}
if (["止损", "强制清仓", "手动平仓"].indexOf(er) >= 0) {
return '<span class="badge loss">' + esc(er) + "</span>";
}
if (er === "时间平仓") return '<span class="badge miss">' + esc(er) + "</span>";
return '<span class="badge">' + esc(er || "-") + "</span>";
}
function pnlClass(v) {
var n = Number(v);
if (!Number.isFinite(n) || n === 0) return "";
return n > 0 ? "pnl-profit" : "pnl-loss";
}
function beginSoft(wrapId, soft) {
var wrap = $(wrapId);
if (!wrap) return null;
if (soft) {
if (!wrap.style.minHeight) {
wrap.style.minHeight = Math.max(wrap.offsetHeight, 1) + "px";
}
wrap.classList.add("rr-list-loading");
} else {
wrap.classList.remove("rr-list-loading");
wrap.style.minHeight = "";
}
return wrap;
}
function endSoft(wrap) {
if (!wrap) return;
wrap.classList.remove("rr-list-loading");
wrap.style.minHeight = "";
}
function updatePager(kind) {
var map = {
trades: {
page: tradesPage,
pages: tradesPages,
label: "rr-trades-page-label",
prev: "rr-trades-prev",
next: "rr-trades-next",
},
journals: {
page: journalsPage,
pages: journalsPages,
label: "rr-journals-page-label",
prev: "rr-journals-prev",
next: "rr-journals-next",
},
reviews: {
page: reviewsPage,
pages: reviewsPages,
label: "rr-reviews-page-label",
prev: "rr-reviews-prev",
next: "rr-reviews-next",
},
};
var m = map[kind];
if (!m) return;
var label = $(m.label);
var prev = $(m.prev);
var next = $(m.next);
if (label) label.textContent = "第 " + (m.page + 1) + " / " + m.pages + " 页";
if (prev) prev.disabled = m.page <= 0;
if (next) next.disabled = m.page + 1 >= m.pages;
}
function fillPayload(t) {
return {
symbol: t.symbol,
monitor_type: t.monitor_type,
key_signal_type: t.key_signal_type || "",
direction: t.direction,
trigger_price: t.trigger_price,
stop_loss: t.display_open_stop_loss || t.initial_stop_loss || t.stop_loss,
take_profit: t.effective_take_profit || t.take_profit,
opened_at: t.effective_opened_at,
closed_at: t.effective_closed_at,
pnl_amount: t.effective_pnl_amount,
result: t.effective_result,
risk_amount: t.risk_amount,
effective_entry_reason: t.effective_entry_reason || "",
};
}
function editPayload(t) {
return {
id: t.id,
opened_at: t.effective_opened_at,
closed_at: t.effective_closed_at,
stop_loss: t.effective_stop_loss || t.initial_stop_loss || t.stop_loss,
take_profit: t.effective_take_profit || t.take_profit,
pnl_amount: t.effective_pnl_amount,
result: t.effective_result,
miss_reason: t.effective_miss_reason,
effective_entry_reason: t.effective_entry_reason || "",
};
}
function renderTradesRows(rows) {
var tbody = $("rr-trades-tbody");
if (!tbody) return;
tradesCache = {};
if (!rows || !rows.length) {
tbody.innerHTML = '<tr><td colspan="15" class="muted">暂无交易记录</td></tr>';
return;
}
tbody.innerHTML = rows
.map(function (t) {
tradesCache[t.id] = t;
var mon = esc(t.monitor_type || "");
if (t.key_signal_type) mon += " · " + esc(t.key_signal_type);
var stopShow = t.display_open_stop_loss || t.initial_stop_loss || t.stop_loss;
var tpShow = t.effective_take_profit || t.take_profit;
var pnl = t.effective_pnl_amount;
var pnlSrc = "";
if (t.display_pnl_source === "exchange") {
pnlSrc = '<span style="font-size:.68rem;color:#6ab88a">所</span>';
} else if (t.display_pnl_source !== "reviewed") {
pnlSrc = '<span style="font-size:.68rem;color:#8892b0">估</span>';
}
var dirCls = t.direction === "long" ? "direction-long" : "direction-short";
var dirTxt = t.direction === "long" ? "做多" : "做空";
var margin =
t.margin_capital != null && t.margin_capital !== ""
? fmtNum(t.margin_capital, 2)
: "-";
return (
'<tr id="trade-row-' +
esc(t.id) +
'">' +
"<td>" +
esc(t.symbol) +
"</td>" +
"<td>" +
mon +
"</td>" +
"<td>" +
esc(t.effective_entry_reason || "-") +
"</td>" +
'<td><span class="badge ' +
dirCls +
'">' +
dirTxt +
"</span></td>" +
"<td>" +
fmtNum(t.trigger_price, 4) +
"</td>" +
"<td>" +
fmtNum(stopShow, 4) +
"</td>" +
"<td>" +
fmtNum(tpShow, 4) +
"</td>" +
"<td>" +
margin +
"</td>" +
"<td>" +
esc(t.leverage != null ? t.leverage : "-") +
"</td>" +
"<td>" +
esc(t.effective_hold_minutes || 0) +
"</td>" +
"<td>" +
fmtTime(t.effective_opened_at) +
"</td>" +
"<td>" +
fmtTime(t.effective_closed_at || t.created_at) +
"</td>" +
'<td><span class="' +
pnlClass(pnl) +
'">' +
fmtNum(pnl, 2) +
"</span>" +
pnlSrc +
"</td>" +
"<td>" +
resultBadge(t.effective_result) +
"</td>" +
"<td>" +
'<button type="button" class="table-del rr-fill-btn" style="background:#1f3a5a;color:#8fc8ff;margin-right:6px" data-id="' +
esc(t.id) +
'">填入复盘</button> ' +
'<button type="button" class="table-del review-edit-btn" style="background:#1f3a5a;color:#8fc8ff;margin-right:6px" data-id="' +
esc(t.id) +
'" disabled>核对修改</button> ' +
'<button type="button" class="table-del" onclick="deleteTradeRecord(' +
Number(t.id) +
')">删除</button>' +
"</td>" +
"</tr>"
);
})
.join("");
tbody.querySelectorAll(".rr-fill-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
var id = btn.getAttribute("data-id");
var t = tradesCache[id];
if (!t) return;
showJournalCard();
if (typeof global.fillJournalFromTrade === "function") {
global.fillJournalFromTrade(fillPayload(t));
}
});
});
tbody.querySelectorAll(".review-edit-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
var id = btn.getAttribute("data-id");
var t = tradesCache[id];
if (!t) return;
if (typeof global.editTradeRecordReview === "function") {
global.editTradeRecordReview(editPayload(t));
}
});
});
if (typeof global.toggleReviewMode === "function") {
global.toggleReviewMode();
}
}
function loadTradeRecords(opts) {
opts = opts || {};
var soft = !!opts.soft;
var tbody = $("rr-trades-tbody");
if (!tbody) return;
var wrap = beginSoft("rr-trades-wrap", soft);
if (!soft) {
tbody.innerHTML = '<tr><td colspan="15" class="muted">加载中…</td></tr>';
}
var qs = listQs();
var p = new URLSearchParams(qs || "");
p.set("limit", String(PAGE_SIZE));
p.set("offset", String(tradesPage * PAGE_SIZE));
fetch("/api/trade_records?" + p.toString(), { credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
if (!data || !data.ok) {
tbody.innerHTML = '<tr><td colspan="15" class="muted">加载失败</td></tr>';
endSoft(wrap);
return;
}
tradesPages = Math.max(1, Number(data.pages) || 1);
if (tradesPage >= tradesPages) {
tradesPage = Math.max(0, tradesPages - 1);
updatePager("trades");
if (Number(data.total || 0) > 0) {
loadTradeRecords(opts);
return;
}
}
updatePager("trades");
renderTradesRows(data.items || []);
endSoft(wrap);
})
.catch(function () {
tbody.innerHTML = '<tr><td colspan="15" class="muted">加载失败</td></tr>';
endSoft(wrap);
});
}
function renderJournalsPage(soft) {
var box = $("journal-list");
if (!box) return;
var wrap = beginSoft("journal-list-wrap", soft);
var total = journalsAll.length;
journalsPages = Math.max(1, Math.ceil(total / PAGE_SIZE) || 1);
if (journalsPage >= journalsPages) journalsPage = Math.max(0, journalsPages - 1);
updatePager("journals");
var hint = $("rr-journals-hint");
if (hint) {
hint.textContent =
total > 0
? "已保存的复盘(共" + total + "条,每页5条)."
: "已保存的复盘(每页5条).";
}
var slice = journalsAll.slice(
journalsPage * PAGE_SIZE,
journalsPage * PAGE_SIZE + PAGE_SIZE
);
if (global.InstanceUI && typeof InstanceUI.renderJournalListHtml === "function") {
var html = InstanceUI.renderJournalListHtml(slice);
box.innerHTML = html || "<div class='journal-empty-msg'>暂无数据</div>";
} else {
box.innerHTML = "<div class='journal-empty-msg'>暂无数据</div>";
}
endSoft(wrap);
}
function renderReviewsPage(soft) {
var box = $("review-list");
if (!box) return;
var wrap = beginSoft("review-list-wrap", soft);
var total = reviewsAll.length;
reviewsPages = Math.max(1, Math.ceil(total / PAGE_SIZE) || 1);
if (reviewsPage >= reviewsPages) reviewsPage = Math.max(0, reviewsPages - 1);
updatePager("reviews");
var slice = reviewsAll.slice(
reviewsPage * PAGE_SIZE,
reviewsPage * PAGE_SIZE + PAGE_SIZE
);
if (!slice.length) {
box.innerHTML = "<div class='entry'>暂无数据</div>";
endSoft(wrap);
return;
}
var html = "";
slice.forEach(function (r) {
if (global.reviewCache) global.reviewCache[r.id] = r;
var preview = (r.content || "").replace(/\s+/g, " ").trim();
var shortText = preview.length > 90 ? preview.slice(0, 90) + "..." : preview;
html +=
'<div class="entry">' +
"<div><strong>" +
(r.review_type === "daily" ? "日复盘" : "周复盘") +
"</strong> | " +
esc(r.target_date) +
"</div>" +
'<div style="font-size:12px;color:#9aa">' +
esc(r.created_at || "") +
"</div>" +
'<div style="margin-top:4px;color:#c9d2ff">' +
esc(shortText || "(空)") +
"</div>" +
'<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:6px">' +
'<button type="button" class="btn-del" style="border:none;cursor:pointer;background:#1f3a5a;color:#8fc8ff" onclick="openReviewDetail(\'' +
esc(r.id) +
"', false)\">查看</button>" +
'<button type="button" class="btn-del" style="border:none;cursor:pointer;background:#1f3a5a;color:#8fc8ff" onclick="openReviewDetail(\'' +
esc(r.id) +
"', true)\">全屏</button>" +
'<a class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff" href="/export/review_md/' +
esc(r.id) +
'">导出MD</a>' +
'<button type="button" class="btn-del" onclick="deleteReview(\'' +
esc(r.id) +
"')\">删除</button>" +
"</div></div>";
});
box.innerHTML = html;
endSoft(wrap);
}
function loadJournalsPaged() {
var qs = listQs();
fetch("/api/journals" + (qs ? "?" + qs : ""), { credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
journalsAll = Array.isArray(data) ? data : [];
if (global.journalCache) {
Object.keys(global.journalCache).forEach(function (k) {
delete global.journalCache[k];
});
journalsAll.forEach(function (o) {
global.journalCache[o.id] = o;
});
}
journalsPage = 0;
renderJournalsPage(false);
});
}
function loadReviewsPaged() {
var qs = listQs();
fetch("/api/reviews" + (qs ? "?" + qs : ""), { credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
reviewsAll = Array.isArray(data) ? data : [];
if (global.reviewCache) {
Object.keys(global.reviewCache).forEach(function (k) {
delete global.reviewCache[k];
});
} else {
global.reviewCache = {};
}
reviewsAll.forEach(function (r) {
global.reviewCache[r.id] = r;
});
reviewsPage = 0;
renderReviewsPage(false);
});
}
function showJournalCard() {
var card = $("journal-card");
if (card) card.classList.remove("hidden");
var hint = $("rr-journal-fill-hint");
if (hint) hint.style.display = "";
}
function hideJournalCard() {
var card = $("journal-card");
if (card) card.classList.add("hidden");
var hint = $("rr-journal-fill-hint");
if (hint) hint.style.display = "none";
}
function patchFillJournalFromTrade() {
var prev = global.fillJournalFromTrade;
if (typeof prev !== "function") return;
if (prev.__rrPatched) return;
global.fillJournalFromTrade = function (t) {
showJournalCard();
prev(t);
var hint = $("rr-journal-fill-hint");
if (hint) hint.style.display = "";
};
global.fillJournalFromTrade.__rrPatched = true;
}
function patchDeleteTradeRecord() {
var prev = global.deleteTradeRecord;
if (typeof prev !== "function") return;
if (prev.__rrPatched) return;
global.deleteTradeRecord = function (id) {
if (!confirm("确定删除这条交易记录?")) return;
fetch("/delete_trade_record/" + id, { method: "POST", credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
if (data && data.ok) {
loadTradeRecords({ soft: true });
return;
}
if (typeof prev === "function") {
/* fallthrough reload */
}
global.location.href =
(global.location.pathname || "/records") + "?_ts=" + Date.now();
})
.catch(function () {
global.location.href =
(global.location.pathname || "/records") + "?_ts=" + Date.now();
});
};
global.deleteTradeRecord.__rrPatched = true;
}
function bindPagers() {
var tp = $("rr-trades-prev");
var tn = $("rr-trades-next");
var jp = $("rr-journals-prev");
var jn = $("rr-journals-next");
var rp = $("rr-reviews-prev");
var rn = $("rr-reviews-next");
var hideBtn = $("rr-journal-hide-btn");
if (tp) {
tp.addEventListener("click", function (ev) {
ev.preventDefault();
if (tradesPage <= 0) return;
tradesPage -= 1;
updatePager("trades");
loadTradeRecords({ soft: true });
});
}
if (tn) {
tn.addEventListener("click", function (ev) {
ev.preventDefault();
if (tradesPage + 1 >= tradesPages) return;
tradesPage += 1;
updatePager("trades");
loadTradeRecords({ soft: true });
});
}
if (jp) {
jp.addEventListener("click", function (ev) {
ev.preventDefault();
if (journalsPage <= 0) return;
journalsPage -= 1;
renderJournalsPage(true);
});
}
if (jn) {
jn.addEventListener("click", function (ev) {
ev.preventDefault();
if (journalsPage + 1 >= journalsPages) return;
journalsPage += 1;
renderJournalsPage(true);
});
}
if (rp) {
rp.addEventListener("click", function (ev) {
ev.preventDefault();
if (reviewsPage <= 0) return;
reviewsPage -= 1;
renderReviewsPage(true);
});
}
if (rn) {
rn.addEventListener("click", function (ev) {
ev.preventDefault();
if (reviewsPage + 1 >= reviewsPages) return;
reviewsPage += 1;
renderReviewsPage(true);
});
}
if (hideBtn) {
hideBtn.addEventListener("click", function (ev) {
ev.preventDefault();
hideJournalCard();
});
}
}
function init(opts) {
opts = opts || {};
if (!$("records-panel-root")) return;
if (booted) {
if (opts.refresh) {
loadTradeRecords({ soft: true });
loadJournalsPaged();
loadReviewsPaged();
}
patchFillJournalFromTrade();
patchDeleteTradeRecord();
return;
}
booted = true;
if (!global.journalCache) global.journalCache = {};
if (!global.reviewCache) global.reviewCache = {};
global.loadJournals = loadJournalsPaged;
global.loadReviews = loadReviewsPaged;
global.loadTradeRecords = loadTradeRecords;
patchFillJournalFromTrade();
patchDeleteTradeRecord();
bindPagers();
updatePager("trades");
updatePager("journals");
updatePager("reviews");
loadTradeRecords({ soft: false });
loadJournalsPaged();
loadReviewsPaged();
}
global.RecordsReviewPage = {
init: init,
loadTradeRecords: loadTradeRecords,
loadJournals: loadJournalsPaged,
loadReviews: loadReviewsPaged,
showJournalCard: showJournalCard,
hideJournalCard: hideJournalCard,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(typeof window !== "undefined" ? window : globalThis);
+318
View File
@@ -0,0 +1,318 @@
(function () {
"use strict";
function syncRollFormMode(form, mode) {
if (!form) return;
const m = mode || "market";
form.setAttribute("data-add-mode", m);
const showFib = m === "fib_618" || m === "fib_786";
const showBreakout = m === "breakout";
const fibWrap = form.querySelector(".roll-field-fib");
const breakoutWrap = form.querySelector(".roll-field-breakout");
const fibUpper = form.querySelector("#roll-fib-upper");
const fibLower = form.querySelector("#roll-fib-lower");
const breakoutInput = form.querySelector("#roll-breakout");
function tuneInput(inp, active, required) {
if (!inp) return;
inp.disabled = !active;
inp.required = !!required && active;
inp.tabIndex = active ? 0 : -1;
if (!active) inp.value = "";
}
if (fibWrap) fibWrap.setAttribute("aria-hidden", showFib ? "false" : "true");
if (breakoutWrap) breakoutWrap.setAttribute("aria-hidden", showBreakout ? "false" : "true");
tuneInput(fibUpper, showFib, showFib);
tuneInput(fibLower, showFib, showFib);
tuneInput(breakoutInput, showBreakout, showBreakout);
}
window.syncRollFormMode = syncRollFormMode;
function isEmbedShell() {
return document.body && document.body.getAttribute("data-embed-shell") === "1";
}
function submitRollForm(form) {
if (isEmbedShell() && window.InstanceEmbed && typeof window.InstanceEmbed.postFormAndReload === "function") {
window.InstanceEmbed.postFormAndReload(form, "执行中…");
return;
}
if (window.FormSubmitGuard && typeof window.FormSubmitGuard.nativeSubmitOnce === "function") {
window.FormSubmitGuard.nativeSubmitOnce(form, "执行中…");
return;
}
form.submit();
}
function initStrategyRollForm() {
const form = document.getElementById("roll-form");
if (!form) return;
if (form.dataset.rollJsInit === "1") return;
form.dataset.rollJsInit = "1";
const symbolSel = document.getElementById("roll-symbol");
const dirInput = document.getElementById("roll-direction");
const modeSel = document.getElementById("roll-add-mode");
const riskBanner = document.getElementById("roll-risk-banner");
const previewBtn = document.getElementById("roll-preview-btn");
const submitBtn = document.getElementById("roll-submit-btn");
const previewBox = document.getElementById("roll-preview-box");
const previewText = document.getElementById("roll-preview-text");
const countdownEl = document.getElementById("roll-countdown");
const trendLocked = submitBtn && submitBtn.getAttribute("data-trend-locked") === "1";
let countdownTimer = null;
let previewOk = false;
let lastPreviewMode = "";
let monitorSubmitting = false;
function isMarketMode() {
return (modeSel.value || "market") === "market";
}
function isMonitorMode() {
const m = modeSel.value || "market";
return m === "fib_618" || m === "fib_786" || m === "breakout";
}
function selectedOption() {
return symbolSel.options[symbolSel.selectedIndex];
}
function syncDirectionLock() {
const opt = selectedOption();
if (!opt || !opt.value) {
riskBanner.textContent = "当前风险:请选择持仓币种";
return;
}
const dir = opt.getAttribute("data-direction") || "long";
const rp = opt.getAttribute("data-risk-percent") || "—";
dirInput.value = dir;
riskBanner.textContent =
"当前风险:" + rp + "%(来自监控单 #" + (opt.getAttribute("data-monitor-id") || "?") + ")";
}
function syncSubmitButton() {
if (!submitBtn || trendLocked) return;
if (isMonitorMode()) {
submitBtn.disabled = false;
submitBtn.removeAttribute("disabled");
return;
}
const blocked = !previewOk || !!countdownTimer;
submitBtn.disabled = blocked;
if (!blocked) submitBtn.removeAttribute("disabled");
}
function clearMessageBox() {
if (!previewBox) return;
previewBox.style.display = "none";
previewBox.classList.remove("is-error", "is-preview");
if (previewText) previewText.textContent = "";
if (countdownEl) countdownEl.style.display = "none";
}
function showReject(msg) {
if (!previewBox || !previewText) return;
previewBox.style.display = "block";
previewBox.classList.remove("is-preview");
previewBox.classList.add("is-error");
previewText.textContent = msg || "无法执行";
if (countdownEl) countdownEl.style.display = "none";
previewBox.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
function showPreviewResult(p) {
if (!previewBox || !previewText) return;
previewBox.style.display = "block";
previewBox.classList.remove("is-error");
previewBox.classList.add("is-preview");
previewText.innerHTML =
"<strong>" +
(p.add_mode_label || "") +
"</strong> · 约 <strong>" +
(p.add_amount_display != null ? p.add_amount_display : p.add_amount_raw) +
"</strong> 张<br>" +
"加仓参考价 " +
(p.add_price_display != null ? p.add_price_display : p.add_price) +
" · 新止损 " +
(p.new_sl_display != null ? p.new_sl_display : p.new_stop_loss) +
"<br>" +
"合并均价 " +
p.avg_entry_after +
" · 打到止损约 " +
p.loss_at_sl_usdt +
"U(风险预算 " +
(p.risk_budget_usdt != null ? p.risk_budget_usdt : "—") +
"U)";
}
function syncFieldVisibility() {
syncRollFormMode(form, modeSel.value || "market");
resetPreview();
}
function resetPreview() {
previewOk = false;
monitorSubmitting = false;
clearMessageBox();
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
syncSubmitButton();
}
function formPayload() {
const fd = new FormData(form);
const obj = {};
fd.forEach(function (v, k) {
if (v !== "") obj[k] = v;
});
return obj;
}
function requestPreview() {
return fetch("/strategy/roll/preview", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(formPayload()),
credentials: "same-origin",
}).then(function (r) {
return r.json();
});
}
function runPreview() {
resetPreview();
if (!symbolSel.value) {
showReject("请先选择持仓币种");
return;
}
if (previewBtn) previewBtn.disabled = true;
requestPreview()
.then(function (data) {
if (previewBtn) previewBtn.disabled = false;
if (!data.ok) {
showReject(data.msg || "预览失败");
return;
}
const p = data.preview || {};
lastPreviewMode = p.add_mode || modeSel.value;
showPreviewResult(p);
previewOk = true;
if (lastPreviewMode === "market") {
startCountdown(10);
} else {
syncSubmitButton();
}
})
.catch(function () {
if (previewBtn) previewBtn.disabled = false;
showReject("预览请求失败,请稍后重试");
});
}
function runMonitorSubmit() {
if (monitorSubmitting) return;
if (!symbolSel.value) {
showReject("请先选择持仓币种");
return;
}
monitorSubmitting = true;
if (submitBtn) submitBtn.disabled = true;
requestPreview()
.then(function (data) {
monitorSubmitting = false;
if (submitBtn && !trendLocked) {
submitBtn.disabled = false;
submitBtn.removeAttribute("disabled");
}
if (!data.ok) {
showReject(data.msg || "无法提交监控");
return;
}
const p = data.preview || {};
const modeLabel = modeSel.options[modeSel.selectedIndex].text;
const summary =
"约 " +
(p.add_amount_display != null ? p.add_amount_display : p.add_amount_raw) +
" 张 · 触发参考价 " +
(p.add_price_display != null ? p.add_price_display : p.add_price) +
" · 新止损 " +
(p.new_sl_display != null ? p.new_sl_display : p.new_stop_loss);
if (!confirm("确认提交「" + modeLabel + "」?\n" + summary)) {
return;
}
submitRollForm(form);
})
.catch(function () {
monitorSubmitting = false;
if (submitBtn && !trendLocked) {
submitBtn.disabled = false;
submitBtn.removeAttribute("disabled");
}
showReject("校验请求失败,请稍后重试");
});
}
function startCountdown(sec) {
let left = sec;
if (submitBtn) submitBtn.disabled = true;
if (countdownEl) {
countdownEl.style.display = "block";
countdownEl.textContent = "市价加仓:" + left + " 秒后可执行(修改表单将取消预览)";
}
countdownTimer = setInterval(function () {
left -= 1;
if (left <= 0) {
clearInterval(countdownTimer);
countdownTimer = null;
if (countdownEl) countdownEl.textContent = "可以执行市价加仓";
syncSubmitButton();
return;
}
if (countdownEl) countdownEl.textContent = "市价加仓:" + left + " 秒后可执行";
}, 1000);
}
symbolSel.addEventListener("change", function () {
syncDirectionLock();
resetPreview();
});
modeSel.addEventListener("change", syncFieldVisibility);
form.addEventListener("input", resetPreview);
form.addEventListener("change", function (e) {
if (e.target !== previewBtn) resetPreview();
});
if (previewBtn) previewBtn.addEventListener("click", runPreview);
form.addEventListener("submit", function (e) {
e.preventDefault();
if (isMonitorMode()) {
runMonitorSubmit();
return;
}
if (!previewOk) {
showReject("请先点击「预览」并通过校验");
return;
}
if (submitBtn && submitBtn.disabled) {
showReject("请等待 10 秒确认倒计时结束后再执行市价加仓");
return;
}
const modeLabel = modeSel.options[modeSel.selectedIndex].text;
if (!confirm("确认提交「" + modeLabel + "」?")) {
return;
}
submitRollForm(form);
});
syncDirectionLock();
syncFieldVisibility();
}
window.initStrategyRollForm = initStrategyRollForm;
initStrategyRollForm();
})();
+169
View File
@@ -0,0 +1,169 @@
/**
* 表单币种输入:防抖 + 定时刷新,展示交易所最新价(/api/order_defaults).
*/
(function (global) {
"use strict";
const DEFAULT_DEBOUNCE_MS = 350;
const DEFAULT_POLL_MS = 5000;
const bound = new WeakSet();
function $(id) {
return id ? document.getElementById(id) : null;
}
function symbolValue(el) {
if (!el) return "";
return (el.value || "").trim();
}
function directionValue(dirId) {
const el = dirId ? $(dirId) : null;
const v = (el && el.value ? el.value : "long").trim().toLowerCase();
return v === "short" ? "short" : "long";
}
function formatPrice(px, sym) {
const n = Number(px);
if (!Number.isFinite(n)) return "—";
const u = (sym || "").trim().toUpperCase();
let digits = 4;
if (u.startsWith("BTC") || u.startsWith("ETH") || n >= 1000) digits = 2;
else if (n >= 10) digits = 3;
else if (n >= 1) digits = 4;
else if (n >= 0.01) digits = 5;
else digits = 6;
return n.toFixed(digits);
}
function pollMs() {
const raw =
(document.body && document.body.getAttribute("data-price-refresh-ms")) || "";
const n = Number(raw);
return Number.isFinite(n) && n >= 2000 ? n : DEFAULT_POLL_MS;
}
function paint(el, sym, px, err) {
if (!el) return;
if (err) {
el.textContent = "现价:—";
el.classList.add("symbol-live-price--err");
el.classList.remove("symbol-live-price--ok");
el.title = err;
return;
}
if (px === null || typeof px === "undefined") {
el.textContent = "现价:—";
el.classList.remove("symbol-live-price--ok", "symbol-live-price--err");
el.title = sym ? "无法读取交易所价格" : "";
return;
}
const label = sym ? sym.toUpperCase().replace(/\/USDT.*/, "") : "";
el.textContent = label ? label + " 现价 " + formatPrice(px, sym) : "现价 " + formatPrice(px, sym);
el.classList.add("symbol-live-price--ok");
el.classList.remove("symbol-live-price--err");
el.title = "交易所最新价(约 " + pollMs() / 1000 + "s 刷新)";
}
function bindOne(el) {
if (!el || bound.has(el)) return;
bound.add(el);
const symId = el.getAttribute("data-symbol-input");
const dirId = el.getAttribute("data-direction-input") || "";
let debounceTimer = null;
let pollTimer = null;
let fetchSeq = 0;
function clearPoll() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function startPoll() {
clearPoll();
pollTimer = setInterval(refresh, pollMs());
}
function refresh() {
const symEl = $(symId);
const sym = symbolValue(symEl);
if (!sym) {
paint(el, "", null, "");
clearPoll();
return;
}
const dir = directionValue(dirId);
const seq = ++fetchSeq;
el.classList.add("symbol-live-price--loading");
fetch(
"/api/order_defaults?symbol=" +
encodeURIComponent(sym) +
"&direction=" +
encodeURIComponent(dir)
)
.then(function (r) {
return r.json().then(function (d) {
return { status: r.status, data: d };
}).catch(function () {
return { status: r.status, data: null };
});
})
.then(function (res) {
if (seq !== fetchSeq) return;
el.classList.remove("symbol-live-price--loading");
const data = res.data || {};
if (res.status >= 400 || !data || !data.ok) {
paint(el, sym, null, (data && data.msg) || "读取失败");
return;
}
const px = data.last_price != null ? data.last_price : data.price;
if (px === null || typeof px === "undefined") {
paint(el, data.symbol || sym, null, "无法读取交易所价格");
return;
}
paint(el, data.symbol || sym, px, "");
if (!pollTimer) startPoll();
})
.catch(function () {
if (seq !== fetchSeq) return;
el.classList.remove("symbol-live-price--loading");
paint(el, sym, null, "网络错误");
});
}
function schedule() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(refresh, DEFAULT_DEBOUNCE_MS);
}
const symEl = $(symId);
if (symEl) {
symEl.addEventListener("input", schedule);
symEl.addEventListener("change", schedule);
}
const dirEl = dirId ? $(dirId) : null;
if (dirEl) {
dirEl.addEventListener("change", schedule);
}
schedule();
}
function init(root) {
const scope = root || document;
scope.querySelectorAll(".symbol-live-price").forEach(bindOne);
}
global.SymbolLivePrice = { init: init, bind: bindOne };
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
init(document);
});
} else {
init(document);
}
})(typeof window !== "undefined" ? window : globalThis);
+194
View File
@@ -0,0 +1,194 @@
/**
* 时间平仓 + 整点强制清仓:表单开关 + 持仓/顶栏倒计时.
*/
(function (global) {
"use strict";
function pad2(n) {
return n < 10 ? "0" + n : String(n);
}
function formatCountdown(sec) {
const s = Math.max(0, parseInt(sec, 10) || 0);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const r = s % 60;
return pad2(h) + ":" + pad2(m) + ":" + pad2(r);
}
function isForceCloseActive(wrap) {
if (!wrap) return false;
const raw =
wrap.dataset.forceCloseActive ||
wrap.getAttribute("data-force-close-active") ||
"";
return raw === "1" || raw === "true";
}
function bindTimeCloseForm(checkboxId, selectId, wrapId) {
const cb = document.getElementById(checkboxId);
const sel = document.getElementById(selectId);
const wrap = wrapId ? document.getElementById(wrapId) : null;
if (!cb || !sel) return;
function sync() {
const on = !!cb.checked;
sel.disabled = false;
sel.tabIndex = 0;
if (wrap) wrap.classList.toggle("is-disabled", !on);
}
sel.addEventListener("mousedown", function (ev) {
ev.stopPropagation();
});
sel.addEventListener("click", function (ev) {
ev.stopPropagation();
});
cb.addEventListener("change", sync);
sync();
}
function paintCountdownEl(cd, rem, active) {
if (!cd) return;
if (active) {
cd.textContent = "执行中";
return;
}
cd.textContent = Number.isFinite(rem) ? formatCountdown(rem) : "--:--:--";
}
function paintOrderTimeClose(order) {
if (!order || order.id == null) return;
const wrap = document.getElementById("order-time-close-wrap-" + order.id);
const cd = document.getElementById("order-time-close-cd-" + order.id);
if (!wrap || !cd) return;
const enabled = !!(order.time_close_enabled || order.time_close_at_ms);
if (!enabled) {
wrap.style.display = "none";
return;
}
wrap.style.display = "";
const hours = order.time_close_hours;
const label = order.time_close_label || (hours ? "时间平仓 " + hours + "h" : "时间平仓");
const labelEl = wrap.querySelector(".pos-time-close-label");
if (labelEl) labelEl.textContent = label;
let rem =
order.time_close_remaining_sec != null
? Number(order.time_close_remaining_sec)
: null;
if ((rem == null || !Number.isFinite(rem)) && order.time_close_at_ms) {
rem = Math.max(0, Math.floor((Number(order.time_close_at_ms) - Date.now()) / 1000));
}
paintCountdownEl(cd, rem, false);
wrap.dataset.closeAtMs = order.time_close_at_ms ? String(order.time_close_at_ms) : "";
}
function paintOrderForceClose(order) {
if (!order || order.id == null) return;
const wrap = document.getElementById("order-force-close-wrap-" + order.id);
const cd = document.getElementById("order-force-close-cd-" + order.id);
if (!wrap || !cd) return;
const enabled = !!order.force_close_enabled;
if (!enabled) {
wrap.style.display = "none";
return;
}
wrap.style.display = "";
const label = order.force_close_label || "强制清仓";
const labelEl = wrap.querySelector(".pos-force-close-label");
if (labelEl) labelEl.textContent = label;
let rem =
order.force_close_remaining_sec != null
? Number(order.force_close_remaining_sec)
: null;
const atMs = order.force_close_at_ms;
if ((rem == null || !Number.isFinite(rem)) && atMs) {
rem = Math.max(0, Math.floor((Number(atMs) - Date.now()) / 1000));
}
const active = !!order.force_close_active;
paintCountdownEl(cd, rem, active);
wrap.dataset.forceCloseAtMs = atMs ? String(atMs) : "";
wrap.dataset.forceCloseActive = active ? "1" : "0";
}
function paintForceCloseHeader(state) {
const wrap = document.getElementById("force-close-header-badge");
if (!wrap) return;
if (!state || !state.enabled) {
wrap.style.display = "none";
return;
}
wrap.style.display = "";
const label = state.label || "强制清仓";
const labelPrefix = label + " 已开启 · ";
let prefixNode = wrap.querySelector(".force-close-header-prefix");
if (!prefixNode) {
wrap.textContent = "";
prefixNode = document.createElement("span");
prefixNode.className = "force-close-header-prefix";
prefixNode.textContent = labelPrefix;
wrap.appendChild(prefixNode);
const cd = document.createElement("span");
cd.className = "force-close-header-cd";
wrap.appendChild(cd);
} else {
prefixNode.textContent = labelPrefix;
}
const cd = wrap.querySelector(".force-close-header-cd");
let rem = state.remaining_sec != null ? Number(state.remaining_sec) : null;
if ((rem == null || !Number.isFinite(rem)) && state.next_at_ms) {
rem = Math.max(0, Math.floor((Number(state.next_at_ms) - Date.now()) / 1000));
}
paintCountdownEl(cd, rem, !!state.active);
wrap.dataset.forceCloseAtMs = state.next_at_ms ? String(state.next_at_ms) : "";
wrap.dataset.forceCloseActive = state.active ? "1" : "0";
}
function tickLocalCountdowns() {
document.querySelectorAll("[data-close-at-ms]").forEach(function (wrap) {
const closeAtRaw = wrap.dataset.closeAtMs || wrap.getAttribute("data-close-at-ms") || "";
const cd = wrap.querySelector(".pos-time-close-cd");
if (!cd) return;
const closeAt = Number(closeAtRaw);
if (!closeAt) return;
const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000));
cd.textContent = formatCountdown(rem);
});
document.querySelectorAll("[data-force-close-at-ms]").forEach(function (wrap) {
const closeAtRaw =
wrap.dataset.forceCloseAtMs || wrap.getAttribute("data-force-close-at-ms") || "";
const cd = wrap.querySelector(".pos-force-close-cd, .force-close-header-cd");
if (!cd) return;
const closeAt = Number(closeAtRaw);
if (!closeAt) return;
const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000));
paintCountdownEl(cd, rem, isForceCloseActive(wrap));
});
}
function paintOrders(orders) {
(orders || []).forEach(function (order) {
paintOrderTimeClose(order);
paintOrderForceClose(order);
});
}
function syncKeyTimeCloseVisibility(show) {
const wrap = document.getElementById("key-time-close-wrap");
if (!wrap) return;
wrap.style.display = show ? "inline-flex" : "none";
}
global.TimeCloseUI = {
bindTimeCloseForm: bindTimeCloseForm,
paintOrderTimeClose: paintOrderTimeClose,
paintOrderForceClose: paintOrderForceClose,
paintForceCloseHeader: paintForceCloseHeader,
paintOrders: paintOrders,
tickLocalCountdowns: tickLocalCountdowns,
syncKeyTimeCloseVisibility: syncKeyTimeCloseVisibility,
formatCountdown: formatCountdown,
};
if (!global.__timeCloseCountdownTimer) {
global.__timeCloseCountdownTimer = setInterval(tickLocalCountdowns, 1000);
}
})(typeof window !== "undefined" ? window : globalThis);
+171
View File
@@ -0,0 +1,171 @@
/* 交易日历:内照明心 + 三所统计分析共用,随 data-theme 浅/深切换 */
.trade-cal-wrap {
--trade-cal-wrap-bg: var(--inset-surface, rgba(0, 0, 0, 0.22));
--trade-cal-cell-bg: var(--section-surface, var(--inset-surface, rgba(0, 0, 0, 0.32)));
--trade-cal-cell-border: rgba(255, 255, 255, 0.14);
--trade-cal-cell-shadow: 0 1px 3px rgba(0, 0, 0, 0.22);
--trade-cal-cell-empty-bg: color-mix(in srgb, var(--trade-cal-cell-bg) 72%, transparent);
--trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #6366f1) 12%, var(--trade-cal-cell-bg));
--trade-cal-cell-hover-border: color-mix(in srgb, var(--accent, #6366f1) 45%, transparent);
--trade-cal-selected-border: rgba(59, 130, 246, 0.85);
--trade-cal-selected-bg: color-mix(in srgb, #3b82f6 16%, var(--trade-cal-cell-bg));
--trade-cal-selected-shadow: rgba(59, 130, 246, 0.45);
--trade-cal-sick-bg: color-mix(in srgb, var(--red, #ef4444) 14%, var(--trade-cal-cell-bg));
--trade-cal-sick-border: color-mix(in srgb, var(--red, #ef4444) 55%, transparent);
--trade-cal-sick-shadow: color-mix(in srgb, var(--red, #ef4444) 45%, transparent);
--trade-cal-sick-tag-bg: color-mix(in srgb, var(--red, #ef4444) 25%, transparent);
--trade-cal-sick-tag-fg: color-mix(in srgb, var(--red, #ef4444) 70%, #fff);
--trade-cal-pos: var(--green, #22c55e);
--trade-cal-neg: var(--red, #ef4444);
margin-top: 4px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border-soft, rgba(120, 140, 200, 0.28));
background: var(--trade-cal-wrap-bg);
}
.stats-calendar-wrap {
margin-bottom: 14px;
}
.trade-cal-wrap button.trade-cal-cell {
background: var(--trade-cal-cell-bg) !important;
background-image: none !important;
border: 1px solid var(--trade-cal-cell-border);
box-shadow: var(--trade-cal-cell-shadow);
padding: 6px 4px;
min-height: 72px;
width: 100%;
line-height: 1.15;
font-size: inherit;
text-align: center;
}
.trade-cal-wrap button.trade-cal-cell:not(.has-trade) {
background: var(--trade-cal-cell-empty-bg) !important;
cursor: default;
}
.trade-cal-wrap button.trade-cal-cell:disabled {
opacity: 1;
cursor: default;
}
.trade-cal-wrap .trade-cal-head .btn,
.trade-cal-wrap .trade-cal-head button {
min-height: 0;
min-width: 34px;
padding: 4px 12px;
line-height: 1.2;
}
.trade-cal-head {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.trade-cal-title {
font-size: 0.95rem;
font-weight: 600;
min-width: 120px;
text-align: center;
color: var(--text, #e8ecff);
}
.trade-cal-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 4px;
}
.trade-cal-wd {
text-align: center;
font-size: 0.72rem;
color: var(--muted, #8892b0);
}
.trade-cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
}
.trade-cal-cell {
min-height: 72px;
padding: 6px 4px;
border-radius: 8px;
border: 1px solid var(--trade-cal-cell-border);
box-shadow: var(--trade-cal-cell-shadow);
background: var(--trade-cal-cell-bg);
color: inherit;
font: inherit;
cursor: default;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 2px;
}
.trade-cal-cell.has-trade {
cursor: pointer;
}
.trade-cal-wrap button.trade-cal-cell.has-trade:hover {
background: var(--trade-cal-cell-hover-bg) !important;
background-image: none !important;
border-color: var(--trade-cal-cell-hover-border);
}
.trade-cal-cell.is-selected {
border-color: var(--trade-cal-selected-border);
background: var(--trade-cal-selected-bg);
box-shadow: 0 0 0 2px var(--trade-cal-selected-shadow);
}
.trade-cal-cell.is-sick-day {
border-color: var(--trade-cal-sick-border);
background: var(--trade-cal-sick-bg);
}
.trade-cal-cell.is-sick-day.is-selected {
border-color: var(--trade-cal-selected-border);
background: color-mix(in srgb, #3b82f6 14%, var(--trade-cal-sick-bg));
box-shadow: 0 0 0 2px var(--trade-cal-selected-shadow);
}
.trade-cal-day-num {
font-size: 0.78rem;
font-weight: 600;
color: var(--text, #e8ecff);
}
.trade-cal-pnl {
font-size: 0.72rem;
font-weight: 600;
line-height: 1.1;
color: var(--text, #e8ecff);
}
.trade-cal-cell.pnl-pos .trade-cal-pnl {
color: var(--trade-cal-pos);
}
.trade-cal-cell.pnl-neg .trade-cal-pnl {
color: var(--trade-cal-neg);
}
.trade-cal-cnt {
font-size: 0.65rem;
color: var(--muted, #8892b0);
font-weight: 500;
}
.trade-cal-sick-tag {
font-size: 0.62rem;
padding: 1px 4px;
border-radius: 4px;
background: var(--trade-cal-sick-tag-bg);
color: var(--trade-cal-sick-tag-fg);
font-weight: 600;
}
.trade-cal-pad {
background: transparent;
border: none;
min-height: 0;
}
html[data-theme="light"] .trade-cal-wrap {
--trade-cal-wrap-bg: var(--inset-surface, #eef3f8);
--trade-cal-cell-bg: #ffffff;
--trade-cal-cell-empty-bg: #f6f9fc;
--trade-cal-cell-border: rgba(0, 75, 115, 0.18);
--trade-cal-cell-shadow: 0 1px 4px rgba(30, 60, 100, 0.08);
--trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #2563eb) 10%, #ffffff);
--trade-cal-selected-border: rgba(37, 99, 235, 0.75);
--trade-cal-selected-bg: color-mix(in srgb, #2563eb 12%, #ffffff);
--trade-cal-selected-shadow: rgba(37, 99, 235, 0.35);
--trade-cal-sick-tag-fg: #b91c1c;
}
+314
View File
@@ -0,0 +1,314 @@
/**
* 交易日历组件:内照明心档案 + 三所统计分析共用.
*/
(function (global) {
"use strict";
var WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function monthLabel(y, m) {
return y + "年" + m + "月";
}
function formatCalPnl(pnl) {
var n = Number(pnl);
if (!Number.isFinite(n)) n = 0;
return (n >= 0 ? "+" : "") + n.toFixed(1) + "U";
}
function dayHasTrade(info) {
if (!info) return false;
var cnt = Number(info.open_count);
if (Number.isFinite(cnt) && cnt > 0) return true;
var pnl = Number(info.pnl_total);
return Number.isFinite(pnl) && Math.abs(pnl) > 0.0001;
}
function dayOpenCount(info) {
var cnt = Number(info && info.open_count);
return Number.isFinite(cnt) && cnt > 0 ? cnt : 0;
}
function dayPnl(info) {
return Number(info && info.pnl_total) || 0;
}
function TradeStatsCalendar(config) {
this.gridEl = config.gridEl;
this.titleEl = config.titleEl;
this.prevBtn = config.prevBtn || null;
this.nextBtn = config.nextBtn || null;
this.apiUrl = config.apiUrl || "/api/stats/calendar";
this.buildQuery =
config.buildQuery ||
function (year, month) {
var q = new URLSearchParams();
q.set("year", String(year));
q.set("month", String(month));
return q;
};
this.parseResponse =
config.parseResponse ||
function (data) {
if (data && data.ok === false) return {};
return (data && data.days) || {};
};
this.fetchFn = config.fetchFn || null;
this.showSick = config.showSick !== false;
this.selectedDay = config.selectedDay || "";
this.onDayClick = config.onDayClick || null;
this.onMonthChange = config.onMonthChange || null;
this.year = config.year || 0;
this.month = config.month || 0;
this.days = {};
this.monthPnlTotal = 0;
this.monthOpenCount = 0;
this._navBound = false;
this._bindNav();
}
TradeStatsCalendar.prototype.ensureMonth = function (ref) {
if (this.year > 0 && this.month > 0) return;
var d;
if (ref instanceof Date) d = ref;
else if (typeof ref === "string" && ref.length >= 7) {
var p = ref.slice(0, 10).split("-");
this.year = parseInt(p[0], 10) || new Date().getFullYear();
this.month = parseInt(p[1], 10) || new Date().getMonth() + 1;
return;
} else d = new Date();
this.year = d.getFullYear();
this.month = d.getMonth() + 1;
};
TradeStatsCalendar.prototype.applyPayload = function (data) {
if (!data) return;
var y = Number(data.year);
var m = Number(data.month);
if (Number.isFinite(y) && y > 0) this.year = y;
if (Number.isFinite(m) && m > 0) this.month = m;
this.days = this.parseResponse(data) || {};
this.monthPnlTotal = Number(data.month_pnl_total) || 0;
this.monthOpenCount = Number(data.month_open_count) || 0;
if (!this.monthOpenCount) {
var self = this;
Object.keys(this.days).forEach(function (k) {
if (dayHasTrade(self.days[k])) {
self.monthOpenCount += dayOpenCount(self.days[k]);
self.monthPnlTotal += dayPnl(self.days[k]);
}
});
this.monthPnlTotal = Math.round(this.monthPnlTotal * 10000) / 10000;
}
};
function readStatsCalendarBootstrap() {
var el = document.getElementById("stats-calendar-bootstrap");
if (!el || !el.textContent) return null;
try {
return JSON.parse(el.textContent);
} catch (e) {
console.warn("[trade calendar] bootstrap parse", e);
return null;
}
}
TradeStatsCalendar.prototype.setSelectedDay = function (day) {
this.selectedDay = day || "";
this.render();
};
TradeStatsCalendar.prototype.render = function () {
if (!this.gridEl || !this.titleEl) return;
if (this.year <= 0 || this.month <= 0) this.ensureMonth(new Date());
var title = monthLabel(this.year, this.month);
if (this.monthOpenCount > 0) {
title +=
" · " + formatCalPnl(this.monthPnlTotal) + " · " + this.monthOpenCount + "笔";
}
this.titleEl.textContent = title;
var first = new Date(this.year, this.month - 1, 1);
var lastDay = new Date(this.year, this.month, 0).getDate();
var startWd = first.getDay();
var html =
'<div class="trade-cal-weekdays">' +
WEEKDAYS.map(function (w) {
return '<span class="trade-cal-wd">' + w + "</span>";
}).join("") +
'</div><div class="trade-cal-grid">';
var i;
for (i = 0; i < startWd; i++) {
html += '<span class="trade-cal-cell trade-cal-pad"></span>';
}
for (var d = 1; d <= lastDay; d++) {
var dayStr =
this.year +
"-" +
String(this.month).padStart(2, "0") +
"-" +
String(d).padStart(2, "0");
var info = this.days[dayStr];
var hasTrade = dayHasTrade(info);
var sick = this.showSick && info && info.has_sick;
var pnl = hasTrade ? dayPnl(info) : null;
var cnt = hasTrade ? dayOpenCount(info) : 0;
var cls =
"trade-cal-cell" +
(hasTrade ? " has-trade" : "") +
(sick ? " is-sick-day" : "") +
(this.selectedDay === dayStr ? " is-selected" : "") +
(pnl != null && pnl > 0.0001
? " pnl-pos"
: pnl != null && pnl < -0.0001
? " pnl-neg"
: "");
var body = '<span class="trade-cal-day-num">' + d + "</span>";
if (hasTrade) {
body +=
'<span class="trade-cal-pnl">' +
esc(formatCalPnl(pnl)) +
"</span>" +
'<span class="trade-cal-cnt">' +
cnt +
"笔</span>";
if (sick) body += '<span class="trade-cal-sick-tag">犯病</span>';
}
html +=
'<button type="button" class="' +
cls +
'" data-day="' +
dayStr +
'" data-sick="' +
(sick ? "1" : "0") +
'"' +
(hasTrade ? "" : " disabled") +
">" +
body +
"</button>";
}
html += "</div>";
this.gridEl.innerHTML = html;
var self = this;
this.gridEl.querySelectorAll(".trade-cal-cell[data-day]").forEach(function (btn) {
btn.addEventListener("click", function () {
var day = btn.getAttribute("data-day");
if (!day || !self.onDayClick) return;
self.selectedDay = day;
self.render();
self.onDayClick(day, btn.getAttribute("data-sick") === "1", self.days[day] || null);
});
});
};
TradeStatsCalendar.prototype.load = async function () {
this.ensureMonth(new Date());
this.render();
var q = this.buildQuery(this.year, this.month);
if (!q.has("year")) q.set("year", String(this.year));
if (!q.has("month")) q.set("month", String(this.month));
try {
var data;
if (this.fetchFn) {
data = await this.fetchFn(q);
} else {
var resp = await fetch(this.apiUrl + "?" + q.toString(), {
credentials: "same-origin",
});
if (!resp.ok) {
console.warn("[trade calendar] api", resp.status);
this.render();
return;
}
data = await resp.json();
}
this.applyPayload(data);
this.render();
if (this.onMonthChange) this.onMonthChange(this.year, this.month, this.days);
} catch (e) {
console.warn("[trade calendar]", e);
this.render();
}
};
TradeStatsCalendar.prototype.shiftMonth = function (delta) {
this.ensureMonth(new Date());
this.month += delta;
if (this.month > 12) {
this.month = 1;
this.year += 1;
} else if (this.month < 1) {
this.month = 12;
this.year -= 1;
}
void this.load();
};
TradeStatsCalendar.prototype._bindNav = function () {
if (this._navBound) return;
var self = this;
if (this.prevBtn) {
this.prevBtn.addEventListener("click", function () {
self.shiftMonth(-1);
});
}
if (this.nextBtn) {
this.nextBtn.addEventListener("click", function () {
self.shiftMonth(1);
});
}
this._navBound = true;
};
global.TradeStatsCalendar = TradeStatsCalendar;
global.statsCalendarWidget = null;
global.initInstanceStatsCalendar = function () {
var grid = document.getElementById("stats-calendar");
if (!grid || !global.TradeStatsCalendar) return null;
var bootstrap = readStatsCalendarBootstrap();
if (
global.statsCalendarWidget &&
global.statsCalendarWidget.gridEl === grid
) {
if (bootstrap) global.statsCalendarWidget.applyPayload(bootstrap);
global.statsCalendarWidget.render();
void global.statsCalendarWidget.load();
return global.statsCalendarWidget;
}
global.statsCalendarWidget = new TradeStatsCalendar({
gridEl: grid,
titleEl: document.getElementById("stats-cal-title"),
prevBtn: document.getElementById("stats-cal-prev"),
nextBtn: document.getElementById("stats-cal-next"),
apiUrl: "/api/stats/calendar",
showSick: false,
buildQuery: function (year, month) {
var q = new URLSearchParams();
q.set("year", String(year));
q.set("month", String(month));
var sel = document.getElementById("stats-segment-select");
if (sel) q.set("segment", sel.value || "all");
return q;
},
parseResponse: function (data) {
if (data && data.ok === false) return {};
return (data && data.days) || {};
},
});
if (bootstrap) global.statsCalendarWidget.applyPayload(bootstrap);
global.statsCalendarWidget.render();
void global.statsCalendarWidget.load();
return global.statsCalendarWidget;
};
global.initStatsCalendarWidget = global.initInstanceStatsCalendar;
})(window);