Files
crypto_okx/lib/common/static/options_review.js
T
dekun a1abe159fa Initial standalone crypto_okx with one-click deploy.
Add deploy/manage.sh bootstrap for git.bz121.com/dekun/crypto_okx and point docs at this repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:00:59 +08:00

1363 lines
44 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 closeReasonLabel(r) {
var map = {
perp_tp: "永续止盈",
perp_sl: "永续止损",
oo_expiry_loss: "期期到期亏损",
oo_expiry_win: "期期到期盈利",
target_win_leg: "期期平盈利腿",
target_up_win_leg: "期期上破·平盈利腿",
target_down_win_leg: "期期下破·平盈利腿",
profit_rr_win_leg: "期期盈亏比达标·平盈利腿",
oo_rest_closing: "期期残值平·清亏损腿中",
oo_rest_closed: "期期残值平·两腿已平",
orphaned_after_tp: "止盈后持有至到期",
orphaned_option_expiry: "残腿到期",
hold_to_expiry: "持有至到期",
expiry: "到期",
manual: "人工结束",
partial_fail: "半腿失败",
cancelled: "已取消",
tp: "止盈",
sl: "止损",
};
var key = String(r || "").trim();
if (!key) return "—";
return map[key] || key;
}
function legRoleLabel(role) {
var map = {
perp: "永续腿",
option_hedge: "保险期权",
option_a: "期期腿A",
option_b: "期期腿B",
};
var key = String(role || "").trim();
if (!key) return "—";
return map[key] || key;
}
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 ? " · " + closeReasonLabel(t.plan_close_reason) : "")
);
}
function pnlClass(v) {
var n = Number(v);
if (n > 0) return "pos-pnl-profit";
if (n < 0) return "pos-pnl-loss";
return "";
}
function resultClass(tag) {
var t = String(tag || "").trim();
if (t === "盈利") return "pos-pnl-profit";
if (t === "亏损") return "pos-pnl-loss";
return "";
}
function tradeContractLabel(t) {
if (!t) return "—";
if (t.source_type === "option_spot") return t.inst_id || t.underlying || "—";
return t.underlying || "—";
}
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 tradeModeFromDom() {
var tabs = document.querySelector(".or-tabs");
return (tabs && tabs.getAttribute("data-okx-trade-mode")) || "options";
}
function defaultSourceForMode(mode) {
if (mode === "options_options") return "options_options";
if (mode === "perp_options") return "perp_options";
return "option_spot";
}
function setActiveTab(source) {
var mode = tradeModeFromDom();
var allowed = defaultSourceForMode(mode);
activeSource = source || allowed;
if (activeSource !== allowed) activeSource = allowed;
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 class="' +
pnlClass(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="11" 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="11" 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="11" class="muted">暂无复盘记录</td></tr>';
endListLoad(wrap);
return;
}
tbody.innerHTML = rows
.map(function (t) {
reviewedCache[t.id] = t;
var entry = t.entry || {};
var direction = t.direction_view || entry.direction_view || "";
var entryLogic = t.entry_logic || entry.entry_logic || "";
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(tradeContractLabel(t)) +
"</td>" +
"<td>" +
escapeHtml(direction || "—") +
"</td>" +
'<td class="' +
pnlClass(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(fmtHold(t.hold_seconds)) +
"</td>" +
"<td>" +
escapeHtml(t.strategy_tag || "—") +
"</td>" +
"<td>" +
escapeHtml(entryLogic || "—") +
"</td>" +
'<td class="' +
resultClass(t.result_tag) +
'">' +
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="11" 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) {
var cls = "";
if (pair[0] === "盈亏" || pair[0] === "永续盈亏" || pair[0] === "期权盈亏") {
cls = pnlClass(t.realized_pnl_total);
if (pair[0] === "永续盈亏") cls = pnlClass(t.realized_pnl_perp);
if (pair[0] === "期权盈亏") cls = pnlClass(t.realized_pnl_options);
} else if (pair[0] === "结果") {
cls = resultClass(e.result_tag);
}
return (
"<div><div class=\"muted\" style=\"font-size:11px\">" +
escapeHtml(pair[0]) +
'</div><div class="' +
cls +
'">' +
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(legRoleLabel(leg.leg_role)) +
"</td><td>" +
escapeHtml(leg.inst_id || leg.symbol || "") +
"</td><td class=\"" +
pnlClass(leg.realized_pnl) +
"\">" +
fmtPnl(leg.realized_pnl) +
"</td><td>" +
escapeHtml(closeReasonLabel(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) {
var keyLabel =
title === "对冲结束原因" ? closeReasonLabel(g.key) : String(g.key || "");
return (
'<div class="or-stat-row">' +
'<span class="or-stat-key">' +
escapeHtml(keyLabel) +
" · " +
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 cls = "";
if (pair[0] === "累计盈亏") cls = pnlClass(k.pnl_sum);
if (pair[0] === "平均盈亏") cls = pnlClass(k.avg_pnl);
return (
'<div class="or-kpi-tile"><div class="or-kpi-label">' +
pair[0] +
'</div><div class="or-kpi-value' +
(cls ? " " + cls : "") +
'">' +
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 ? " · " + closeReasonLabel(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 || closeReasonLabel(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(legRoleLabel(leg.leg_role)) +
"</td><td>" +
escapeHtml(leg.inst_id || leg.symbol || "") +
"</td><td>" +
fmtPnl(leg.realized_pnl) +
"</td><td>" +
escapeHtml(closeReasonLabel(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(defaultSourceForMode(tradeModeFromDom()));
}
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);