Compare commits
10
Commits
510bd36733
...
a7d9836d1c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7d9836d1c | ||
|
|
c2308d5c1b | ||
|
|
1cdb5c3aef | ||
|
|
19bf8bfb38 | ||
|
|
194abb35c1 | ||
|
|
dd468f8576 | ||
|
|
b31a2ef382 | ||
|
|
157753d1a4 | ||
|
|
40d4e49e42 | ||
|
|
b64856f3c3 |
No files matched your search
+112
-33
@@ -3,9 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import sqlite3
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
@@ -26,6 +26,7 @@ from app.models import (
|
||||
SettingsUpdate,
|
||||
)
|
||||
from app.monitor import Monitor
|
||||
from app.providers import get_provider, list_providers as list_providers_svc
|
||||
|
||||
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="登录已失效")
|
||||
|
||||
|
||||
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:
|
||||
cfg = cfg or load_config()
|
||||
db.init_db()
|
||||
db.sync_platforms()
|
||||
monitor = Monitor(cfg)
|
||||
|
||||
@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)])
|
||||
def list_platforms():
|
||||
with db.get_conn() as conn:
|
||||
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"""
|
||||
).fetchall()
|
||||
result = []
|
||||
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
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@app.post("/api/platforms", dependencies=[Depends(require_auth)])
|
||||
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:
|
||||
with db.get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO platforms (name, currency, icon, method, url, headers, body,
|
||||
balance_path, interval_seconds, retry_count, timeout_seconds, enabled, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
"""INSERT INTO platforms (provider_id, name, currency, icon,
|
||||
interval_seconds, retry_count, timeout_seconds, enabled, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
body.name, body.currency, body.icon, body.method, body.url,
|
||||
__import__("json").dumps(body.headers, ensure_ascii=False),
|
||||
body.body, body.balance_path, body.interval_seconds,
|
||||
body.retry_count, body.timeout_seconds, int(body.enabled), body.note,
|
||||
body.provider_id, body.name, currency, icon,
|
||||
body.interval_seconds, body.retry_count, body.timeout_seconds,
|
||||
int(body.enabled), body.note,
|
||||
),
|
||||
)
|
||||
pid = cur.lastrowid
|
||||
@@ -139,22 +177,22 @@ def create_app(cfg: Config | None = None) -> FastAPI:
|
||||
@app.put("/api/platforms/{pid}", dependencies=[Depends(require_auth)])
|
||||
def update_platform(pid: int, body: PlatformUpdate):
|
||||
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:
|
||||
fields["name"] = body.name
|
||||
if body.currency is not None:
|
||||
fields["currency"] = body.currency
|
||||
if body.icon is not None:
|
||||
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:
|
||||
fields["interval_seconds"] = body.interval_seconds
|
||||
if body.retry_count is not None:
|
||||
@@ -279,14 +317,55 @@ def create_app(cfg: Config | None = None) -> FastAPI:
|
||||
return {"ok": True}
|
||||
|
||||
@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:
|
||||
rows = conn.execute(
|
||||
"SELECT balance, checked_at FROM balance_history WHERE account_id=? "
|
||||
"ORDER BY id DESC LIMIT ?",
|
||||
(aid, limit),
|
||||
f"SELECT balance, checked_at FROM balance_history WHERE {where} ORDER BY id",
|
||||
params,
|
||||
).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
|
||||
|
||||
# ---------- 设置 ----------
|
||||
|
||||
|
||||
@@ -11,14 +11,10 @@ DB_PATH = DATA_DIR / "monitor.db"
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS platforms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider_id TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
currency TEXT NOT NULL DEFAULT 'USD',
|
||||
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,
|
||||
retry_count INTEGER,
|
||||
timeout_seconds INTEGER,
|
||||
@@ -79,5 +75,30 @@ def init_db() -> None:
|
||||
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]:
|
||||
return [dict(r) for r in rows]
|
||||
+236
@@ -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
@@ -2,18 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import requests
|
||||
|
||||
from app.expr import extract_path, to_number
|
||||
from app.providers.base import BalanceProvider
|
||||
|
||||
logger = logging.getLogger("monitor.fetcher")
|
||||
|
||||
PLACEHOLDER = "{{apiKey}}"
|
||||
_TOKEN_RE = re.compile(r"([^.\[]+)|\[(\d+)\]")
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -33,57 +33,31 @@ def render_template(text: str, api_key: str) -> str:
|
||||
|
||||
|
||||
def extract_balance(data: object, path: str) -> float:
|
||||
"""按点路径/数组索引提取余额,如 data.balance、data[0].balance、$.data[0].balance。
|
||||
"""按点路径/数组索引提取余额(兼容旧写法,返回 float)。
|
||||
|
||||
取到的值必须是数字或可转数字的字符串,否则抛 ValueError。
|
||||
新写法请用 evaluate_balance(支持运算符与函数)。
|
||||
"""
|
||||
p = path.strip()
|
||||
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}")
|
||||
return to_number(extract_path(data, path), path)
|
||||
|
||||
|
||||
def _build_request(platform: dict, api_key: str) -> tuple[str, dict, str | None]:
|
||||
url = render_template(platform["url"], api_key)
|
||||
headers_raw = platform.get("headers") or {}
|
||||
if isinstance(headers_raw, str):
|
||||
headers_raw = json.loads(headers_raw) if headers_raw.strip() else {}
|
||||
headers = json.loads(render_template(json.dumps(headers_raw), api_key))
|
||||
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:
|
||||
def fetch_balance(
|
||||
provider: BalanceProvider,
|
||||
api_key: str,
|
||||
retry_count: int,
|
||||
timeout: int,
|
||||
) -> FetchResult:
|
||||
"""执行一次余额拉取。
|
||||
|
||||
策略:
|
||||
- 401/403 → auth_error(不重试,由调用方禁用账号并通知)
|
||||
- 其他 4xx → 直接失败(配置问题,不重试)
|
||||
- 401/403 → 先按重试次数确认(可能瞬时),仍失败 → auth_error(调用方禁用账号并通知)
|
||||
- 其他 4xx → 直接失败(不重试)
|
||||
- 网络异常 / 5xx → 重试 retry_count 次,间隔 2s
|
||||
- JSON 解析/路径提取失败 → 直接失败
|
||||
- JSON 解析/余额提取失败 → 直接失败
|
||||
"""
|
||||
url, headers, body = _build_request(platform, api_key)
|
||||
method = platform["method"]
|
||||
url, headers, body = provider.build_request(api_key)
|
||||
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
|
||||
for attempt in range(attempts):
|
||||
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,
|
||||
error="响应不是合法 JSON")
|
||||
try:
|
||||
balance = extract_balance(data, platform["balance_path"])
|
||||
except ValueError as exc:
|
||||
balance = provider.extract_balance(data)
|
||||
except Exception as 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=False, error=f"网络错误,重试 {retry_count} 次后仍失败")
|
||||
+5
-13
@@ -8,14 +8,10 @@ from pydantic import BaseModel, Field, model_validator
|
||||
# ---------- 平台 ----------
|
||||
|
||||
class PlatformBase(BaseModel):
|
||||
provider_id: 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)
|
||||
icon: str = Field(default="", 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)
|
||||
currency: str | None = Field(default=None, min_length=1, max_length=16)
|
||||
icon: str | None = Field(default=None, max_length=64)
|
||||
interval_seconds: int | None = Field(default=None, ge=10)
|
||||
retry_count: int | None = Field(default=None, ge=0, le=10)
|
||||
timeout_seconds: int | None = Field(default=None, ge=1, le=120)
|
||||
@@ -28,14 +24,10 @@ class PlatformCreate(PlatformBase):
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
retry_count: int | None = Field(default=None, ge=0, le=10)
|
||||
timeout_seconds: int | None = Field(default=None, ge=1, le=120)
|
||||
|
||||
+19
-8
@@ -18,6 +18,7 @@ from app import db
|
||||
from app.alert import evaluate, notify_disabled
|
||||
from app.config import Config
|
||||
from app.fetcher import fetch_balance
|
||||
from app.providers import get_provider
|
||||
|
||||
logger = logging.getLogger("monitor.scheduler")
|
||||
|
||||
@@ -28,17 +29,15 @@ _ACCOUNT_FIELDS = [
|
||||
"last_check_at", "note",
|
||||
]
|
||||
_PLATFORM_FIELDS = [
|
||||
"platform_id", "platform_name", "currency", "icon", "method", "url",
|
||||
"headers", "body", "balance_path", "interval_seconds",
|
||||
"retry_count", "timeout_seconds", "platform_enabled", "note",
|
||||
"platform_id", "provider_id", "platform_name", "currency", "icon",
|
||||
"interval_seconds", "retry_count", "timeout_seconds", "platform_enabled", "note",
|
||||
]
|
||||
|
||||
_ACCOUNT_SQL = """
|
||||
SELECT a.*,
|
||||
p.name AS platform_name, p.currency, p.icon, p.method, p.url,
|
||||
p.headers, p.body, p.balance_path, p.interval_seconds,
|
||||
p.retry_count, p.timeout_seconds, p.enabled AS platform_enabled,
|
||||
p.note AS platform_note
|
||||
p.provider_id, p.name AS platform_name, p.currency, p.icon,
|
||||
p.interval_seconds, p.retry_count, p.timeout_seconds,
|
||||
p.enabled AS platform_enabled, p.note AS platform_note
|
||||
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:
|
||||
cfg = self.cfg
|
||||
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")
|
||||
if retry is None:
|
||||
retry = cfg.retry_count
|
||||
timeout = platform.get("timeout_seconds")
|
||||
if timeout is None:
|
||||
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:
|
||||
if result.ok:
|
||||
|
||||
@@ -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())
|
||||
]
|
||||
@@ -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 中提取余额(数字),失败抛异常由上层记录。"""
|
||||
@@ -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/balance,Bearer 认证)"
|
||||
|
||||
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")
|
||||
@@ -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/credits,Bearer 认证,剩余 = 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
@@ -6,8 +6,23 @@
|
||||
let token = localStorage.getItem(TOKEN_KEY) || "";
|
||||
let accounts = [];
|
||||
let platforms = [];
|
||||
let providersList = [];
|
||||
let settings = 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;
|
||||
}
|
||||
|
||||
/* 内联 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 */
|
||||
function parseJsonInput(text, label, errorId) {
|
||||
let s = (text || "").trim();
|
||||
@@ -106,6 +130,15 @@
|
||||
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) {
|
||||
@@ -120,11 +153,16 @@
|
||||
|
||||
async function refresh() {
|
||||
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;
|
||||
platforms = plat;
|
||||
renderAccounts();
|
||||
if (!document.getElementById("view-platforms").classList.contains("hidden")) renderPlatforms();
|
||||
historyCache = hist || {};
|
||||
providersList = provs || [];
|
||||
renderAccounts(!hasRendered);
|
||||
if (!document.getElementById("view-platforms").classList.contains("hidden")) renderPlatforms(!hasRendered);
|
||||
hasRendered = true;
|
||||
const d = new Date();
|
||||
document.getElementById("refresh-time").textContent =
|
||||
d.toLocaleTimeString("zh-CN", { hour12: false });
|
||||
@@ -148,50 +186,143 @@
|
||||
return { cls: "pending", text: "待检查" };
|
||||
}
|
||||
|
||||
function accountCard(a, idx) {
|
||||
function accountCard(a, idx, animate = true) {
|
||||
const st = statusInfo(a);
|
||||
const plat = platforms.find((p) => p.id === a.platform_id) || {};
|
||||
const b = brandStyle(plat.icon || "");
|
||||
const bal = fmtBalance(a.last_balance);
|
||||
const th = fmtBalance(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" : "",
|
||||
st.cls === "disabled" ? "disabled" : ""].join(" ").trim();
|
||||
const pct = th && bal !== null ? Math.min(100, (Number(a.last_balance) / Number(th)) * 100) : 100;
|
||||
const fillCls = below ? "threshold-fill below" : "threshold-fill";
|
||||
const checking = checkingIds.has(a.id);
|
||||
const cardCls = ["account-card",
|
||||
st.cls === "warn" ? "below" : "",
|
||||
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 `
|
||||
<div class="${cardCls}" style="animation-delay:${Math.min(idx, 12) * 40}ms">
|
||||
<div class="card-top">
|
||||
<div class="badge" style="background:${b.color}">${escapeHtml(b.abbr)}</div>
|
||||
<div class="${cardCls}" style="${animStyle}">
|
||||
<div class="card-head">
|
||||
${badgeHtml(plat.icon || "", b.color, b.abbr)}
|
||||
<div class="card-title">
|
||||
<div class="card-platform">${escapeHtml(plat.name || "未知平台")} · ${escapeHtml(plat.currency || "")}</div>
|
||||
<div class="card-name" title="${escapeHtml(a.name)}">${escapeHtml(a.name)}</div>
|
||||
</div>
|
||||
<span class="badge-pill ${st.cls}">${st.text}</span>
|
||||
</div>
|
||||
<div class="balance-row">
|
||||
${bal !== null
|
||||
? `<span class="balance-value ${below ? "below" : ""}">${bal}</span><span class="balance-currency">${escapeHtml(plat.currency || "")}</span>`
|
||||
: `<span class="balance-empty">—</span>`}
|
||||
${th !== null ? `<span class="balance-vs">/ 阈值 ${th}</span>` : ""}
|
||||
</div>
|
||||
<div class="threshold-bar" title="阈值 ${th ?? "—"} ${escapeHtml(plat.currency || "")}">
|
||||
<div class="${fillCls}" style="width:${bal === null ? 0 : pct}%"></div>
|
||||
</div>
|
||||
${hist.length > 0
|
||||
? `<div class="spark-wrap">
|
||||
<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">
|
||||
<span class="badge-pill ${st.cls}">${st.text}</span>
|
||||
<span>上次检查:${fmtTime(a.last_check_at)}</span>
|
||||
${a.last_error ? `<span style="color:var(--err)">${escapeHtml(a.last_error)}</span>` : ""}
|
||||
<span class="meta-item">${ICONS.clock}上次 ${fmtTime(a.last_check_at)}</span>
|
||||
<span class="meta-item">${ICONS.refresh}每 ${plat.interval_seconds || (settings ? settings.global_interval_seconds : 300)}s</span>
|
||||
</div>
|
||||
${a.last_error ? `<div class="card-error" title="${escapeHtml(a.last_error)}">${ICONS.alert}${escapeHtml(a.last_error)}</div>` : ""}
|
||||
<div class="card-actions">
|
||||
<button class="btn sm" data-act="history" data-id="${a.id}">历史</button>
|
||||
<button class="btn sm" data-act="check" data-id="${a.id}" ${!a.enabled ? "disabled" : ""}>立即检查</button>
|
||||
<button class="btn sm" data-act="edit" data-id="${a.id}">编辑</button>
|
||||
<button class="btn sm danger" data-act="del" data-id="${a.id}">删除</button>
|
||||
${checkBtn}
|
||||
<button class="btn sm icon-btn" data-act="history" data-id="${a.id}" title="余额历史">${ICONS.clock}</button>
|
||||
<button class="btn sm icon-btn" data-act="edit" data-id="${a.id}" title="编辑账号">${ICONS.pencil}</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>`;
|
||||
}
|
||||
|
||||
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");
|
||||
destroyCardCharts();
|
||||
const stats = { total: accounts.length, ok: 0, below: 0, error: 0, disabled: 0, pending: 0 };
|
||||
accounts.forEach((a) => {
|
||||
const st = statusInfo(a);
|
||||
@@ -208,10 +339,43 @@
|
||||
document.getElementById("stat-disabled").textContent = stats.disabled;
|
||||
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");
|
||||
if (accounts.length > 0) {
|
||||
hint.classList.add("hidden");
|
||||
if (list.length === 0) {
|
||||
grid.innerHTML = '<div class="empty-filter">没有符合条件的账号,试试调整筛选条件。</div>';
|
||||
}
|
||||
} else {
|
||||
hint.classList.remove("hidden");
|
||||
hint.innerHTML = platforms.length === 0
|
||||
@@ -222,33 +386,35 @@
|
||||
|
||||
/* ---------- 渲染:平台 ---------- */
|
||||
|
||||
function renderPlatforms() {
|
||||
function renderPlatforms(animate = true) {
|
||||
const list = document.getElementById("platform-list");
|
||||
if (platforms.length === 0) {
|
||||
list.innerHTML = `<div class="empty-hint">还没有平台,点击右上角「+ 添加平台」创建。</div>`;
|
||||
list.innerHTML = `<div class="empty-hint">暂无平台(代码内置平台会在启动时自动同步)。</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = platforms.map((p, i) => {
|
||||
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 `
|
||||
<div class="platform-row ${p.enabled ? "" : "platform-off"}" style="animation-delay:${Math.min(i, 10) * 35}ms">
|
||||
<div class="badge" style="background:${b.color}">${escapeHtml(b.abbr)}</div>
|
||||
<div class="${rowCls}" style="${animStyle}">
|
||||
${badgeHtml(p.icon || "", b.color, b.abbr)}
|
||||
<div class="platform-info">
|
||||
<div class="platform-name">${escapeHtml(p.name)}
|
||||
${p.enabled ? "" : '<span class="badge-pill disabled">已停用</span>'}
|
||||
</div>
|
||||
<div class="platform-meta">
|
||||
${escapeHtml(p.method)} ${escapeHtml(p.url)} ·
|
||||
提取 ${escapeHtml(p.balance_path)} ·
|
||||
${prov ? escapeHtml(prov.name) : "未知提供方"} ·
|
||||
${escapeHtml(p.currency)} ·
|
||||
间隔 ${p.interval_seconds || "全局"}s ·
|
||||
${p.account_count} 个账号
|
||||
${prov && prov.description ? `<div style="color:var(--text-3)">${escapeHtml(prov.description)}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="platform-actions">
|
||||
<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 danger" data-act="del" data-id="${p.id}">删除</button>
|
||||
<button class="btn sm" data-act="edit" data-id="${p.id}">监控参数</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
@@ -256,11 +422,12 @@
|
||||
|
||||
/* ---------- 模态框 ---------- */
|
||||
|
||||
function openModal(html, onMount) {
|
||||
function openModal(html, onMount, onClose) {
|
||||
const root = document.getElementById("modal-root");
|
||||
const mask = document.createElement("div");
|
||||
mask.className = "modal-mask";
|
||||
mask.innerHTML = html;
|
||||
if (onClose) mask._onClose = onClose;
|
||||
mask.addEventListener("click", (e) => { if (e.target === mask) closeModal(mask); });
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeModal(mask); }, { once: true });
|
||||
root.appendChild(mask);
|
||||
@@ -269,7 +436,9 @@
|
||||
}
|
||||
|
||||
function closeModal(mask) {
|
||||
if (mask) mask.remove();
|
||||
if (!mask) return;
|
||||
if (mask._onClose) mask._onClose();
|
||||
mask.remove();
|
||||
}
|
||||
|
||||
function confirmDialog(text, onOk) {
|
||||
@@ -286,39 +455,23 @@
|
||||
mask.querySelector("[data-ok]").onclick = () => { closeModal(mask); onOk(); };
|
||||
}
|
||||
|
||||
/* 平台表单 */
|
||||
/* 平台监控参数表单(提供方由代码内置,不可改) */
|
||||
function platformForm(p) {
|
||||
const isEdit = !!p;
|
||||
const v = p || {};
|
||||
let savedHeaders = v.headers || {};
|
||||
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}';
|
||||
const prov = providersList.find((x) => x.id === v.provider_id);
|
||||
openModal(`
|
||||
<div class="modal">
|
||||
<h3>${isEdit ? "编辑平台" : "添加平台"}</h3>
|
||||
<h3>${escapeHtml(v.name || "平台")} · 监控参数</h3>
|
||||
<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"><label>货币单位 *</label><input id="pf-currency" value="${escapeHtml(v.currency || "USD")}"></div>
|
||||
<div class="form-row"><label>图标键(@lobehub/icons)</label><input id="pf-icon" value="${escapeHtml(v.icon || "")}" placeholder="如 OpenAI / DeepSeek"></div>
|
||||
<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 class="form-row full">
|
||||
<label>平台提供方(代码内置)</label>
|
||||
<input value="${prov ? escapeHtml(prov.name + " · " + (prov.description || "")) : escapeHtml(v.provider_id || "")}" disabled>
|
||||
</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>Headers(JSON,值支持 {{apiKey}})</label><textarea id="pf-headers">${escapeHtml(headersStr)}</textarea></div>
|
||||
<div class="form-row full"><label>Body(POST 时使用,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>重试次数(留空用全局 ${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 full"><label>备注</label><input id="pf-note" value="${escapeHtml(v.note || "")}"></div>
|
||||
</div>
|
||||
<p class="form-hint">提示:apikey 在 URL / Header / Body 中统一用 {{apiKey}} 占位,添加账号时自动替换。Headers/Body 需为 JSON,键值用双引号(单引号会自动兼容)。</p>
|
||||
<p class="modal-error" id="pf-error"></p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" data-cancel>取消</button>
|
||||
@@ -328,28 +481,17 @@
|
||||
mask.querySelector("[data-cancel]").onclick = () => closeModal(mask);
|
||||
mask.querySelector("#pf-save").onclick = async () => {
|
||||
const payload = {
|
||||
name: val("#pf-name"), currency: val("#pf-currency"), icon: val("#pf-icon"),
|
||||
method: val("#pf-method"), url: val("#pf-url"), balance_path: val("#pf-path"),
|
||||
interval_seconds: numOrNull("#pf-interval"),
|
||||
retry_count: numOrNull("#pf-retry"),
|
||||
timeout_seconds: numOrNull("#pf-timeout"),
|
||||
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 {
|
||||
if (isEdit) await api("/platforms/" + p.id, { method: "PUT", body: JSON.stringify(payload) });
|
||||
else await api("/platforms", { method: "POST", body: JSON.stringify(payload) });
|
||||
await api("/platforms/" + v.id, { method: "PUT", body: JSON.stringify(payload) });
|
||||
closeModal(mask);
|
||||
toast(isEdit ? "平台已更新" : "平台已添加");
|
||||
toast("平台监控参数已更新");
|
||||
await refresh();
|
||||
switchView("platforms");
|
||||
renderPlatforms();
|
||||
} catch (e) { err("#pf-error", e.message); }
|
||||
};
|
||||
});
|
||||
@@ -404,7 +546,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* 历史弹窗 */
|
||||
/* 历史弹窗:纯表格(时间 + 余额),无图表 */
|
||||
async function showHistory(accountId) {
|
||||
const acc = accounts.find((a) => a.id === accountId);
|
||||
if (!acc) return;
|
||||
@@ -421,12 +563,15 @@
|
||||
body.innerHTML = `<div style="padding:20px 0;text-align:center">暂无记录(检查成功后自动记录)</div>`;
|
||||
return;
|
||||
}
|
||||
const max = Math.max(...hist.map((h) => h.balance), 1e-9);
|
||||
const bars = hist.map((h) =>
|
||||
`<i title="${fmtBalance(h.balance)} ${escapeHtml(plat.currency || "")} @ ${escapeHtml(h.checked_at)}" style="height:${Math.max(6, (h.balance / max) * 100)}%"></i>`).join("");
|
||||
const items = [...hist].reverse().map((h) =>
|
||||
`<div class="history-item"><span>${escapeHtml(h.checked_at)}</span><span class="h-bal">${fmtBalance(h.balance)} ${escapeHtml(plat.currency || "")}</span></div>`).join("");
|
||||
body.innerHTML = `<div class="spark">${bars}</div>${items}`;
|
||||
const rows = [...hist].reverse().map((h) =>
|
||||
`<tr><td>${escapeHtml(h.checked_at)}</td><td class="h-bal">${fmtBalance(h.balance)} ${escapeHtml(plat.currency || "")}</td></tr>`).join("");
|
||||
body.innerHTML = `
|
||||
<div class="hist-list-wrap">
|
||||
<table class="hist-table">
|
||||
<thead><tr><th>时间</th><th>余额</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
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("logout-btn").onclick = logout;
|
||||
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 = () => {
|
||||
if (platforms.length === 0) {
|
||||
toast("请先在「平台」页添加平台", "err");
|
||||
@@ -469,6 +613,18 @@
|
||||
document.getElementById("save-settings-btn").onclick = saveSettings;
|
||||
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("platform-list").addEventListener("click", onPlatformAction);
|
||||
}
|
||||
@@ -512,19 +668,51 @@
|
||||
const id = Number(btn.dataset.id);
|
||||
const act = btn.dataset.act;
|
||||
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);
|
||||
confirmDialog(`确定删除账号「${escapeHtml(a ? a.name : id)}」?其历史记录将一并删除,此操作不可恢复。`, async () => {
|
||||
try { await api("/accounts/" + id, { method: "DELETE" }); toast("账号已删除"); await refresh(); }
|
||||
catch (err2) { toast(err2.message, "err"); }
|
||||
});
|
||||
} else if (act === "check") {
|
||||
btn.disabled = true;
|
||||
try { await api(`/accounts/${id}/check`, { method: "POST" }); toast("已开始检查"); }
|
||||
catch (err2) { toast(err2.message, "err"); btn.disabled = false; }
|
||||
setTimeout(refresh, 1500);
|
||||
if (checkingIds.has(id)) return;
|
||||
checkingIds.add(id);
|
||||
renderAccounts();
|
||||
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") {
|
||||
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();
|
||||
renderPlatforms();
|
||||
} 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"); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -54,6 +54,18 @@
|
||||
<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>
|
||||
<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="empty-hint" class="empty-hint hidden">
|
||||
<p>还没有账号。先到「平台」页添加一个平台,再回来添加账号。</p>
|
||||
@@ -63,8 +75,7 @@
|
||||
<!-- 平台视图 -->
|
||||
<section id="view-platforms" class="view hidden">
|
||||
<div class="view-head">
|
||||
<h2>平台配置</h2>
|
||||
<button id="add-platform-btn" class="btn primary">+ 添加平台</button>
|
||||
<h2>平台(代码内置,自动同步)</h2>
|
||||
</div>
|
||||
<div id="platform-list" class="platform-list"></div>
|
||||
</section>
|
||||
@@ -127,6 +138,8 @@
|
||||
<div id="modal-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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -235,6 +235,8 @@ label { font-size: 12px; font-weight: 500; color: var(--text-2); margin-bottom:
|
||||
letter-spacing: 0.02em;
|
||||
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-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; }
|
||||
@@ -356,6 +358,41 @@ label { font-size: 12px; font-weight: 500; color: var(--text-2); margin-bottom:
|
||||
}
|
||||
.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-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; }
|
||||
.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); }
|
||||
Vendored
+20
File diff suppressed because one or more lines are too long.
+1
-1
@@ -14,7 +14,7 @@ PLATFORM = {"name": "OpenAI", "currency": "USD"}
|
||||
|
||||
def _insert_account(test_db, armed=1, enabled=1):
|
||||
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(
|
||||
"""INSERT INTO accounts (id, platform_id, name, api_key, threshold, enabled, alert_armed)
|
||||
VALUES (1, 1, '主账号', 'a2tva2V5', 20.0, ?, ?)""",
|
||||
|
||||
+79
-11
@@ -45,39 +45,57 @@ class TestAuth:
|
||||
|
||||
|
||||
PLATFORM_PAYLOAD = {
|
||||
"name": "OpenAI", "currency": "USD", "icon": "OpenAI", "method": "GET",
|
||||
"url": "https://x.test?key={{apiKey}}",
|
||||
"headers": {"Authorization": "Bearer {{apiKey}}"},
|
||||
"body": "", "balance_path": "data.balance",
|
||||
"provider_id": "deepseek", "name": "DeepSeek-Test",
|
||||
"interval_seconds": 120, "retry_count": 1, "timeout_seconds": 15,
|
||||
"enabled": True, "note": "",
|
||||
}
|
||||
|
||||
|
||||
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):
|
||||
h = _auth(client)
|
||||
pid = client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h).json()["id"]
|
||||
|
||||
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 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.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):
|
||||
h = _auth(client)
|
||||
client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h)
|
||||
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):
|
||||
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, url=""), 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, 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):
|
||||
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"]
|
||||
acc = client.get("/api/accounts", headers=h).json()[0]
|
||||
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):
|
||||
from app import db
|
||||
@@ -144,6 +162,56 @@ class TestAccounts:
|
||||
hist = client.get(f"/api/accounts/{aid}/history", headers=h).json()
|
||||
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:
|
||||
def test_update_settings(self, client, tmp_path):
|
||||
|
||||
@@ -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
@@ -68,13 +68,32 @@ class FakeResponse:
|
||||
return self._json
|
||||
|
||||
|
||||
PLATFORM_GET = {
|
||||
"method": "GET",
|
||||
"url": "https://x.test/api?key={{apiKey}}",
|
||||
"headers": {"Authorization": "Bearer {{apiKey}}"},
|
||||
"body": "",
|
||||
"balance_path": "data.balance",
|
||||
}
|
||||
class FakeProvider:
|
||||
"""测试用 provider:URL 带 key、余额在 data.balance。"""
|
||||
|
||||
method = "GET"
|
||||
|
||||
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:
|
||||
@@ -88,12 +107,11 @@ class TestFetchBalance:
|
||||
return FakeResponse(200, json_data={"data": {"balance": 42.5}})
|
||||
|
||||
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 len(calls) == 1
|
||||
|
||||
def test_post_with_body(self, monkeypatch):
|
||||
platform = dict(PLATFORM_GET, method="POST", body='{"api_key": "{{apiKey}}"}')
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, headers=None, data=None, timeout=10):
|
||||
@@ -101,10 +119,21 @@ class TestFetchBalance:
|
||||
return FakeResponse(200, json_data={"data": {"balance": 1}})
|
||||
|
||||
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 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):
|
||||
calls = []
|
||||
|
||||
@@ -113,7 +142,7 @@ class TestFetchBalance:
|
||||
return FakeResponse(401, text="unauthorized")
|
||||
|
||||
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 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}})
|
||||
|
||||
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 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}})
|
||||
|
||||
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 len(calls) == 3
|
||||
|
||||
def test_5xx_all_fail(self, monkeypatch):
|
||||
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
|
||||
|
||||
def test_network_error_retries(self, monkeypatch):
|
||||
@@ -157,7 +186,7 @@ class TestFetchBalance:
|
||||
return FakeResponse(200, json_data={"data": {"balance": 8}})
|
||||
|
||||
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
|
||||
|
||||
def test_other_4xx_no_retry(self, monkeypatch):
|
||||
@@ -168,19 +197,19 @@ class TestFetchBalance:
|
||||
return FakeResponse(404, text="not found")
|
||||
|
||||
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 "404" in result.error
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_bad_json_path(self, monkeypatch):
|
||||
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 "路径不存在" in result.error
|
||||
|
||||
def test_invalid_json_body(self, monkeypatch):
|
||||
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 "JSON" in result.error
|
||||
@@ -12,8 +12,7 @@ def _row(**overrides):
|
||||
"threshold": 0, "enabled": 1, "alert_armed": 1,
|
||||
"last_balance": None, "last_status": "pending", "last_error": "",
|
||||
"last_check_at": None, "note": "",
|
||||
"platform_name": "P", "currency": "USD", "icon": "", "method": "GET",
|
||||
"url": "http://x", "headers": "{}", "body": "", "balance_path": "b",
|
||||
"provider_id": "deepseek", "platform_name": "P", "currency": "USD", "icon": "",
|
||||
"interval_seconds": None, "retry_count": None, "timeout_seconds": None,
|
||||
"platform_enabled": 1, "platform_note": "",
|
||||
}
|
||||
@@ -32,8 +31,8 @@ class TestSplit:
|
||||
assert platform["name"] == "P"
|
||||
assert platform["id"] == 2
|
||||
assert platform["enabled"] == 1
|
||||
assert platform["url"] == "http://x"
|
||||
assert platform["balance_path"] == "b"
|
||||
assert platform["provider_id"] == "deepseek"
|
||||
assert platform["currency"] == "USD"
|
||||
|
||||
def test_plain_key_passthrough(self):
|
||||
"""未编码的 key(历史数据)原样使用,不抛错。"""
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user