/** * 开仓计划:新建 / 进行中 / 历史 / 胜率统计 */ (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, ">") .replace(/"/g, """); } 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 = ''; (meta.entry_schemes || []).forEach(function (s) { html += '"; }); 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 = '

暂无进行中的计划

'; return; } host.innerHTML = activePlans .map(function (p) { return ( '
' + '
' + '
' + planSummaryLine(p) + "
" + '
' + '' + '' + "
" + '
' + esc(p.plan_date) + " · 趋势 " + esc(p.trend_timeframe) + " / 入场 " + esc(p.entry_timeframe) + "
" + '
目标 ' + esc(p.target_level || "—") + " · 区间 " + esc(p.current_range || "—") + "
" + (p.note ? '
' + esc(p.note) + "
" : "") + '
' + '" + "
" + '
' + '" + '' + '' + "
" ); }) .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 = '

暂无历史计划

'; 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 ( '" ); }) .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 = '

该范围内暂无已归档且有结果的计划

'; return; } const dimLabel = stats.dimension === "trend_tf" ? "趋势周期" : stats.dimension === "entry_scheme" ? "入场方案" : "币种"; let rows = items .map(function (it) { return ( "" + esc(it.label || it.key) + "" + (it.total || 0) + "" + (it.win_count || 0) + "" + (it.loss_count || 0) + "" + (it.win_rate != null ? it.win_rate + "%" : "—") + "" ); }) .join(""); host.innerHTML = '' + "" + "" + rows + "
" + esc(dimLabel) + "计划数盈利亏损胜率
"; } 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 ( '
' + esc(pair[0]) + '' + esc(pair[1]) + "
" ); }) .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 ( '" ); }) .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 ( '" ); }) .join(""); } return ( '
' + '' + '" + '' + '" + '" + '" + '" + '' + '' + '" + '" + "
" + '' ); } 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 }; })();