Files
crypto_monitor/manual_trading_hub/static/plan.js
T
2026-06-24 01:43:27 +08:00

773 lines
26 KiB
JavaScript

/**
* 开仓计划:新建 / 进行中 / 历史 / 胜率统计
*/
(function () {
const page = document.getElementById("page-plan");
if (!page) return;
let meta = null;
let activePlans = [];
let archivedPlans = [];
let statsPeriod = "all";
let statsDim = "symbol";
let statsDateFrom = "";
let statsDateTo = "";
let editingPlanId = null;
let inited = false;
function $(id) {
return document.getElementById(id);
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function toast(msg, isErr) {
const el = $("toast");
if (!el) return;
el.textContent = msg;
el.className = isErr ? "err" : "ok";
clearTimeout(el._t);
el._t = setTimeout(function () {
el.className = "";
el.textContent = "";
}, 3200);
}
async function api(path, opts) {
const r = await fetch(path, Object.assign({ credentials: "same-origin" }, opts || {}));
let data = {};
try {
data = await r.json();
} catch (_e) {
data = {};
}
if (!r.ok) {
const detail = (data && data.detail) || r.statusText || "请求失败";
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
}
return data;
}
function todayIso() {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return y + "-" + m + "-" + day;
}
function exchangeLabel(key) {
const ex = (meta && meta.exchanges) || [];
const row = ex.find(function (e) {
return String(e.key) === String(key);
});
return (row && row.name) || key || "—";
}
function fmtPnl(v) {
if (v == null || v === "") return "";
const n = Number(v);
if (!Number.isFinite(n)) return String(v);
return (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
}
function fillSelect(el, options, valueKey, labelKey) {
if (!el) return;
el.innerHTML = "";
(options || []).forEach(function (opt) {
const o = document.createElement("option");
if (typeof opt === "string") {
o.value = opt;
o.textContent = opt;
} else {
o.value = opt[valueKey];
o.textContent = opt[labelKey];
}
el.appendChild(o);
});
}
function renderDirectionRadios(container, name, selected) {
if (!container || !meta) return;
container.innerHTML = "";
(meta.directions || []).forEach(function (d) {
const label = document.createElement("label");
label.className = "plan-radio-label";
const input = document.createElement("input");
input.type = "radio";
input.name = name;
input.value = d.value;
if (d.value === selected) input.checked = true;
label.appendChild(input);
label.appendChild(document.createTextNode(" " + d.label));
container.appendChild(label);
});
}
function bindMetaToCreateForm() {
fillSelect($("plan-create-exchange"), meta.exchanges, "key", "name");
fillSelect($("plan-create-type"), meta.plan_types, "value", "label");
fillSelect($("plan-create-trend-tf"), meta.trend_timeframes);
fillSelect($("plan-create-entry-tf"), meta.entry_timeframes);
renderDirectionRadios($("plan-create-direction"), "plan-direction", "long");
const dateEl = $("plan-create-date");
if (dateEl && !dateEl.value) dateEl.value = todayIso();
}
function planSummaryLine(p) {
return (
esc(p.symbol) +
" · " +
esc(exchangeLabel(p.exchange_key)) +
" · " +
esc(p.direction_label || p.direction) +
" · " +
esc(p.plan_type_label || p.plan_type)
);
}
function schemeOptionsHtml(selected) {
let html = '<option value="">请选择</option>';
(meta.entry_schemes || []).forEach(function (s) {
html +=
'<option value="' +
esc(s.value) +
'"' +
(selected === s.value ? " selected" : "") +
">" +
esc(s.label) +
"</option>";
});
return html;
}
function renderActiveList() {
const host = $("plan-active-list");
const cnt = $("plan-active-count");
if (!host) return;
if (cnt) cnt.textContent = activePlans.length ? activePlans.length + " 条" : "";
if (!activePlans.length) {
host.innerHTML = '<p class="plan-empty">暂无进行中的计划</p>';
return;
}
host.innerHTML = activePlans
.map(function (p) {
return (
'<article class="plan-active-card" data-id="' +
esc(p.id) +
'">' +
'<div class="plan-active-head">' +
'<div class="plan-active-title">' +
planSummaryLine(p) +
"</div>" +
'<div class="plan-active-actions">' +
'<button type="button" class="ghost plan-btn-edit" data-id="' +
esc(p.id) +
'">修改</button>' +
'<button type="button" class="ghost plan-btn-del" data-id="' +
esc(p.id) +
'">删除</button>' +
"</div></div>" +
'<div class="plan-active-meta">' +
esc(p.plan_date) +
" · 趋势 " +
esc(p.trend_timeframe) +
" / 入场 " +
esc(p.entry_timeframe) +
"</div>" +
'<div class="plan-active-levels">目标 ' +
esc(p.target_level || "—") +
" · 区间 " +
esc(p.current_range || "—") +
"</div>" +
(p.note ? '<div class="plan-active-note">' + esc(p.note) + "</div>" : "") +
'<div class="plan-scheme-row">' +
'<label class="plan-field plan-field-inline plan-field-scheme"><span>入场方案</span>' +
'<select class="plan-active-scheme" data-id="' +
esc(p.id) +
'">' +
schemeOptionsHtml(p.entry_scheme || "") +
"</select></label>" +
"</div>" +
'<div class="plan-close-row">' +
'<label class="plan-field plan-field-inline"><span>结果</span>' +
'<select class="plan-close-result" data-id="' +
esc(p.id) +
'"><option value="">—</option>' +
(meta.results || [])
.map(function (r) {
return (
'<option value="' +
esc(r.value) +
'"' +
(p.result === r.value ? " selected" : "") +
">" +
esc(r.label) +
"</option>"
);
})
.join("") +
"</select></label>" +
'<label class="plan-field plan-field-inline"><span>盈亏</span>' +
'<input class="plan-close-pnl" data-id="' +
esc(p.id) +
'" type="number" step="any" placeholder="U(可选)" value="' +
(p.pnl_amount != null ? esc(p.pnl_amount) : "") +
'" /></label>' +
'<button type="button" class="primary plan-btn-archive" data-id="' +
esc(p.id) +
'">填写结果并归档</button>' +
"</div></article>"
);
})
.join("");
}
function renderHistoryList() {
const host = $("plan-history-list");
const cnt = $("plan-history-count");
if (!host) return;
if (cnt) cnt.textContent = archivedPlans.length ? archivedPlans.length + " 条" : "";
if (!archivedPlans.length) {
host.innerHTML = '<p class="plan-empty">暂无历史计划</p>';
return;
}
host.innerHTML = archivedPlans
.map(function (p) {
const pnlTxt = fmtPnl(p.pnl_amount);
const resCls = p.result === "win" ? "plan-res-win" : "plan-res-loss";
return (
'<button type="button" class="plan-history-row" data-id="' +
esc(p.id) +
'">' +
'<span class="plan-history-date">' +
esc(p.plan_date) +
"</span>" +
'<span class="plan-history-main">' +
esc(p.symbol) +
" · " +
esc(exchangeLabel(p.exchange_key)) +
"</span>" +
'<span class="plan-history-scheme">' +
esc(p.entry_scheme_label || p.entry_scheme) +
"</span>" +
'<span class="plan-history-result ' +
resCls +
'">' +
esc(p.result_label || p.result) +
(pnlTxt ? " " + esc(pnlTxt) : "") +
"</span></button>"
);
})
.join("");
}
function renderStatsTable(stats) {
const host = $("plan-stats-table");
const labelEl = $("plan-stats-label");
if (labelEl) labelEl.textContent = (stats && stats.period_label) || "";
if (!host) return;
const items = (stats && stats.items) || [];
if (!items.length) {
host.innerHTML = '<p class="plan-empty">该范围内暂无已归档且有结果的计划</p>';
return;
}
const dimLabel =
stats.dimension === "trend_tf"
? "趋势周期"
: stats.dimension === "entry_scheme"
? "入场方案"
: "币种";
let rows = items
.map(function (it) {
return (
"<tr><td>" +
esc(it.label || it.key) +
"</td><td>" +
(it.total || 0) +
"</td><td>" +
(it.win_count || 0) +
"</td><td>" +
(it.loss_count || 0) +
"</td><td>" +
(it.win_rate != null ? it.win_rate + "%" : "—") +
"</td></tr>"
);
})
.join("");
host.innerHTML =
'<table class="plan-stats-table"><thead><tr>' +
"<th>" +
esc(dimLabel) +
"</th><th>计划数</th><th>盈利</th><th>亏损</th><th>胜率</th>" +
"</tr></thead><tbody>" +
rows +
"</tbody></table>";
}
function statsQuery() {
const q = new URLSearchParams();
q.set("dimension", statsDim);
q.set("period", statsPeriod);
if (statsPeriod === "range") {
if (statsDateFrom) q.set("date_from", statsDateFrom);
if (statsDateTo) q.set("date_to", statsDateTo);
}
return q.toString();
}
async function loadMeta() {
const data = await api("/api/entry-plans/meta");
meta = data;
bindMetaToCreateForm();
}
async function loadActive() {
const data = await api("/api/entry-plans?status=active");
activePlans = data.plans || [];
renderActiveList();
}
async function loadHistory() {
const data = await api("/api/entry-plans?status=archived");
archivedPlans = data.plans || [];
renderHistoryList();
}
async function loadStats() {
const data = await api("/api/entry-plans/stats?" + statsQuery());
renderStatsTable(data.stats || {});
}
async function refreshAll() {
await Promise.all([loadActive(), loadHistory(), loadStats()]);
}
function fmtRefreshTime() {
const d = new Date();
const h = String(d.getHours()).padStart(2, "0");
const m = String(d.getMinutes()).padStart(2, "0");
const s = String(d.getSeconds()).padStart(2, "0");
return h + ":" + m + ":" + s;
}
async function refreshPage() {
const btn = $("plan-btn-refresh");
const status = $("plan-refresh-status");
if (btn) btn.disabled = true;
if (status) status.textContent = "刷新中…";
try {
await loadMeta();
await refreshAll();
if (status) status.textContent = "已刷新 " + fmtRefreshTime();
} catch (e) {
toast(e.message || "刷新失败", true);
if (status) status.textContent = "刷新失败";
} finally {
if (btn) btn.disabled = false;
}
}
function readCreateForm() {
const dir = document.querySelector('input[name="plan-direction"]:checked');
return {
plan_date: ($("plan-create-date") && $("plan-create-date").value) || "",
exchange_key: ($("plan-create-exchange") && $("plan-create-exchange").value) || "",
symbol: ($("plan-create-symbol") && $("plan-create-symbol").value) || "",
plan_type: ($("plan-create-type") && $("plan-create-type").value) || "",
trend_timeframe: ($("plan-create-trend-tf") && $("plan-create-trend-tf").value) || "",
entry_timeframe: ($("plan-create-entry-tf") && $("plan-create-entry-tf").value) || "",
direction: (dir && dir.value) || "",
target_level: ($("plan-create-target") && $("plan-create-target").value) || "",
current_range: ($("plan-create-range") && $("plan-create-range").value) || "",
note: ($("plan-create-note") && $("plan-create-note").value) || "",
};
}
function resetCreateForm() {
const form = $("plan-create-form");
if (form) form.reset();
bindMetaToCreateForm();
if ($("plan-create-date")) $("plan-create-date").value = todayIso();
}
function openDetailModal(plan) {
const modal = $("plan-detail-modal");
const body = $("plan-detail-body");
const title = $("plan-detail-title");
if (!modal || !body || !plan) return;
if (title) title.textContent = plan.symbol + " · " + (plan.result_label || "计划");
const rows = [
["日期", plan.plan_date],
["交易所", exchangeLabel(plan.exchange_key)],
["币种", plan.symbol],
["类型", plan.plan_type_label],
["趋势周期", plan.trend_timeframe],
["入场周期", plan.entry_timeframe],
["方向", plan.direction_label],
["目标位", plan.target_level || "—"],
["当前区间", plan.current_range || "—"],
["入场方案", plan.entry_scheme_label],
["结果", plan.result_label || "—"],
["盈亏", fmtPnl(plan.pnl_amount) || "—"],
["备注", plan.note || "—"],
];
body.innerHTML = rows
.map(function (pair) {
return (
'<div class="plan-detail-row"><span class="plan-detail-k">' +
esc(pair[0]) +
'</span><span class="plan-detail-v">' +
esc(pair[1]) +
"</span></div>"
);
})
.join("");
modal.classList.remove("hidden");
modal.setAttribute("aria-hidden", "false");
}
function closeDetailModal() {
const modal = $("plan-detail-modal");
if (!modal) return;
modal.classList.add("hidden");
modal.setAttribute("aria-hidden", "true");
}
function buildEditFormHtml(p) {
const dirs = (meta.directions || [])
.map(function (d) {
return (
'<label class="plan-radio-label"><input type="radio" name="edit-direction" value="' +
esc(d.value) +
'"' +
(p.direction === d.value ? " checked" : "") +
" /> " +
esc(d.label) +
"</label>"
);
})
.join("");
function opts(list, key, valKey, labelKey) {
return (list || [])
.map(function (o) {
const v = typeof o === "string" ? o : o[valKey];
const lbl = typeof o === "string" ? o : o[labelKey];
return (
'<option value="' +
esc(v) +
'"' +
(String(p[key]) === String(v) ? " selected" : "") +
">" +
esc(lbl) +
"</option>"
);
})
.join("");
}
return (
'<div class="plan-form-grid">' +
'<label class="plan-field"><span>日期</span><input name="plan_date" type="date" value="' +
esc(p.plan_date) +
'" required /></label>' +
'<label class="plan-field"><span>交易所</span><select name="exchange_key" required>' +
opts(meta.exchanges, "exchange_key", "key", "name") +
"</select></label>" +
'<label class="plan-field"><span>币种</span><input name="symbol" type="text" value="' +
esc(p.symbol) +
'" required /></label>' +
'<label class="plan-field"><span>类型</span><select name="plan_type" required>' +
opts(meta.plan_types, "plan_type", "value", "label") +
"</select></label>" +
'<label class="plan-field"><span>趋势周期</span><select name="trend_timeframe" required>' +
opts(meta.trend_timeframes, "trend_timeframe") +
"</select></label>" +
'<label class="plan-field"><span>入场周期</span><select name="entry_timeframe" required>' +
opts(meta.entry_timeframes, "entry_timeframe") +
"</select></label>" +
'<label class="plan-field plan-field-full"><span>方向</span><span class="plan-radio-row">' +
dirs +
"</span></label>" +
'<label class="plan-field"><span>目标位</span><input name="target_level" type="text" value="' +
esc(p.target_level || "") +
'" /></label>' +
'<label class="plan-field"><span>当前区间</span><input name="current_range" type="text" value="' +
esc(p.current_range || "") +
'" /></label>' +
'<label class="plan-field plan-field-full"><span>入场方案</span><select name="entry_scheme" required>' +
opts(meta.entry_schemes, "entry_scheme", "value", "label") +
"</select></label>" +
'<label class="plan-field plan-field-full"><span>备注</span><textarea name="note" rows="2">' +
esc(p.note || "") +
"</textarea></label>" +
"</div>" +
'<div class="modal-actions"><button type="button" class="ghost" data-plan-edit-close>取消</button>' +
'<button type="submit" class="primary">保存修改</button></div>'
);
}
function openEditModal(plan) {
const modal = $("plan-edit-modal");
const form = $("plan-edit-form");
if (!modal || !form || !plan) return;
editingPlanId = plan.id;
form.innerHTML = buildEditFormHtml(plan);
modal.classList.remove("hidden");
modal.setAttribute("aria-hidden", "false");
}
function closeEditModal() {
const modal = $("plan-edit-modal");
if (!modal) return;
editingPlanId = null;
modal.classList.add("hidden");
modal.setAttribute("aria-hidden", "true");
}
function readEditForm(form) {
const fd = new FormData(form);
const dir = form.querySelector('input[name="edit-direction"]:checked');
return {
plan_date: fd.get("plan_date") || "",
exchange_key: fd.get("exchange_key") || "",
symbol: fd.get("symbol") || "",
plan_type: fd.get("plan_type") || "",
trend_timeframe: fd.get("trend_timeframe") || "",
entry_timeframe: fd.get("entry_timeframe") || "",
direction: (dir && dir.value) || "",
target_level: fd.get("target_level") || "",
current_range: fd.get("current_range") || "",
entry_scheme: fd.get("entry_scheme") || "",
note: fd.get("note") || "",
};
}
function bindEvents() {
const refreshBtn = $("plan-btn-refresh");
if (refreshBtn) {
refreshBtn.addEventListener("click", function () {
void refreshPage();
});
}
const createForm = $("plan-create-form");
if (createForm) {
createForm.addEventListener("submit", function (ev) {
ev.preventDefault();
api("/api/entry-plans", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(readCreateForm()),
})
.then(function () {
toast("计划已加入进行中");
resetCreateForm();
return refreshAll();
})
.catch(function (e) {
toast(e.message || "保存失败", true);
});
});
}
const activeList = $("plan-active-list");
if (activeList) {
activeList.addEventListener("click", function (ev) {
const t = ev.target;
if (!(t instanceof HTMLElement)) return;
const id = t.getAttribute("data-id");
if (!id) return;
if (t.classList.contains("plan-btn-del")) {
if (!window.confirm("确定删除该进行中的计划?")) return;
api("/api/entry-plans/" + id, { method: "DELETE" })
.then(function () {
toast("已删除");
return refreshAll();
})
.catch(function (e) {
toast(e.message || "删除失败", true);
});
return;
}
if (t.classList.contains("plan-btn-edit")) {
const plan = activePlans.find(function (p) {
return String(p.id) === String(id);
});
if (plan) openEditModal(plan);
return;
}
if (t.classList.contains("plan-btn-archive")) {
const card = t.closest(".plan-active-card");
const resultEl = card && card.querySelector('.plan-close-result[data-id="' + id + '"]');
const pnlEl = card && card.querySelector('.plan-close-pnl[data-id="' + id + '"]');
const schemeEl = card && card.querySelector('.plan-active-scheme[data-id="' + id + '"]');
const result = resultEl && resultEl.value;
if (!result) {
toast("请先选择结果(盈/亏)", true);
return;
}
const scheme = schemeEl && schemeEl.value;
if (!scheme) {
toast("请先选择入场方案(根据实际进场填写)", true);
return;
}
const payload = { result: result, entry_scheme: scheme };
const pnlRaw = pnlEl && pnlEl.value;
if (pnlRaw !== "" && pnlRaw != null) payload.pnl_amount = Number(pnlRaw);
api("/api/entry-plans/" + id, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
.then(function () {
toast("已归档");
return refreshAll();
})
.catch(function (e) {
toast(e.message || "归档失败", true);
});
}
});
activeList.addEventListener("change", function (ev) {
const t = ev.target;
if (!(t instanceof HTMLElement) || !t.classList.contains("plan-active-scheme")) return;
const id = t.getAttribute("data-id");
const scheme = t.value;
if (!id || !scheme) return;
api("/api/entry-plans/" + id, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entry_scheme: scheme }),
})
.then(function () {
toast("入场方案已保存");
return loadActive();
})
.catch(function (e) {
toast(e.message || "保存失败", true);
});
});
}
const historyList = $("plan-history-list");
if (historyList) {
historyList.addEventListener("click", function (ev) {
const row = ev.target.closest(".plan-history-row");
if (!row) return;
const id = row.getAttribute("data-id");
const plan = archivedPlans.find(function (p) {
return String(p.id) === String(id);
});
if (plan) openDetailModal(plan);
else {
api("/api/entry-plans/" + id).then(function (data) {
openDetailModal(data.plan);
});
}
});
}
document.querySelectorAll("[data-plan-modal-close]").forEach(function (el) {
el.addEventListener("click", closeDetailModal);
});
document.querySelectorAll("[data-plan-edit-close]").forEach(function (el) {
el.addEventListener("click", closeEditModal);
});
const editForm = $("plan-edit-form");
if (editForm) {
editForm.addEventListener("submit", function (ev) {
ev.preventDefault();
if (!editingPlanId) return;
api("/api/entry-plans/" + editingPlanId, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(readEditForm(editForm)),
})
.then(function () {
toast("已保存");
closeEditModal();
return refreshAll();
})
.catch(function (e) {
toast(e.message || "保存失败", true);
});
});
}
const periodTabs = $("plan-stats-period-tabs");
if (periodTabs) {
periodTabs.addEventListener("click", function (ev) {
const btn = ev.target.closest(".plan-period-btn");
if (!btn) return;
statsPeriod = btn.getAttribute("data-period") || "all";
periodTabs.querySelectorAll(".plan-period-btn").forEach(function (b) {
b.classList.toggle("is-active", b === btn);
});
const rangeWrap = $("plan-stats-range-wrap");
if (rangeWrap) rangeWrap.classList.toggle("hidden", statsPeriod !== "range");
loadStats().catch(function (e) {
toast(e.message || "统计加载失败", true);
});
});
}
const dimTabs = $("plan-stats-dim-tabs");
if (dimTabs) {
dimTabs.addEventListener("click", function (ev) {
const btn = ev.target.closest(".plan-dim-btn");
if (!btn) return;
statsDim = btn.getAttribute("data-dim") || "symbol";
dimTabs.querySelectorAll(".plan-dim-btn").forEach(function (b) {
b.classList.toggle("is-active", b === btn);
});
loadStats().catch(function (e) {
toast(e.message || "统计加载失败", true);
});
});
}
["plan-stats-date-from", "plan-stats-date-to"].forEach(function (id) {
const el = $(id);
if (!el) return;
el.addEventListener("change", function () {
statsDateFrom = ($("plan-stats-date-from") && $("plan-stats-date-from").value) || "";
statsDateTo = ($("plan-stats-date-to") && $("plan-stats-date-to").value) || "";
if (statsPeriod === "range") {
loadStats().catch(function (e) {
toast(e.message || "统计加载失败", true);
});
}
});
});
}
async function init() {
if (inited) {
await refreshPage();
return;
}
inited = true;
bindEvents();
try {
await loadMeta();
await refreshAll();
const status = $("plan-refresh-status");
if (status) status.textContent = "已刷新 " + fmtRefreshTime();
} catch (e) {
toast(e.message || "加载失败", true);
}
}
function destroy() {}
window.hubPlanPage = { init: init, refresh: refreshPage, destroy: destroy };
})();