Files
crypto_monitor/manual_trading_hub/static/archive.js
T

878 lines
28 KiB
JavaScript

/**
* 中控币种档案:列表筛选、交易时间线、永久 K 线(lightweight-charts)。
*/
(function () {
const page = document.getElementById("page-archive");
if (!page) return;
const elExchange = document.getElementById("archive-exchange");
const elFilterProfit = document.getElementById("archive-filter-profit");
const elFilterLoss = document.getElementById("archive-filter-loss");
const elFilterSick = document.getElementById("archive-filter-sick");
const elFilterEmotion = document.getElementById("archive-filter-emotion");
const elBtnRefresh = document.getElementById("archive-btn-refresh");
const elBtnSync = document.getElementById("archive-btn-sync");
const elStatus = document.getElementById("archive-status");
const elList = document.getElementById("archive-list");
const elDetailPanel = document.getElementById("archive-detail-panel");
const elDetailTitle = document.getElementById("archive-detail-title");
const elDetailStats = document.getElementById("archive-detail-stats");
const elTfTabs = document.getElementById("archive-tf-tabs");
const elViewMode = document.getElementById("archive-view-mode");
const elJumpAt = document.getElementById("archive-jump-at");
const elBtnJump = document.getElementById("archive-btn-jump");
const elBtnReloadChart = document.getElementById("archive-btn-reload-chart");
const elChartHost = document.getElementById("archive-chart");
const elMarkAuto = document.getElementById("archive-mark-auto");
const elTrades = document.getElementById("archive-trades");
const ARCHIVE_MARK_AUTO_KEY = "hubArchiveMarkAuto";
const TF_MS = {
"5m": 5 * 60_000,
"15m": 15 * 60_000,
"1h": 60 * 60_000,
"4h": 4 * 60 * 60_000,
};
let meta = null;
let listRows = [];
let selected = null;
let trades = [];
let selectedTradeId = null;
let timeframe = "15m";
let chart = null;
let candleSeries = null;
let volumeSeries = null;
let inited = false;
let markAuto = true;
let lastCandles = [];
function loadMarkAutoPref() {
try {
const raw = localStorage.getItem(ARCHIVE_MARK_AUTO_KEY);
if (raw === "0" || raw === "false") markAuto = false;
else if (raw === "1" || raw === "true") markAuto = true;
} catch (_) {}
syncMarkAutoBtn();
}
function syncMarkAutoBtn() {
if (!elMarkAuto) return;
elMarkAuto.classList.toggle("is-on", markAuto);
elMarkAuto.setAttribute("aria-pressed", markAuto ? "true" : "false");
}
function saveMarkAutoPref() {
try {
localStorage.setItem(ARCHIVE_MARK_AUTO_KEY, markAuto ? "1" : "0");
} catch (_) {}
}
function tradeHistoryBounds(tradeList) {
let minOpen = null;
let maxClose = null;
(tradeList || []).forEach(function (tr) {
const o = tradeOpenMs(tr);
const c = tradeCloseMs(tr);
if (o != null) minOpen = minOpen == null ? o : Math.min(minOpen, o);
if (c != null) maxClose = maxClose == null ? c : Math.max(maxClose, c);
});
return { minOpen: minOpen, maxClose: maxClose };
}
function fmt(n, d) {
if (n == null || n === "" || !Number.isFinite(Number(n))) return "—";
return Number(n).toFixed(d == null ? 2 : d);
}
function fmtPnl(v) {
const n = Number(v);
if (!Number.isFinite(n)) return "—";
const s = (n >= 0 ? "+" : "") + n.toFixed(2);
return s;
}
function fmtDt(raw) {
if (raw == null || raw === "") return "—";
return String(raw).replace("T", " ").slice(0, 16);
}
function fmtHoldMinutes(tr) {
if (!tr) return "—";
const text = tr.hold_minutes_text;
if (text) return text;
const n = Number(tr.hold_minutes);
if (!Number.isFinite(n) || n <= 0) return "0分钟";
const hours = Math.floor(n / 60);
const mins = Math.floor(n % 60);
if (hours) return hours + "小时" + mins + "分钟";
return mins + "分钟";
}
const ENTRY_TYPE_LABELS = {
trend_pullback: "趋势回调",
roll: "顺势加仓",
trend: "趋势回调",
};
function fmtEntryType(tr) {
if (!tr) return "—";
const raw = String(
tr.entry_type || tr.entry_reason || tr.reviewed_entry_reason || ""
).trim();
if (raw) {
return ENTRY_TYPE_LABELS[raw] || raw;
}
const mt = String(tr.monitor_type || "").trim();
if (mt && mt !== "下单监控") {
return ENTRY_TYPE_LABELS[mt] || mt;
}
return "—";
}
function reviewMark(tr) {
return tr && tr.reviewed ? "复" : "";
}
function pnlClass(v) {
const n = Number(v);
if (!Number.isFinite(n) || Math.abs(n) < 1e-6) return "";
return n > 0 ? "pos" : "neg";
}
function setStatus(text) {
if (elStatus) elStatus.textContent = text || "";
}
async function apiFetch(url, opts) {
const r = await fetch(url, opts);
if (r.status === 401) {
location.href = "/login?next=" + encodeURIComponent(location.pathname);
throw new Error("未登录");
}
return r;
}
function queryListParams() {
const q = new URLSearchParams();
const ex = (elExchange && elExchange.value) || "";
if (ex) q.set("exchange_key", ex);
if (elFilterProfit && elFilterProfit.checked) q.set("filter_profit", "1");
if (elFilterLoss && elFilterLoss.checked) q.set("filter_loss", "1");
if (elFilterSick && elFilterSick.checked) q.set("filter_sick", "1");
if (elFilterEmotion && elFilterEmotion.checked) q.set("filter_emotion", "1");
return q.toString();
}
function renderExchangeOptions() {
if (!elExchange || !meta) return;
const cur = elExchange.value;
elExchange.innerHTML = '<option value="">全部</option>';
(meta.exchanges || []).forEach(function (ex) {
const opt = document.createElement("option");
opt.value = ex.key || "";
opt.textContent = (ex.name || ex.key || "") + " (" + (ex.key || "") + ")";
elExchange.appendChild(opt);
});
if (cur) elExchange.value = cur;
}
function renderList() {
if (!elList) return;
if (!listRows.length) {
elList.innerHTML = '<p class="archive-empty">暂无档案数据。点击「同步交易与 K 线」从四所拉取。</p>';
return;
}
elList.innerHTML = listRows
.map(function (row) {
const active =
selected &&
selected.exchange_key === row.exchange_key &&
selected.symbol === row.symbol
? " is-active"
: "";
const seed = row.seed_complete ? "已建档" : "待种子";
return (
'<button type="button" class="archive-row' +
active +
'" data-ex="' +
row.exchange_key +
'" data-sym="' +
row.symbol +
'">' +
'<span class="archive-row-sym">' +
row.symbol +
"</span>" +
'<span class="archive-row-ex">' +
row.exchange_key +
"</span>" +
'<span class="archive-row-stat">' +
row.trade_count +
" 笔 · " +
fmtPnl(row.total_pnl) +
" U</span>" +
'<span class="archive-row-meta">' +
seed +
"</span>" +
"</button>"
);
})
.join("");
elList.querySelectorAll(".archive-row").forEach(function (btn) {
btn.addEventListener("click", function () {
openDetail(btn.getAttribute("data-ex"), btn.getAttribute("data-sym"));
});
});
}
function pickAnchorTrade() {
if (!trades.length) return null;
if (selectedTradeId != null) {
const hit = trades.find(function (t) {
return String(t.trade_id || t.id) === String(selectedTradeId);
});
if (hit) return hit;
}
return trades[0];
}
function parseTimeMs(raw) {
if (raw == null || raw === "") return null;
if (typeof raw === "number" && Number.isFinite(raw)) {
const v = Math.trunc(raw);
return v > 1e12 ? v : v * 1000;
}
const s = String(raw).trim().replace("Z", "").replace("T", " ");
if (!s) return null;
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2})(?::(\d{2}))?)?/);
if (!m) return null;
const dt = new Date(
Number(m[1]),
Number(m[2]) - 1,
Number(m[3]),
Number(m[4] || 0),
Number(m[5] || 0),
Number(m[6] || 0)
);
const ms = dt.getTime();
return Number.isFinite(ms) ? ms : null;
}
function tradeOpenMs(tr) {
if (!tr) return null;
return tr.opened_at_ms || parseTimeMs(tr.opened_at);
}
function tradeCloseMs(tr) {
if (!tr) return null;
return tr.closed_at_ms || parseTimeMs(tr.closed_at);
}
function anchorMsForTrade(tr) {
if (!tr) return null;
const mode = (elViewMode && elViewMode.value) || "hold";
if (mode === "entry") {
return tradeOpenMs(tr);
}
return tradeCloseMs(tr) || tradeOpenMs(tr);
}
function msToBarTime(ms, tf) {
const period = TF_MS[tf] || TF_MS["15m"];
const aligned = Math.floor(Number(ms) / period) * period;
return Math.floor(aligned / 1000);
}
function snapToCandleTime(targetSec, candles) {
if (!candles || !candles.length) return targetSec;
let best = candles[0].time;
let bestDiff = Math.abs(candles[0].time - targetSec);
for (let i = 0; i < candles.length; i++) {
const d = Math.abs(candles[i].time - targetSec);
if (d < bestDiff) {
bestDiff = d;
best = candles[i].time;
}
}
return best;
}
const OPEN_ARROW_LONG = "#22c55e";
const OPEN_ARROW_SHORT = "#ef4444";
const OPEN_ARROW_LONG_HI = "#4ade80";
const OPEN_ARROW_SHORT_HI = "#f87171";
function isLongDirection(dir) {
const d = String(dir || "").trim().toLowerCase();
if (d === "short" || d === "空" || d === "sell" || d === "做空" || d === "shorts") {
return false;
}
if (d === "long" || d === "多" || d === "buy" || d === "做多" || d === "longs") {
return true;
}
return true;
}
function openArrowColor(long, highlight) {
if (long) return highlight ? OPEN_ARROW_LONG_HI : OPEN_ARROW_LONG;
return highlight ? OPEN_ARROW_SHORT_HI : OPEN_ARROW_SHORT;
}
function buildTradeMarkers(tr, candles, tf, opts) {
if (!tr || !candles.length) return [];
const options = opts || {};
const suffix = options.labelSuffix ? String(options.labelSuffix) : "";
const highlight = !!options.highlight;
const long = isLongDirection(tr.direction);
const openMs = tradeOpenMs(tr);
const closeMs = tradeCloseMs(tr);
const openColor = openArrowColor(long, highlight);
let closeColor = highlight ? "#fbbf24" : "#f59e0b";
const pnl = Number(tr.pnl_amount);
if (!highlight && Number.isFinite(pnl) && pnl < -0.0001) {
closeColor = "#a855f7";
}
const markers = [];
if (openMs) {
markers.push({
time: snapToCandleTime(msToBarTime(openMs, tf), candles),
position: long ? "belowBar" : "aboveBar",
color: openColor,
shape: long ? "arrowUp" : "arrowDown",
text: "开" + suffix,
});
}
if (closeMs) {
markers.push({
time: snapToCandleTime(msToBarTime(closeMs, tf), candles),
position: long ? "aboveBar" : "belowBar",
color: closeColor,
shape: long ? "arrowDown" : "arrowUp",
text: "平" + suffix,
});
}
return markers;
}
function buildChartMarkers(candles, tf) {
if (!candles.length) return [];
const tr = pickAnchorTrade();
if (!markAuto || !trades.length) {
return buildTradeMarkers(tr, candles, tf, { highlight: true });
}
const sorted = trades.slice().sort(function (a, b) {
return (tradeOpenMs(a) || 0) - (tradeOpenMs(b) || 0);
});
const multi = sorted.length > 1;
const out = [];
sorted.forEach(function (row, idx) {
const tid = String(row.trade_id || row.id);
const parts = buildTradeMarkers(row, candles, tf, {
labelSuffix: multi ? String(idx + 1) : "",
highlight: tid === String(selectedTradeId),
});
out.push.apply(out, parts);
});
return out.sort(function (a, b) {
return a.time > b.time ? 1 : a.time < b.time ? -1 : 0;
});
}
function applyChartMarkers() {
if (!candleSeries || !candleSeries.setMarkers || !lastCandles.length) return;
candleSeries.setMarkers(buildChartMarkers(lastCandles, timeframe));
}
/** 初始只聚焦持仓段;完整历史已加载,可向左拖动/滚轮缩小查看建仓前全局。 */
function focusInitialTradeView(candles, tr, tf) {
if (!chart || !candles.length || !tr) return;
const mode = (elViewMode && elViewMode.value) || "hold";
const openSec = tradeOpenMs(tr) ? msToBarTime(tradeOpenMs(tr), tf) : null;
const closeSec = tradeCloseMs(tr) ? msToBarTime(tradeCloseMs(tr), tf) : null;
let openIdx = 0;
let closeIdx = candles.length - 1;
if (openSec != null) {
for (let i = 0; i < candles.length; i++) {
if (candles[i].time >= openSec) {
openIdx = i;
break;
}
}
}
if (closeSec != null) {
for (let i = candles.length - 1; i >= 0; i--) {
if (candles[i].time <= closeSec) {
closeIdx = i;
break;
}
}
}
const span = Math.max(24, closeIdx - openIdx + 20);
let fromIdx;
let toIdx;
if (mode === "entry") {
fromIdx = Math.max(0, openIdx - Math.floor(span * 0.35));
toIdx = Math.min(candles.length - 1, openIdx + Math.floor(span * 0.65));
} else {
fromIdx = Math.max(0, openIdx - 10);
toIdx = Math.min(candles.length - 1, closeIdx + 14);
}
if (toIdx <= fromIdx) {
toIdx = Math.min(candles.length - 1, fromIdx + 80);
}
chart.timeScale().setVisibleLogicalRange({ from: fromIdx, to: toIdx + 4 });
}
function destroyChart() {
if (chart) {
chart.remove();
chart = null;
candleSeries = null;
volumeSeries = null;
}
if (elChartHost) elChartHost.innerHTML = "";
}
function ensureChart() {
if (!elChartHost || !window.LightweightCharts) return;
if (chart) return;
const isDark = document.documentElement.getAttribute("data-theme") !== "light";
chart = LightweightCharts.createChart(elChartHost, {
layout: {
background: { color: isDark ? "#0b0e18" : "#f8f9fc" },
textColor: isDark ? "#9aa4b8" : "#4a5568",
},
grid: {
vertLines: { color: isDark ? "#1a2030" : "#e8ecf2" },
horzLines: { color: isDark ? "#1a2030" : "#e8ecf2" },
},
rightPriceScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2", autoScale: true },
timeScale: {
borderColor: isDark ? "#2a3348" : "#d0d7e2",
timeVisible: true,
secondsVisible: false,
},
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
handleScroll: {
mouseWheel: true,
pressedMouseMove: true,
horzTouchDrag: true,
vertTouchDrag: false,
},
handleScale: {
axisPressedMouseMove: true,
mouseWheel: true,
pinch: true,
},
});
candleSeries = chart.addCandlestickSeries({
upColor: "#22c55e",
downColor: "#ef4444",
borderVisible: false,
wickUpColor: "#22c55e",
wickDownColor: "#ef4444",
});
volumeSeries = chart.addHistogramSeries({
color: "#3b82f680",
priceFormat: { type: "volume" },
priceScaleId: "",
});
volumeSeries.priceScale().applyOptions({
scaleMargins: { top: 0.82, bottom: 0 },
});
new ResizeObserver(function () {
if (chart && elChartHost) {
chart.applyOptions({ width: elChartHost.clientWidth, height: elChartHost.clientHeight });
}
}).observe(elChartHost);
chart.applyOptions({ width: elChartHost.clientWidth, height: elChartHost.clientHeight });
}
async function loadChart() {
if (!selected) return;
const tr = pickAnchorTrade();
const anchor = anchorMsForTrade(tr);
const jump = (elJumpAt && elJumpAt.value || "").trim();
let openMs = null;
let closeMs = null;
if (markAuto && trades.length) {
const bounds = tradeHistoryBounds(trades);
openMs = bounds.minOpen;
closeMs = bounds.maxClose;
} else if (tr) {
openMs = tradeOpenMs(tr);
closeMs = tradeCloseMs(tr);
}
const params = new URLSearchParams({
exchange_key: selected.exchange_key,
symbol: selected.symbol,
timeframe: timeframe,
mode: (elViewMode && elViewMode.value) || "hold",
});
if (openMs && closeMs) {
params.set("range", "history");
params.set("opened_ms", String(openMs));
params.set("closed_ms", String(closeMs));
} else {
params.set("bars", "200");
if (jump) params.set("at", jump);
else if (anchor) params.set("anchor_ms", String(anchor));
}
setStatus("加载 K 线…");
const r = await apiFetch("/api/archive/ohlcv?" + params.toString());
const j = await r.json();
if (!r.ok) {
setStatus(j.detail || "K 线加载失败");
return;
}
ensureChart();
const candles = j.candles || [];
lastCandles = candles;
candleSeries.setData(
candles.map(function (c) {
return { time: c.time, open: c.open, high: c.high, low: c.low, close: c.close };
})
);
volumeSeries.setData(
candles.map(function (c) {
return {
time: c.time,
value: c.volume || 0,
color: c.close >= c.open ? "#22c55e55" : "#ef444455",
};
})
);
applyChartMarkers();
if (tr && tradeOpenMs(tr) && tradeCloseMs(tr)) {
focusInitialTradeView(candles, tr, timeframe);
} else if (candles.length > 10) {
chart.timeScale().setVisibleLogicalRange({ from: candles.length - 120, to: candles.length + 5 });
}
const markHint = markAuto && trades.length > 1 ? " · 自动标注 " + trades.length + " 笔" : tr ? " · 已标注开/平" : "";
const histHint = openMs && closeMs ? " · 可拖动/滚轮缩放查看建仓前走势" : "";
setStatus("K 线 " + candles.length + " 根 · " + timeframe + markHint + histHint);
}
function renderTrades() {
if (!elTrades) return;
if (!trades.length) {
elTrades.innerHTML = '<p class="archive-empty">该币种暂无已平仓记录。</p>';
return;
}
elTrades.innerHTML =
'<table class="archive-trades-table"><thead><tr>' +
"<th>开仓类型</th><th>开仓时间</th><th>平仓时间</th><th>持仓时长</th>" +
"<th>方向</th><th>结果</th><th>盈亏</th><th>标签</th><th>备注</th><th>操作</th>" +
"</tr></thead><tbody>" +
trades
.map(function (t) {
const tid = t.trade_id || t.id;
const active = String(tid) === String(selectedTradeId) ? " is-active" : "";
const tag = t.behavior_tag || "";
const rev = reviewMark(t);
return (
'<tr class="archive-trade-row' +
active +
'" data-id="' +
tid +
'">' +
'<td' +
(rev ? ' title="复盘记录"' : "") +
">" +
(rev ? '<span class="archive-review-mark">' + rev + "</span>" : "") +
fmtEntryType(t) +
"</td>" +
'<td class="archive-dt"' +
(rev ? ' title="复盘记录"' : "") +
">" +
(rev ? '<span class="archive-review-mark">' + rev + "</span>" : "") +
fmtDt(t.opened_at) +
"</td>" +
'<td class="archive-dt"' +
(rev ? ' title="复盘记录"' : "") +
">" +
(rev ? '<span class="archive-review-mark">' + rev + "</span>" : "") +
fmtDt(t.closed_at) +
"</td>" +
'<td class="archive-hold"' +
(rev ? ' title="复盘记录"' : "") +
">" +
(rev ? '<span class="archive-review-mark">' + rev + "</span>" : "") +
fmtHoldMinutes(t) +
"</td>" +
"<td>" +
(t.direction || "—") +
"</td>" +
"<td>" +
(t.result || "—") +
"</td>" +
'<td class="' +
pnlClass(t.pnl_amount) +
'">' +
fmtPnl(t.pnl_amount) +
"</td>" +
'<td><select class="archive-tag-select" data-id="' +
tid +
'">' +
'<option value=""' +
(tag === "" ? " selected" : "") +
">—</option>" +
'<option value="sick"' +
(tag === "sick" ? " selected" : "") +
">犯病</option>" +
'<option value="emotion"' +
(tag === "emotion" ? " selected" : "") +
">情绪</option>" +
"</select></td>" +
'<td><input class="archive-note-input" data-id="' +
tid +
'" value="' +
String(t.note || "").replace(/"/g, "&quot;") +
'" placeholder="备注" /></td>' +
'<td><button type="button" class="archive-del-btn" data-id="' +
tid +
'" title="从档案移除(不影响实例复盘库)">删除</button></td>' +
"</tr>"
);
})
.join("") +
"</tbody></table>";
elTrades.querySelectorAll(".archive-del-btn").forEach(function (btn) {
btn.addEventListener("click", function (ev) {
ev.stopPropagation();
void deleteTrade(btn.getAttribute("data-id"));
});
});
elTrades.querySelectorAll(".archive-trade-row").forEach(function (row) {
row.addEventListener("click", function (ev) {
if (
ev.target.closest("select") ||
ev.target.closest("input") ||
ev.target.closest(".archive-del-btn")
) {
return;
}
selectedTradeId = row.getAttribute("data-id");
renderTrades();
applyChartMarkers();
const trSel = pickAnchorTrade();
if (trSel && tradeOpenMs(trSel) && tradeCloseMs(trSel)) {
focusInitialTradeView(lastCandles, trSel, timeframe);
}
});
});
elTrades.querySelectorAll(".archive-tag-select").forEach(function (sel) {
sel.addEventListener("change", function () {
saveOverlay(sel.getAttribute("data-id"), sel.value, null);
});
});
elTrades.querySelectorAll(".archive-note-input").forEach(function (inp) {
inp.addEventListener("change", function () {
const row = inp.closest(".archive-trade-row");
const tagSel = row && row.querySelector(".archive-tag-select");
saveOverlay(inp.getAttribute("data-id"), tagSel ? tagSel.value : "", inp.value);
});
});
}
async function deleteTrade(tradeId) {
if (!selected || tradeId == null) return;
if (!window.confirm("从币种档案移除该笔交易?(不影响交易所实例里的复盘记录)")) return;
const r = await apiFetch(
"/api/archive/trade/" + selected.exchange_key + "/" + tradeId,
{ method: "DELETE" }
);
if (!r.ok) {
const j = await r.json().catch(function () {
return {};
});
setStatus(j.detail || j.msg || "删除失败");
return;
}
if (String(selectedTradeId) === String(tradeId)) {
selectedTradeId = null;
}
trades = trades.filter(function (t) {
return String(t.trade_id || t.id) !== String(tradeId);
});
renderTrades();
applyChartMarkers();
await loadList();
setStatus("已移除 1 笔档案记录");
}
async function saveOverlay(tradeId, tag, note) {
if (!selected) return;
const body = { behavior_tag: tag || "", note: note != null ? note : undefined };
if (note == null) {
const row = elTrades.querySelector('.archive-trade-row[data-id="' + tradeId + '"]');
const inp = row && row.querySelector(".archive-note-input");
body.note = inp ? inp.value : "";
}
await apiFetch("/api/archive/trade/" + selected.exchange_key + "/" + tradeId, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const tr = trades.find(function (t) {
return String(t.trade_id || t.id) === String(tradeId);
});
if (tr) {
tr.behavior_tag = body.behavior_tag;
tr.note = body.note;
}
}
async function openDetail(exchangeKey, symbol) {
selected = { exchange_key: exchangeKey, symbol: symbol };
if (elDetailPanel) elDetailPanel.classList.remove("hidden");
const row = listRows.find(function (r) {
return r.exchange_key === exchangeKey && r.symbol === symbol;
});
if (elDetailTitle) {
elDetailTitle.textContent = symbol + " · " + exchangeKey;
}
if (elDetailStats && row) {
elDetailStats.textContent =
row.trade_count +
" 笔 · 胜 " +
row.win_count +
" / 负 " +
row.loss_count +
" · 合计 " +
fmtPnl(row.total_pnl) +
" U";
}
renderList();
setStatus("加载交易明细…");
const r = await apiFetch(
"/api/archive/detail?exchange_key=" +
encodeURIComponent(exchangeKey) +
"&symbol=" +
encodeURIComponent(symbol)
);
const j = await r.json();
trades = j.trades || [];
selectedTradeId = trades.length ? String(trades[0].trade_id || trades[0].id) : null;
renderTrades();
await loadChart();
}
async function loadList() {
setStatus("加载列表…");
const r = await apiFetch("/api/archive/list?" + queryListParams());
const j = await r.json();
listRows = j.rows || [];
renderList();
setStatus("共 " + listRows.length + " 个币种档案 · " + new Date().toLocaleTimeString());
}
async function loadMeta() {
const r = await apiFetch("/api/archive/meta");
meta = await r.json();
timeframe = (meta && meta.default_timeframe) || "15m";
if (meta && meta.last_sync && elStatus && !elStatus.textContent) {
setStatus(formatSyncSummary(meta.last_sync));
}
renderExchangeOptions();
if (elTfTabs) {
elTfTabs.querySelectorAll(".archive-tf-btn").forEach(function (btn) {
btn.classList.toggle("is-active", btn.getAttribute("data-tf") === timeframe);
});
}
}
function formatSyncSummary(j) {
const results = j.results || [];
const okN = results.filter(function (x) {
return x.ok !== false;
}).length;
const parts = ["同步完成 · " + okN + "/" + (j.exchanges || 0) + " 所"];
results.forEach(function (row) {
const label = row.exchange_key || row.name || "?";
if (row.ok === false) {
parts.push(label + " 失败: " + (row.msg || "未知错误"));
} else {
let line =
label + " " + (row.trade_count != null ? row.trade_count : row.trades || 0) + " 笔";
if (row.trades_removed > 0) {
line += " 清" + row.trades_removed;
}
parts.push(line);
}
});
return parts.join(" · ");
}
async function syncAll() {
setStatus("同步中(可能需数分钟)…");
elBtnSync && (elBtnSync.disabled = true);
try {
const r = await apiFetch("/api/archive/sync", { method: "POST" });
const j = await r.json();
setStatus(formatSyncSummary(j));
await loadList();
if (selected) await openDetail(selected.exchange_key, selected.symbol);
} catch (e) {
setStatus(String(e));
} finally {
elBtnSync && (elBtnSync.disabled = false);
}
}
function bindEvents() {
if (elBtnRefresh) elBtnRefresh.addEventListener("click", loadList);
if (elBtnSync) elBtnSync.addEventListener("click", syncAll);
if (elExchange) elExchange.addEventListener("change", loadList);
[elFilterProfit, elFilterLoss, elFilterSick, elFilterEmotion].forEach(function (el) {
if (el) el.addEventListener("change", loadList);
});
if (elTfTabs) {
elTfTabs.addEventListener("click", function (ev) {
const btn = ev.target.closest(".archive-tf-btn");
if (!btn) return;
timeframe = btn.getAttribute("data-tf") || "15m";
elTfTabs.querySelectorAll(".archive-tf-btn").forEach(function (b) {
b.classList.toggle("is-active", b === btn);
});
loadChart();
});
}
if (elViewMode) elViewMode.addEventListener("change", loadChart);
if (elBtnReloadChart) elBtnReloadChart.addEventListener("click", loadChart);
if (elMarkAuto) {
elMarkAuto.addEventListener("click", function () {
markAuto = !markAuto;
syncMarkAutoBtn();
saveMarkAutoPref();
loadChart();
});
}
if (elBtnJump) {
elBtnJump.addEventListener("click", function () {
loadChart();
});
}
}
async function init() {
if (!document.getElementById("page-archive") || document.getElementById("page-archive").classList.contains("hidden")) {
return;
}
if (!inited) {
loadMarkAutoPref();
bindEvents();
inited = true;
}
await loadMeta();
await loadList();
}
function destroy() {
destroyChart();
}
window.hubArchivePage = { init: init, destroy: destroy };
})();