From 60320c3a3e8ddea71bf26c7ab75e5b65fcda043b Mon Sep 17 00:00:00 2001 From: a2893005741 Date: Mon, 11 May 2026 20:49:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(webui):=20=E6=B7=BB=E5=8A=A0=E4=BD=9C?= =?UTF-8?q?=E6=88=98=E8=A1=A5=E7=BB=99=E5=87=AD=E8=AF=81/=E7=89=B9?= =?UTF-8?q?=E5=88=AB=E5=85=91=E6=8D=A2=E5=87=AD=E8=AF=81=E7=9A=84=E5=9B=BE?= =?UTF-8?q?=E8=A1=A8=E7=BB=9F=E8=AE=A1=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增凭证快照数据存储与展示功能,在行动力图表中叠加展示黄币和紫币的变化曲线,同时添加对应统计信息和图例说明。 --- module/os_handler/os_status.py | 14 ++++ module/statistics/cl1_database.py | 29 ++++++++ module/statistics/opsi_month.py | 43 +++++++++++- module/webui/app.py | 61 ++++++++++++++++- webapp/ap_chart.js | 110 ++++++++++++++++++++++++++++-- webapp/ap_chart_panel.html | 5 ++ 6 files changed, 255 insertions(+), 7 deletions(-) diff --git a/module/os_handler/os_status.py b/module/os_handler/os_status.py index 66473280a..d97ec5757 100644 --- a/module/os_handler/os_status.py +++ b/module/os_handler/os_status.py @@ -136,6 +136,20 @@ class OSStatus(UI): self._shop_purple_coins = self.get_purple_coins() logger.info(f'Yellow coins: {self._shop_yellow_coins}, purple coins: {self._shop_purple_coins}') + # 记录凭证快照到数据库(用于 WebUI 凭证变化曲线图) + try: + instance_name = getattr(self.config, 'config_name', 'default') + source = 'cl1' if self.is_in_task_cl1_leveling else ('meow' if self.is_in_task_meow else 'other') + from module.statistics.cl1_database import db as cl1_db + cl1_db.add_coins_snapshot( + instance_name, + self._shop_yellow_coins, + self._shop_purple_coins, + source=source + ) + except Exception: + logger.exception('Failed to record coins snapshot') + def cl1_task_call(self): if self.is_cl1_enabled and self.cl1_enough_yellow_coins: self.config.task_call('OpsiHazard1Leveling') diff --git a/module/statistics/cl1_database.py b/module/statistics/cl1_database.py index 521d8cbb6..2b484f979 100644 --- a/module/statistics/cl1_database.py +++ b/module/statistics/cl1_database.py @@ -180,6 +180,8 @@ class Cl1Database: 'meow_hazard_stats': {}, # 委托收益数据 'commission_income_entries': [], + # 凭证快照数据(作战补给凭证/特别兑换凭证) + 'coins_snapshots': [], } def _empty_siren_research_devices(self) -> Dict[str, Any]: @@ -481,6 +483,33 @@ class Cl1Database: data['ap_snapshots'] = snapshots self.save_stats(instance, month, data) + def add_coins_snapshot(self, instance: str, yellow_coins: int, purple_coins: int = 0, source: str = 'cl1'): + """记录凭证快照(作战补给凭证/特别兑换凭证) + + Args: + instance: 实例名称 + yellow_coins: 当前作战补给凭证(黄币)数量 + purple_coins: 当前特别兑换凭证(紫币)数量 + source: 数据来源标记 (cl1 / meow 等) + """ + month = datetime.now().strftime('%Y-%m') + data = self.get_stats(instance, month) + + snapshot = { + 'ts': datetime.now().isoformat(), + 'yellow_coins': int(yellow_coins), + 'purple_coins': int(purple_coins), + 'source': source, + } + + snapshots = data.get('coins_snapshots', []) + snapshots.append(snapshot) + # 保留最近 500 条记录,避免数据过大 + if len(snapshots) > 500: + snapshots = snapshots[-500:] + data['coins_snapshots'] = snapshots + self.save_stats(instance, month, data) + def get_last_ap_notification(self, instance: str) -> Optional[Dict[str, Any]]: """获取最近一次成功推送时记录的行动力值。""" current_month = datetime.now().strftime('%Y-%m') diff --git a/module/statistics/opsi_month.py b/module/statistics/opsi_month.py index 7c30e01d8..38dc3204a 100644 --- a/module/statistics/opsi_month.py +++ b/module/statistics/opsi_month.py @@ -147,4 +147,45 @@ def get_ap_timeline(year: int | None = None, month: int | None = None, instance_ return snapshots_sorted -__all__ = ["get_opsi_stats", "OpsiMonthStats", "compute_monthly_cl1_akashi_ap", "get_ap_timeline"] +def get_coins_timeline(year: int | None = None, month: int | None = None, instance_name: str | None = None) -> list: + """ + 获取凭证变化时间序列数据(作战补给凭证/特别兑换凭证),用于绘制凭证变化曲线。 + + 返回按时间排序的数据点列表,每个数据点包含: + - ts: ISO 格式时间戳 + - yellow_coins: 当时的作战补给凭证(黄币)数量 + - purple_coins: 当时的特别兑换凭证(紫币)数量 + - source: 数据来源 (cl1 / meow / other) + + Args: + year: 年份,默认当前年 + month: 月份,默认当前月 + instance_name: 实例名称 + + Returns: + list[dict]: 时间序列数据点 + """ + now = datetime.now() + if year is None: + year = now.year + if month is None: + month = now.month + key_prefix = f"{year:04d}-{month:02d}" + + instance_name = instance_name or "default" + data = cl1_db.get_stats(instance_name, key_prefix) + + snapshots = data.get('coins_snapshots', []) + if not snapshots: + return [] + + # 按时间排序 + try: + snapshots_sorted = sorted(snapshots, key=lambda e: e.get('ts', '')) + except Exception: + snapshots_sorted = snapshots + + return snapshots_sorted + + +__all__ = ["get_opsi_stats", "OpsiMonthStats", "compute_monthly_cl1_akashi_ap", "get_ap_timeline", "get_coins_timeline"] diff --git a/module/webui/app.py b/module/webui/app.py index 020b12412..04a52eac1 100644 --- a/module/webui/app.py +++ b/module/webui/app.py @@ -420,13 +420,14 @@ class AlasGUI(Frame): def _render_ap_chart(): try: - from module.statistics.opsi_month import get_ap_timeline + from module.statistics.opsi_month import get_ap_timeline, get_coins_timeline instance_name = self.alas_name if hasattr(self, 'alas_name') and self.alas_name else None if not instance_name: from module.config.utils import alas_instance all_instances = alas_instance() instance_name = all_instances[0] if all_instances else None timeline = get_ap_timeline(instance_name=instance_name) + coins_timeline = get_coins_timeline(instance_name=instance_name) except Exception as e: with use_scope("ap_chart", clear=True): put_text(t("Gui.Stat.LoadApDataFailed", e=e)) @@ -553,6 +554,58 @@ class AlasGUI(Frame): change_color = '#ef5350' if ap_change >= 0 else '#26a69a' change_sign = '+' if ap_change >= 0 else '' + yellow_coins_list = [] + purple_coins_list = [] + coins_sources_list = [] + show_coins = False + coins_stats_html = '' + coins_legend_html = '' + + if coins_timeline and current_view in ('line', 'detail'): + show_coins = True + coins_raw_points = [] + for pt in coins_timeline: + ts_raw = pt.get('ts', '') + try: + dt = _dt.fromisoformat(ts_raw) + except Exception: + continue + coins_raw_points.append({ + 'dt': dt, + 'yellow_coins': int(pt.get('yellow_coins', 0)), + 'purple_coins': int(pt.get('purple_coins', 0)), + 'source': pt.get('source', '-') + }) + + if coins_raw_points: + coins_raw_points.sort(key=lambda p: p['dt']) + for p in coins_raw_points: + yellow_coins_list.append(p['yellow_coins']) + purple_coins_list.append(p['purple_coins']) + coins_sources_list.append(p.get('source', '-')) + + if yellow_coins_list: + yc_cur = yellow_coins_list[-1] + yc_change = yellow_coins_list[-1] - yellow_coins_list[0] if len(yellow_coins_list) >= 2 else 0 + yc_change_color = '#ef5350' if yc_change >= 0 else '#26a69a' + yc_change_sign = '+' if yc_change >= 0 else '' + yc_max = max(yellow_coins_list) + yc_min = min(yellow_coins_list) + + coins_stats_html += f'
黄币: {yc_cur}变化: {yc_change_sign}{yc_change}最高: {yc_max}最低: {yc_min}
' + coins_legend_html += '黄币' + + if purple_coins_list: + pc_cur = purple_coins_list[-1] + pc_change = purple_coins_list[-1] - purple_coins_list[0] if len(purple_coins_list) >= 2 else 0 + pc_change_color = '#ef5350' if pc_change >= 0 else '#26a69a' + pc_change_sign = '+' if pc_change >= 0 else '' + pc_max = max(purple_coins_list) + pc_min = min(purple_coins_list) + + coins_stats_html += f'
紫币: {pc_cur}变化: {pc_change_sign}{pc_change}最高: {pc_max}最低: {pc_min}
' + coins_legend_html += '紫币' + chart_id = f"ap_cv_{id(self)}" detail_controls_display = 'display:flex;' if is_detail_mode else 'display:none;' @@ -569,6 +622,8 @@ class AlasGUI(Frame): ap_avg=ap_avg, data_points_text=data_points_text, detail_controls_display=detail_controls_display, + coins_stats_html=coins_stats_html, + coins_legend_html=coins_legend_html, ) js_tpl = read_webapp_template('ap_chart.js') @@ -585,6 +640,10 @@ class AlasGUI(Frame): .replace('__CHART_ID__', chart_id) .replace('__IS_DETAIL_MODE__', 'true' if is_detail_mode else 'false') .replace('__SOURCES__', _json.dumps(detail_sources if is_detail_mode else [])) + .replace('__YELLOW_COINS__', _json.dumps(yellow_coins_list)) + .replace('__PURPLE_COINS__', _json.dumps(purple_coins_list)) + .replace('__COINS_SOURCES__', _json.dumps(coins_sources_list)) + .replace('__SHOW_COINS__', 'true' if show_coins else 'false') ) from pywebio.session import run_js with use_scope("ap_chart", clear=True): diff --git a/webapp/ap_chart.js b/webapp/ap_chart.js index 69bcc69f2..57970ac1c 100644 --- a/webapp/ap_chart.js +++ b/webapp/ap_chart.js @@ -51,6 +51,10 @@ var avg = __AVG__; var isDetailMode = __IS_DETAIL_MODE__; var sources = __SOURCES__; + var yellowCoins = __YELLOW_COINS__; + var purpleCoins = __PURPLE_COINS__; + var coinsSources = __COINS_SOURCES__; + var showCoins = __SHOW_COINS__; var nn = chartType === 'line' ? ap.length : labels.length; if (nn < 1) return; @@ -70,7 +74,7 @@ ctx.scale(dpr, dpr); var oc = ovCv.getContext("2d"); - var pad = {t: 20, r: 20, b: 52, l: 52}; + var pad = {t: 20, r: showCoins ? 72 : 20, b: 52, l: 52}; var gW = W - pad.l - pad.r, gH = H - pad.t - pad.b; var allMin = Infinity, allMax = -Infinity; @@ -89,8 +93,28 @@ allMin -= rng * 0.08; allMax += rng * 0.08; + var coinsMin = Infinity, coinsMax = -Infinity; + var yellowCoinsLen = yellowCoins ? yellowCoins.length : 0; + var purpleCoinsLen = purpleCoins ? purpleCoins.length : 0; + if (showCoins && chartType === 'line') { + for (var i = 0; i < yellowCoinsLen; i++) { + if (yellowCoins[i] < coinsMin) coinsMin = yellowCoins[i]; + if (yellowCoins[i] > coinsMax) coinsMax = yellowCoins[i]; + } + for (var i = 0; i < purpleCoinsLen; i++) { + if (purpleCoins[i] < coinsMin) coinsMin = purpleCoins[i]; + if (purpleCoins[i] > coinsMax) coinsMax = purpleCoins[i]; + } + if (coinsMin === Infinity) coinsMin = 0; + if (coinsMax === -Infinity) coinsMax = 1000; + var coinsRng = coinsMax - coinsMin || 1; + coinsMin -= coinsRng * 0.08; + coinsMax += coinsRng * 0.08; + } + function xOfLine(i) { return pad.l + (i / Math.max(nn - 1, 1)) * gW; } function yOf(v) { return pad.t + gH - (v - allMin) / (allMax - allMin) * gH; } + function yOfCoins(v) { return pad.t + gH - (v - coinsMin) / (coinsMax - coinsMin) * gH; } var candleSpace = gW / nn; var candleW = Math.max(3, Math.min(candleSpace * 0.6, 30)); @@ -112,6 +136,16 @@ ctx.fillText(Math.round(v), pad.l - 8, y); } + if (showCoins && chartType === 'line' && yellowCoinsLen > 0) { + ctx.fillStyle = "#ffd54f"; + ctx.textAlign = "left"; + for (var i = 0; i <= 5; i++) { + var v = coinsMin + (coinsMax - coinsMin) * (i / 5); + var y = yOfCoins(v); + ctx.fillText(Math.round(v), W - pad.r + 8, y); + } + } + var avgY = yOf(avg); ctx.save(); ctx.strokeStyle = "#ff9800"; @@ -244,6 +278,36 @@ drawMA(10, "#e91e63"); } + if (showCoins && chartType === 'line' && yellowCoinsLen > 0) { + ctx.lineWidth = 1.5; + ctx.lineJoin = "round"; + ctx.setLineDash([4, 2]); + + if (yellowCoinsLen > 0) { + ctx.beginPath(); + ctx.strokeStyle = "#ffd54f"; + for (var i = 0; i < yellowCoinsLen; i++) { + var x = xOfLine(i), y = yOfCoins(yellowCoins[i]); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + } + + if (purpleCoinsLen > 0) { + ctx.beginPath(); + ctx.strokeStyle = "#ce93d8"; + for (var i = 0; i < purpleCoinsLen; i++) { + var x = xOfLine(i), y = yOfCoins(purpleCoins[i]); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + } + + ctx.setLineDash([]); + } + cv.addEventListener("mousemove", function(e) { var rect = cv.getBoundingClientRect(); var mx_ = e.clientX - rect.left; @@ -304,12 +368,30 @@ var source = sources && sources[idx] ? sources[idx] : '-'; var sourceColor = source === 'cl1' ? '#64b5f6' : (source === 'meow' ? '#ff9800' : '#888'); - setTooltipContent(tipEl, [ + var tooltipRows = [ { style: { color: "#888", marginBottom: "4px", fontWeight: "600" }, parts: [{ type: 'text', value: labels[idx] }] }, { parts: [{ type: 'text', value: "体力: " }, { type: 'bold', value: String(ap[idx]), style: { color: "#64b5f6" } }] }, { parts: [{ type: 'text', value: "单次变化: " }, { type: 'bold', value: ds, style: { color: dc } }] }, { parts: [{ type: 'text', value: "来源: " }, { type: 'bold', value: source, style: { color: sourceColor } }] } - ]); + ]; + + if (showCoins && yellowCoinsLen > 0 && idx < yellowCoinsLen) { + var yc = yellowCoins[idx]; + var ycDiff = idx > 0 && idx < yellowCoinsLen ? (yc - yellowCoins[idx - 1]) : 0; + var ycColor = ycDiff >= 0 ? "#ef5350" : "#26a69a"; + var ycDiffStr = (ycDiff >= 0 ? "+" : "") + ycDiff; + tooltipRows.push({ parts: [{ type: 'text', value: "黄币: " }, { type: 'bold', value: String(yc), style: { color: "#ffd54f" } }, { type: 'text', value: " (" + ycDiffStr + ")", style: { color: ycColor } }] }); + } + + if (showCoins && purpleCoinsLen > 0 && idx < purpleCoinsLen) { + var pc = purpleCoins[idx]; + var pcDiff = idx > 0 && idx < purpleCoinsLen ? (pc - purpleCoins[idx - 1]) : 0; + var pcColor = pcDiff >= 0 ? "#ef5350" : "#26a69a"; + var pcDiffStr = (pcDiff >= 0 ? "+" : "") + pcDiff; + tooltipRows.push({ parts: [{ type: 'text', value: "紫币: " }, { type: 'bold', value: String(pc), style: { color: "#ce93d8" } }, { type: 'text', value: " (" + pcDiffStr + ")", style: { color: pcColor } }] }); + } + + setTooltipContent(tipEl, tooltipRows); } else { var ratio = (mx_ - pad.l) / gW; var idx = Math.round(ratio * (nn - 1)); @@ -335,11 +417,29 @@ var dc = isUp ? "#ef5350" : "#26a69a"; var ds = (isUp ? "+" : "") + diff; - setTooltipContent(tipEl, [ + var tooltipRows = [ { style: { color: "#888", marginBottom: "4px", fontWeight: "600" }, parts: [{ type: 'text', value: labels[idx] }] }, { parts: [{ type: 'text', value: "体力: " }, { type: 'bold', value: String(ap[idx]), style: { color: "#64b5f6" } }] }, { parts: [{ type: 'text', value: "单次变化: " }, { type: 'bold', value: ds, style: { color: dc } }] } - ]); + ]; + + if (showCoins && yellowCoinsLen > 0 && idx < yellowCoinsLen) { + var yc = yellowCoins[idx]; + var ycDiff = idx > 0 && idx < yellowCoinsLen ? (yc - yellowCoins[idx - 1]) : 0; + var ycColor = ycDiff >= 0 ? "#ef5350" : "#26a69a"; + var ycDiffStr = (ycDiff >= 0 ? "+" : "") + ycDiff; + tooltipRows.push({ parts: [{ type: 'text', value: "黄币: " }, { type: 'bold', value: String(yc), style: { color: "#ffd54f" } }, { type: 'text', value: " (" + ycDiffStr + ")", style: { color: ycColor } }] }); + } + + if (showCoins && purpleCoinsLen > 0 && idx < purpleCoinsLen) { + var pc = purpleCoins[idx]; + var pcDiff = idx > 0 && idx < purpleCoinsLen ? (pc - purpleCoins[idx - 1]) : 0; + var pcColor = pcDiff >= 0 ? "#ef5350" : "#26a69a"; + var pcDiffStr = (pcDiff >= 0 ? "+" : "") + pcDiff; + tooltipRows.push({ parts: [{ type: 'text', value: "紫币: " }, { type: 'bold', value: String(pc), style: { color: "#ce93d8" } }, { type: 'text', value: " (" + pcDiffStr + ")", style: { color: pcColor } }] }); + } + + setTooltipContent(tipEl, tooltipRows); } } else { var idx = Math.floor((mx_ - pad.l) / candleSpace); diff --git a/webapp/ap_chart_panel.html b/webapp/ap_chart_panel.html index 3d124fab9..dceec563d 100644 --- a/webapp/ap_chart_panel.html +++ b/webapp/ap_chart_panel.html @@ -8,6 +8,11 @@ 均值: {ap_avg} {data_points_text} + {coins_stats_html} + +
+ 体力 + {coins_legend_html}