mirror of
https://github.com/wess09/AzurLaneAutoScript.git
synced 2026-05-14 06:58:22 +08:00
feat(webui): 添加作战补给凭证/特别兑换凭证的图表统计功能
新增凭证快照数据存储与展示功能,在行动力图表中叠加展示黄币和紫币的变化曲线,同时添加对应统计信息和图例说明。
This commit is contained in:
parent
24276a4d9f
commit
60320c3a3e
@ -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')
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user