Compare commits

..
10 Commits
Author SHA1 Message Date
earthjasonlin a7d9836d1c fix(ui): 阈值虚线贯穿整图(数据与 x 轴等长) 之前只给两个点,虚线只出现在最左两个刻度之间 2026-08-05 10:07:38 +08:00
earthjasonlin c2308d5c1b fix(ui): 修复 Chart.js 卡片图无限变高(响应式尺寸反馈循环) - canvas 外包固定高度 .chart-box,canvas 绝对定位 inset:0 - 容器高度不再受 canvas 内联尺寸影响,resize 循环终止 2026-08-05 10:04:19 +08:00
earthjasonlin 1cdb5c3aef feat(ui): 卡片迷你图改用 Chart.js,历史弹窗改为纯表格 - 新增 app/static/vendor/chart.umd.min.js(Chart.js 4.4.3 离线内嵌) - 卡片折线图:Chart.js line(无坐标轴、线色随状态、阈值虚线、动画关闭避免刷新闪烁) - renderAccounts 重绘前销毁旧 Chart 实例防泄漏 - 历史弹窗:移除图表,仅表格(时间/余额,sticky 表头,hover 高亮) 2026-08-05 10:01:54 +08:00
earthjasonlin 19bf8bfb38 feat(ui): 内置品牌图标库,badge 显示真实平台图标 - 新增 app/static/icons.js:17 个品牌 SVG 离线内嵌 (simple-icons CC0 + tabler MIT:OpenAI/Anthropic/Claude/DeepSeek/OpenRouter/ Google/Gemini/Meta/Perplexity/HuggingFace/Replicate/Baidu/MiniMax/NVIDIA/Qwen/Ollama/LMStudio) - badge 渲染:有图标显示白色品牌图形,无图标回落品牌色缩写 2026-08-05 08:05:58 +08:00
earthjasonlin 194abb35c1 feat(provider): 新增 OpenRouter 平台适配器 - GET /api/v1/credits,Bearer 认证 - 余额 = data.total_credits - data.total_usage(剩余可用) - 重启后自动同步为平台记录 2026-08-05 07:46:19 +08:00
earthjasonlin dd468f8576 fix(ui): 修复无动画模式卡片消失(no-anim 显式置为最终态) animation:none 会停住动画但保留 opacity:0 基础样式;改用 no-anim 类显式 opacity:1/transform:none 2026-08-05 07:43:16 +08:00
earthjasonlin b31a2ef382 fix(ui): 自动刷新时静默更新卡片,不再重播进入动画导致闪烁 - renderAccounts/renderPlatforms 增加 animate 参数 - refresh 自动轮询传 false(无动画),首次渲染与用户操作保留 stagger 动画 2026-08-05 07:41:23 +08:00
earthjasonlin 157753d1a4 feat: 平台自动同步代码内置 provider,移除手动添加/删除平台 - 启动时 sync_platforms:注册表中的 provider 自动生成为平台记录 - 前端移除添加平台按钮与删除入口,平台列表只读 + 停用/启用 + 监控参数编辑 - 平台编辑表单仅保留间隔/重试/超时/备注(提供方锁定) - 测试更新:内置平台自动同步用例,共 93 个 2026-08-05 07:37:30 +08:00
earthjasonlin 40d4e49e42 refactor: 平台改为代码内置 provider 架构,一次性迁移现有数据 - 新增 app/providers:BalanceProvider 基类 + DeepSeek 适配器 + 注册表 - platforms 表去除 method/url/headers/body/balance_path,新增 provider_id - 现有数据库已一次性迁移(备份 data/monitor.db.bak-v1),不保留迁移工具 - 平台 UI 改为选择内置提供方;fetcher/monitor 走 provider 构建请求与解析 - 表达式引擎(运算符/函数)随架构保留,供 provider 内部使用 - 测试更新至 provider 模式,共 92 个 2026-08-05 00:56:22 +08:00
earthjasonlin b64856f3c3 feat: 账号卡片余额趋势折线图 + 时间窗口切换(all/1h/1d/1w/1mo/1yr) - api: history 接口支持 window 参数与窗口内均匀降采样;新增批量 /api/history - ui: 阈值进度条替换为 SVG 迷你折线图(状态色 + 阈值虚线),卡片级窗口切换器 - test: 窗口过滤、降采样、批量历史用例,共 54 个 2026-08-05 00:32:22 +08:00
21 changed files with 1450 additions and 232 deletions

No files matched your search

+112 -33
View File
@@ -3,9 +3,9 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import json
import logging import logging
import secrets import secrets
import sqlite3
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@@ -26,6 +26,7 @@ from app.models import (
SettingsUpdate, SettingsUpdate,
) )
from app.monitor import Monitor from app.monitor import Monitor
from app.providers import get_provider, list_providers as list_providers_svc
logger = logging.getLogger("monitor.api") logger = logging.getLogger("monitor.api")
@@ -65,9 +66,42 @@ def require_auth(authorization: str | None = Header(default=None)) -> None:
raise HTTPException(status_code=401, detail="登录已失效") raise HTTPException(status_code=401, detail="登录已失效")
WINDOW_OFFSETS = {
"1h": "-1 hour",
"1d": "-1 day",
"1w": "-7 days",
"1mo": "-1 month",
"1yr": "-1 year",
}
def _window_time(window: str) -> str | None:
"""把窗口键转成 SQLite 时间边界(localtime 字符串)。未知窗口返回 None。"""
offset = WINDOW_OFFSETS.get(window)
if offset is None:
return None
with db.get_conn() as conn:
row = conn.execute(
"SELECT datetime('now', 'localtime', ?) AS t", (offset,)
).fetchone()
return row["t"]
def _downsample(rows: list[sqlite3.Row], limit: int) -> list[sqlite3.Row]:
"""按索引均匀抽样到 limit 条,保证首尾尽量覆盖。"""
if len(rows) <= limit:
return rows
step = len(rows) / limit
picked = [rows[int(i * step)] for i in range(limit)]
if picked[-1] is not rows[-1]:
picked[-1] = rows[-1]
return picked
def create_app(cfg: Config | None = None) -> FastAPI: def create_app(cfg: Config | None = None) -> FastAPI:
cfg = cfg or load_config() cfg = cfg or load_config()
db.init_db() db.init_db()
db.sync_platforms()
monitor = Monitor(cfg) monitor = Monitor(cfg)
@asynccontextmanager @asynccontextmanager
@@ -97,36 +131,40 @@ def create_app(cfg: Config | None = None) -> FastAPI:
# ---------- 平台 ---------- # ---------- 平台 ----------
@app.get("/api/providers", dependencies=[Depends(require_auth)])
def list_providers():
"""代码内置的平台适配器列表(新增平台 = 代码扩展)。"""
return list_providers_svc()
@app.get("/api/platforms", dependencies=[Depends(require_auth)]) @app.get("/api/platforms", dependencies=[Depends(require_auth)])
def list_platforms(): def list_platforms():
with db.get_conn() as conn: with db.get_conn() as conn:
rows = conn.execute( rows = conn.execute(
"""SELECT p.*, (SELECT COUNT(*) FROM accounts a WHERE a.platform_id = p.id) AS account_count """SELECT p.id, p.name, p.currency, p.icon, p.provider_id,
p.interval_seconds, p.retry_count, p.timeout_seconds,
p.enabled, p.note, p.created_at, p.updated_at,
(SELECT COUNT(*) FROM accounts a WHERE a.platform_id = p.id) AS account_count
FROM platforms p ORDER BY p.id""" FROM platforms p ORDER BY p.id"""
).fetchall() ).fetchall()
result = [] return [dict(r) for r in rows]
for r in rows:
d = dict(r)
try:
d["headers"] = json.loads(d["headers"]) if d["headers"] else {}
except (ValueError, TypeError):
d["headers"] = {}
result.append(d)
return result
@app.post("/api/platforms", dependencies=[Depends(require_auth)]) @app.post("/api/platforms", dependencies=[Depends(require_auth)])
def create_platform(body: PlatformCreate): def create_platform(body: PlatformCreate):
provider = get_provider(body.provider_id)
if provider is None:
raise HTTPException(status_code=400, detail=f"不支持的平台提供方: {body.provider_id}")
currency = body.currency or provider.currency
icon = body.icon or provider.icon
try: try:
with db.get_conn() as conn: with db.get_conn() as conn:
cur = conn.execute( cur = conn.execute(
"""INSERT INTO platforms (name, currency, icon, method, url, headers, body, """INSERT INTO platforms (provider_id, name, currency, icon,
balance_path, interval_seconds, retry_count, timeout_seconds, enabled, note) interval_seconds, retry_count, timeout_seconds, enabled, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
( (
body.name, body.currency, body.icon, body.method, body.url, body.provider_id, body.name, currency, icon,
__import__("json").dumps(body.headers, ensure_ascii=False), body.interval_seconds, body.retry_count, body.timeout_seconds,
body.body, body.balance_path, body.interval_seconds, int(body.enabled), body.note,
body.retry_count, body.timeout_seconds, int(body.enabled), body.note,
), ),
) )
pid = cur.lastrowid pid = cur.lastrowid
@@ -139,22 +177,22 @@ def create_app(cfg: Config | None = None) -> FastAPI:
@app.put("/api/platforms/{pid}", dependencies=[Depends(require_auth)]) @app.put("/api/platforms/{pid}", dependencies=[Depends(require_auth)])
def update_platform(pid: int, body: PlatformUpdate): def update_platform(pid: int, body: PlatformUpdate):
fields = {} fields = {}
if body.provider_id is not None:
provider = get_provider(body.provider_id)
if provider is None:
raise HTTPException(status_code=400, detail=f"不支持的平台提供方: {body.provider_id}")
fields["provider_id"] = body.provider_id
# 换 provider 时若未显式给货币/图标,则重置为 provider 默认
if "currency" not in body.model_fields_set:
fields["currency"] = provider.currency
if "icon" not in body.model_fields_set:
fields["icon"] = provider.icon
if body.name is not None: if body.name is not None:
fields["name"] = body.name fields["name"] = body.name
if body.currency is not None: if body.currency is not None:
fields["currency"] = body.currency fields["currency"] = body.currency
if body.icon is not None: if body.icon is not None:
fields["icon"] = body.icon fields["icon"] = body.icon
if body.method is not None:
fields["method"] = body.method
if body.url is not None:
fields["url"] = body.url
if body.headers is not None:
fields["headers"] = __import__("json").dumps(body.headers, ensure_ascii=False)
if body.body is not None:
fields["body"] = body.body
if body.balance_path is not None:
fields["balance_path"] = body.balance_path
if body.interval_seconds is not None: if body.interval_seconds is not None:
fields["interval_seconds"] = body.interval_seconds fields["interval_seconds"] = body.interval_seconds
if body.retry_count is not None: if body.retry_count is not None:
@@ -279,14 +317,55 @@ def create_app(cfg: Config | None = None) -> FastAPI:
return {"ok": True} return {"ok": True}
@app.get("/api/accounts/{aid}/history", dependencies=[Depends(require_auth)]) @app.get("/api/accounts/{aid}/history", dependencies=[Depends(require_auth)])
def account_history(aid: int, limit: int = Query(default=30, ge=1, le=200)): def account_history(
aid: int,
limit: int = Query(default=120, ge=2, le=500),
window: str = Query(default="all", pattern="^(all|1h|1d|1w|1mo|1yr)$"),
):
where = "account_id=?"
params: list = [aid]
t0 = _window_time(window)
if t0 is not None:
where += " AND checked_at >= ?"
params.append(t0)
with db.get_conn() as conn: with db.get_conn() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT balance, checked_at FROM balance_history WHERE account_id=? " f"SELECT balance, checked_at FROM balance_history WHERE {where} ORDER BY id",
"ORDER BY id DESC LIMIT ?", params,
(aid, limit),
).fetchall() ).fetchall()
return [dict(r) for r in reversed(rows)] return [
{"balance": r["balance"], "checked_at": r["checked_at"]}
for r in _downsample(rows, limit)
]
@app.get("/api/history", dependencies=[Depends(require_auth)])
def all_history(
limit: int = Query(default=30, ge=2, le=200),
window: str = Query(default="all", pattern="^(all|1h|1d|1w|1mo|1yr)$"),
):
"""批量返回所有账号的余额历史(窗口内均匀降采样),供监控页趋势图一次拉取。"""
where = "1=1"
params: list = []
t0 = _window_time(window)
if t0 is not None:
where += " AND checked_at >= ?"
params.append(t0)
with db.get_conn() as conn:
rows = conn.execute(
f"""SELECT account_id, balance, checked_at FROM (
SELECT h.*, ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY id) AS rn,
COUNT(*) OVER (PARTITION BY account_id) AS total
FROM balance_history h WHERE {where}
) WHERE rn = 1 OR rn = total OR rn % MAX(1, total / ?) = 0
ORDER BY account_id, rn""",
(*params, max(limit, 1)),
).fetchall()
result: dict[int, list] = {}
for r in rows:
result.setdefault(r["account_id"], []).append(
{"balance": r["balance"], "checked_at": r["checked_at"]}
)
return result
# ---------- 设置 ---------- # ---------- 设置 ----------
+26 -5
View File
@@ -11,14 +11,10 @@ DB_PATH = DATA_DIR / "monitor.db"
SCHEMA = """ SCHEMA = """
CREATE TABLE IF NOT EXISTS platforms ( CREATE TABLE IF NOT EXISTS platforms (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
currency TEXT NOT NULL DEFAULT 'USD', currency TEXT NOT NULL DEFAULT 'USD',
icon TEXT NOT NULL DEFAULT '', icon TEXT NOT NULL DEFAULT '',
method TEXT NOT NULL DEFAULT 'GET',
url TEXT NOT NULL,
headers TEXT NOT NULL DEFAULT '{}',
body TEXT NOT NULL DEFAULT '',
balance_path TEXT NOT NULL,
interval_seconds INTEGER, interval_seconds INTEGER,
retry_count INTEGER, retry_count INTEGER,
timeout_seconds INTEGER, timeout_seconds INTEGER,
@@ -79,5 +75,30 @@ def init_db() -> None:
conn.executescript(SCHEMA) conn.executescript(SCHEMA)
def sync_platforms() -> None:
"""把代码内置的 provider 自动同步为平台记录(缺失时创建)。
平台 = 代码注册表,无需用户手动添加;删除也会在下一次启动时重建。
"""
from app.providers import list_providers
with get_conn() as conn:
existing = {
r["provider_id"]
for r in conn.execute(
"SELECT provider_id FROM platforms WHERE provider_id != ''"
).fetchall()
}
for p in list_providers():
if p["id"] in existing:
continue
conn.execute(
"INSERT INTO platforms (provider_id, name, currency, icon, enabled) "
"VALUES (?, ?, ?, ?, 1)",
(p["id"], p["name"], p["currency"], p["icon"]),
)
existing.add(p["id"])
def rows_to_dicts(rows: list[sqlite3.Row]) -> list[dict]: def rows_to_dicts(rows: list[sqlite3.Row]) -> list[dict]:
return [dict(r) for r in rows] return [dict(r) for r in rows]
+236
View File
@@ -0,0 +1,236 @@
"""受限的余额表达式引擎。
在 JSON 提取路径基础上支持:
- 运算符:+ - * / // % ** 一元负号、括号
- 函数(白名单):float / int / abs / round / min / max / sum / len
- 路径写法与原来完全兼容:data.balance、data[0].x、$.data[0].x
示例:
data.balance / 100 # 分转元
float(data.balance) # 字符串转数字
data.granted + data.topped_up # 多字段求和
sum(data[0].balances) # 数组求和
round(data.balance, 2) * 0.9
安全性:无 eval、无任意变量访问,函数名严格白名单,路径只支持
dict 键 / list 索引访问。
"""
from __future__ import annotations
import re
_PATH_SEG_RE = re.compile(r"([^.\[]+)|\[(-?\d+)\]")
class ExprError(ValueError):
"""表达式语法错误 / 求值错误。"""
# ---------- 路径提取(返回原始值,不强制数字) ----------
def extract_path(data: object, path: str) -> object:
p = path.strip()
if p.startswith("$"):
p = p[1:]
cur: object = data
for key, idx in _PATH_SEG_RE.findall(p):
if key:
if not isinstance(cur, dict) or key not in cur:
raise ExprError(f"路径不存在: {path}(在 {key!r} 处)")
cur = cur[key]
if idx != "":
n = int(idx)
if not isinstance(cur, list) or n >= len(cur):
raise ExprError(f"数组索引越界: {path}(索引 {n}")
cur = cur[n]
return cur
def to_number(value: object, src: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
raise ExprError(f"余额不是数字: {src} -> {value!r}")
try:
return float(value)
except (TypeError, ValueError):
raise ExprError(f"余额不是数字: {src} -> {value!r}")
# ---------- 词法 ----------
_TOKEN_RE = re.compile(r"""
(?P<num>\d+(?:\.\d+)?)
| (?P<path>\$?\.?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*|\[-?\d+\])*)
| (?P<op>//|\*\*|[+\-*/%(),])
| (?P<ws>\s+)
""", re.VERBOSE)
def _tokenize(text: str) -> list[tuple[str, str]]:
tokens: list[tuple[str, str]] = []
pos = 0
for m in _TOKEN_RE.finditer(text):
if m.start() != pos:
raise ExprError(f"表达式语法错误(第 {pos + 1} 字符附近): {text!r}")
pos = m.end()
kind = m.lastgroup
if kind == "ws":
continue
val = m.group()
if kind == "path" and text[m.end():m.end() + 1] == "(" and "." not in val and "[" not in val:
tokens.append(("FUNC", val))
else:
tokens.append((kind.upper(), val))
if pos != len(text):
raise ExprError(f"表达式语法错误(第 {pos + 1} 字符附近): {text!r}")
tokens.append(("EOF", ""))
return tokens
# ---------- 函数白名单 ----------
def _call(name: str, args: list) -> object:
if name == "float":
_need_arity(name, args, 1)
return to_number(args[0], name)
if name == "int":
_need_arity(name, args, 1)
return int(to_number(args[0], name))
if name == "abs":
_need_arity(name, args, 1)
return abs(to_number(args[0], name))
if name == "round":
if len(args) == 1:
return round(to_number(args[0], name))
if len(args) == 2:
return round(to_number(args[0], name), int(args[1]))
raise ExprError("round() 需要 1 或 2 个参数")
if name == "len":
_need_arity(name, args, 1)
if not isinstance(args[0], (list, dict, str)):
raise ExprError("len() 参数必须是数组/对象/字符串")
return len(args[0])
if name == "sum":
if len(args) == 1 and isinstance(args[0], list):
return sum(to_number(v, name) for v in args[0])
if not args:
raise ExprError("sum() 至少需要 1 个参数")
return sum(to_number(v, name) for v in args)
if name == "min":
values = args[0] if len(args) == 1 and isinstance(args[0], list) else args
if not values:
raise ExprError("min() 参数不能为空")
return min(to_number(v, name) for v in values)
if name == "max":
values = args[0] if len(args) == 1 and isinstance(args[0], list) else args
if not values:
raise ExprError("max() 参数不能为空")
return max(to_number(v, name) for v in values)
raise ExprError(f"不支持的函数: {name}()(可用: float/int/abs/round/min/max/sum/len")
def _need_arity(name: str, args: list, n: int) -> None:
if len(args) != n:
raise ExprError(f"{name}() 需要 {n} 个参数,实际 {len(args)}")
# ---------- 语法分析 + 求值(递归下降,直接求值) ----------
class _Evaluator:
def __init__(self, data: object, text: str) -> None:
self.data = data
self.tokens = _tokenize(text)
self.pos = 0
def _peek(self) -> tuple[str, str]:
return self.tokens[self.pos]
def _next(self) -> tuple[str, str]:
tok = self.tokens[self.pos]
self.pos += 1
return tok
def _accept_op(self, *ops: str) -> tuple[str, str] | None:
kind, val = self._peek()
if kind == "OP" and val in ops:
return self._next()
return None
def _expect_op(self, op: str) -> None:
kind, val = self._next()
if kind != "OP" or val != op:
raise ExprError(f"期望 {op!r},实际 {val!r}")
def evaluate(self) -> float:
value = self._expr()
if self._peek() != ("EOF", ""):
raise ExprError(f"表达式多余内容: {self._peek()!r}")
return to_number(value, "表达式")
def _expr(self) -> object:
v = self._term()
while (op := self._accept_op("+", "-")) is not None:
rhs = self._term()
v = v + rhs if op[1] == "+" else v - rhs
return v
def _term(self) -> object:
v = self._factor()
while (op := self._accept_op("*", "/", "//", "%")) is not None:
rhs = self._factor()
if op[1] == "*":
v = v * rhs
elif op[1] == "/":
v = v / rhs
elif op[1] == "//":
v = v // rhs
else:
v = v % rhs
return v
def _factor(self) -> object:
v = self._unary()
if self._accept_op("**") is not None:
rhs = self._factor() # 右结合
v = v ** rhs
return v
def _unary(self) -> object:
if (op := self._accept_op("-", "+")) is not None:
v = self._unary()
return -v if op[1] == "-" else v
return self._primary()
def _primary(self) -> object:
kind, val = self._peek()
if kind == "NUM":
self._next()
return float(val) if "." in val else int(val)
if kind == "PATH":
self._next()
return extract_path(self.data, val)
if kind == "FUNC":
return self._call()
if kind == "OP" and val == "(":
self._next()
v = self._expr()
self._expect_op(")")
return v
raise ExprError(f"表达式语法错误: 意外的 {kind} {val!r}")
def _call(self) -> object:
name = self._next()[1]
self._expect_op("(")
args: list = []
if not (self._peek()[0] == "OP" and self._peek()[1] == ")"):
while True:
args.append(self._expr())
if self._accept_op(",") is None:
break
self._expect_op(")")
return _call(name, args)
def evaluate_balance(data: object, expression: str) -> float:
"""对响应 JSON 求值余额表达式,返回 float。失败抛 ExprError。"""
return _Evaluator(data, expression).evaluate()
+21 -47
View File
@@ -2,18 +2,18 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import re
import time import time
from dataclasses import dataclass from dataclasses import dataclass
import requests import requests
from app.expr import extract_path, to_number
from app.providers.base import BalanceProvider
logger = logging.getLogger("monitor.fetcher") logger = logging.getLogger("monitor.fetcher")
PLACEHOLDER = "{{apiKey}}" PLACEHOLDER = "{{apiKey}}"
_TOKEN_RE = re.compile(r"([^.\[]+)|\[(\d+)\]")
@dataclass @dataclass
@@ -33,57 +33,31 @@ def render_template(text: str, api_key: str) -> str:
def extract_balance(data: object, path: str) -> float: def extract_balance(data: object, path: str) -> float:
"""按点路径/数组索引提取余额,如 data.balance、data[0].balance、$.data[0].balance """按点路径/数组索引提取余额(兼容旧写法,返回 float
取到的值必须是数字或可转数字的字符串,否则抛 ValueError 新写法请用 evaluate_balance(支持运算符与函数)
""" """
p = path.strip() return to_number(extract_path(data, path), path)
if p.startswith("$"):
p = p[1:]
cur: object = data
for key, idx in _TOKEN_RE.findall(p):
if key:
if not isinstance(cur, dict) or key not in cur:
raise ValueError(f"路径不存在: {path}(在 {key!r} 处)")
cur = cur[key]
if idx != "":
n = int(idx)
if not isinstance(cur, list) or n >= len(cur):
raise ValueError(f"数组索引越界: {path}(索引 {n}")
cur = cur[n]
if isinstance(cur, bool) or not isinstance(cur, (int, float, str)):
raise ValueError(f"余额不是数字: {path} -> {cur!r}")
try:
return float(cur)
except (TypeError, ValueError):
raise ValueError(f"余额不是数字: {path} -> {cur!r}")
def _build_request(platform: dict, api_key: str) -> tuple[str, dict, str | None]: def fetch_balance(
url = render_template(platform["url"], api_key) provider: BalanceProvider,
headers_raw = platform.get("headers") or {} api_key: str,
if isinstance(headers_raw, str): retry_count: int,
headers_raw = json.loads(headers_raw) if headers_raw.strip() else {} timeout: int,
headers = json.loads(render_template(json.dumps(headers_raw), api_key)) ) -> FetchResult:
body = None
if platform["method"] == "POST" and platform.get("body"):
body = render_template(platform["body"], api_key)
if not any(k.lower() == "content-type" for k in headers):
headers["Content-Type"] = "application/json"
return url, headers, body
def fetch_balance(platform: dict, api_key: str, retry_count: int, timeout: int) -> FetchResult:
"""执行一次余额拉取。 """执行一次余额拉取。
策略: 策略:
- 401/403 → auth_error不重试,由调用方禁用账号并通知) - 401/403 → 先按重试次数确认(可能瞬时),仍失败 → auth_error(调用方禁用账号并通知)
- 其他 4xx → 直接失败(配置问题,不重试) - 其他 4xx → 直接失败(不重试)
- 网络异常 / 5xx → 重试 retry_count 次,间隔 2s - 网络异常 / 5xx → 重试 retry_count 次,间隔 2s
- JSON 解析/路径提取失败 → 直接失败 - JSON 解析/余额提取失败 → 直接失败
""" """
url, headers, body = _build_request(platform, api_key) url, headers, body = provider.build_request(api_key)
method = platform["method"] method = provider.method
if method == "POST" and body and not any(k.lower() == "content-type" for k in headers):
headers["Content-Type"] = "application/json"
attempts = retry_count + 1 attempts = retry_count + 1
for attempt in range(attempts): for attempt in range(attempts):
try: try:
@@ -118,8 +92,8 @@ def fetch_balance(platform: dict, api_key: str, retry_count: int, timeout: int)
return FetchResult(ok=False, status_code=resp.status_code, return FetchResult(ok=False, status_code=resp.status_code,
error="响应不是合法 JSON") error="响应不是合法 JSON")
try: try:
balance = extract_balance(data, platform["balance_path"]) balance = provider.extract_balance(data)
except ValueError as exc: except Exception as exc:
return FetchResult(ok=False, status_code=resp.status_code, error=str(exc)) return FetchResult(ok=False, status_code=resp.status_code, error=str(exc))
return FetchResult(ok=True, balance=balance, status_code=resp.status_code) return FetchResult(ok=True, balance=balance, status_code=resp.status_code)
return FetchResult(ok=False, error=f"网络错误,重试 {retry_count} 次后仍失败") return FetchResult(ok=False, error=f"网络错误,重试 {retry_count} 次后仍失败")
+5 -13
View File
@@ -8,14 +8,10 @@ from pydantic import BaseModel, Field, model_validator
# ---------- 平台 ---------- # ---------- 平台 ----------
class PlatformBase(BaseModel): class PlatformBase(BaseModel):
provider_id: str = Field(min_length=1, max_length=64)
name: str = Field(min_length=1, max_length=64) name: str = Field(min_length=1, max_length=64)
currency: str = Field(default="USD", min_length=1, max_length=16) currency: str | None = Field(default=None, min_length=1, max_length=16)
icon: str = Field(default="", max_length=64) icon: str | None = Field(default=None, max_length=64)
method: str = Field(default="GET", pattern="^(GET|POST)$")
url: str = Field(min_length=1)
headers: dict = Field(default_factory=dict)
body: str = Field(default="")
balance_path: str = Field(min_length=1)
interval_seconds: int | None = Field(default=None, ge=10) interval_seconds: int | None = Field(default=None, ge=10)
retry_count: int | None = Field(default=None, ge=0, le=10) retry_count: int | None = Field(default=None, ge=0, le=10)
timeout_seconds: int | None = Field(default=None, ge=1, le=120) timeout_seconds: int | None = Field(default=None, ge=1, le=120)
@@ -28,14 +24,10 @@ class PlatformCreate(PlatformBase):
class PlatformUpdate(BaseModel): class PlatformUpdate(BaseModel):
provider_id: str | None = Field(default=None, min_length=1, max_length=64)
name: str | None = Field(default=None, min_length=1, max_length=64) name: str | None = Field(default=None, min_length=1, max_length=64)
currency: str | None = Field(default=None, min_length=1, max_length=16) currency: str | None = Field(default=None, max_length=16)
icon: str | None = Field(default=None, max_length=64) icon: str | None = Field(default=None, max_length=64)
method: str | None = Field(default=None, pattern="^(GET|POST)$")
url: str | None = Field(default=None, min_length=1)
headers: dict | None = None
body: str | None = None
balance_path: str | None = Field(default=None, min_length=1)
interval_seconds: int | None = Field(default=None, ge=10) interval_seconds: int | None = Field(default=None, ge=10)
retry_count: int | None = Field(default=None, ge=0, le=10) retry_count: int | None = Field(default=None, ge=0, le=10)
timeout_seconds: int | None = Field(default=None, ge=1, le=120) timeout_seconds: int | None = Field(default=None, ge=1, le=120)
+19 -8
View File
@@ -18,6 +18,7 @@ from app import db
from app.alert import evaluate, notify_disabled from app.alert import evaluate, notify_disabled
from app.config import Config from app.config import Config
from app.fetcher import fetch_balance from app.fetcher import fetch_balance
from app.providers import get_provider
logger = logging.getLogger("monitor.scheduler") logger = logging.getLogger("monitor.scheduler")
@@ -28,17 +29,15 @@ _ACCOUNT_FIELDS = [
"last_check_at", "note", "last_check_at", "note",
] ]
_PLATFORM_FIELDS = [ _PLATFORM_FIELDS = [
"platform_id", "platform_name", "currency", "icon", "method", "url", "platform_id", "provider_id", "platform_name", "currency", "icon",
"headers", "body", "balance_path", "interval_seconds", "interval_seconds", "retry_count", "timeout_seconds", "platform_enabled", "note",
"retry_count", "timeout_seconds", "platform_enabled", "note",
] ]
_ACCOUNT_SQL = """ _ACCOUNT_SQL = """
SELECT a.*, SELECT a.*,
p.name AS platform_name, p.currency, p.icon, p.method, p.url, p.provider_id, p.name AS platform_name, p.currency, p.icon,
p.headers, p.body, p.balance_path, p.interval_seconds, p.interval_seconds, p.retry_count, p.timeout_seconds,
p.retry_count, p.timeout_seconds, p.enabled AS platform_enabled, p.enabled AS platform_enabled, p.note AS platform_note
p.note AS platform_note
FROM accounts a JOIN platforms p ON p.id = a.platform_id FROM accounts a JOIN platforms p ON p.id = a.platform_id
""" """
@@ -138,13 +137,25 @@ class Monitor:
def _check_one(self, row: dict) -> None: def _check_one(self, row: dict) -> None:
cfg = self.cfg cfg = self.cfg
account, platform = _split(row) account, platform = _split(row)
provider = get_provider(platform["provider_id"])
if provider is None:
logger.warning("平台 %s 的 provider 不存在: %r", platform["name"], platform["provider_id"])
with db.get_conn() as conn:
conn.execute(
"UPDATE accounts SET last_status='error', last_error=?, "
"last_check_at=datetime('now','localtime') WHERE id=?",
(f"provider 不存在: {platform['provider_id']}", account["id"]),
)
with self._lock:
self._next_check[account["id"]] = time.time() + _account_interval(platform, cfg)
return
retry = platform.get("retry_count") retry = platform.get("retry_count")
if retry is None: if retry is None:
retry = cfg.retry_count retry = cfg.retry_count
timeout = platform.get("timeout_seconds") timeout = platform.get("timeout_seconds")
if timeout is None: if timeout is None:
timeout = cfg.timeout_seconds timeout = cfg.timeout_seconds
result = fetch_balance(platform, account["api_key"], int(retry), int(timeout)) result = fetch_balance(provider, account["api_key"], int(retry), int(timeout))
with db.get_conn() as conn: with db.get_conn() as conn:
if result.ok: if result.ok:
+30
View File
@@ -0,0 +1,30 @@
"""平台适配器注册表:新增平台 = 加一个文件 + 在这里登记。"""
from __future__ import annotations
from app.providers.base import BalanceProvider
from app.providers.deepseek import DeepSeekProvider
from app.providers.openrouter import OpenRouterProvider
PROVIDERS: dict[str, type[BalanceProvider]] = {
"deepseek": DeepSeekProvider,
"openrouter": OpenRouterProvider,
}
def get_provider(provider_id: str) -> BalanceProvider | None:
cls = PROVIDERS.get(provider_id)
return cls() if cls else None
def list_providers() -> list[dict]:
return [
{
"id": p.id,
"name": p.name,
"currency": p.currency,
"icon": p.icon,
"description": p.description,
}
for p in (cls() for cls in PROVIDERS.values())
]
+39
View File
@@ -0,0 +1,39 @@
"""平台适配器基类。
多平台支持通过继承 BalanceProvider 实现:
1. 新建 app/providers/<name>.py
2. 继承 BalanceProvider,实现 build_request / extract_balance
3. 在 __init__.py 的 PROVIDERS 注册表中登记
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class BalanceProvider(ABC):
"""一个平台的余额获取实现。"""
#: 唯一标识(存入 platforms.provider_id
id: str = ""
#: 平台显示名
name: str = ""
#: 默认货币单位
currency: str = "USD"
#: @lobehub/icons 键(前端品牌色映射)
icon: str = ""
#: 简要说明(前端展示)
description: str = ""
#: 请求方法
method: str = "GET"
@abstractmethod
def build_request(self, api_key: str) -> tuple[str, dict, str | None]:
"""根据 api_key 构建请求,返回 (url, headers, body)。
body 为 None 表示无请求体;POST 平台返回 JSON 字符串。
"""
@abstractmethod
def extract_balance(self, data: object) -> float:
"""从响应 JSON 中提取余额(数字),失败抛异常由上层记录。"""
+28
View File
@@ -0,0 +1,28 @@
"""DeepSeek 开放平台余额。
接口:GET https://api.deepseek.com/user/balance
响应:{"is_available": true, "balance_infos": [{"currency": "CNY", "total_balance": "1.34", ...}]}
"""
from __future__ import annotations
from app.expr import evaluate_balance
from app.providers.base import BalanceProvider
class DeepSeekProvider(BalanceProvider):
id = "deepseek"
name = "DeepSeek"
currency = "CNY"
icon = "DeepSeek"
description = "DeepSeek 开放平台余额(GET /user/balanceBearer 认证)"
def build_request(self, api_key: str) -> tuple[str, dict, str | None]:
return (
"https://api.deepseek.com/user/balance",
{"Accept": "application/json", "Authorization": f"Bearer {api_key}"},
None,
)
def extract_balance(self, data: object) -> float:
return evaluate_balance(data, "balance_infos[0].total_balance")
+29
View File
@@ -0,0 +1,29 @@
"""OpenRouter 余额。
接口:GET https://openrouter.ai/api/v1/credits
响应:{"data": {"total_credits": 100.5, "total_usage": 25.75}}
余额取剩余可用:total_credits - total_usage
"""
from __future__ import annotations
from app.expr import evaluate_balance
from app.providers.base import BalanceProvider
class OpenRouterProvider(BalanceProvider):
id = "openrouter"
name = "OpenRouter"
currency = "USD"
icon = "OpenRouter"
description = "OpenRouter 余额(GET /api/v1/creditsBearer 认证,剩余 = total_credits - total_usage"
def build_request(self, api_key: str) -> tuple[str, dict, str | None]:
return (
"https://openrouter.ai/api/v1/credits",
{"Authorization": f"Bearer {api_key}"},
None,
)
def extract_balance(self, data: object) -> float:
return evaluate_balance(data, "data.total_credits - data.total_usage")
+273 -90
View File
@@ -6,8 +6,23 @@
let token = localStorage.getItem(TOKEN_KEY) || ""; let token = localStorage.getItem(TOKEN_KEY) || "";
let accounts = []; let accounts = [];
let platforms = []; let platforms = [];
let providersList = [];
let settings = null; let settings = null;
let refreshTimer = null; let refreshTimer = null;
let filterStatus = "all";
let filterPlatform = 0;
const checkingIds = new Set();
let historyCache = {};
let hasRendered = false; // 首次渲染保留进入动画,自动刷新静默更新
const cardCharts = {}; // {accountId: Chart} 卡片迷你图实例
const accountWindows = {}; // {accountId: 'all'|'1h'|'1d'|'1w'|'1mo'|'1yr'}
const windowCache = {}; // {'aid:win': [...]}
const WINDOW_LABELS = {
all: "全部记录", "1h": "最近 1 小时", "1d": "最近 1 天",
"1w": "最近 1 周", "1mo": "最近 1 个月", "1yr": "最近 1 年",
};
const WINDOW_KEYS = ["all", "1h", "1d", "1w", "1mo", "1yr"];
/* ---------- 工具 ---------- */ /* ---------- 工具 ---------- */
@@ -54,6 +69,15 @@
return s; return s;
} }
/* 内联 SVG 小图标(lucide 风格描边) */
const ICONS = {
refresh: '<svg viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>',
clock: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
pencil: '<svg viewBox="0 0 24 24"><path d="M17 3l4 4L8 20l-5 1 1-5L17 3z"/></svg>',
trash: '<svg viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M6 6l1 14h10l1-14"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>',
alert: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>',
};
/* 容错 JSON 解析:允许用户写 Python 风格的单引号;失败返回 null */ /* 容错 JSON 解析:允许用户写 Python 风格的单引号;失败返回 null */
function parseJsonInput(text, label, errorId) { function parseJsonInput(text, label, errorId) {
let s = (text || "").trim(); let s = (text || "").trim();
@@ -106,6 +130,15 @@
return { color, abbr }; return { color, abbr };
} }
/* 品牌图标 badge:有内嵌 SVG 显示真实图标,否则品牌色 + 缩写 */
function badgeHtml(iconKey, color, abbr) {
const svg = BRAND_ICONS[iconKey];
if (!svg) {
return `<span class="badge" style="background:${color}">${escapeHtml(abbr)}</span>`;
}
return `<span class="badge" style="background:${color}"><span class="badge-icon">${svg}</span></span>`;
}
/* ---------- 视图/状态 ---------- */ /* ---------- 视图/状态 ---------- */
function switchView(name) { function switchView(name) {
@@ -120,11 +153,16 @@
async function refresh() { async function refresh() {
try { try {
const [acc, plat] = await Promise.all([api("/accounts"), api("/platforms")]); const [acc, plat, hist, provs] = await Promise.all([
api("/accounts"), api("/platforms"), api("/history"), api("/providers"),
]);
accounts = acc; accounts = acc;
platforms = plat; platforms = plat;
renderAccounts(); historyCache = hist || {};
if (!document.getElementById("view-platforms").classList.contains("hidden")) renderPlatforms(); providersList = provs || [];
renderAccounts(!hasRendered);
if (!document.getElementById("view-platforms").classList.contains("hidden")) renderPlatforms(!hasRendered);
hasRendered = true;
const d = new Date(); const d = new Date();
document.getElementById("refresh-time").textContent = document.getElementById("refresh-time").textContent =
d.toLocaleTimeString("zh-CN", { hour12: false }); d.toLocaleTimeString("zh-CN", { hour12: false });
@@ -148,50 +186,143 @@
return { cls: "pending", text: "待检查" }; return { cls: "pending", text: "待检查" };
} }
function accountCard(a, idx) { function accountCard(a, idx, animate = true) {
const st = statusInfo(a); const st = statusInfo(a);
const plat = platforms.find((p) => p.id === a.platform_id) || {}; const plat = platforms.find((p) => p.id === a.platform_id) || {};
const b = brandStyle(plat.icon || ""); const b = brandStyle(plat.icon || "");
const bal = fmtBalance(a.last_balance); const bal = fmtBalance(a.last_balance);
const th = fmtBalance(a.threshold); const th = fmtBalance(a.threshold);
const below = a.last_status === "ok" && bal !== null && Number(a.last_balance) < a.threshold; const below = a.last_status === "ok" && bal !== null && Number(a.last_balance) < a.threshold;
const cardCls = ["account-card", st.cls === "warn" ? "below" : "", st.cls === "err" ? "error" : "", const checking = checkingIds.has(a.id);
st.cls === "disabled" ? "disabled" : ""].join(" ").trim(); const cardCls = ["account-card",
const pct = th && bal !== null ? Math.min(100, (Number(a.last_balance) / Number(th)) * 100) : 100; st.cls === "warn" ? "below" : "",
const fillCls = below ? "threshold-fill below" : "threshold-fill"; st.cls === "err" ? "error" : "",
st.cls === "disabled" ? "disabled" : "",
st.cls === "pending" ? "pending" : "",
animate ? "" : "no-anim",
].join(" ").trim();
const animStyle = animate ? `animation-delay:${Math.min(idx, 12) * 40}ms` : "";
const win = accountWindows[a.id] || "all";
let hist;
if (win === "all") {
hist = historyCache[a.id] || [];
} else {
hist = windowCache[a.id + ":" + win] || historyCache[a.id] || [];
}
const winBtns = WINDOW_KEYS.map((k) =>
`<button class="win-btn ${win === k ? "active" : ""}" data-act="win" data-win="${k}" title="${WINDOW_LABELS[k]}">${k === "all" ? "ALL" : k.toUpperCase()}</button>`
).join("");
const checkBtn = checking
? `<button class="btn sm check-btn loading" disabled><span class="spinner"></span>检查中</button>`
: `<button class="btn sm check-btn" data-act="check" data-id="${a.id}" ${!a.enabled ? "disabled title=启用后可用" : ""}>${ICONS.refresh}立即检查</button>`;
return ` return `
<div class="${cardCls}" style="animation-delay:${Math.min(idx, 12) * 40}ms"> <div class="${cardCls}" style="${animStyle}">
<div class="card-top"> <div class="card-head">
<div class="badge" style="background:${b.color}">${escapeHtml(b.abbr)}</div> ${badgeHtml(plat.icon || "", b.color, b.abbr)}
<div class="card-title"> <div class="card-title">
<div class="card-platform">${escapeHtml(plat.name || "未知平台")} · ${escapeHtml(plat.currency || "")}</div> <div class="card-platform">${escapeHtml(plat.name || "未知平台")} · ${escapeHtml(plat.currency || "")}</div>
<div class="card-name" title="${escapeHtml(a.name)}">${escapeHtml(a.name)}</div> <div class="card-name" title="${escapeHtml(a.name)}">${escapeHtml(a.name)}</div>
</div> </div>
<span class="badge-pill ${st.cls}">${st.text}</span>
</div> </div>
<div class="balance-row"> <div class="balance-row">
${bal !== null ${bal !== null
? `<span class="balance-value ${below ? "below" : ""}">${bal}</span><span class="balance-currency">${escapeHtml(plat.currency || "")}</span>` ? `<span class="balance-value ${below ? "below" : ""}">${bal}</span><span class="balance-currency">${escapeHtml(plat.currency || "")}</span>`
: `<span class="balance-empty">—</span>`} : `<span class="balance-empty">—</span>`}
${th !== null ? `<span class="balance-vs">/ 阈值 ${th}</span>` : ""}
</div> </div>
<div class="threshold-bar" title="阈值 ${th ?? "—"} ${escapeHtml(plat.currency || "")}"> ${hist.length > 0
<div class="${fillCls}" style="width:${bal === null ? 0 : pct}%"></div> ? `<div class="spark-wrap">
</div> <div class="spark-title">余额历史 · ${WINDOW_LABELS[win]}${hist.length}${th !== null ? `,虚线阈值 ${th}` : ""}</div>
<div class="chart-box"><canvas class="card-chart" data-aid="${a.id}"></canvas></div>
<div class="window-switch" data-aid="${a.id}">${winBtns}</div>
</div>`
: ""}
<div class="card-meta"> <div class="card-meta">
<span class="badge-pill ${st.cls}">${st.text}</span> <span class="meta-item">${ICONS.clock}上次 ${fmtTime(a.last_check_at)}</span>
<span>上次检查:${fmtTime(a.last_check_at)}</span> <span class="meta-item">${ICONS.refresh}${plat.interval_seconds || (settings ? settings.global_interval_seconds : 300)}s</span>
${a.last_error ? `<span style="color:var(--err)">${escapeHtml(a.last_error)}</span>` : ""}
</div> </div>
${a.last_error ? `<div class="card-error" title="${escapeHtml(a.last_error)}">${ICONS.alert}${escapeHtml(a.last_error)}</div>` : ""}
<div class="card-actions"> <div class="card-actions">
<button class="btn sm" data-act="history" data-id="${a.id}">历史</button> ${checkBtn}
<button class="btn sm" data-act="check" data-id="${a.id}" ${!a.enabled ? "disabled" : ""}>立即检查</button> <button class="btn sm icon-btn" data-act="history" data-id="${a.id}" title="余额历史">${ICONS.clock}</button>
<button class="btn sm" data-act="edit" data-id="${a.id}">编辑</button> <button class="btn sm icon-btn" data-act="edit" data-id="${a.id}" title="编辑账号">${ICONS.pencil}</button>
<button class="btn sm danger" data-act="del" data-id="${a.id}">删除</button> <button class="btn sm icon-btn danger" data-act="del" data-id="${a.id}" title="删除账号">${ICONS.trash}</button>
<label class="switch" title="${a.enabled ? "点击停用监控" : "点击启用监控"}">
<input type="checkbox" data-act="toggle" data-id="${a.id}" ${a.enabled ? "checked" : ""}>
<span class="slider"></span>
</label>
</div> </div>
</div>`; </div>`;
} }
function renderAccounts() { const STATUS_ORDER = { warn: 0, err: 1, pending: 2, ok: 3, disabled: 4 };
/* 切换某账号折线图的时间窗口;窗口数据按需拉取并缓存 */
async function setWindow(aid, win) {
accountWindows[aid] = win;
renderAccounts();
if (win === "all") return;
const key = aid + ":" + win;
if (windowCache[key]) return;
try {
const data = await api(`/accounts/${aid}/history?window=${win}&limit=120`);
windowCache[key] = data;
renderAccounts();
} catch (e) {
toast(e.message, "err");
}
}
/* 销毁所有卡片迷你图实例(重绘前调用,避免 canvas 替换后泄漏) */
function destroyCardCharts() {
Object.values(cardCharts).forEach((c) => { try { c.destroy(); } catch (_) { /* 忽略 */ } });
for (const k in cardCharts) delete cardCharts[k];
}
/* 卡片迷你折线图(Chart.js):线色跟随状态,阈值画虚线,无坐标轴 */
function createCardChart(canvas, hist, threshold, statusCls) {
const css = getComputedStyle(document.documentElement);
const lineColor = statusCls === "warn" ? (css.getPropertyValue("--warn").trim() || "#d97706")
: statusCls === "err" || statusCls === "disabled" ? (css.getPropertyValue("--text-3").trim() || "#9aa1ad")
: (css.getPropertyValue("--accent").trim() || "#3b6ef6");
const thColor = css.getPropertyValue("--text-3").trim() || "#9aa1ad";
const datasets = [{
label: "余额",
data: hist.map((h) => h.balance),
borderColor: lineColor,
backgroundColor: lineColor + "1f",
fill: true,
tension: 0.35,
pointRadius: 0,
borderWidth: 1.5,
}];
if (threshold > 0) {
datasets.push({
label: "阈值",
data: hist.map(() => threshold), // 与 x 轴等长,虚线水平贯穿整图
borderColor: thColor,
borderDash: [4, 4],
pointRadius: 0,
borderWidth: 1,
});
}
return new Chart(canvas, {
type: "line",
data: { labels: hist.map((h) => h.checked_at), datasets },
options: {
responsive: true,
maintainAspectRatio: false,
animation: false, // 自动刷新时不重播动画,避免闪烁
plugins: { legend: { display: false }, tooltip: { enabled: false } },
scales: { x: { display: false }, y: { display: false } },
},
});
}
function renderAccounts(animate = true) {
const grid = document.getElementById("account-grid"); const grid = document.getElementById("account-grid");
destroyCardCharts();
const stats = { total: accounts.length, ok: 0, below: 0, error: 0, disabled: 0, pending: 0 }; const stats = { total: accounts.length, ok: 0, below: 0, error: 0, disabled: 0, pending: 0 };
accounts.forEach((a) => { accounts.forEach((a) => {
const st = statusInfo(a); const st = statusInfo(a);
@@ -208,10 +339,43 @@
document.getElementById("stat-disabled").textContent = stats.disabled; document.getElementById("stat-disabled").textContent = stats.disabled;
document.getElementById("stat-pending").textContent = stats.pending; document.getElementById("stat-pending").textContent = stats.pending;
grid.innerHTML = accounts.map(accountCard).join(""); // 同步平台筛选下拉
const sel = document.getElementById("filter-platform");
const cur = filterPlatform;
const opts = ['<option value="0">全部平台</option>'].concat(
platforms.map((p) => `<option value="${p.id}">${escapeHtml(p.name)}</option>`)
).join("");
if (sel.innerHTML !== opts) sel.innerHTML = opts;
if (!platforms.some((p) => p.id === cur)) filterPlatform = 0;
sel.value = filterPlatform;
// 筛选 + 排序(低于阈值/异常优先)
let list = accounts.filter((a) => {
if (filterPlatform !== 0 && a.platform_id !== filterPlatform) return false;
if (filterStatus === "all") return true;
return statusInfo(a).cls === filterStatus;
});
list = [...list].sort((x, y) => STATUS_ORDER[statusInfo(x).cls] - STATUS_ORDER[statusInfo(y).cls]);
grid.innerHTML = list.map((a, i) => accountCard(a, i, animate)).join("");
// 为每张卡片的 canvas 创建 Chart.js 迷你图
grid.querySelectorAll("canvas.card-chart").forEach((canvas) => {
const aid = Number(canvas.dataset.aid);
const acc = accounts.find((a) => a.id === aid);
if (!acc) return;
const win = accountWindows[aid] || "all";
const hist = win === "all"
? (historyCache[aid] || [])
: (windowCache[aid + ":" + win] || historyCache[aid] || []);
if (hist.length === 0) return;
cardCharts[aid] = createCardChart(canvas, hist, acc.threshold, statusInfo(acc).cls);
});
const hint = document.getElementById("empty-hint"); const hint = document.getElementById("empty-hint");
if (accounts.length > 0) { if (accounts.length > 0) {
hint.classList.add("hidden"); hint.classList.add("hidden");
if (list.length === 0) {
grid.innerHTML = '<div class="empty-filter">没有符合条件的账号,试试调整筛选条件。</div>';
}
} else { } else {
hint.classList.remove("hidden"); hint.classList.remove("hidden");
hint.innerHTML = platforms.length === 0 hint.innerHTML = platforms.length === 0
@@ -222,33 +386,35 @@
/* ---------- 渲染:平台 ---------- */ /* ---------- 渲染:平台 ---------- */
function renderPlatforms() { function renderPlatforms(animate = true) {
const list = document.getElementById("platform-list"); const list = document.getElementById("platform-list");
if (platforms.length === 0) { if (platforms.length === 0) {
list.innerHTML = `<div class="empty-hint">还没有平台,点击右上角「+ 添加平台」创建。</div>`; list.innerHTML = `<div class="empty-hint">暂无平台(代码内置平台会在启动时自动同步)。</div>`;
return; return;
} }
list.innerHTML = platforms.map((p, i) => { list.innerHTML = platforms.map((p, i) => {
const b = brandStyle(p.icon || ""); const b = brandStyle(p.icon || "");
const prov = providersList.find((x) => x.id === p.provider_id);
const animStyle = animate ? `animation-delay:${Math.min(i, 10) * 35}ms` : "";
const rowCls = `platform-row ${p.enabled ? "" : "platform-off"}${animate ? "" : " no-anim"}`;
return ` return `
<div class="platform-row ${p.enabled ? "" : "platform-off"}" style="animation-delay:${Math.min(i, 10) * 35}ms"> <div class="${rowCls}" style="${animStyle}">
<div class="badge" style="background:${b.color}">${escapeHtml(b.abbr)}</div> ${badgeHtml(p.icon || "", b.color, b.abbr)}
<div class="platform-info"> <div class="platform-info">
<div class="platform-name">${escapeHtml(p.name)} <div class="platform-name">${escapeHtml(p.name)}
${p.enabled ? "" : '<span class="badge-pill disabled">已停用</span>'} ${p.enabled ? "" : '<span class="badge-pill disabled">已停用</span>'}
</div> </div>
<div class="platform-meta"> <div class="platform-meta">
${escapeHtml(p.method)} ${escapeHtml(p.url)} · ${prov ? escapeHtml(prov.name) : "未知提供方"} ·
提取 ${escapeHtml(p.balance_path)} ·
${escapeHtml(p.currency)} · ${escapeHtml(p.currency)} ·
间隔 ${p.interval_seconds || "全局"}s · 间隔 ${p.interval_seconds || "全局"}s ·
${p.account_count} 个账号 ${p.account_count} 个账号
${prov && prov.description ? `<div style="color:var(--text-3)">${escapeHtml(prov.description)}</div>` : ""}
</div> </div>
</div> </div>
<div class="platform-actions"> <div class="platform-actions">
<button class="btn sm" data-act="toggle" data-id="${p.id}">${p.enabled ? "停用" : "启用"}</button> <button class="btn sm" data-act="toggle" data-id="${p.id}">${p.enabled ? "停用" : "启用"}</button>
<button class="btn sm" data-act="edit" data-id="${p.id}">编辑</button> <button class="btn sm" data-act="edit" data-id="${p.id}">监控参数</button>
<button class="btn sm danger" data-act="del" data-id="${p.id}">删除</button>
</div> </div>
</div>`; </div>`;
}).join(""); }).join("");
@@ -256,11 +422,12 @@
/* ---------- 模态框 ---------- */ /* ---------- 模态框 ---------- */
function openModal(html, onMount) { function openModal(html, onMount, onClose) {
const root = document.getElementById("modal-root"); const root = document.getElementById("modal-root");
const mask = document.createElement("div"); const mask = document.createElement("div");
mask.className = "modal-mask"; mask.className = "modal-mask";
mask.innerHTML = html; mask.innerHTML = html;
if (onClose) mask._onClose = onClose;
mask.addEventListener("click", (e) => { if (e.target === mask) closeModal(mask); }); mask.addEventListener("click", (e) => { if (e.target === mask) closeModal(mask); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeModal(mask); }, { once: true }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeModal(mask); }, { once: true });
root.appendChild(mask); root.appendChild(mask);
@@ -269,7 +436,9 @@
} }
function closeModal(mask) { function closeModal(mask) {
if (mask) mask.remove(); if (!mask) return;
if (mask._onClose) mask._onClose();
mask.remove();
} }
function confirmDialog(text, onOk) { function confirmDialog(text, onOk) {
@@ -286,39 +455,23 @@
mask.querySelector("[data-ok]").onclick = () => { closeModal(mask); onOk(); }; mask.querySelector("[data-ok]").onclick = () => { closeModal(mask); onOk(); };
} }
/* 平台表单 */ /* 平台监控参数表单(提供方由代码内置,不可改) */
function platformForm(p) { function platformForm(p) {
const isEdit = !!p;
const v = p || {}; const v = p || {};
let savedHeaders = v.headers || {}; const prov = providersList.find((x) => x.id === v.provider_id);
if (typeof savedHeaders === "string") {
try { savedHeaders = JSON.parse(savedHeaders); } catch (_) { savedHeaders = {}; }
}
const headersStr = Object.keys(savedHeaders).length ? JSON.stringify(savedHeaders, null, 2) : '{\n "Authorization": "Bearer {{apiKey}}"\n}';
openModal(` openModal(`
<div class="modal"> <div class="modal">
<h3>${isEdit ? "编辑平台" : "添加平台"}</h3> <h3>${escapeHtml(v.name || "平台")} · 监控参数</h3>
<div class="form-grid"> <div class="form-grid">
<div class="form-row"><label>名称 *</label><input id="pf-name" value="${escapeHtml(v.name || "")}" placeholder="如 OpenAI"></div> <div class="form-row full">
<div class="form-row"><label>货币单位 *</label><input id="pf-currency" value="${escapeHtml(v.currency || "USD")}"></div> <label>平台提供方(代码内置)</label>
<div class="form-row"><label>图标键(@lobehub/icons</label><input id="pf-icon" value="${escapeHtml(v.icon || "")}" placeholder="如 OpenAI / DeepSeek"></div> <input value="${prov ? escapeHtml(prov.name + " · " + (prov.description || "")) : escapeHtml(v.provider_id || "")}" disabled>
<div class="form-row">
<label>请求方法</label>
<select id="pf-method">
<option value="GET" ${(v.method || "GET") === "GET" ? "selected" : ""}>GET</option>
<option value="POST" ${v.method === "POST" ? "selected" : ""}>POST</option>
</select>
</div> </div>
<div class="form-row full"><label>URL *(支持 {{apiKey}}</label><input id="pf-url" value="${escapeHtml(v.url || "")}" placeholder="https://api.openai.com/v1/dashboard/billing/credit_grants?api_key={{apiKey}}"></div>
<div class="form-row full"><label>HeadersJSON,值支持 {{apiKey}}</label><textarea id="pf-headers">${escapeHtml(headersStr)}</textarea></div>
<div class="form-row full"><label>BodyPOST 时使用,JSON 模板,支持 {{apiKey}}</label><textarea id="pf-body" placeholder='{"api_key": "{{apiKey}}"}'>${escapeHtml(v.body || "")}</textarea></div>
<div class="form-row"><label>余额提取路径 *</label><input id="pf-path" value="${escapeHtml(v.balance_path || "")}" placeholder="data.balance 或 data[0].balance"></div>
<div class="form-row"><label>监控间隔(秒,留空用全局)</label><input id="pf-interval" type="number" min="10" value="${v.interval_seconds ?? ""}" placeholder="全局 ${settings ? settings.global_interval_seconds : 300}s"></div> <div class="form-row"><label>监控间隔(秒,留空用全局)</label><input id="pf-interval" type="number" min="10" value="${v.interval_seconds ?? ""}" placeholder="全局 ${settings ? settings.global_interval_seconds : 300}s"></div>
<div class="form-row"><label>重试次数(留空用全局 ${settings ? settings.retry_count : 2}</label><input id="pf-retry" type="number" min="0" max="10" value="${v.retry_count ?? ""}"></div> <div class="form-row"><label>重试次数(留空用全局 ${settings ? settings.retry_count : 2}</label><input id="pf-retry" type="number" min="0" max="10" value="${v.retry_count ?? ""}"></div>
<div class="form-row"><label>超时秒数(留空用全局 ${settings ? settings.timeout_seconds : 10}</label><input id="pf-timeout" type="number" min="1" max="120" value="${v.timeout_seconds ?? ""}"></div> <div class="form-row"><label>超时秒数(留空用全局 ${settings ? settings.timeout_seconds : 10}</label><input id="pf-timeout" type="number" min="1" max="120" value="${v.timeout_seconds ?? ""}"></div>
<div class="form-row full"><label>备注</label><input id="pf-note" value="${escapeHtml(v.note || "")}"></div> <div class="form-row full"><label>备注</label><input id="pf-note" value="${escapeHtml(v.note || "")}"></div>
</div> </div>
<p class="form-hint">提示:apikey 在 URL / Header / Body 中统一用 {{apiKey}} 占位,添加账号时自动替换。Headers/Body 需为 JSON,键值用双引号(单引号会自动兼容)。</p>
<p class="modal-error" id="pf-error"></p> <p class="modal-error" id="pf-error"></p>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn" data-cancel>取消</button> <button class="btn" data-cancel>取消</button>
@@ -328,28 +481,17 @@
mask.querySelector("[data-cancel]").onclick = () => closeModal(mask); mask.querySelector("[data-cancel]").onclick = () => closeModal(mask);
mask.querySelector("#pf-save").onclick = async () => { mask.querySelector("#pf-save").onclick = async () => {
const payload = { const payload = {
name: val("#pf-name"), currency: val("#pf-currency"), icon: val("#pf-icon"), interval_seconds: numOrNull("#pf-interval"),
method: val("#pf-method"), url: val("#pf-url"), balance_path: val("#pf-path"), retry_count: numOrNull("#pf-retry"),
timeout_seconds: numOrNull("#pf-timeout"),
note: val("#pf-note"), note: val("#pf-note"),
interval_seconds: numOrNull("#pf-interval"), retry_count: numOrNull("#pf-retry"),
timeout_seconds: numOrNull("#pf-timeout"), enabled: p ? p.enabled : true,
}; };
let headers = parseJsonInput(val("#pf-headers"), "Headers", "#pf-error");
if (headers === null) return;
payload.headers = headers;
if (!payload.name || !payload.url || !payload.balance_path) { err("#pf-error", "名称 / URL / 提取路径必填"); return; }
if (payload.method === "POST") {
const bodyObj = parseJsonInput(val("#pf-body"), "Body", "#pf-error");
if (bodyObj === null) return;
}
payload.body = val("#pf-body");
try { try {
if (isEdit) await api("/platforms/" + p.id, { method: "PUT", body: JSON.stringify(payload) }); await api("/platforms/" + v.id, { method: "PUT", body: JSON.stringify(payload) });
else await api("/platforms", { method: "POST", body: JSON.stringify(payload) });
closeModal(mask); closeModal(mask);
toast(isEdit ? "平台已更新" : "平台已添加"); toast("平台监控参数已更新");
await refresh(); await refresh();
switchView("platforms"); renderPlatforms();
} catch (e) { err("#pf-error", e.message); } } catch (e) { err("#pf-error", e.message); }
}; };
}); });
@@ -404,7 +546,7 @@
}); });
} }
/* 历史弹窗 */ /* 历史弹窗:纯表格(时间 + 余额),无图表 */
async function showHistory(accountId) { async function showHistory(accountId) {
const acc = accounts.find((a) => a.id === accountId); const acc = accounts.find((a) => a.id === accountId);
if (!acc) return; if (!acc) return;
@@ -421,12 +563,15 @@
body.innerHTML = `<div style="padding:20px 0;text-align:center">暂无记录(检查成功后自动记录)</div>`; body.innerHTML = `<div style="padding:20px 0;text-align:center">暂无记录(检查成功后自动记录)</div>`;
return; return;
} }
const max = Math.max(...hist.map((h) => h.balance), 1e-9); const rows = [...hist].reverse().map((h) =>
const bars = hist.map((h) => `<tr><td>${escapeHtml(h.checked_at)}</td><td class="h-bal">${fmtBalance(h.balance)} ${escapeHtml(plat.currency || "")}</td></tr>`).join("");
`<i title="${fmtBalance(h.balance)} ${escapeHtml(plat.currency || "")} @ ${escapeHtml(h.checked_at)}" style="height:${Math.max(6, (h.balance / max) * 100)}%"></i>`).join(""); body.innerHTML = `
const items = [...hist].reverse().map((h) => <div class="hist-list-wrap">
`<div class="history-item"><span>${escapeHtml(h.checked_at)}</span><span class="h-bal">${fmtBalance(h.balance)} ${escapeHtml(plat.currency || "")}</span></div>`).join(""); <table class="hist-table">
body.innerHTML = `<div class="spark">${bars}</div>${items}`; <thead><tr><th>时间</th><th>余额</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
} catch (e) { } catch (e) {
mask.querySelector("#hist-body").textContent = "加载失败:" + e.message; mask.querySelector("#hist-body").textContent = "加载失败:" + e.message;
} }
@@ -457,7 +602,6 @@
document.getElementById("login-password").addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); }); document.getElementById("login-password").addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); });
document.getElementById("logout-btn").onclick = logout; document.getElementById("logout-btn").onclick = logout;
document.querySelectorAll(".tab").forEach((t) => t.addEventListener("click", () => switchView(t.dataset.view))); document.querySelectorAll(".tab").forEach((t) => t.addEventListener("click", () => switchView(t.dataset.view)));
document.getElementById("add-platform-btn").onclick = () => platformForm(null);
document.getElementById("add-account-btn").onclick = () => { document.getElementById("add-account-btn").onclick = () => {
if (platforms.length === 0) { if (platforms.length === 0) {
toast("请先在「平台」页添加平台", "err"); toast("请先在「平台」页添加平台", "err");
@@ -469,6 +613,18 @@
document.getElementById("save-settings-btn").onclick = saveSettings; document.getElementById("save-settings-btn").onclick = saveSettings;
document.getElementById("change-pwd-btn").onclick = changePassword; document.getElementById("change-pwd-btn").onclick = changePassword;
document.getElementById("filter-pills").addEventListener("click", (e) => {
const pill = e.target.closest(".pill");
if (!pill) return;
filterStatus = pill.dataset.filter;
document.querySelectorAll("#filter-pills .pill").forEach((p) => p.classList.toggle("active", p === pill));
renderAccounts();
});
document.getElementById("filter-platform").addEventListener("change", (e) => {
filterPlatform = Number(e.target.value);
renderAccounts();
});
document.getElementById("account-grid").addEventListener("click", onAccountAction); document.getElementById("account-grid").addEventListener("click", onAccountAction);
document.getElementById("platform-list").addEventListener("click", onPlatformAction); document.getElementById("platform-list").addEventListener("click", onPlatformAction);
} }
@@ -512,19 +668,51 @@
const id = Number(btn.dataset.id); const id = Number(btn.dataset.id);
const act = btn.dataset.act; const act = btn.dataset.act;
if (act === "edit") accountForm(accounts.find((a) => a.id === id)); if (act === "edit") accountForm(accounts.find((a) => a.id === id));
else if (act === "del") { else if (act === "toggle") {
const checked = btn.checked;
try {
await api("/accounts/" + id, { method: "PUT", body: JSON.stringify({ enabled: checked }) });
toast(checked ? "账号已启用" : "账号已停用");
await refresh();
} catch (err2) { toast(err2.message, "err"); await refresh(); }
} else if (act === "del") {
const a = accounts.find((x) => x.id === id); const a = accounts.find((x) => x.id === id);
confirmDialog(`确定删除账号「${escapeHtml(a ? a.name : id)}」?其历史记录将一并删除,此操作不可恢复。`, async () => { confirmDialog(`确定删除账号「${escapeHtml(a ? a.name : id)}」?其历史记录将一并删除,此操作不可恢复。`, async () => {
try { await api("/accounts/" + id, { method: "DELETE" }); toast("账号已删除"); await refresh(); } try { await api("/accounts/" + id, { method: "DELETE" }); toast("账号已删除"); await refresh(); }
catch (err2) { toast(err2.message, "err"); } catch (err2) { toast(err2.message, "err"); }
}); });
} else if (act === "check") { } else if (act === "check") {
btn.disabled = true; if (checkingIds.has(id)) return;
try { await api(`/accounts/${id}/check`, { method: "POST" }); toast("已开始检查"); } checkingIds.add(id);
catch (err2) { toast(err2.message, "err"); btn.disabled = false; } renderAccounts();
setTimeout(refresh, 1500); const before = accounts.find((a) => a.id === id);
const prev = before ? before.last_check_at : null;
try {
await api(`/accounts/${id}/check`, { method: "POST" });
} catch (err2) {
toast(err2.message, "err");
}
// 轮询等待本次检查落库(last_check_at 变化),最长 120s
const deadline = Date.now() + 120000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 2500));
try { await refresh(); } catch (_) { /* 忽略刷新错误 */ }
const acc = accounts.find((a) => a.id === id);
if (!acc || acc.last_check_at !== prev) break;
}
checkingIds.delete(id);
renderAccounts();
const acc = accounts.find((a) => a.id === id);
if (acc && acc.last_status === "ok") {
const cur = (platforms.find((p) => p.id === acc.platform_id) || {}).currency || "";
toast(`检查完成:余额 ${fmtBalance(acc.last_balance)} ${cur}`);
}
} else if (act === "history") { } else if (act === "history") {
showHistory(id); showHistory(id);
} else if (act === "win") {
const wrap = btn.closest(".window-switch");
const aid = Number(wrap ? wrap.dataset.aid : 0);
if (aid) setWindow(aid, btn.dataset.win);
} }
} }
@@ -542,11 +730,6 @@
await refresh(); await refresh();
renderPlatforms(); renderPlatforms();
} catch (err2) { toast(err2.message, "err"); } } catch (err2) { toast(err2.message, "err"); }
} else if (act === "del") {
confirmDialog(`确定删除平台「${escapeHtml(p ? p.name : id)}」?<br>其下 ${p ? p.account_count : 0} 个账号及历史记录将一并删除,此操作不可恢复。`, async () => {
try { await api("/platforms/" + id, { method: "DELETE" }); toast("平台已删除"); await refresh(); switchView("platforms"); }
catch (err2) { toast(err2.message, "err"); }
});
} }
} }
+20
View File
@@ -0,0 +1,20 @@
/* 品牌图标(simple-icons CC0 / tabler MIT),离线内嵌 */
const BRAND_ICONS = {
"Anthropic": "<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z\"/></svg>",
"Claude": "<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z\"/></svg>",
"DeepSeek": "<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M23.748 4.651c-.254-.124-.364.113-.512.233-.051.04-.094.09-.137.137-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.155-.708-.311-.955-.65-.172-.24-.219-.509-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.094.172.187.129.323-.082.28-.18.553-.266.833-.055.179-.137.218-.328.14a5.5 5.5 0 0 1-1.737-1.179c-.857-.828-1.631-1.743-2.597-2.46a12 12 0 0 0-.689-.47c-.985-.957.13-1.743.387-1.836.27-.098.094-.433-.778-.428-.872.003-1.67.295-2.687.685a3 3 0 0 1-.465.136 9.6 9.6 0 0 0-2.883-.101c-1.885.21-3.39 1.1-4.497 2.622C.082 8.776-.231 10.854.152 13.02c.403 2.284 1.568 4.175 3.36 5.653 1.857 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.132-.284 4.994-1.86.47.234.962.328 1.78.398.629.058 1.235-.031 1.705-.129.735-.155.684-.836.418-.961-2.155-1.004-1.682-.595-2.112-.926 1.095-1.295 2.768-3.598 3.284-6.733.05-.346.115-.834.108-1.114-.004-.171.035-.238.23-.257a4.2 4.2 0 0 0 1.545-.475c1.397-.763 1.96-2.016 2.093-3.517.02-.23-.004-.467-.247-.588M11.58 18.168c-2.088-1.642-3.101-2.183-3.52-2.16-.39.024-.32.472-.234.763.09.288.207.487.371.74.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.168-1.361-.801-2.5-1.86-3.301-3.306-.775-1.393-1.225-2.888-1.299-4.482-.02-.385.094-.522.477-.592a4.7 4.7 0 0 1 1.53-.038c2.131.311 3.946 1.264 5.467 2.774.868.86 1.525 1.887 2.202 2.89.72 1.066 1.494 2.082 2.48 2.915.348.291.626.513.892.677-.802.09-2.14.109-3.055-.615zm1.001-6.44a.306.306 0 0 1 .415-.287.3.3 0 0 1 .113.074.3.3 0 0 1 .086.214c0 .17-.136.307-.308.307a.303.303 0 0 1-.306-.307m3.11 1.596c-.2.081-.4.151-.591.16a1.25 1.25 0 0 1-.798-.254c-.274-.23-.47-.358-.551-.758a1.7 1.7 0 0 1 .015-.588c.07-.327-.007-.537-.238-.727-.188-.156-.426-.199-.689-.199a.6.6 0 0 1-.254-.078.253.253 0 0 1-.114-.358 1 1 0 0 1 .192-.21c.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.392.451.462.576.685.915.176.264.336.536.446.848.066.194-.02.353-.25.45\"/></svg>",
"OpenRouter": "<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z\"/></svg>",
"Google": "<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z\"/></svg>",
"Gemini": "<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81\"/></svg>",
"Meta": "<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z\"/></svg>",
"Perplexity": "<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M22.3977 7.0896h-2.3106V.0676l-7.5094 6.3542V.1577h-1.1554v6.1966L4.4904 0v7.0896H1.6023v10.3976h2.8882V24l6.932-6.3591v6.2005h1.1554v-6.0469l6.9318 6.1807v-6.4879h2.8882V7.0896zm-3.4657-4.531v4.531h-5.355l5.355-4.531zm-13.2862.0676 4.8691 4.4634H5.6458V2.6262zM2.7576 16.332V8.245h7.8476l-6.1149 6.1147v1.9723H2.7576zm2.8882 5.0404v-3.8852h.0001v-2.6488l5.7763-5.7764v7.0111l-5.7764 5.2993zm12.7086.0248-5.7766-5.1509V9.0618l5.7766 5.7766v6.5588zm2.8882-5.0652h-1.733v-1.9723L13.3948 8.245h7.8478v8.087z\"/></svg>",
"HuggingFace": "<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M12.025 1.13c-5.77 0-10.449 4.647-10.449 10.378 0 1.112.178 2.181.503 3.185.064-.222.203-.444.416-.577a.96.96 0 0 1 .524-.15c.293 0 .584.124.84.284.278.173.48.408.71.694.226.282.458.611.684.951v-.014c.017-.324.106-.622.264-.874s.403-.487.762-.543c.3-.047.596.06.787.203s.31.313.4.467c.15.257.212.468.233.542.01.026.653 1.552 1.657 2.54.616.605 1.01 1.223 1.082 1.912.055.537-.096 1.059-.38 1.572.637.121 1.294.187 1.967.187.657 0 1.298-.063 1.921-.178-.287-.517-.44-1.041-.384-1.581.07-.69.465-1.307 1.081-1.913 1.004-.987 1.647-2.513 1.657-2.539.021-.074.083-.285.233-.542.09-.154.208-.323.4-.467a1.08 1.08 0 0 1 .787-.203c.359.056.604.29.762.543s.247.55.265.874v.015c.225-.34.457-.67.683-.952.23-.286.432-.52.71-.694.257-.16.547-.284.84-.285a.97.97 0 0 1 .524.151c.228.143.373.388.43.625l.006.04a10.3 10.3 0 0 0 .534-3.273c0-5.731-4.678-10.378-10.449-10.378M8.327 6.583a1.5 1.5 0 0 1 .713.174 1.487 1.487 0 0 1 .617 2.013c-.183.343-.762-.214-1.102-.094-.38.134-.532.914-.917.71a1.487 1.487 0 0 1 .69-2.803m7.486 0a1.487 1.487 0 0 1 .689 2.803c-.385.204-.536-.576-.916-.71-.34-.12-.92.437-1.103.094a1.487 1.487 0 0 1 .617-2.013 1.5 1.5 0 0 1 .713-.174m-10.68 1.55a.96.96 0 1 1 0 1.921.96.96 0 0 1 0-1.92m13.838 0a.96.96 0 1 1 0 1.92.96.96 0 0 1 0-1.92M8.489 11.458c.588.01 1.965 1.157 3.572 1.164 1.607-.007 2.984-1.155 3.572-1.164.196-.003.305.12.305.454 0 .886-.424 2.328-1.563 3.202-.22-.756-1.396-1.366-1.63-1.32q-.011.001-.02.006l-.044.026-.01.008-.03.024q-.018.017-.035.036l-.032.04a1 1 0 0 0-.058.09l-.014.025q-.049.088-.11.19a1 1 0 0 1-.083.116 1.2 1.2 0 0 1-.173.18q-.035.029-.075.058a1.3 1.3 0 0 1-.251-.243 1 1 0 0 1-.076-.107c-.124-.193-.177-.363-.337-.444-.034-.016-.104-.008-.2.022q-.094.03-.216.087-.06.028-.125.063l-.13.074q-.067.04-.136.086a3 3 0 0 0-.135.096 3 3 0 0 0-.26.219 2 2 0 0 0-.12.121 2 2 0 0 0-.106.128l-.002.002a2 2 0 0 0-.09.132l-.001.001a1.2 1.2 0 0 0-.105.212q-.013.036-.024.073c-1.139-.875-1.563-2.317-1.563-3.203 0-.334.109-.457.305-.454m.836 10.354c.824-1.19.766-2.082-.365-3.194-1.13-1.112-1.789-2.738-1.789-2.738s-.246-.945-.806-.858-.97 1.499.202 2.362c1.173.864-.233 1.45-.685.64-.45-.812-1.683-2.896-2.322-3.295s-1.089-.175-.938.647 2.822 2.813 2.562 3.244-1.176-.506-1.176-.506-2.866-2.567-3.49-1.898.473 1.23 2.037 2.16c1.564.932 1.686 1.178 1.464 1.53s-3.675-2.511-4-1.297c-.323 1.214 3.524 1.567 3.287 2.405-.238.839-2.71-1.587-3.216-.642-.506.946 3.49 2.056 3.522 2.064 1.29.33 4.568 1.028 5.713-.624m5.349 0c-.824-1.19-.766-2.082.365-3.194 1.13-1.112 1.789-2.738 1.789-2.738s.246-.945.806-.858.97 1.499-.202 2.362c-1.173.864.233 1.45.685.64.451-.812 1.683-2.896 2.322-3.295s1.089-.175.938.647-2.822 2.813-2.562 3.244 1.176-.506 1.176-.506 2.866-2.567 3.49-1.898-.473 1.23-2.037 2.16c-1.564.932-1.686 1.178-1.464 1.53s3.675-2.511 4-1.297c.323 1.214-3.524 1.567-3.287 2.405.238.839 2.71-1.587 3.216-.642.506.946-3.49 2.056-3.522 2.064-1.29.33-4.568 1.028-5.713-.624\"/></svg>",
"Replicate": "<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M24 10.262v2.712h-9.518V24h-3.034V10.262zm0-5.131v2.717H8.755V24H5.722V5.131zM24 0v2.717H3.034V24H0V0z\"/></svg>",
"Baidu": "<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M9.154 0C7.71 0 6.54 1.658 6.54 3.707c0 2.051 1.171 3.71 2.615 3.71 1.446 0 2.614-1.659 2.614-3.71C11.768 1.658 10.6 0 9.154 0zm7.025.594C14.86.58 13.347 2.589 13.2 3.927c-.187 1.745.25 3.487 2.179 3.735 1.933.25 3.175-1.806 3.422-3.364.252-1.555-.995-3.364-2.362-3.674a1.218 1.218 0 0 0-.261-.03zM3.582 5.535a2.811 2.811 0 0 0-.156.008c-2.118.19-2.428 3.24-2.428 3.24-.287 1.41.686 4.425 3.297 3.864 2.617-.561 2.262-3.68 2.183-4.362-.125-1.018-1.292-2.773-2.896-2.75zm16.534 1.753c-2.308 0-2.617 2.119-2.617 3.616 0 1.43.121 3.425 2.988 3.362 2.867-.063 2.553-3.238 2.553-3.988 0-.745-.62-2.99-2.924-2.99zm-8.264 2.478c-1.424.014-2.708.925-3.323 1.947-1.118 1.868-2.863 3.05-3.112 3.363-.25.309-3.61 2.116-2.864 5.42.746 3.301 3.365 3.237 3.365 3.237s1.93.19 4.171-.31c2.24-.495 4.17.123 4.17.123s5.233 1.748 6.665-1.616c1.43-3.364-.808-5.109-.808-5.109s-2.99-2.306-4.736-4.798c-1.072-1.665-2.348-2.268-3.528-2.257zm-2.234 3.84l1.542.024v8.197H7.758c-1.47-.291-2.055-1.292-2.13-1.462-.072-.173-.488-.976-.268-2.343.635-2.049 2.447-2.196 2.447-2.196h1.81zm3.964 2.39v3.881c.096.413.612.488.612.488h1.614v-4.343h1.689v5.782h-3.915c-1.517-.39-1.59-1.465-1.59-1.465v-4.317zm-5.458 1.147c-.66.197-.978.708-1.05.928-.076.22-.247.78-.1 1.269.294 1.095 1.248 1.144 1.248 1.144h1.37v-3.34z\"/></svg>",
"MiniMax": "<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M11.43 3.92a.86.86 0 1 0-1.718 0v14.236a1.999 1.999 0 0 1-3.997 0V9.022a.86.86 0 1 0-1.718 0v3.87a1.999 1.999 0 0 1-3.997 0V11.49a.57.57 0 0 1 1.139 0v1.404a.86.86 0 0 0 1.719 0V9.022a1.999 1.999 0 0 1 3.997 0v9.134a.86.86 0 0 0 1.719 0V3.92a1.998 1.998 0 1 1 3.996 0v11.788a.57.57 0 1 1-1.139 0zm10.572 3.105a2 2 0 0 0-1.999 1.997v7.63a.86.86 0 0 1-1.718 0V3.923a1.999 1.999 0 0 0-3.997 0v16.16a.86.86 0 0 1-1.719 0V18.08a.57.57 0 1 0-1.138 0v2a1.998 1.998 0 0 0 3.996 0V3.92a.86.86 0 0 1 1.719 0v12.73a1.999 1.999 0 0 0 3.996 0V9.023a.86.86 0 1 1 1.72 0v6.686a.57.57 0 0 0 1.138 0V9.022a2 2 0 0 0-1.998-1.997\"/></svg>",
"NVIDIA": "<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M8.948 8.798v-1.43a6.7 6.7 0 0 1 .424-.018c3.922-.124 6.493 3.374 6.493 3.374s-2.774 3.851-5.75 3.851c-.398 0-.787-.062-1.158-.185v-4.346c1.528.185 1.837.857 2.747 2.385l2.04-1.714s-1.492-1.952-4-1.952a6.016 6.016 0 0 0-.796.035m0-4.735v2.138l.424-.027c5.45-.185 9.01 4.47 9.01 4.47s-4.08 4.964-8.33 4.964c-.37 0-.733-.035-1.095-.097v1.325c.3.035.61.062.91.062 3.957 0 6.82-2.023 9.593-4.408.459.371 2.34 1.263 2.73 1.652-2.633 2.208-8.772 3.984-12.253 3.984-.335 0-.653-.018-.971-.053v1.864H24V4.063zm0 10.326v1.131c-3.657-.654-4.673-4.46-4.673-4.46s1.758-1.944 4.673-2.262v1.237H8.94c-1.528-.186-2.73 1.245-2.73 1.245s.68 2.412 2.739 3.11M2.456 10.9s2.164-3.197 6.5-3.533V6.201C4.153 6.59 0 10.653 0 10.653s2.35 6.802 8.948 7.42v-1.237c-4.84-.6-6.492-5.936-6.492-5.936z\"/></svg>",
"Qwen": "<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M23.919 14.545 20.817 9.17l1.47-2.544a.56.56 0 0 0 0-.566l-1.633-2.83a.57.57 0 0 0-.49-.283h-6.207L12.487.402a.57.57 0 0 0-.49-.284H8.732a.56.56 0 0 0-.49.284L5.139 5.775h-2.94a.56.56 0 0 0-.49.284L.077 8.887a.56.56 0 0 0 0 .567L3.18 14.83l-1.47 2.545a.56.56 0 0 0 0 .566l1.634 2.83a.57.57 0 0 0 .49.283h6.205l1.47 2.545a.57.57 0 0 0 .49.284h3.266a.57.57 0 0 0 .49-.284l3.104-5.375h2.94a.57.57 0 0 0 .49-.283l1.634-2.828a.55.55 0 0 0-.004-.568M8.733.686l1.634 2.828-1.634 2.828H21.8L20.164 9.17H7.425L5.63 6.06Zm1.306 19.801-6.205-.002 1.634-2.83h3.265L2.201 6.344h3.267q3.182 5.517 6.367 11.032zm10.124-5.66L18.53 12l-6.532 11.315-1.634-2.83c2.129-3.673 4.25-7.351 6.373-11.028h3.592l3.102 5.374z\"/></svg>",
"Ollama": "<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M16.361 10.26a.894.894 0 0 0-.558.47l-.072.148.001.207c0 .193.004.217.059.353.076.193.152.312.291.448.24.238.51.3.872.205a.86.86 0 0 0 .517-.436.752.752 0 0 0 .08-.498c-.064-.453-.33-.782-.724-.897a1.06 1.06 0 0 0-.466 0zm-9.203.005c-.305.096-.533.32-.65.639a1.187 1.187 0 0 0-.06.52c.057.309.31.59.598.667.362.095.632.033.872-.205.14-.136.215-.255.291-.448.055-.136.059-.16.059-.353l.001-.207-.072-.148a.894.894 0 0 0-.565-.472 1.02 1.02 0 0 0-.474.007Zm4.184 2c-.131.071-.223.25-.195.383.031.143.157.288.353.407.105.063.112.072.117.136.004.038-.01.146-.029.243-.02.094-.036.194-.036.222.002.074.07.195.143.253.064.052.076.054.255.059.164.005.198.001.264-.03.169-.082.212-.234.15-.525-.052-.243-.042-.28.087-.355.137-.08.281-.219.324-.314a.365.365 0 0 0-.175-.48.394.394 0 0 0-.181-.033c-.126 0-.207.03-.355.124l-.085.053-.053-.032c-.219-.13-.259-.145-.391-.143a.396.396 0 0 0-.193.032zm.39-2.195c-.373.036-.475.05-.654.086-.291.06-.68.195-.951.328-.94.46-1.589 1.226-1.787 2.114-.04.176-.045.234-.045.53 0 .294.005.357.043.524.264 1.16 1.332 2.017 2.714 2.173.3.033 1.596.033 1.896 0 1.11-.125 2.064-.727 2.493-1.571.114-.226.169-.372.22-.602.039-.167.044-.23.044-.523 0-.297-.005-.355-.045-.531-.288-1.29-1.539-2.304-3.072-2.497a6.873 6.873 0 0 0-.855-.031zm.645.937a3.283 3.283 0 0 1 1.44.514c.223.148.537.458.671.662.166.251.26.508.303.82.02.143.01.251-.043.482-.08.345-.332.705-.672.957a3.115 3.115 0 0 1-.689.348c-.382.122-.632.144-1.525.138-.582-.006-.686-.01-.853-.042-.57-.107-1.022-.334-1.35-.68-.264-.28-.385-.535-.45-.946-.03-.192.025-.509.137-.776.136-.326.488-.73.836-.963.403-.269.934-.46 1.422-.512.187-.02.586-.02.773-.002zm-5.503-11a1.653 1.653 0 0 0-.683.298C5.617.74 5.173 1.666 4.985 2.819c-.07.436-.119 1.04-.119 1.503 0 .544.064 1.24.155 1.721.02.107.031.202.023.208a8.12 8.12 0 0 1-.187.152 5.324 5.324 0 0 0-.949 1.02 5.49 5.49 0 0 0-.94 2.339 6.625 6.625 0 0 0-.023 1.357c.091.78.325 1.438.727 2.04l.13.195-.037.064c-.269.452-.498 1.105-.605 1.732-.084.496-.095.629-.095 1.294 0 .67.009.803.088 1.266.095.555.288 1.143.503 1.534.071.128.243.393.264.407.007.003-.014.067-.046.141a7.405 7.405 0 0 0-.548 1.873c-.062.417-.071.552-.071.991 0 .56.031.832.148 1.279L3.42 24h1.478l-.05-.091c-.297-.552-.325-1.575-.068-2.597.117-.472.25-.819.498-1.296l.148-.29v-.177c0-.165-.003-.184-.057-.293a.915.915 0 0 0-.194-.25 1.74 1.74 0 0 1-.385-.543c-.424-.92-.506-2.286-.208-3.451.124-.486.329-.918.544-1.154a.787.787 0 0 0 .223-.531c0-.195-.07-.355-.224-.522a3.136 3.136 0 0 1-.817-1.729c-.14-.96.114-2.005.69-2.834.563-.814 1.353-1.336 2.237-1.475.199-.033.57-.028.776.01.226.04.367.028.512-.041.179-.085.268-.19.374-.431.093-.215.165-.333.36-.576.234-.29.46-.489.822-.729.413-.27.884-.467 1.352-.561.17-.035.25-.04.569-.04.319 0 .398.005.569.04a4.07 4.07 0 0 1 1.914.997c.117.109.398.457.488.602.034.057.095.177.132.267.105.241.195.346.374.43.14.068.286.082.503.045.343-.058.607-.053.943.016 1.144.23 2.14 1.173 2.581 2.437.385 1.108.276 2.267-.296 3.153-.097.15-.193.27-.333.419-.301.322-.301.722-.001 1.053.493.539.801 1.866.708 3.036-.062.772-.26 1.463-.533 1.854a2.096 2.096 0 0 1-.224.258.916.916 0 0 0-.194.25c-.054.109-.057.128-.057.293v.178l.148.29c.248.476.38.823.498 1.295.253 1.008.231 2.01-.059 2.581a.845.845 0 0 0-.044.098c0 .006.329.009.732.009h.73l.02-.074.036-.134c.019-.076.057-.3.088-.516.029-.217.029-1.016 0-1.258-.11-.875-.295-1.57-.597-2.226-.032-.074-.053-.138-.046-.141.008-.005.057-.074.108-.152.376-.569.607-1.284.724-2.228.031-.26.031-1.378 0-1.628-.083-.645-.182-1.082-.348-1.525a6.083 6.083 0 0 0-.329-.7l-.038-.064.131-.194c.402-.604.636-1.262.727-2.04a6.625 6.625 0 0 0-.024-1.358 5.512 5.512 0 0 0-.939-2.339 5.325 5.325 0 0 0-.95-1.02 8.097 8.097 0 0 1-.186-.152.692.692 0 0 1 .023-.208c.208-1.087.201-2.443-.017-3.503-.19-.924-.535-1.658-.98-2.082-.354-.338-.716-.482-1.15-.455-.996.059-1.8 1.205-2.116 3.01a6.805 6.805 0 0 0-.097.726c0 .036-.007.066-.015.066a.96.96 0 0 1-.149-.078A4.857 4.857 0 0 0 12 3.03c-.832 0-1.687.243-2.456.698a.958.958 0 0 1-.148.078c-.008 0-.015-.03-.015-.066a6.71 6.71 0 0 0-.097-.725C8.997 1.392 8.337.319 7.46.048a2.096 2.096 0 0 0-.585-.041Zm.293 1.402c.248.197.523.759.682 1.388.03.113.06.244.069.292.007.047.026.152.041.233.067.365.098.76.102 1.24l.002.475-.12.175-.118.178h-.278c-.324 0-.646.041-.954.124l-.238.06c-.033.007-.038-.003-.057-.144a8.438 8.438 0 0 1 .016-2.323c.124-.788.413-1.501.696-1.711.067-.05.079-.049.157.013zm9.825-.012c.17.126.358.46.498.888.28.854.36 2.028.212 3.145-.019.14-.024.151-.057.144l-.238-.06a3.693 3.693 0 0 0-.954-.124h-.278l-.119-.178-.119-.175.002-.474c.004-.669.066-1.19.214-1.772.157-.623.434-1.185.68-1.382.078-.062.09-.063.159-.012z\"/></svg>",
"LMStudio": "<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M5.6 0A5.6 5.6 0 0 0 0 5.6v12.8A5.6 5.6 0 0 0 5.6 24h12.8a5.6 5.6 0 0 0 5.6-5.6V5.6A5.6 5.6 0 0 0 18.4 0zm0 2h12.8A3.6 3.6 0 0 1 22 5.6v12.8a3.6 3.6 0 0 1-3.6 3.6H5.6A3.6 3.6 0 0 1 2 18.4V5.6A3.6 3.6 0 0 1 5.6 2m-.4 2.8a1.2 1.2 0 0 0 0 2.4h10.4a1.2 1.2 0 0 0 0-2.4zm3.2 4a1.2 1.2 0 0 0 0 2.4h10.4a1.2 1.2 0 0 0 0-2.4zm-3.2 4a1.2 1.2 0 0 0 0 2.4h10.4a1.2 1.2 0 0 0 0-2.4zm3.2 4a1.2 1.2 0 0 0 0 2.4h10.4a1.2 1.2 0 0 0 0-2.4z\"/></svg>",
"OpenAI": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M11.217 19.384A3.501 3.501 0 0 0 18 18.167V13l-6-3.35\"/><path d=\"M5.214 15.014A3.501 3.501 0 0 0 9.66 20.28L14 17.746V10.8\"/><path d=\"M6 7.63c-1.391-.236-2.787.395-3.534 1.689a3.474 3.474 0 0 0 1.271 4.745L8 16.578l6-3.348\"/><path d=\"M12.783 4.616A3.501 3.501 0 0 0 6 5.833V10.9l6 3.45\"/><path d=\"M18.786 8.986A3.501 3.501 0 0 0 14.34 3.72L10 6.254V13.2\"/><path d=\"M18 16.302c1.391.236 2.787-.395 3.534-1.689a3.474 3.474 0 0 0-1.271-4.745l-4.308-2.514L10 10.774\"/></g></svg>"
};
+15 -2
View File
@@ -54,6 +54,18 @@
<div class="stat disabled"><span id="stat-disabled">0</span><label>已禁用</label></div> <div class="stat disabled"><span id="stat-disabled">0</span><label>已禁用</label></div>
<div class="stat pending"><span id="stat-pending">0</span><label>待检查</label></div> <div class="stat pending"><span id="stat-pending">0</span><label>待检查</label></div>
</div> </div>
<div class="filter-bar">
<div class="filter-pills" id="filter-pills">
<button class="pill active" data-filter="all">全部</button>
<button class="pill warn" data-filter="below">低于阈值</button>
<button class="pill err" data-filter="error">异常</button>
<button class="pill pending" data-filter="pending">待检查</button>
<button class="pill disabled" data-filter="disabled">已禁用</button>
</div>
<select id="filter-platform" class="filter-select" title="按平台筛选">
<option value="0">全部平台</option>
</select>
</div>
<div id="account-grid" class="account-grid"></div> <div id="account-grid" class="account-grid"></div>
<div id="empty-hint" class="empty-hint hidden"> <div id="empty-hint" class="empty-hint hidden">
<p>还没有账号。先到「平台」页添加一个平台,再回来添加账号。</p> <p>还没有账号。先到「平台」页添加一个平台,再回来添加账号。</p>
@@ -63,8 +75,7 @@
<!-- 平台视图 --> <!-- 平台视图 -->
<section id="view-platforms" class="view hidden"> <section id="view-platforms" class="view hidden">
<div class="view-head"> <div class="view-head">
<h2>平台配置</h2> <h2>平台(代码内置,自动同步)</h2>
<button id="add-platform-btn" class="btn primary">+ 添加平台</button>
</div> </div>
<div id="platform-list" class="platform-list"></div> <div id="platform-list" class="platform-list"></div>
</section> </section>
@@ -127,6 +138,8 @@
<div id="modal-root"></div> <div id="modal-root"></div>
<div id="toast-root"></div> <div id="toast-root"></div>
<script src="/static/icons.js"></script>
<script src="/static/vendor/chart.umd.min.js"></script>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
</body> </body>
</html> </html>
+237
View File
@@ -235,6 +235,8 @@ label { font-size: 12px; font-weight: 500; color: var(--text-2); margin-bottom:
letter-spacing: 0.02em; letter-spacing: 0.02em;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.18); box-shadow: inset 0 0 0 1px rgba(255,255,255,0.18);
} }
.badge-icon { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
.badge-icon svg { width: 18px; height: 18px; }
.card-title { min-width: 0; } .card-title { min-width: 0; }
.card-platform { font-size: 11px; color: var(--text-3); display: flex; align-items: center; gap: 5px; } .card-platform { font-size: 11px; color: var(--text-3); display: flex; align-items: center; gap: 5px; }
.card-name { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .card-name { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -356,6 +358,41 @@ label { font-size: 12px; font-weight: 500; color: var(--text-2); margin-bottom:
} }
.spark i:hover { opacity: 1; } .spark i:hover { opacity: 1; }
/* 历史弹窗表格 */
.hist-list-wrap {
max-height: 320px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 8px;
}
.hist-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
.hist-table th, .hist-table td {
padding: 7px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.hist-table th {
position: sticky;
top: 0;
background: var(--bg-elev);
color: var(--text-3);
font-weight: 500;
font-size: 11px;
}
.hist-table tbody tr:last-child td { border-bottom: none; }
.hist-table td.h-bal {
text-align: right;
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--text);
}
.hist-table tbody tr { transition: background 120ms ease; }
.hist-table tbody tr:hover { background: var(--bg-elev-2); }
/* 确认弹窗 */ /* 确认弹窗 */
.confirm-modal .modal { width: 340px; } .confirm-modal .modal { width: 340px; }
.confirm-text { color: var(--text-2); font-size: 13px; margin-bottom: 18px; line-height: 1.6; } .confirm-text { color: var(--text-2); font-size: 13px; margin-bottom: 18px; line-height: 1.6; }
@@ -407,3 +444,203 @@ label { font-size: 12px; font-weight: 500; color: var(--text-2); margin-bottom:
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
.account-card { opacity: 1; transform: none; } .account-card { opacity: 1; transform: none; }
} }
/* 无动画模式:自动刷新静默更新,直接以最终态显示 */
.account-card.no-anim, .platform-row.no-anim {
opacity: 1;
transform: none;
animation: none;
}
/* ===== 账号监控:筛选栏 ===== */
.filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.filter-pills { display: flex; gap: 6px; flex-wrap: wrap; }
.pill {
font-family: inherit;
font-size: 12px;
font-weight: 500;
padding: 5px 13px;
border-radius: 999px;
border: 1px solid var(--border-strong);
background: var(--bg-elev);
color: var(--text-2);
cursor: pointer;
transition: background 150ms ease, color 150ms ease, border-color 150ms ease, transform 120ms var(--ease-out);
}
.pill:hover { color: var(--text); border-color: var(--text-3); }
.pill:active { transform: scale(0.96); }
.pill.active { background: var(--accent); border-color: transparent; color: #fff; }
.pill.warn.active { background: var(--warn); }
.pill.err.active { background: var(--err); }
.pill.pending.active { background: var(--text-2); }
.pill.disabled.active { background: var(--text-3); }
.filter-select { width: auto; min-width: 130px; }
/* ===== 账号卡片:重构 ===== */
.card-head { display: flex; align-items: center; gap: 10px; }
.card-head .card-title { flex: 1; min-width: 0; }
.card-head .badge-pill { margin-left: auto; flex-shrink: 0; }
.balance-vs {
margin-left: auto;
font-size: 11px;
color: var(--text-3);
white-space: nowrap;
}
.card-meta { display: flex; flex-direction: column; gap: 3px; }
.meta-item { display: inline-flex; align-items: center; gap: 5px; }
.meta-item svg { width: 11px; height: 11px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; }
.card-error {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--err);
background: var(--err-soft);
padding: 4px 8px;
border-radius: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-error svg { width: 11px; height: 11px; stroke: currentColor; fill: none; stroke-width: 2; flex-shrink: 0; }
/* 阈值条(新名,旧 .threshold-bar 保留) */
.threshold-track {
height: 4px;
border-radius: 2px;
background: var(--bg-elev-2);
overflow: hidden;
}
/* 操作区 */
.card-actions { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
.card-actions .btn { display: inline-flex; align-items: center; gap: 5px; }
.card-actions .btn svg {
width: 13px; height: 13px;
stroke: currentColor; fill: none; stroke-width: 2;
stroke-linecap: round; stroke-linejoin: round;
flex-shrink: 0;
}
.icon-btn { padding: 6px; line-height: 0; }
.icon-btn.danger:hover { color: var(--err); background: var(--err-soft); }
/* 检查按钮 loading 态 */
.check-btn.loading { pointer-events: none; opacity: 0.75; }
.spinner {
width: 11px; height: 11px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
display: inline-block;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* 启用/停用开关 */
.switch {
position: relative;
width: 36px; height: 20px;
flex-shrink: 0;
margin-left: auto;
cursor: pointer;
}
.switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.switch .slider {
position: absolute; inset: 0;
background: var(--bg-elev-2);
border: 1px solid var(--border-strong);
border-radius: 999px;
transition: background 180ms ease, border-color 180ms ease;
}
.switch .slider::before {
content: "";
position: absolute;
width: 14px; height: 14px;
border-radius: 50%;
left: 2px; top: 2px;
background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
transition: transform 180ms var(--ease-out);
}
.switch input:checked + .slider { background: var(--ok); border-color: transparent; }
.switch input:checked + .slider::before { transform: translateX(16px); }
.switch:active .slider::before { transform: scale(0.9); }
.switch input:checked:active + .slider::before { transform: translateX(16px) scale(0.9); }
/* 筛选空结果 */
.empty-filter {
text-align: center;
color: var(--text-3);
padding: 48px 0;
font-size: 13px;
}
/* 余额历史迷你折线图 */
.spark-wrap {
background: var(--bg-elev-2);
border-radius: 6px;
padding: 6px 8px 4px;
overflow: hidden;
}
.sparkline {
display: block;
width: 100%;
height: 30px;
}
/* 卡片迷你图(Chart.js canvas)——chart-box 固定高度,canvas 绝对定位,
避免 Chart.js responsive 尺寸与容器高度互相反馈导致无限变高 */
.chart-box {
position: relative;
height: 36px;
}
.card-chart {
position: absolute;
inset: 0;
width: 100% !important;
height: 100% !important;
display: block;
}
.spark-title {
font-size: 10px;
color: var(--text-3);
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.window-switch {
display: flex;
gap: 2px;
justify-content: flex-end;
margin-top: 3px;
}
.win-btn {
font-family: inherit;
font-size: 9.5px;
font-weight: 600;
letter-spacing: 0.02em;
padding: 1px 5px;
border-radius: 4px;
border: none;
background: transparent;
color: var(--text-3);
cursor: pointer;
transition: color 120ms ease, background 120ms ease, transform 100ms var(--ease-out);
}
.win-btn:hover { color: var(--text); background: var(--bg-elev-2); }
.win-btn:active { transform: scale(0.92); }
.win-btn.active { color: var(--accent); background: var(--accent-soft); }
/* 禁用卡片:原因提示保留可读性 */
.account-card.disabled .balance-value { color: var(--text-3); }
.account-card.pending { border-color: var(--border-strong); }
File diff suppressed because one or more lines are too long.
+1 -1
View File
@@ -14,7 +14,7 @@ PLATFORM = {"name": "OpenAI", "currency": "USD"}
def _insert_account(test_db, armed=1, enabled=1): def _insert_account(test_db, armed=1, enabled=1):
with db.get_conn() as conn: with db.get_conn() as conn:
conn.execute("INSERT INTO platforms (id, name, url, balance_path) VALUES (1, 'OpenAI', 'http://x', 'b')") conn.execute("INSERT INTO platforms (id, provider_id, name) VALUES (1, 'deepseek', 'OpenAI')")
conn.execute( conn.execute(
"""INSERT INTO accounts (id, platform_id, name, api_key, threshold, enabled, alert_armed) """INSERT INTO accounts (id, platform_id, name, api_key, threshold, enabled, alert_armed)
VALUES (1, 1, '主账号', 'a2tva2V5', 20.0, ?, ?)""", VALUES (1, 1, '主账号', 'a2tva2V5', 20.0, ?, ?)""",
+79 -11
View File
@@ -45,39 +45,57 @@ class TestAuth:
PLATFORM_PAYLOAD = { PLATFORM_PAYLOAD = {
"name": "OpenAI", "currency": "USD", "icon": "OpenAI", "method": "GET", "provider_id": "deepseek", "name": "DeepSeek-Test",
"url": "https://x.test?key={{apiKey}}",
"headers": {"Authorization": "Bearer {{apiKey}}"},
"body": "", "balance_path": "data.balance",
"interval_seconds": 120, "retry_count": 1, "timeout_seconds": 15, "interval_seconds": 120, "retry_count": 1, "timeout_seconds": 15,
"enabled": True, "note": "", "enabled": True, "note": "",
} }
class TestPlatforms: class TestPlatforms:
def test_builtin_platforms_auto_synced(self, client):
"""代码内置 provider 启动时自动同步为平台记录,无需手动添加。"""
h = _auth(client)
provs = client.get("/api/providers", headers=h).json()
plats = client.get("/api/platforms", headers=h).json()
for p in provs:
assert any(x["provider_id"] == p["id"] for x in plats), p["id"]
dp = next(x for x in plats if x["provider_id"] == "deepseek")
assert dp["name"] == "DeepSeek" and dp["currency"] == "CNY"
def test_crud_flow(self, client): def test_crud_flow(self, client):
h = _auth(client) h = _auth(client)
pid = client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h).json()["id"] pid = client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h).json()["id"]
lst = client.get("/api/platforms", headers=h).json() lst = client.get("/api/platforms", headers=h).json()
assert len(lst) == 1 and lst[0]["account_count"] == 0 and lst[0]["url"] == PLATFORM_PAYLOAD["url"] created = next(p for p in lst if p["id"] == pid)
assert created["provider_id"] == "deepseek"
assert created["currency"] == "CNY" # 默认取 provider 的货币
upd = client.put(f"/api/platforms/{pid}", json={"currency": "CNY", "interval_seconds": 300}, headers=h) upd = client.put(f"/api/platforms/{pid}", json={"currency": "USD", "interval_seconds": 300}, headers=h)
assert upd.status_code == 200 assert upd.status_code == 200
assert client.get("/api/platforms", headers=h).json()[0]["currency"] == "CNY" assert next(p for p in client.get("/api/platforms", headers=h).json() if p["id"] == pid)["currency"] == "USD"
assert client.delete(f"/api/platforms/{pid}", headers=h).status_code == 200 assert client.delete(f"/api/platforms/{pid}", headers=h).status_code == 200
assert client.get("/api/platforms", headers=h).json() == [] assert all(p["id"] != pid for p in client.get("/api/platforms", headers=h).json())
def test_duplicate_name_409(self, client): def test_duplicate_name_409(self, client):
h = _auth(client) h = _auth(client)
client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h) client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h)
assert client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h).status_code == 409 assert client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h).status_code == 409
def test_unknown_provider_400(self, client):
h = _auth(client)
assert client.post("/api/platforms", json=dict(PLATFORM_PAYLOAD, provider_id="nope"), headers=h).status_code == 400
def test_validation_errors(self, client): def test_validation_errors(self, client):
h = _auth(client) h = _auth(client)
assert client.post("/api/platforms", json=dict(PLATFORM_PAYLOAD, method="DELETE"), headers=h).status_code == 422 assert client.post("/api/platforms", json=dict(PLATFORM_PAYLOAD, name=""), headers=h).status_code == 422
assert client.post("/api/platforms", json=dict(PLATFORM_PAYLOAD, url=""), headers=h).status_code == 422 assert client.post("/api/platforms", json=dict(PLATFORM_PAYLOAD, interval_seconds=5), headers=h).status_code == 422
def test_providers_list(self, client):
h = _auth(client)
provs = client.get("/api/providers", headers=h).json()
assert any(p["id"] == "deepseek" and p["currency"] == "CNY" for p in provs)
def test_delete_cascades_accounts(self, client, test_db): def test_delete_cascades_accounts(self, client, test_db):
from app import db from app import db
@@ -100,7 +118,7 @@ class TestAccounts:
aid = client.post("/api/accounts", json={"platform_id": pid, "name": "", "api_key": "sk-secret", "threshold": 10}, headers=h).json()["id"] aid = client.post("/api/accounts", json={"platform_id": pid, "name": "", "api_key": "sk-secret", "threshold": 10}, headers=h).json()["id"]
acc = client.get("/api/accounts", headers=h).json()[0] acc = client.get("/api/accounts", headers=h).json()[0]
assert acc["id"] == aid and acc["api_key"] == "sk-secret" assert acc["id"] == aid and acc["api_key"] == "sk-secret"
assert acc["platform_name"] == "OpenAI" and acc["currency"] == "USD" assert acc["platform_name"] == "DeepSeek-Test" and acc["currency"] == "CNY"
def test_key_stored_base64(self, client, test_db): def test_key_stored_base64(self, client, test_db):
from app import db from app import db
@@ -144,6 +162,56 @@ class TestAccounts:
hist = client.get(f"/api/accounts/{aid}/history", headers=h).json() hist = client.get(f"/api/accounts/{aid}/history", headers=h).json()
assert [x["balance"] for x in hist] == [1.5, 2.5] assert [x["balance"] for x in hist] == [1.5, 2.5]
def test_batch_history(self, client, test_db):
from app import db
h = _auth(client)
pid = self._make_platform(client, h)
a1 = client.post("/api/accounts", json={"platform_id": pid, "name": "x", "api_key": "k"}, headers=h).json()["id"]
a2 = client.post("/api/accounts", json={"platform_id": pid, "name": "y", "api_key": "k2"}, headers=h).json()["id"]
with db.get_conn() as conn:
conn.execute("INSERT INTO balance_history (account_id, balance) VALUES (?, ?)", (a1, 1.0))
conn.execute("INSERT INTO balance_history (account_id, balance) VALUES (?, ?)", (a1, 2.0))
conn.execute("INSERT INTO balance_history (account_id, balance) VALUES (?, ?)", (a2, 9.0))
data = client.get("/api/history?limit=30", headers=h).json()
assert [x["balance"] for x in data[str(a1)]] == [1.0, 2.0]
assert [x["balance"] for x in data[str(a2)]] == [9.0]
assert len(data) == 2
def test_history_window_filter(self, client, test_db):
from app import db
h = _auth(client)
pid = self._make_platform(client, h)
aid = client.post("/api/accounts", json={"platform_id": pid, "name": "x", "api_key": "k"}, headers=h).json()["id"]
with db.get_conn() as conn:
conn.execute(
"INSERT INTO balance_history (account_id, balance, checked_at) VALUES (?, ?, datetime('now','localtime','-30 days'))",
(aid, 100.0),
)
conn.execute("INSERT INTO balance_history (account_id, balance) VALUES (?, ?)", (aid, 1.0))
all_rows = client.get(f"/api/accounts/{aid}/history", headers=h).json()
assert len(all_rows) == 2
day_rows = client.get(f"/api/accounts/{aid}/history?window=1d", headers=h).json()
assert len(day_rows) == 1 and day_rows[0]["balance"] == 1.0
yr_rows = client.get(f"/api/accounts/{aid}/history?window=1yr", headers=h).json()
assert len(yr_rows) == 2
def test_history_downsample(self, client, test_db):
from app import db
h = _auth(client)
pid = self._make_platform(client, h)
aid = client.post("/api/accounts", json={"platform_id": pid, "name": "x", "api_key": "k"}, headers=h).json()["id"]
with db.get_conn() as conn:
for i in range(50):
conn.execute("INSERT INTO balance_history (account_id, balance) VALUES (?, ?)", (aid, i))
rows = client.get(f"/api/accounts/{aid}/history?limit=10", headers=h).json()
assert len(rows) == 10
assert rows[0]["balance"] == 0 and rows[-1]["balance"] == 49 # 首尾保留
# 非法窗口参数被拒
assert client.get(f"/api/accounts/{aid}/history?window=2h", headers=h).status_code == 422
class TestSettings: class TestSettings:
def test_update_settings(self, client, tmp_path): def test_update_settings(self, client, tmp_path):
+134
View File
@@ -0,0 +1,134 @@
"""余额表达式引擎测试:运算符、函数、兼容性与安全。"""
import pytest
from app.expr import ExprError, evaluate_balance
DATA = {
"data": {
"balance": "1.50",
"total": 100,
"used": 40,
"fee": 2.5,
},
"list": [
{"x": 1, "y": 10},
{"x": 2, "y": 20},
],
"balances": [1, 2, 3],
"nested": {"a": {"b": 6}},
}
class TestPlainPath:
def test_dot_path(self):
assert evaluate_balance({"data": {"balance": 12.5}}, "data.balance") == 12.5
def test_array_index(self):
assert evaluate_balance(DATA, "list[0].x") == 1
def test_dollar_prefix(self):
assert evaluate_balance(DATA, "$.data.total") == 100
def test_string_number(self):
assert evaluate_balance(DATA, "data.balance") == 1.5
def test_missing_path(self):
with pytest.raises(ExprError, match="路径不存在"):
evaluate_balance(DATA, "data.nope")
def test_index_out_of_range(self):
with pytest.raises(ExprError, match="数组索引越界"):
evaluate_balance(DATA, "list[5].x")
class TestOperators:
def test_division(self):
assert evaluate_balance(DATA, "data.total / 100") == 1.0
def test_addition(self):
assert evaluate_balance(DATA, "data.total + data.used") == 140
def test_priority(self):
assert evaluate_balance(DATA, "data.total + data.used * 2") == 180
assert evaluate_balance(DATA, "(data.total + data.used) * 2") == 280
def test_floor_div_and_mod(self):
assert evaluate_balance(DATA, "data.total // 30") == 3
assert evaluate_balance(DATA, "data.total % 30") == 10
def test_power(self):
assert evaluate_balance(DATA, "2 ** 3 * 5") == 40
def test_unary_minus(self):
assert evaluate_balance(DATA, "-data.total") == -100
assert evaluate_balance(DATA, "data.total - -data.used") == 140
def test_float_result(self):
assert evaluate_balance(DATA, "data.total / 8") == 12.5
class TestFunctions:
def test_float(self):
assert evaluate_balance(DATA, "float(data.balance)") == 1.5
def test_int(self):
assert evaluate_balance(DATA, "int(data.total / 3)") == 33
def test_abs(self):
assert evaluate_balance(DATA, "abs(data.used - data.total)") == 60
def test_round_one_arg(self):
assert evaluate_balance(DATA, "round(data.fee * 3)") == 8
def test_round_two_args(self):
assert evaluate_balance(DATA, "round(data.fee, 1)") == 2.5
assert evaluate_balance(DATA, "round(3.14159, 2)") == 3.14
def test_sum_multi_args(self):
assert evaluate_balance(DATA, "sum(data.total, data.used, data.fee)") == 142.5
def test_sum_array(self):
assert evaluate_balance(DATA, "sum(balances)") == 6
def test_min_max(self):
assert evaluate_balance(DATA, "min(data.total, data.used)") == 40
assert evaluate_balance(DATA, "max(data.total, data.used)") == 100
assert evaluate_balance(DATA, "min(balances)") == 1
assert evaluate_balance(DATA, "max(balances)") == 3
def test_len(self):
assert evaluate_balance(DATA, "len(balances)") == 3
def test_nested_call(self):
assert evaluate_balance(DATA, "round(abs(data.used - data.total) / 3, 1)") == 20.0
class TestErrors:
def test_unknown_function(self):
with pytest.raises(ExprError, match="不支持的函数"):
evaluate_balance(DATA, "eval(data.balance)")
def test_syntax_error(self):
with pytest.raises(ExprError):
evaluate_balance(DATA, "data.total +")
with pytest.raises(ExprError):
evaluate_balance(DATA, "(data.total")
def test_bad_arity(self):
with pytest.raises(ExprError, match="float"):
evaluate_balance(DATA, "float()")
with pytest.raises(ExprError, match="round"):
evaluate_balance(DATA, "round(data.total, 2, 3)")
def test_division_by_zero(self):
with pytest.raises((ZeroDivisionError, ExprError)):
evaluate_balance(DATA, "data.total / 0")
def test_non_numeric_result(self):
with pytest.raises(ExprError, match="不是数字"):
evaluate_balance(DATA, "sum(list)") # 数组元素是对象,无法转数字
def test_string_literal_rejected(self):
with pytest.raises(ExprError):
evaluate_balance(DATA, "data.total + 'abc'")
+47 -18
View File
@@ -68,13 +68,32 @@ class FakeResponse:
return self._json return self._json
PLATFORM_GET = { class FakeProvider:
"method": "GET", """测试用 providerURL 带 key、余额在 data.balance。"""
"url": "https://x.test/api?key={{apiKey}}",
"headers": {"Authorization": "Bearer {{apiKey}}"}, method = "GET"
"body": "",
"balance_path": "data.balance", def __init__(self, path="data.balance"):
} self.path = path
def build_request(self, api_key):
return (
"https://x.test/api?key=" + api_key,
{"Authorization": "Bearer " + api_key},
None,
)
def extract_balance(self, data):
from app.expr import evaluate_balance
return evaluate_balance(data, self.path)
class FakePostProvider(FakeProvider):
method = "POST"
def build_request(self, api_key):
return ("https://x.test/api", {}, '{"api_key": "' + api_key + '"}')
class TestFetchBalance: class TestFetchBalance:
@@ -88,12 +107,11 @@ class TestFetchBalance:
return FakeResponse(200, json_data={"data": {"balance": 42.5}}) return FakeResponse(200, json_data={"data": {"balance": 42.5}})
monkeypatch.setattr("requests.get", fake_get) monkeypatch.setattr("requests.get", fake_get)
result = fetch_balance(PLATFORM_GET, "sk-1", retry_count=2, timeout=10) result = fetch_balance(FakeProvider(), "sk-1", retry_count=2, timeout=10)
assert result.ok and result.balance == 42.5 assert result.ok and result.balance == 42.5
assert len(calls) == 1 assert len(calls) == 1
def test_post_with_body(self, monkeypatch): def test_post_with_body(self, monkeypatch):
platform = dict(PLATFORM_GET, method="POST", body='{"api_key": "{{apiKey}}"}')
captured = {} captured = {}
def fake_post(url, headers=None, data=None, timeout=10): def fake_post(url, headers=None, data=None, timeout=10):
@@ -101,10 +119,21 @@ class TestFetchBalance:
return FakeResponse(200, json_data={"data": {"balance": 1}}) return FakeResponse(200, json_data={"data": {"balance": 1}})
monkeypatch.setattr("requests.post", fake_post) monkeypatch.setattr("requests.post", fake_post)
result = fetch_balance(platform, "sk-2", retry_count=2, timeout=10) result = fetch_balance(FakePostProvider(), "sk-2", retry_count=2, timeout=10)
assert result.ok assert result.ok
assert captured["data"] == '{"api_key": "sk-2"}' assert captured["data"] == '{"api_key": "sk-2"}'
def test_post_content_type_auto(self, monkeypatch):
captured = {}
def fake_post(url, headers=None, data=None, timeout=10):
captured["headers"] = headers
return FakeResponse(200, json_data={"data": {"balance": 1}})
monkeypatch.setattr("requests.post", fake_post)
fetch_balance(FakePostProvider(), "sk-2", retry_count=0, timeout=10)
assert captured["headers"].get("Content-Type") == "application/json"
def test_401_retries_then_auth_error(self, monkeypatch): def test_401_retries_then_auth_error(self, monkeypatch):
calls = [] calls = []
@@ -113,7 +142,7 @@ class TestFetchBalance:
return FakeResponse(401, text="unauthorized") return FakeResponse(401, text="unauthorized")
monkeypatch.setattr("requests.get", fake_get) monkeypatch.setattr("requests.get", fake_get)
result = fetch_balance(PLATFORM_GET, "bad", retry_count=2, timeout=10) result = fetch_balance(FakeProvider(), "bad", retry_count=2, timeout=10)
assert not result.ok and result.auth_error assert not result.ok and result.auth_error
assert len(calls) == 3 # 401 也按重试次数确认后再判定 assert len(calls) == 3 # 401 也按重试次数确认后再判定
@@ -125,7 +154,7 @@ class TestFetchBalance:
return FakeResponse(401) if len(calls) == 1 else FakeResponse(200, json_data={"data": {"balance": 6.6}}) return FakeResponse(401) if len(calls) == 1 else FakeResponse(200, json_data={"data": {"balance": 6.6}})
monkeypatch.setattr("requests.get", fake_get) monkeypatch.setattr("requests.get", fake_get)
result = fetch_balance(PLATFORM_GET, "k", retry_count=2, timeout=10) result = fetch_balance(FakeProvider(), "k", retry_count=2, timeout=10)
assert result.ok and result.balance == 6.6 assert result.ok and result.balance == 6.6
assert len(calls) == 2 # 瞬时 401 重试后成功,不误判禁用 assert len(calls) == 2 # 瞬时 401 重试后成功,不误判禁用
@@ -137,13 +166,13 @@ class TestFetchBalance:
return FakeResponse(500) if len(calls) < 3 else FakeResponse(200, json_data={"data": {"balance": 5}}) return FakeResponse(500) if len(calls) < 3 else FakeResponse(200, json_data={"data": {"balance": 5}})
monkeypatch.setattr("requests.get", fake_get) monkeypatch.setattr("requests.get", fake_get)
result = fetch_balance(PLATFORM_GET, "k", retry_count=2, timeout=10) result = fetch_balance(FakeProvider(), "k", retry_count=2, timeout=10)
assert result.ok and result.balance == 5 assert result.ok and result.balance == 5
assert len(calls) == 3 assert len(calls) == 3
def test_5xx_all_fail(self, monkeypatch): def test_5xx_all_fail(self, monkeypatch):
monkeypatch.setattr("requests.get", lambda *a, **k: FakeResponse(503)) monkeypatch.setattr("requests.get", lambda *a, **k: FakeResponse(503))
result = fetch_balance(PLATFORM_GET, "k", retry_count=2, timeout=10) result = fetch_balance(FakeProvider(), "k", retry_count=2, timeout=10)
assert not result.ok and not result.auth_error assert not result.ok and not result.auth_error
def test_network_error_retries(self, monkeypatch): def test_network_error_retries(self, monkeypatch):
@@ -157,7 +186,7 @@ class TestFetchBalance:
return FakeResponse(200, json_data={"data": {"balance": 8}}) return FakeResponse(200, json_data={"data": {"balance": 8}})
monkeypatch.setattr("requests.get", fake_get) monkeypatch.setattr("requests.get", fake_get)
result = fetch_balance(PLATFORM_GET, "k", retry_count=2, timeout=10) result = fetch_balance(FakeProvider(), "k", retry_count=2, timeout=10)
assert result.ok and result.balance == 8 assert result.ok and result.balance == 8
def test_other_4xx_no_retry(self, monkeypatch): def test_other_4xx_no_retry(self, monkeypatch):
@@ -168,19 +197,19 @@ class TestFetchBalance:
return FakeResponse(404, text="not found") return FakeResponse(404, text="not found")
monkeypatch.setattr("requests.get", fake_get) monkeypatch.setattr("requests.get", fake_get)
result = fetch_balance(PLATFORM_GET, "k", retry_count=2, timeout=10) result = fetch_balance(FakeProvider(), "k", retry_count=2, timeout=10)
assert not result.ok and not result.auth_error assert not result.ok and not result.auth_error
assert "404" in result.error assert "404" in result.error
assert len(calls) == 1 assert len(calls) == 1
def test_bad_json_path(self, monkeypatch): def test_bad_json_path(self, monkeypatch):
monkeypatch.setattr("requests.get", lambda *a, **k: FakeResponse(200, json_data={"x": 1})) monkeypatch.setattr("requests.get", lambda *a, **k: FakeResponse(200, json_data={"x": 1}))
result = fetch_balance(PLATFORM_GET, "k", retry_count=0, timeout=10) result = fetch_balance(FakeProvider(), "k", retry_count=0, timeout=10)
assert not result.ok assert not result.ok
assert "路径不存在" in result.error assert "路径不存在" in result.error
def test_invalid_json_body(self, monkeypatch): def test_invalid_json_body(self, monkeypatch):
monkeypatch.setattr("requests.get", lambda *a, **k: FakeResponse(200, text="<html>")) monkeypatch.setattr("requests.get", lambda *a, **k: FakeResponse(200, text="<html>"))
result = fetch_balance(PLATFORM_GET, "k", retry_count=0, timeout=10) result = fetch_balance(FakeProvider(), "k", retry_count=0, timeout=10)
assert not result.ok assert not result.ok
assert "JSON" in result.error assert "JSON" in result.error
+3 -4
View File
@@ -12,8 +12,7 @@ def _row(**overrides):
"threshold": 0, "enabled": 1, "alert_armed": 1, "threshold": 0, "enabled": 1, "alert_armed": 1,
"last_balance": None, "last_status": "pending", "last_error": "", "last_balance": None, "last_status": "pending", "last_error": "",
"last_check_at": None, "note": "", "last_check_at": None, "note": "",
"platform_name": "P", "currency": "USD", "icon": "", "method": "GET", "provider_id": "deepseek", "platform_name": "P", "currency": "USD", "icon": "",
"url": "http://x", "headers": "{}", "body": "", "balance_path": "b",
"interval_seconds": None, "retry_count": None, "timeout_seconds": None, "interval_seconds": None, "retry_count": None, "timeout_seconds": None,
"platform_enabled": 1, "platform_note": "", "platform_enabled": 1, "platform_note": "",
} }
@@ -32,8 +31,8 @@ class TestSplit:
assert platform["name"] == "P" assert platform["name"] == "P"
assert platform["id"] == 2 assert platform["id"] == 2
assert platform["enabled"] == 1 assert platform["enabled"] == 1
assert platform["url"] == "http://x" assert platform["provider_id"] == "deepseek"
assert platform["balance_path"] == "b" assert platform["currency"] == "USD"
def test_plain_key_passthrough(self): def test_plain_key_passthrough(self):
"""未编码的 key(历史数据)原样使用,不抛错。""" """未编码的 key(历史数据)原样使用,不抛错。"""
+76
View File
@@ -0,0 +1,76 @@
"""内置平台适配器测试。"""
import pytest
from app.expr import ExprError
from app.providers import get_provider, list_providers
from app.providers.deepseek import DeepSeekProvider
from app.providers.openrouter import OpenRouterProvider
DEEPSEEK_RESPONSE = {
"is_available": True,
"balance_infos": [
{
"currency": "CNY",
"total_balance": "1.34",
"granted_balance": "0.00",
"topped_up_balance": "1.34",
}
],
}
class TestDeepSeek:
def test_build_request(self):
prov = DeepSeekProvider()
url, headers, body = prov.build_request("sk-abc")
assert url == "https://api.deepseek.com/user/balance"
assert headers["Authorization"] == "Bearer sk-abc"
assert body is None
assert prov.method == "GET"
def test_extract_balance(self):
assert DeepSeekProvider().extract_balance(DEEPSEEK_RESPONSE) == 1.34
def test_extract_missing(self):
with pytest.raises(ExprError):
DeepSeekProvider().extract_balance({"is_available": False})
class TestOpenRouter:
def test_build_request(self):
prov = OpenRouterProvider()
url, headers, body = prov.build_request("sk-or-1")
assert url == "https://openrouter.ai/api/v1/credits"
assert headers["Authorization"] == "Bearer sk-or-1"
assert body is None
def test_extract_balance_remaining(self):
resp = {"data": {"total_credits": 100.5, "total_usage": 25.75}}
assert OpenRouterProvider().extract_balance(resp) == 74.75
def test_extract_zero_usage(self):
resp = {"data": {"total_credits": 10.0, "total_usage": 0}}
assert OpenRouterProvider().extract_balance(resp) == 10.0
def test_extract_missing(self):
with pytest.raises(ExprError):
OpenRouterProvider().extract_balance({"data": {}})
class TestRegistry:
def test_get_provider(self):
assert isinstance(get_provider("deepseek"), DeepSeekProvider)
assert isinstance(get_provider("openrouter"), OpenRouterProvider)
def test_unknown_provider_none(self):
assert get_provider("nope") is None
def test_list_providers(self):
provs = list_providers()
ids = [p["id"] for p in provs]
assert "deepseek" in ids and "openrouter" in ids
dp = next(p for p in provs if p["id"] == "deepseek")
assert dp["name"] == "DeepSeek" and dp["currency"] == "CNY" and dp["icon"] == "DeepSeek"
orp = next(p for p in provs if p["id"] == "openrouter")
assert orp["currency"] == "USD" and orp["icon"] == "OpenRouter"