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:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* 三所实例共用 UI:复盘详情,盈亏着色等.
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* 交易日历组件:内照明心档案 + 三所统计分析共用.
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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);
|
||||
Reference in New Issue
Block a user