feat(webui): 添加作战补给凭证/特别兑换凭证的图表统计功能

新增凭证快照数据存储与展示功能,在行动力图表中叠加展示黄币和紫币的变化曲线,同时添加对应统计信息和图例说明。
This commit is contained in:
a2893005741 2026-05-11 20:49:04 +08:00
parent 24276a4d9f
commit 60320c3a3e
6 changed files with 255 additions and 7 deletions

View File

@ -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')

View File

@ -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')

View File

@ -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"]

View File

@ -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'<div style="display:flex; flex-wrap:wrap; gap:12px; margin-bottom:4px; font-size:12px; color:#aaa;"><span>黄币: <b style="color:#ffd54f">{yc_cur}</b></span><span>变化: <b style="color:{yc_change_color}">{yc_change_sign}{yc_change}</b></span><span>最高: <b style="color:#ef5350">{yc_max}</b></span><span>最低: <b style="color:#26a69a">{yc_min}</b></span></div>'
coins_legend_html += '<span style="display:flex; align-items:center; gap:4px;"><span style="width:12px; height:2px; background:#ffd54f; border-radius:1px; border-top:1px dashed #ffd54f;"></span>黄币</span>'
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'<div style="display:flex; flex-wrap:wrap; gap:12px; margin-bottom:4px; font-size:12px; color:#aaa;"><span>紫币: <b style="color:#ce93d8">{pc_cur}</b></span><span>变化: <b style="color:{pc_change_color}">{pc_change_sign}{pc_change}</b></span><span>最高: <b style="color:#ef5350">{pc_max}</b></span><span>最低: <b style="color:#26a69a">{pc_min}</b></span></div>'
coins_legend_html += '<span style="display:flex; align-items:center; gap:4px;"><span style="width:12px; height:2px; background:#ce93d8; border-radius:1px; border-top:1px dashed #ce93d8;"></span>紫币</span>'
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):

View File

@ -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);

View File

@ -8,6 +8,11 @@
<span>均值: <b style="color:#ff9800">{ap_avg}</b></span>
<span style="color:#666">{data_points_text}</span>
</div>
{coins_stats_html}
</div>
<div style="display:flex; flex-wrap:wrap; gap:12px; margin-bottom:8px; font-size:12px; color:#888;">
<span style="display:flex; align-items:center; gap:4px;"><span style="width:12px; height:3px; background:#64b5f6; border-radius:1px;"></span>体力</span>
{coins_legend_html}
</div>
<div id="{chart_id}_container" style="position:relative;background:#1a1a2e;border-radius:8px;border:1px solid #333;padding:4px;transition:opacity 0.3s ease;">
<canvas id="{chart_id}" style="width:100%;height:360px;display:block;cursor:crosshair;"></canvas>