14dbf25798
内照明心统计表移至顶部卡片,右侧为日历/图表/交易记录;日历样式适配浅深主题,四所统计分析页同步展示按月盈亏日历。 Co-authored-by: Cursor <cursoragent@cursor.com>
204 lines
6.1 KiB
JavaScript
204 lines
6.1 KiB
JavaScript
/**
|
|
* 交易日历组件:内照明心档案 + 四所统计分析共用。
|
|
*/
|
|
(function (global) {
|
|
"use strict";
|
|
|
|
var WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
|
|
|
|
function esc(s) {
|
|
return String(s == null ? "" : s)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
function monthLabel(y, m) {
|
|
return y + "年" + m + "月";
|
|
}
|
|
|
|
function TradeStatsCalendar(config) {
|
|
this.gridEl = config.gridEl;
|
|
this.titleEl = config.titleEl;
|
|
this.prevBtn = config.prevBtn || null;
|
|
this.nextBtn = config.nextBtn || null;
|
|
this.apiUrl = config.apiUrl || "/api/stats/calendar";
|
|
this.buildQuery =
|
|
config.buildQuery ||
|
|
function (year, month) {
|
|
var q = new URLSearchParams();
|
|
q.set("year", String(year));
|
|
q.set("month", String(month));
|
|
return q;
|
|
};
|
|
this.parseResponse =
|
|
config.parseResponse ||
|
|
function (data) {
|
|
if (data && data.ok === false) return {};
|
|
return (data && data.days) || {};
|
|
};
|
|
this.fetchFn = config.fetchFn || null;
|
|
this.showSick = config.showSick !== false;
|
|
this.selectedDay = config.selectedDay || "";
|
|
this.onDayClick = config.onDayClick || null;
|
|
this.onMonthChange = config.onMonthChange || null;
|
|
this.year = config.year || 0;
|
|
this.month = config.month || 0;
|
|
this.days = {};
|
|
this._navBound = false;
|
|
this._bindNav();
|
|
}
|
|
|
|
TradeStatsCalendar.prototype.ensureMonth = function (ref) {
|
|
if (this.year > 0 && this.month > 0) return;
|
|
var d;
|
|
if (ref instanceof Date) d = ref;
|
|
else if (typeof ref === "string" && ref.length >= 7) {
|
|
var p = ref.slice(0, 10).split("-");
|
|
this.year = parseInt(p[0], 10) || new Date().getFullYear();
|
|
this.month = parseInt(p[1], 10) || new Date().getMonth() + 1;
|
|
return;
|
|
} else d = new Date();
|
|
this.year = d.getFullYear();
|
|
this.month = d.getMonth() + 1;
|
|
};
|
|
|
|
TradeStatsCalendar.prototype.setSelectedDay = function (day) {
|
|
this.selectedDay = day || "";
|
|
this.render();
|
|
};
|
|
|
|
TradeStatsCalendar.prototype.render = function () {
|
|
if (!this.gridEl || !this.titleEl) return;
|
|
this.ensureMonth(new Date());
|
|
this.titleEl.textContent = monthLabel(this.year, this.month);
|
|
var first = new Date(this.year, this.month - 1, 1);
|
|
var lastDay = new Date(this.year, this.month, 0).getDate();
|
|
var startWd = first.getDay();
|
|
var html =
|
|
'<div class="trade-cal-weekdays">' +
|
|
WEEKDAYS.map(function (w) {
|
|
return '<span class="trade-cal-wd">' + w + "</span>";
|
|
}).join("") +
|
|
'</div><div class="trade-cal-grid">';
|
|
var i;
|
|
for (i = 0; i < startWd; i++) {
|
|
html += '<span class="trade-cal-cell trade-cal-pad"></span>';
|
|
}
|
|
for (var d = 1; d <= lastDay; d++) {
|
|
var dayStr =
|
|
this.year +
|
|
"-" +
|
|
String(this.month).padStart(2, "0") +
|
|
"-" +
|
|
String(d).padStart(2, "0");
|
|
var info = this.days[dayStr];
|
|
var hasTrade = info && info.open_count > 0;
|
|
var sick = this.showSick && info && info.has_sick;
|
|
var pnl = hasTrade ? Number(info.pnl_total) : null;
|
|
var cnt = hasTrade ? info.open_count : 0;
|
|
var cls =
|
|
"trade-cal-cell" +
|
|
(hasTrade ? " has-trade" : "") +
|
|
(sick ? " is-sick-day" : "") +
|
|
(this.selectedDay === dayStr ? " is-selected" : "") +
|
|
(pnl != null && pnl > 0.0001
|
|
? " pnl-pos"
|
|
: pnl != null && pnl < -0.0001
|
|
? " pnl-neg"
|
|
: "");
|
|
var body = '<span class="trade-cal-day-num">' + d + "</span>";
|
|
if (hasTrade) {
|
|
var pnlTxt = (pnl >= 0 ? "+" : "") + pnl.toFixed(1);
|
|
body +=
|
|
'<span class="trade-cal-pnl">' +
|
|
esc(pnlTxt) +
|
|
"</span>" +
|
|
'<span class="trade-cal-cnt">' +
|
|
cnt +
|
|
"笔</span>";
|
|
if (sick) body += '<span class="trade-cal-sick-tag">犯病</span>';
|
|
}
|
|
html +=
|
|
'<button type="button" class="' +
|
|
cls +
|
|
'" data-day="' +
|
|
dayStr +
|
|
'" data-sick="' +
|
|
(sick ? "1" : "0") +
|
|
'"' +
|
|
(hasTrade ? "" : " disabled") +
|
|
">" +
|
|
body +
|
|
"</button>";
|
|
}
|
|
html += "</div>";
|
|
this.gridEl.innerHTML = html;
|
|
var self = this;
|
|
this.gridEl.querySelectorAll(".trade-cal-cell[data-day]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
var day = btn.getAttribute("data-day");
|
|
if (!day || !self.onDayClick) return;
|
|
self.selectedDay = day;
|
|
self.render();
|
|
self.onDayClick(day, btn.getAttribute("data-sick") === "1", self.days[day] || null);
|
|
});
|
|
});
|
|
};
|
|
|
|
TradeStatsCalendar.prototype.load = async function () {
|
|
this.ensureMonth(new Date());
|
|
var q = this.buildQuery(this.year, this.month);
|
|
if (!q.has("year")) q.set("year", String(this.year));
|
|
if (!q.has("month")) q.set("month", String(this.month));
|
|
try {
|
|
var data;
|
|
if (this.fetchFn) {
|
|
data = await this.fetchFn(q);
|
|
} else {
|
|
var resp = await fetch(this.apiUrl + "?" + q.toString(), {
|
|
credentials: "same-origin",
|
|
});
|
|
data = await resp.json();
|
|
}
|
|
this.days = this.parseResponse(data) || {};
|
|
this.render();
|
|
if (this.onMonthChange) this.onMonthChange(this.year, this.month, this.days);
|
|
} catch (e) {
|
|
console.warn("[trade calendar]", e);
|
|
}
|
|
};
|
|
|
|
TradeStatsCalendar.prototype.shiftMonth = function (delta) {
|
|
this.ensureMonth(new Date());
|
|
this.month += delta;
|
|
if (this.month > 12) {
|
|
this.month = 1;
|
|
this.year += 1;
|
|
} else if (this.month < 1) {
|
|
this.month = 12;
|
|
this.year -= 1;
|
|
}
|
|
void this.load();
|
|
};
|
|
|
|
TradeStatsCalendar.prototype._bindNav = function () {
|
|
if (this._navBound) return;
|
|
var self = this;
|
|
if (this.prevBtn) {
|
|
this.prevBtn.addEventListener("click", function () {
|
|
self.shiftMonth(-1);
|
|
});
|
|
}
|
|
if (this.nextBtn) {
|
|
this.nextBtn.addEventListener("click", function () {
|
|
self.shiftMonth(1);
|
|
});
|
|
}
|
|
this._navBound = true;
|
|
};
|
|
|
|
global.TradeStatsCalendar = TradeStatsCalendar;
|
|
})(window);
|