f435e9dfaa
Desktop journal list now uses columns for coin, direction, PnL, times, and actions instead of stacked entry cards. Co-authored-by: Cursor <cursoragent@cursor.com>
456 lines
17 KiB
JavaScript
456 lines
17 KiB
JavaScript
/**
|
||
* 三所实例共用 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(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);
|