Files
eink-push/data.py
YANG JIANKUAN 6dc89b504e feat: E1002 宽版用量配色改三档绿/黑/红+今日实时与近7天版式重做
- 用量与 pace 配色由「红/黑」二档、黄「注意」档,改为绿(健康)/黑(正常)/红(危险)三档,新增 USAGE_OK/PACE_TOL 阈值,去掉黄色进度条
- Claude/ChatGPT 用量行标签改用 statusline 同款长标签(all 5h/fable 7d 等)
- 今日实时区块改为指标名角标+数值与环比同比整组居中;近7天区块改为真实数据表(取30天趋势缓存最后7天),不画折线
- renderer.py 抽出 _cmp_at/_pace_color 钩子+新增 F36 字体供宽版复用;CLAUDE.md 同步版式与配置项说明

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 15:42:17 +08:00

298 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
数据层 —— 汇总仪表盘所需的全部数据。
橘喵今日经营 + 近30天趋势来自 jm_apijm-devops 后端统计接口,免鉴权 GET
天气来自 weather_api和风天气定位南京·秦淮。日期/时间用真实系统时间。
任何接口失败时相关数据以占位符 "--" 呈现,不回退 mock。
"""
import json
from datetime import datetime
import config
import jm_api
import weather_api
import usage_local
WEEK_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
TREND_DAYS = 30 # 近 N 天趋势
FIVE_HOUR, SEVEN_DAY = 5 * 3600, 7 * 86400 # 两个滚动窗口长度(秒),用于算「时间已流逝%」
def _read_weather_cache():
try:
with open(config.WEATHER_CACHE_PATH, encoding="utf-8") as f:
return json.load(f) # {"ts": float, "data": {...}}
except Exception:
return None
def _write_weather_cache(ts, data):
try:
with open(config.WEATHER_CACHE_PATH, "w", encoding="utf-8") as f:
json.dump({"ts": ts, "data": data}, f, ensure_ascii=False)
except Exception as e:
print(f"[警告] 天气缓存写入失败:{e}")
def _read_trend_cache():
try:
with open(config.TREND_CACHE_PATH, encoding="utf-8") as f:
return json.load(f) # {"date": "YYYY-MM-DD", "data": {...}}
except Exception:
return None
def _write_trend_cache(date_key, data):
try:
with open(config.TREND_CACHE_PATH, "w", encoding="utf-8") as f:
json.dump({"date": date_key, "data": data}, f, ensure_ascii=False)
except Exception as e:
print(f"[警告] 趋势缓存写入失败:{e}")
def _trend(now):
"""近30天趋势按日期缓存recent-days 每天只更新一次。
当天已有缓存(且有数据)→ 直接复用,不发请求;当天还没有 → 拉取并写缓存。
拉取失败时沿用任意旧缓存(哪怕非当天),仍无缓存才回退空。
API 降序 → 升序:左旧右新。
"""
date_key = now.strftime("%Y-%m-%d")
cached = _read_trend_cache()
if cached and cached.get("date") == date_key and cached.get("data", {}).get("series"):
return cached["data"]
try:
asc = list(reversed(jm_api.get_recent_days(TREND_DAYS)))
trend = {
"dates": [d["date"][5:].replace("-", "/") for d in asc], # yyyy-MM-dd → MM/DD
"series": [
{"k": "单量", "style": "solid", "data": [int(d["orderCount"]) for d in asc]},
{"k": "流水", "style": "dashed", "data": [float(d["payAmount"]) for d in asc]},
{"k": "毛利", "style": "dotted", "data": [float(d["grossProfit"]) for d in asc]},
],
}
_write_trend_cache(date_key, trend)
return trend
except Exception as e:
print(f"[警告] 近30天趋势拉取失败{e}")
if cached and cached.get("data", {}).get("series"): # 失败沿用旧缓存,避免趋势图整片 "--"
print("[警告] 沿用上次趋势缓存")
return cached["data"]
return {"dates": [], "series": []}
def _weather(now):
"""和风天气(南京·秦淮),带 WEATHER_TTL 文件缓存。
缓存未过期 → 直接复用,不发请求(每分钟刷新时省掉 14/15 次天气调用)。
过期 → 重新拉取并写缓存;全部接口失败时沿用上次缓存,仍无缓存才回退占位 "--"
"""
ts = now.timestamp()
cached = _read_weather_cache()
if cached and ts - cached.get("ts", 0) < config.WEATHER_TTL:
return cached["data"]
out = {"loc": config.WEATHER_LOC_NAME, "cond": "--", "temp": "--", "range": "--", "icon": ""}
ok = False
try:
w = weather_api.get_now()
out["cond"] = w.get("text") or "--"
out["temp"] = f"{w['temp']}°"
out["icon"] = w.get("icon") or "" # 和风图标代码,供 renderer 映射图标
ok = True
except Exception as e:
print(f"[警告] 实时天气拉取失败:{e}")
try:
today = weather_api.get_today()
out["range"] = f"{today['tempMin']}°~{today['tempMax']}°"
ok = True
except Exception as e:
print(f"[警告] 今日温区拉取失败:{e}")
if ok:
_write_weather_cache(ts, out)
return out
if cached: # 全部失败:沿用上次缓存,避免天气区整片 "--"
print("[警告] 天气全部拉取失败,沿用上次缓存")
return cached["data"]
return out
# ---------- 格式化 ----------
def _fmt_count(n):
return f"{int(n):,}" # 千分位,如 1,284
def _fmt_money(yuan):
"""元金额 → 紧凑展示≥1万显示「X.XX万」(两位小数),否则取整元。"""
v = float(yuan)
return f"{v / 10000:.2f}" if abs(v) >= 10000 else f"{v:.0f}"
def _fmt_rate(rate):
"""增长率字符串 → (方向, 文本)。null/空 → (None, '--'),方向 None 表示不画三角。
数值统一两位小数;整数部分不足两位用前导 0 补齐(6.5→06.50),便于环比/同比上下对齐;
三/四位数(≥100%)原样完整输出不截断。"""
if rate is None or rate == "":
return (None, "--")
v = float(rate)
return ("down" if v < 0 else "up", f"{abs(v):05.2f}%")
def _col(k, v, wow, yoy):
return {"k": k, "v": v, "hb": _fmt_rate(wow), "tb": _fmt_rate(yoy)}
def _placeholder_col(k):
return {"k": k, "v": "--", "hb": (None, "--"), "tb": (None, "--")}
# ---------- 橘喵业务数据 ----------
def _mao(now):
upd = now.strftime("%H:%M")
# 今日经营realtime
try:
rt = jm_api.get_realtime()
upd = (rt.get("dataTime") or "")[11:16] or upd
cols = [
_col("单量", _fmt_count(rt["todayOrderCount"]), rt["orderCountWowRate"], rt["orderCountYoyRate"]),
_col("流水", _fmt_money(rt["todayPayAmount"]), rt["payAmountWowRate"], rt["payAmountYoyRate"]),
_col("毛利", _fmt_money(rt["todayGrossProfit"]), rt["grossProfitWowRate"], rt["grossProfitYoyRate"]),
]
except Exception as e:
print(f"[警告] 今日经营拉取失败:{e}")
cols = [_placeholder_col(k) for k in ("单量", "流水", "毛利")]
# 近30天趋势按日期缓存每天只拉一次宽版另用其最后 7 天做「近7天」数据表
trend = _trend(now)
return {"upd": upd, "cols": cols, "trend": trend, "week": _recent_days(trend, 7)}
def _recent_days(trend, n):
"""趋势的最后 n 天 → 表格数据 {dates:[MM/DD…], rows:[(名, [文本…], 合计文本)]}供宽版「橘喵·近7天」表使用。
recent-days 不含今日,故表头日期到昨天为止。单量按千分位、流水/毛利按紧凑金额格式;无数据返回空 dict。"""
dates, series = trend.get("dates") or [], trend.get("series") or []
if not dates or not series:
return {}
dates = dates[-n:]
rows = []
for s in series:
data = (s.get("data") or [])[-n:]
if len(data) != len(dates):
continue
fmt = _fmt_count if s["k"] == "单量" else _fmt_money
rows.append((s["k"], [fmt(v) for v in data], fmt(sum(data))))
return {"dates": dates, "rows": rows}
# ---------- Claude Code 用量 ----------
def _parse_reset_ts(s):
"""resets_at → epoch 秒。兼容三种epoch 数字Claude Code stdin 给的就是 epoch
ISO 时间串(含结尾 'Z')、空。失败返回 None。"""
if s is None or s == "":
return None
s = str(s)
if s.replace(".", "", 1).isdigit(): # 纯数字 → 当作 epoch 秒
return float(s)
try:
return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
except Exception:
return None
def _window_time_pct(resets_at, window, now):
"""该滚动窗口「已流逝时间百分比」= (window 距重置剩余) / window ×100。失败返回 None。"""
reset = _parse_reset_ts(resets_at)
if reset is None:
return None
remaining = max(0, min(window, reset - now.timestamp()))
return int(round((window - remaining) / window * 100))
def _fmt_reset(resets_at, window):
"""重置时刻 → 文本≤1 天的窗口只给 HH:MM更长的窗口带 MM/DD。解析失败返回 None。"""
ts = _parse_reset_ts(resets_at)
if ts is None:
return None
dt = datetime.fromtimestamp(ts)
return dt.strftime("%H:%M") if window <= 86400 else dt.strftime("%m/%d %H:%M")
# Claude 用量窗口快照键短标签长标签窗口长度秒scoped
# 短标签 k 给 400x300 小屏(标签列只有 26px长标签 label 给 800x480 宽版,沿用 statusline 的写法
# all 5h / all 7d / fable 7d小写开头。scoped=True 为「按模型限定」的额度Fable 7d来自
# /api/oauth/usage 的 limits[],由 statusline 写入快照 seven_day_fable小屏放不下第三行只在宽版展示。
USAGE_WINDOWS = (
("five_hour", "5h", "all 5h", FIVE_HOUR, False),
("seven_day", "7d", "all 7d", SEVEN_DAY, False),
("seven_day_fable", "Fable 7d", "fable 7d", SEVEN_DAY, True),
)
def _usage(now):
"""Claude Code 用量5h / 7d / Fable 7d——只读本地 statusline 生产的快照(见 usage_local.py**不发任何请求**。
utilization 来自 statusline 从 Claude Code stdin 落盘的权威实时值Fable 行来自其转存的 API limits
time_pct(时间已流逝%) 与 pace 每次按当前时间**现算**resets_at 为绝对时间)。
文件缺失/损坏 → 回退占位 pct=Nonerenderer 显示 "--");快照里没有某窗口(如旧版 statusline 无 Fable同样 "--"
结构契约renderer 依赖):{title, warn, ok, pace_tol, updated?, bars:[{k, label, pct(0~100 或 None), time_pct?, reset?, scoped}]}
pct ≥ warn 时该条进度条与百分比转红(宽版另有 pct ≤ ok 绿档pace = pct time_pct>0 超前↑ / <0 节余↓,宽版按 ±pace_tol 分三色);
reset 为重置时刻文本updated 为快照落盘时刻 HH:MM活跃使用时持续刷新空闲时停在最后一次
"""
raw = {}
try:
raw = usage_local.get_usage()
except Exception as e:
print(f"[警告] Claude 用量读取失败:{e}")
bars = []
for key, k, label, win, scoped in USAGE_WINDOWS:
seg = raw.get(key) or {}
util = seg.get("utilization")
pct = int(round(float(util))) if util is not None else None
bar = {"k": k, "label": label, "pct": pct, "scoped": scoped}
tp = _window_time_pct(seg.get("resets_at"), win, now)
if pct is not None and tp is not None:
bar["time_pct"] = tp
rs = _fmt_reset(seg.get("resets_at"), win)
if rs:
bar["reset"] = rs
bars.append(bar)
out = {"title": "Claude Usage", "warn": config.USAGE_WARN, "ok": config.USAGE_OK, "pace_tol": config.PACE_TOL, "bars": bars}
upd = _parse_reset_ts(raw.get("updated_at"))
if upd:
out["updated"] = datetime.fromtimestamp(upd).strftime("%H:%M")
return out
def _usage_gpt(now):
"""ChatGPT 用量——**目前为 mock 占位**mock=Truerenderer 在标题右侧标「示例数据」)。
结构与 _usage 完全一致,后续接入真实数据源时只需替换本函数、保持返回结构不变。
mock 取值刻意覆盖宽版三档配色5h 88%≥warn 红、7d 71%、Codex 7d 22%≤ok 绿scoped与左侧 fable 行对称)。"""
bars = []
for k, label, pct, win, remain, scoped in (("5h", "all 5h", 88, FIVE_HOUR, 2 * 3600, False),
("7d", "all 7d", 71, SEVEN_DAY, 3 * 86400, False),
("Codex 7d", "codex 7d", 22, SEVEN_DAY, 3 * 86400, True)):
reset_ts = now.timestamp() + remain
bars.append({"k": k, "label": label, "pct": pct, "scoped": scoped,
"time_pct": _window_time_pct(reset_ts, win, now),
"reset": _fmt_reset(reset_ts, win)})
return {"title": "ChatGPT Usage", "warn": config.USAGE_WARN, "ok": config.USAGE_OK, "pace_tol": config.PACE_TOL,
"bars": bars, "mock": True}
def get_dashboard_data(now=None):
"""返回渲染所需的完整数据字典。"""
now = now or datetime.now()
return {
"date": {
"greg": now.strftime("%Y/%m/%d"),
"week": WEEK_CN[now.weekday()],
"time": now.strftime("%H:%M"),
},
"weather": _weather(now),
"mao": _mao(now),
"usage": _usage(now),
"usage_gpt": _usage_gpt(now),
}