Files
crypto_monitor/lib/common/static/options_review.js
T
dekun 17cd835e5d Clarify options review layout with numbered sections and KPI tiles.
Separate trade/review/stats blocks, tuck filters into a toolbar, and hide empty stat groups so the page scans more easily.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 16:32:34 +08:00

1268 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* OKX 期权复盘:待复盘交易(5行) → 点复盘出表单 → 复盘记录详情 → 统计.
*/
(function (global) {
"use strict";
var PAGE_SIZE = 5;
var TAB_LABELS = {
option_spot: "期权交易记录",
options_options: "期期对冲记录",
perp_options: "永期对冲记录",
};
var FORM_PRESETS = {
option_spot: {
strategy: ["顺势", "反转"],
direction: ["多", "空"],
entry: ["假突破", "结构突破"],
},
hedge: {
strategy: ["横盘", "趋势"],
direction: ["多", "空"],
entry: ["横盘博弈方向", "趋势对冲止损"],
},
};
var RESULT_OPTIONS = ["盈利", "亏损", "持平"];
var activeSource = "option_spot";
var currentTradeId = null;
var draftId = "";
var tradesCache = {};
var reviewedCache = {};
var tradesPage = 0;
var tradesPages = 1;
var reviewedPage = 0;
var reviewedPages = 1;
function $(id) {
return document.getElementById(id);
}
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function fmtPnl(v) {
if (v == null || v === "") return "—";
var n = Number(v);
if (Number.isNaN(n)) return "—";
return (n >= 0 ? "+" : "") + n.toFixed(2);
}
function fmtHold(sec) {
if (sec == null) return "—";
var s = Math.max(0, Number(sec) || 0);
if (s < 3600) return Math.round(s / 60) + "m";
if (s < 86400) return (s / 3600).toFixed(1) + "h";
return (s / 86400).toFixed(1) + "d";
}
function toLocalInput(ts) {
if (!ts) return "";
var s = String(ts).trim().replace(" ", "T");
if (s.length >= 16) return s.slice(0, 16);
return s;
}
function tradeTitle(t) {
if (!t) return "—";
if (t.source_type === "option_spot") return t.inst_id || "—";
return (
(t.underlying || "") +
(t.direction ? " " + t.direction : "") +
(t.plan_close_reason ? " · " + t.plan_close_reason : "")
);
}
function pnlStyle(v) {
var n = Number(v);
if (n > 0) return "color:#3dd68c";
if (n < 0) return "color:#f07178";
return "";
}
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 baseQs() {
var p = new URLSearchParams();
p.set("source_type", activeSource);
var uly = ($("or-filter-uly") || {}).value || "";
var opt = ($("or-filter-opt") || {}).value || "";
var q = (($("or-filter-q") || $("or-filter-strategy") || {}).value || "").trim();
var from = ($("or-filter-from") || {}).value || "";
var to = ($("or-filter-to") || {}).value || "";
if (uly) p.set("underlying", uly);
if (opt) p.set("opt_type", opt);
if (q) p.set("q", q);
if (from) p.set("closed_from", from.replace("T", " ") + ":00");
if (to) p.set("closed_to", to.replace("T", " ") + ":00");
if (($("or-include-hedge-legs") || {}).checked) p.set("include_hedge_legs", "1");
return p;
}
function setSyncStatus(text) {
var el = $("or-sync-status");
if (el) el.textContent = text || "";
}
function reloadAll() {
setSyncStatus("读取本地记录…");
loadTrades({ sync: true });
loadReviewed({ sync: false });
loadStats();
}
function isHedgeSource(sourceType) {
return sourceType === "options_options" || sourceType === "perp_options";
}
function fillSelect(el, options, placeholder) {
if (!el) return;
var keep = el.value;
el.innerHTML = "";
var first = document.createElement("option");
first.value = "";
first.textContent = placeholder || "";
el.appendChild(first);
(options || []).forEach(function (v) {
var opt = document.createElement("option");
opt.value = v;
opt.textContent = v;
el.appendChild(opt);
});
if (keep) setSelectValue(el, keep);
}
function setSelectValue(el, value) {
if (!el) return;
var v = value == null ? "" : String(value);
if (!v) {
el.value = "";
return;
}
var found = false;
for (var i = 0; i < el.options.length; i++) {
if (el.options[i].value === v) {
found = true;
break;
}
}
if (!found) {
var opt = document.createElement("option");
opt.value = v;
opt.textContent = v;
el.appendChild(opt);
}
el.value = v;
}
function applyFormPresets(sourceType) {
var preset = isHedgeSource(sourceType) ? FORM_PRESETS.hedge : FORM_PRESETS.option_spot;
fillSelect($("or-f-strategy"), preset.strategy, "策略标签");
fillSelect($("or-f-direction"), preset.direction, "方向判断");
fillSelect($("or-f-entry"), preset.entry, "入场逻辑");
fillSelect($("or-f-result"), RESULT_OPTIONS, "结果标签");
}
function autoDirection(t) {
if (!t) return "";
if (isHedgeSource(t.source_type)) {
var d = String(t.direction || "").trim().toLowerCase();
if (d === "long" || d === "buy" || d === "多") return "多";
if (d === "short" || d === "sell" || d === "空") return "空";
return "";
}
var ot = String(t.opt_type || "").trim().toUpperCase();
if (ot === "C" || ot === "CALL") return "多";
if (ot === "P" || ot === "PUT") return "空";
return "";
}
function autoResultTag(pnl) {
if (pnl == null || pnl === "") return "";
var n = Number(pnl);
if (Number.isNaN(n)) return "";
if (n > 0) return "盈利";
if (n < 0) return "亏损";
return "持平";
}
function setActiveTab(source) {
activeSource = source || "option_spot";
tradesPage = 0;
reviewedPage = 0;
document.querySelectorAll(".or-tab").forEach(function (btn) {
btn.classList.toggle("active", btn.getAttribute("data-source") === activeSource);
});
var title = $("or-list-title");
if (title) title.textContent = TAB_LABELS[activeSource] || "记录";
applyFormPresets(activeSource);
hideJournalForm();
hideDetail();
reloadAll();
}
function updateTradesPager() {
var label = $("or-trades-page-label");
var prev = $("or-trades-prev");
var next = $("or-trades-next");
if (label) {
label.textContent = "第 " + (tradesPage + 1) + " / " + tradesPages + " 页";
}
if (prev) prev.disabled = tradesPage <= 0;
if (next) next.disabled = tradesPage + 1 >= tradesPages;
}
function updateReviewedPager() {
var label = $("or-reviewed-page-label");
var prev = $("or-reviewed-prev");
var next = $("or-reviewed-next");
if (label) {
label.textContent = "第 " + (reviewedPage + 1) + " / " + reviewedPages + " 页";
}
if (prev) prev.disabled = reviewedPage <= 0;
if (next) next.disabled = reviewedPage + 1 >= reviewedPages;
}
function applyPagerMeta(data, kind) {
var pages = Number(data.pages || 1);
if (!pages || pages < 1) pages = 1;
var clamped = false;
if (kind === "trades") {
tradesPages = pages;
if (tradesPage >= tradesPages) {
tradesPage = Math.max(0, tradesPages - 1);
clamped = true;
}
updateTradesPager();
} else {
reviewedPages = pages;
if (reviewedPage >= reviewedPages) {
reviewedPage = Math.max(0, reviewedPages - 1);
clamped = true;
}
updateReviewedPager();
}
return clamped;
}
function beginListLoad(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("or-list-loading");
} else {
wrap.classList.remove("or-list-loading");
wrap.style.minHeight = "";
}
return wrap;
}
function endListLoad(wrap) {
if (!wrap) return;
wrap.classList.remove("or-list-loading");
wrap.style.minHeight = "";
}
function loadTrades(opts) {
opts = opts || {};
var doSync = opts.sync !== false;
var soft = !!opts.soft;
var tbody = $("or-trades-tbody");
if (!tbody) return;
var wrap = beginListLoad("or-trades-wrap", soft);
if (!soft) {
tbody.innerHTML = '<tr><td colspan="7" class="muted">加载中…</td></tr>';
}
var p = baseQs();
// 交易记录保留已复盘条目,不再只显示待复盘
p.set("limit", String(PAGE_SIZE));
p.set("offset", String(tradesPage * PAGE_SIZE));
if (!doSync) p.set("sync", "0");
fetch("/api/options/review/trades?" + p.toString(), { credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
if (doSync) setSyncStatus("本地记录已加载");
if (!data.ok) {
tbody.innerHTML = '<tr><td colspan="7" class="muted">加载失败</td></tr>';
endListLoad(wrap);
return;
}
if (applyPagerMeta(data, "trades") && Number(data.total || 0) > 0) {
loadTrades(opts);
return;
}
var rows = data.trades || [];
tradesCache = {};
if (!rows.length) {
tbody.innerHTML =
'<tr><td colspan="7" class="muted">暂无交易记录</td></tr>';
endListLoad(wrap);
return;
}
tbody.innerHTML = rows
.map(function (t) {
tradesCache[t.id] = t;
var active = currentTradeId === t.id ? " or-row-active" : "";
var reviewed = !!t.reviewed;
var actionBtn = reviewed
? '<button type="button" class="btn or-review-btn" data-id="' +
t.id +
'" style="font-size:.72rem;padding:2px 8px">编辑</button>'
: '<button type="button" class="btn or-review-btn" data-id="' +
t.id +
'" style="font-size:.72rem;padding:2px 8px">复盘</button>';
var badgeExtra = reviewed
? ' <span class="or-badge" style="background:rgba(61,214,140,.2)">已复盘</span>'
: "";
return (
'<tr class="or-trade-row' +
active +
'" data-id="' +
t.id +
'">' +
"<td><span class=\"or-badge\">" +
escapeHtml(t.source_label || t.source_type) +
"</span>" +
badgeExtra +
"</td>" +
"<td>" +
escapeHtml(tradeTitle(t)) +
"</td>" +
'<td style="' +
pnlStyle(t.realized_pnl_total) +
'">' +
fmtPnl(t.realized_pnl_total) +
"</td>" +
'<td class="muted" style="font-size:12px;white-space:nowrap">' +
escapeHtml(t.opened_at || "—") +
"</td>" +
'<td class="muted" style="font-size:12px;white-space:nowrap">' +
escapeHtml(t.closed_at || "—") +
"</td>" +
"<td>" +
fmtHold(t.hold_seconds) +
"</td>" +
"<td>" +
actionBtn +
" " +
'<button type="button" class="btn-secondary or-hide-btn" data-id="' +
t.id +
'" style="font-size:.72rem;padding:2px 8px">删除</button></td>' +
"</tr>"
);
})
.join("");
tbody.querySelectorAll(".or-review-btn").forEach(function (btn) {
btn.addEventListener("click", function (ev) {
ev.preventDefault();
ev.stopPropagation();
openJournalForm(Number(btn.getAttribute("data-id")));
});
});
tbody.querySelectorAll(".or-hide-btn").forEach(function (btn) {
btn.addEventListener("click", function (ev) {
ev.preventDefault();
ev.stopPropagation();
hideTrade(Number(btn.getAttribute("data-id")));
});
});
endListLoad(wrap);
})
.catch(function () {
tbody.innerHTML = '<tr><td colspan="7" class="muted">加载失败</td></tr>';
endListLoad(wrap);
});
}
function loadReviewed(opts) {
opts = opts || {};
var doSync = opts.sync === true;
var soft = !!opts.soft;
var tbody = $("or-reviewed-tbody");
if (!tbody) return;
var wrap = beginListLoad("or-reviewed-wrap", soft);
if (!soft) {
tbody.innerHTML = '<tr><td colspan="8" class="muted">加载中…</td></tr>';
}
var p = baseQs();
p.set("reviewed", "1");
p.set("limit", String(PAGE_SIZE));
p.set("offset", String(reviewedPage * PAGE_SIZE));
if (!doSync) p.set("sync", "0");
fetch("/api/options/review/trades?" + p.toString(), { credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
if (!data.ok) {
tbody.innerHTML = '<tr><td colspan="8" class="muted">加载失败</td></tr>';
endListLoad(wrap);
return;
}
if (applyPagerMeta(data, "reviewed") && Number(data.total || 0) > 0) {
loadReviewed(opts);
return;
}
var rows = data.trades || [];
reviewedCache = {};
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="8" class="muted">暂无复盘记录</td></tr>';
endListLoad(wrap);
return;
}
tbody.innerHTML = rows
.map(function (t) {
reviewedCache[t.id] = t;
return (
'<tr class="or-reviewed-row" data-id="' +
t.id +
'">' +
"<td><span class=\"or-badge\">" +
escapeHtml(t.source_label || t.source_type) +
"</span></td>" +
"<td>" +
escapeHtml(tradeTitle(t)) +
"</td>" +
'<td style="' +
pnlStyle(t.realized_pnl_total) +
'">' +
fmtPnl(t.realized_pnl_total) +
"</td>" +
'<td class="muted" style="font-size:12px;white-space:nowrap">' +
escapeHtml(t.opened_at || "—") +
"</td>" +
'<td class="muted" style="font-size:12px;white-space:nowrap">' +
escapeHtml(t.closed_at || "—") +
"</td>" +
"<td>" +
escapeHtml(t.strategy_tag || "—") +
"</td>" +
"<td>" +
escapeHtml(t.result_tag || "—") +
"</td>" +
'<td class="muted" style="font-size:12px;white-space:nowrap">' +
escapeHtml(t.reviewed_at || "—") +
"</td>" +
"</tr>"
);
})
.join("");
tbody.querySelectorAll(".or-reviewed-row").forEach(function (tr) {
tr.addEventListener("click", function () {
openDetail(Number(tr.getAttribute("data-id")));
});
});
endListLoad(wrap);
})
.catch(function () {
tbody.innerHTML = '<tr><td colspan="8" class="muted">加载失败</td></tr>';
endListLoad(wrap);
});
}
function hideLightbox() {
var box = $("or-img-lightbox");
if (box) box.hidden = true;
var img = $("or-img-lightbox-img");
if (img) img.src = "";
}
function showLightbox(src) {
var url = String(src || "").trim();
if (!url) return;
var box = $("or-img-lightbox");
var img = $("or-img-lightbox-img");
if (box && img) {
img.src = url;
box.hidden = false;
return;
}
if (typeof global.showImage === "function") {
global.showImage(url);
} else if (typeof window.showImage === "function") {
window.showImage(url);
} else {
global.open(url, "_blank");
}
}
function hideDetail() {
hideLightbox();
var backdrop = $("or-detail-backdrop");
if (backdrop) backdrop.hidden = true;
}
function openDetail(tradeId) {
var backdrop = $("or-detail-backdrop");
if (!backdrop) return;
backdrop.hidden = false;
($("or-detail-title") || {}).textContent = "加载中…";
($("or-detail-meta") || {}).innerHTML = "";
($("or-detail-text") || {}).innerHTML = "";
($("or-detail-images") || {}).innerHTML = "";
fetch("/api/options/review/trades/" + tradeId, { credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
if (!data.ok || !data.trade) {
($("or-detail-title") || {}).textContent = "加载失败";
return;
}
renderDetail(data.trade);
})
.catch(function () {
($("or-detail-title") || {}).textContent = "加载失败";
});
}
function optionsJournalImgSrc(file) {
var name = String(file || "").trim().replace(/\\/g, "/");
var slash = name.lastIndexOf("/");
if (slash >= 0) name = name.slice(slash + 1);
if (!name) return "";
// options_journal_* 在子目录;误走合约上传的 journal_* 在 static/images 根目录
var base =
name.toLowerCase().indexOf("options_journal_") === 0
? "/static/images/options_journal/"
: "/static/images/";
return base + encodeURIComponent(name);
}
function renderDetailImages(images) {
var imagesHost = $("or-detail-images");
if (!imagesHost) return;
var byTf = {};
(images || []).forEach(function (img) {
var tf = String((img && img.tf) || "").trim();
var file = String((img && img.file) || "").trim();
if (!file) return;
var key = tf || "_";
byTf[key] = file;
});
var order = ["5m", "15m", "1h", "4h"];
var keys = order.slice();
Object.keys(byTf).forEach(function (k) {
if (keys.indexOf(k) < 0) keys.push(k);
});
var cells = keys
.map(function (tf) {
var file = byTf[tf];
if (!file) {
if (order.indexOf(tf) < 0) return "";
return (
'<div class="or-detail-img-cell">' +
'<span class="or-detail-img-label">' +
escapeHtml(tf) +
"</span>" +
'<div class="or-detail-img-miss">未上传</div>' +
"</div>"
);
}
var src = optionsJournalImgSrc(file);
var label = escapeHtml(tf === "_" ? "截图" : tf);
return (
'<div class="or-detail-img-cell">' +
'<span class="or-detail-img-label">' +
label +
"</span>" +
'<img class="or-detail-img-thumb" src="' +
src +
'" alt="' +
label +
'" data-src="' +
src +
'" loading="lazy">' +
"</div>"
);
})
.filter(Boolean);
if (!cells.length) {
imagesHost.innerHTML = '<div class="muted">无截图</div>';
return;
}
imagesHost.innerHTML = cells.join("");
imagesHost.querySelectorAll("img").forEach(function (img) {
img.addEventListener("error", function () {
var cell = img.closest(".or-detail-img-cell");
if (!cell) return;
var label = cell.querySelector(".or-detail-img-label");
var tf = label ? label.textContent : "截图";
cell.innerHTML =
'<span class="or-detail-img-label">' +
escapeHtml(tf) +
"</span>" +
'<div class="or-detail-img-miss">文件缺失或无法加载</div>';
});
img.addEventListener("click", function () {
var src = img.getAttribute("data-src") || img.src;
showLightbox(src);
});
});
}
function renderDetail(t) {
var e = t.entry || {};
reviewedCache[t.id] = t;
($("or-detail-title") || {}).textContent =
"复盘详情 · " + (t.source_label || "") + " · " + tradeTitle(t);
var editBtn = $("or-detail-edit-btn");
if (editBtn) editBtn.setAttribute("data-id", String(t.id));
var meta = $("or-detail-meta");
if (meta) {
var cells = [
["标的", t.underlying || "—"],
["合约/计划", tradeTitle(t)],
["盈亏", fmtPnl(t.realized_pnl_total)],
["持有", fmtHold(t.hold_seconds)],
["开仓时间", t.opened_at || "—"],
["平仓时间", t.closed_at || "—"],
["策略", e.strategy_tag || "—"],
["方向", e.direction_view || "—"],
["结果", e.result_tag || "—"],
["离场", e.exit_reason || "—"],
["按计划", e.followed_plan || "—"],
["入场逻辑", e.entry_logic || "—"],
];
if (t.is_hedge) {
cells.push(["永续盈亏", fmtPnl(t.realized_pnl_perp)]);
cells.push(["期权盈亏", fmtPnl(t.realized_pnl_options)]);
}
meta.innerHTML = cells
.map(function (pair) {
return (
"<div><div class=\"muted\" style=\"font-size:11px\">" +
escapeHtml(pair[0]) +
"</div><div>" +
escapeHtml(pair[1]) +
"</div></div>"
);
})
.join("");
}
var text = $("or-detail-text");
if (text) {
var lines = [];
if (e.mistake_tags) lines.push("<div><b>心理标签</b>" + escapeHtml(e.mistake_tags) + "</div>");
if (e.note) lines.push("<div><b>备注</b>" + escapeHtml(e.note).replace(/\n/g, "<br>") + "</div>");
if (t.legs && t.legs.length) {
lines.push(
"<div style=\"margin-top:6px\"><b>计划腿</b></div><table class=\"options-strike-table\"><thead><tr><th>腿</th><th>合约</th><th>盈亏</th><th>原因</th></tr></thead><tbody>" +
t.legs
.map(function (leg) {
return (
"<tr><td>" +
escapeHtml(leg.leg_role || "") +
"</td><td>" +
escapeHtml(leg.inst_id || leg.symbol || "") +
"</td><td>" +
fmtPnl(leg.realized_pnl) +
"</td><td>" +
escapeHtml(leg.close_reason || "") +
"</td></tr>"
);
})
.join("") +
"</tbody></table>"
);
}
text.innerHTML = lines.join("") || '<div class="muted">无额外备注</div>';
}
var imagesHost = $("or-detail-images");
if (imagesHost) {
renderDetailImages(e.images || []);
}
}
function renderGroup(title, items) {
if (!items || !items.length) return "";
var lines = items
.slice(0, 8)
.map(function (g) {
return (
'<div class="or-stat-row">' +
'<span class="or-stat-key">' +
escapeHtml(g.key) +
" · " +
g.count +
"笔</span>" +
'<span class="or-stat-val">' +
fmtPnl(g.pnl_sum) +
" / 胜" +
(g.win_rate || 0) +
"%</span>" +
"</div>"
);
})
.join("");
return (
'<div class="or-stat-card"><div class="or-stat-card-title">' +
title +
"</div>" +
lines +
"</div>"
);
}
function loadStats() {
var kpi = $("or-kpi");
var groups = $("or-stats-groups");
if (!kpi || !groups) return;
fetch("/api/options/review/stats?" + baseQs().toString(), { credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
if (!data.ok) return;
var k = data.kpi || {};
kpi.innerHTML = [
["笔数", k.total],
["已复盘率", (k.review_rate || 0) + "%"],
["胜率", (k.win_rate || 0) + "%"],
["累计盈亏", fmtPnl(k.pnl_sum)],
["平均盈亏", fmtPnl(k.avg_pnl)],
["平均持有", fmtHold(k.avg_hold_sec)],
]
.map(function (pair) {
var style = "";
if (pair[0] === "累计盈亏") style = pnlStyle(k.pnl_sum);
if (pair[0] === "平均盈亏") style = pnlStyle(k.avg_pnl);
return (
'<div class="or-kpi-tile"><div class="or-kpi-label">' +
pair[0] +
'</div><div class="or-kpi-value" style="' +
style +
'">' +
pair[1] +
"</div></div>"
);
})
.join("");
var html = [
renderGroup("按类型", data.by_source_type),
renderGroup("按标的", data.by_underlying),
renderGroup("按策略", data.by_strategy),
renderGroup("对冲结束原因", data.by_close_reason),
renderGroup("持有周期", data.by_hold_bucket),
renderGroup("Call/Put", data.by_opt_type),
]
.filter(Boolean)
.join("");
groups.innerHTML = html || '<div class="muted" style="font-size:.76rem">暂无分组数据</div>';
})
.catch(function () {});
}
function resetUploadSlots() {
draftId = newDraftId();
var draftEl = $("or-draft-id");
if (draftEl) draftEl.value = draftId;
document.querySelectorAll("#or-upload-slots .or-upload-hidden").forEach(function (el) {
el.value = "";
});
document.querySelectorAll("#or-upload-slots .or-upload-input").forEach(function (el) {
el.value = "";
});
document.querySelectorAll("#or-upload-slots .or-upload-status").forEach(function (el) {
el.textContent = "";
});
}
function bindUploadSlots() {
document.querySelectorAll("#or-upload-slots .or-upload-input").forEach(function (input) {
if (input.dataset.orBound === "1") return;
input.dataset.orBound = "1";
input.addEventListener("change", function () {
var file = input.files && input.files[0];
var row = input.closest(".journal-upload-row");
var status = row && row.querySelector(".or-upload-status");
var hidden = row && row.querySelector(".or-upload-hidden");
if (!file) {
if (hidden) hidden.value = "";
if (status) {
status.textContent = "";
status.className = "journal-upload-status or-upload-status";
}
return;
}
if (!draftId) draftId = newDraftId();
if (status) {
status.textContent = "上传中…";
status.className = "journal-upload-status or-upload-status journal-upload-status--pending";
}
var fd = new FormData();
fd.append("draft_id", draftId);
fd.append("tf", input.getAttribute("data-tf") || "");
fd.append("file", file);
fetch("/api/options/review/upload_slot", {
method: "POST",
body: fd,
credentials: "same-origin",
})
.then(function (r) {
return r.json();
})
.then(function (data) {
if (!data.ok) throw new Error(data.error || "fail");
if (hidden) hidden.value = data.file;
if (status) {
status.textContent = "上传成功 " + data.file;
status.className = "journal-upload-status or-upload-status journal-upload-status--ok";
}
input.value = "";
})
.catch(function () {
if (hidden) hidden.value = "";
if (status) {
status.textContent = "上传失败";
status.className = "journal-upload-status or-upload-status journal-upload-status--err";
}
});
});
});
}
function setMoodTags(raw) {
var set = {};
String(raw || "")
.split(/[,]/)
.map(function (x) {
return x.trim();
})
.filter(Boolean)
.forEach(function (x) {
set[x] = true;
});
document.querySelectorAll(".or-mood").forEach(function (cb) {
cb.checked = !!set[cb.value];
});
}
function collectMoodTags() {
var out = [];
document.querySelectorAll(".or-mood:checked").forEach(function (cb) {
out.push(cb.value);
});
return out.join(",");
}
function collectImages() {
var out = [];
document.querySelectorAll("#or-upload-slots .or-upload-hidden").forEach(function (el) {
var file = (el.value || "").trim();
if (file) out.push({ tf: el.getAttribute("data-tf") || "", file: file });
});
return out;
}
function hideJournalForm() {
currentTradeId = null;
var card = $("or-journal-card");
if (card) card.classList.add("hidden");
document.querySelectorAll(".or-trade-row").forEach(function (tr) {
tr.classList.remove("or-row-active");
});
($("or-trade-id") || {}).value = "";
($("or-f-open") || {}).value = "";
($("or-f-close") || {}).value = "";
($("or-f-coin") || {}).value = "";
($("or-f-inst") || {}).value = "";
($("or-f-pnl") || {}).value = "";
($("or-f-hold") || {}).value = "";
($("or-f-strategy") || {}).value = "";
($("or-f-direction") || {}).value = "";
($("or-f-exit") || {}).value = "";
($("or-f-followed") || {}).value = "";
($("or-f-result") || {}).value = "";
($("or-f-entry") || {}).value = "";
($("or-f-note") || {}).value = "";
setMoodTags("");
resetUploadSlots();
var summary = $("or-journal-summary");
if (summary) {
summary.textContent = "截图槽位与合约复盘相同(5m / 15m / 1h / 4h).";
}
var legsHost = $("or-legs-host");
if (legsHost) legsHost.innerHTML = "";
($("or-save-status") || {}).textContent = "";
}
function openJournalForm(tradeId) {
currentTradeId = tradeId;
var card = $("or-journal-card");
if (!card) return;
card.classList.remove("hidden");
card.scrollIntoView({ behavior: "smooth", block: "start" });
document.querySelectorAll(".or-trade-row").forEach(function (tr) {
tr.classList.toggle("or-row-active", Number(tr.getAttribute("data-id")) === tradeId);
});
resetUploadSlots();
bindUploadSlots();
($("or-save-status") || {}).textContent = "加载中…";
fetch("/api/options/review/trades/" + tradeId, { credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
if (!data.ok || !data.trade) {
($("or-save-status") || {}).textContent = "加载失败";
return;
}
fillForm(data.trade);
($("or-save-status") || {}).textContent = "已选中 #" + tradeId;
})
.catch(function () {
($("or-save-status") || {}).textContent = "加载失败";
});
}
function fillForm(t) {
var e = t.entry || {};
applyFormPresets(t.source_type || activeSource);
($("or-trade-id") || {}).value = String(t.id || "");
($("or-f-open") || {}).value = toLocalInput(t.opened_at);
($("or-f-close") || {}).value = toLocalInput(t.closed_at);
($("or-f-coin") || {}).value = t.underlying || "";
($("or-f-inst") || {}).value =
t.source_type === "option_spot"
? t.inst_id || ""
: (t.source_label || "") + (t.plan_close_reason ? " · " + t.plan_close_reason : "");
($("or-f-pnl") || {}).value = fmtPnl(t.realized_pnl_total);
($("or-f-hold") || {}).value = fmtHold(t.hold_seconds);
setSelectValue($("or-f-strategy"), e.strategy_tag || "");
setSelectValue($("or-f-direction"), e.direction_view || autoDirection(t));
($("or-f-exit") || {}).value = e.exit_reason || t.plan_close_reason || "";
($("or-f-followed") || {}).value = e.followed_plan || "";
setSelectValue($("or-f-result"), e.result_tag || autoResultTag(t.realized_pnl_total));
setSelectValue($("or-f-entry"), e.entry_logic || "");
($("or-f-note") || {}).value = e.note || "";
setMoodTags(e.mistake_tags);
var summary = $("or-journal-summary");
if (summary) {
summary.textContent =
(t.source_label || "") +
" · " +
(t.inst_id || t.underlying || "#" + t.id) +
" · 盈亏 " +
fmtPnl(t.realized_pnl_total) +
(t.is_hedge
? " (永续 " + fmtPnl(t.realized_pnl_perp) + " / 期权 " + fmtPnl(t.realized_pnl_options) + ")"
: "");
}
(e.images || []).forEach(function (img) {
var hidden = document.querySelector(
'#or-upload-slots .or-upload-hidden[data-tf="' + img.tf + '"]'
);
var status = document.querySelector(
'#or-upload-slots .or-upload-status[data-tf="' + img.tf + '"]'
);
if (hidden && img.file) {
hidden.value = img.file;
if (status) {
var src = optionsJournalImgSrc(img.file);
status.innerHTML =
'已有 <a href="' +
src +
'" target="_blank" rel="noopener">' +
escapeHtml(img.file) +
'</a><br><img class="or-slot-thumb" src="' +
src +
'" alt="' +
escapeHtml(img.tf || "") +
'" loading="lazy">';
status.className =
"journal-upload-status or-upload-status journal-upload-status--ok";
}
}
});
var legsHost = $("or-legs-host");
if (legsHost) {
if (t.legs && t.legs.length) {
legsHost.innerHTML =
"<h3>计划腿</h3><table class=\"options-strike-table\"><thead><tr><th>腿</th><th>合约</th><th>盈亏</th><th>原因</th></tr></thead><tbody>" +
t.legs
.map(function (leg) {
return (
"<tr><td>" +
escapeHtml(leg.leg_role || "") +
"</td><td>" +
escapeHtml(leg.inst_id || leg.symbol || "") +
"</td><td>" +
fmtPnl(leg.realized_pnl) +
"</td><td>" +
escapeHtml(leg.close_reason || "") +
"</td></tr>"
);
})
.join("") +
"</tbody></table>";
} else {
legsHost.innerHTML = "";
}
}
}
function hideTrade(tradeId) {
if (!tradeId) return;
if (!confirm("从待复盘列表删除并隐藏?刷新后也不会再出现.")) return;
fetch("/api/options/review/trades/" + tradeId, {
method: "DELETE",
credentials: "same-origin",
})
.then(function (r) {
return r.json();
})
.then(function (data) {
if (!data.ok) {
alert(data.msg || "删除失败");
return;
}
if (currentTradeId === tradeId) hideJournalForm();
reloadAll();
})
.catch(function () {
alert("删除失败");
});
}
function saveEntry() {
var tradeId = Number(($("or-trade-id") || {}).value || 0);
if (!tradeId) {
alert("请先点击交易记录中的「复盘」");
return;
}
var strategy = (($("or-f-strategy") || {}).value || "").trim();
if (!strategy) {
alert("请选择策略标签");
return;
}
var payload = {
trade_id: tradeId,
strategy_tag: strategy,
direction_view: ($("or-f-direction") || {}).value || "",
exit_reason: ($("or-f-exit") || {}).value || "",
followed_plan: ($("or-f-followed") || {}).value || "",
result_tag: ($("or-f-result") || {}).value || "",
mistake_tags: collectMoodTags(),
entry_logic: ($("or-f-entry") || {}).value || "",
note: ($("or-f-note") || {}).value || "",
images: collectImages(),
};
($("or-save-status") || {}).textContent = "保存中…";
fetch("/api/options/review/entry", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
.then(function (r) {
return r.json();
})
.then(function (data) {
if (!data.ok) {
($("or-save-status") || {}).textContent = data.msg || "保存失败";
return;
}
($("or-save-status") || {}).textContent = "已保存";
hideJournalForm();
reloadAll();
openDetail(tradeId);
})
.catch(function () {
($("or-save-status") || {}).textContent = "保存失败";
});
}
function deleteEntry() {
var tradeId = Number(($("or-trade-id") || {}).value || 0);
if (!tradeId) return;
if (!confirm("删除该条复盘内容与图片?交易记录会回到待复盘列表.")) return;
fetch("/api/options/review/entry/" + tradeId, {
method: "DELETE",
credentials: "same-origin",
})
.then(function (r) {
return r.json();
})
.then(function () {
hideJournalForm();
hideDetail();
reloadAll();
});
}
function init() {
if (!$("options-review-root")) return;
document.querySelectorAll(".or-tab").forEach(function (btn) {
btn.addEventListener("click", function () {
setActiveTab(btn.getAttribute("data-source"));
});
});
var reloadBtn = $("or-reload-btn");
var saveBtn = $("or-save-btn");
var clearBtn = $("or-clear-btn");
var delBtn = $("or-del-btn");
var prevBtn = $("or-trades-prev");
var nextBtn = $("or-trades-next");
var reviewedPrev = $("or-reviewed-prev");
var reviewedNext = $("or-reviewed-next");
var detailClose = $("or-detail-close-btn");
var detailEdit = $("or-detail-edit-btn");
if (reloadBtn) reloadBtn.addEventListener("click", reloadAll);
if (saveBtn) saveBtn.addEventListener("click", saveEntry);
if (clearBtn) clearBtn.addEventListener("click", hideJournalForm);
if (delBtn) delBtn.addEventListener("click", deleteEntry);
if (prevBtn) {
prevBtn.addEventListener("click", function (ev) {
ev.preventDefault();
ev.stopPropagation();
if (tradesPage <= 0) return;
tradesPage -= 1;
updateTradesPager();
loadTrades({ sync: false, soft: true });
});
}
if (nextBtn) {
nextBtn.addEventListener("click", function (ev) {
ev.preventDefault();
ev.stopPropagation();
if (tradesPage + 1 >= tradesPages) return;
tradesPage += 1;
updateTradesPager();
loadTrades({ sync: false, soft: true });
});
}
if (reviewedPrev) {
reviewedPrev.addEventListener("click", function (ev) {
ev.preventDefault();
ev.stopPropagation();
if (reviewedPage <= 0) return;
reviewedPage -= 1;
updateReviewedPager();
loadReviewed({ sync: false, soft: true });
});
}
if (reviewedNext) {
reviewedNext.addEventListener("click", function (ev) {
ev.preventDefault();
ev.stopPropagation();
if (reviewedPage + 1 >= reviewedPages) return;
reviewedPage += 1;
updateReviewedPager();
loadReviewed({ sync: false, soft: true });
});
}
if (detailClose) detailClose.addEventListener("click", hideDetail);
if (detailEdit) {
detailEdit.addEventListener("click", function () {
var id = Number(detailEdit.getAttribute("data-id") || 0);
if (id) {
hideDetail();
openJournalForm(id);
}
});
}
var detailBackdrop = $("or-detail-backdrop");
if (detailBackdrop) {
detailBackdrop.addEventListener("click", function (ev) {
if (ev.target === detailBackdrop) hideDetail();
});
}
var lightbox = $("or-img-lightbox");
if (lightbox) {
lightbox.addEventListener("click", function () {
hideLightbox();
});
}
document.addEventListener("keydown", function (ev) {
if (ev.key !== "Escape") return;
var lb = $("or-img-lightbox");
if (lb && !lb.hidden) {
hideLightbox();
return;
}
var bd = $("or-detail-backdrop");
if (bd && !bd.hidden) hideDetail();
});
["or-filter-uly", "or-filter-opt", "or-include-hedge-legs"].forEach(function (id) {
var el = $(id);
if (el) {
el.addEventListener("change", function () {
tradesPage = 0;
reviewedPage = 0;
reloadAll();
});
}
});
["or-filter-q", "or-filter-strategy", "or-filter-from", "or-filter-to"].forEach(function (id) {
var el = $(id);
if (el) {
el.addEventListener("change", function () {
tradesPage = 0;
reviewedPage = 0;
reloadAll();
});
}
});
bindUploadSlots();
hideJournalForm();
hideDetail();
hardenSearchAutofill();
setActiveTab("option_spot");
}
function hardenSearchAutofill() {
var qEl = $("or-filter-q");
if (!qEl) return;
function wipe() {
qEl.value = "";
}
wipe();
qEl.addEventListener("focus", function () {
qEl.removeAttribute("readonly");
});
qEl.addEventListener("blur", function () {
if (!qEl.value) qEl.setAttribute("readonly", "readonly");
});
// 密码管理器常延后写入用户名,加载后再清两次
setTimeout(wipe, 200);
setTimeout(wipe, 800);
}
global.OptionsReview = {
init: init,
openJournalForm: openJournalForm,
hideJournalForm: hideJournalForm,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(typeof window !== "undefined" ? window : globalThis);