Compare commits

..
2 Commits
27 changed files with 244 additions and 1496 deletions

No files matched your search

-1
View File
@@ -4,4 +4,3 @@ __pycache__/
*.pyc
.pytest_cache/
*.log
config.json
-2
View File
@@ -52,10 +52,8 @@ def evaluate(account: dict, platform: dict, balance: float, cfg: Config) -> None
below = balance < threshold
if below and armed:
kind = "below"
logger.info("触发提醒: %s / %s 余额 %s < 阈值 %s%s", platform["name"], account["name"], _fmt(balance), _fmt(threshold), platform["currency"])
elif not below and not armed:
kind = "recovered"
logger.info("余额恢复: %s / %s 余额 %s >= 阈值 %s%s", platform["name"], account["name"], _fmt(balance), _fmt(threshold), platform["currency"])
else:
return
message = build_alert_message(kind, account, platform, balance)
+33 -122
View File
@@ -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,7 +26,6 @@ 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")
@@ -66,42 +65,9 @@ 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
@@ -118,11 +84,9 @@ def create_app(cfg: Config | None = None) -> FastAPI:
def login(body: LoginRequest):
import time
if body.password != cfg.password:
logger.warning("登录失败:密码错误")
raise HTTPException(status_code=401, detail="密码错误")
token = secrets.token_urlsafe(32)
_tokens[token] = time.time() + TOKEN_TTL
logger.info("登录成功")
return {"token": token}
@app.post("/api/logout")
@@ -133,40 +97,36 @@ 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.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
"""SELECT p.*, (SELECT COUNT(*) FROM accounts a WHERE a.platform_id = p.id) AS account_count
FROM platforms p ORDER BY p.id"""
).fetchall()
return [dict(r) for r in rows]
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
@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 (provider_id, name, currency, icon,
interval_seconds, retry_count, timeout_seconds, enabled, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
"""INSERT INTO platforms (name, currency, icon, method, url, headers, body,
balance_path, interval_seconds, retry_count, timeout_seconds, enabled, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
body.provider_id, body.name, currency, icon,
body.interval_seconds, body.retry_count, body.timeout_seconds,
int(body.enabled), body.note,
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,
),
)
pid = cur.lastrowid
@@ -174,28 +134,27 @@ def create_app(cfg: Config | None = None) -> FastAPI:
if "UNIQUE" in str(exc):
raise HTTPException(status_code=409, detail="平台名称已存在")
raise HTTPException(status_code=400, detail=str(exc))
logger.info("创建平台: %s (id=%s, provider=%s)", body.name, pid, body.provider_id)
return {"id": pid}
@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:
@@ -222,7 +181,6 @@ def create_app(cfg: Config | None = None) -> FastAPI:
raise HTTPException(status_code=400, detail=str(exc))
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="平台不存在")
logger.info("更新平台 id=%s 字段: %s", pid, ", ".join(fields))
return {"id": pid}
@app.delete("/api/platforms/{pid}", dependencies=[Depends(require_auth)])
@@ -231,7 +189,6 @@ def create_app(cfg: Config | None = None) -> FastAPI:
cur = conn.execute("DELETE FROM platforms WHERE id=?", (pid,))
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="平台不存在")
logger.info("删除平台 id=%s", pid)
return {"ok": True}
# ---------- 账号 ----------
@@ -272,7 +229,6 @@ def create_app(cfg: Config | None = None) -> FastAPI:
aid = cur.lastrowid
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
logger.info("创建账号: %s (id=%s, 平台id=%s)", body.name, aid, body.platform_id)
return {"id": aid}
@app.put("/api/accounts/{aid}", dependencies=[Depends(require_auth)])
@@ -306,7 +262,6 @@ def create_app(cfg: Config | None = None) -> FastAPI:
)
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="账号不存在")
logger.info("更新账号 id=%s 字段: %s", aid, ", ".join(fields))
return {"id": aid}
@app.delete("/api/accounts/{aid}", dependencies=[Depends(require_auth)])
@@ -315,7 +270,6 @@ def create_app(cfg: Config | None = None) -> FastAPI:
cur = conn.execute("DELETE FROM accounts WHERE id=?", (aid,))
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="账号不存在")
logger.info("删除账号 id=%s", aid)
return {"ok": True}
@app.post("/api/accounts/{aid}/check", dependencies=[Depends(require_auth)])
@@ -325,55 +279,14 @@ 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=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)
def account_history(aid: int, limit: int = Query(default=30, ge=1, le=200)):
with db.get_conn() as conn:
rows = conn.execute(
f"SELECT balance, checked_at FROM balance_history WHERE {where} ORDER BY id",
params,
"SELECT balance, checked_at FROM balance_history WHERE account_id=? "
"ORDER BY id DESC LIMIT ?",
(aid, limit),
).fetchall()
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
return [dict(r) for r in reversed(rows)]
# ---------- 设置 ----------
@@ -400,7 +313,6 @@ def create_app(cfg: Config | None = None) -> FastAPI:
changed = True
if changed:
save_config(cfg)
logger.info("设置已更新: %s", ", ".join(f for f in ("global_interval_seconds", "retry_count", "timeout_seconds", "max_workers", "telegram_bot_token", "telegram_chat_id") if getattr(body, f) is not None))
return {"ok": True}
@app.post("/api/settings/password", dependencies=[Depends(require_auth)])
@@ -409,7 +321,6 @@ def create_app(cfg: Config | None = None) -> FastAPI:
raise HTTPException(status_code=401, detail="旧密码错误")
cfg.password = body.new_password
save_config(cfg)
logger.info("管理密码已修改")
return {"ok": True}
# ---------- 静态前端 ----------
+5 -26
View File
@@ -11,10 +11,14 @@ 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,
@@ -75,30 +79,5 @@ 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
View File
@@ -1,236 +0,0 @@
"""受限的余额表达式引擎。
在 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()
+48 -24
View File
@@ -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,42 +33,66 @@ def render_template(text: str, api_key: str) -> str:
def extract_balance(data: object, path: str) -> float:
"""按点路径/数组索引提取余额(兼容旧写法,返回 float
"""按点路径/数组索引提取余额,如 data.balance、data[0].balance、$.data[0].balance
新写法请用 evaluate_balance(支持运算符与函数)
取到的值必须是数字或可转数字的字符串,否则抛 ValueError
"""
return to_number(extract_path(data, path), path)
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}")
def fetch_balance(
provider: BalanceProvider,
api_key: str,
retry_count: int,
timeout: int,
) -> FetchResult:
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:
"""执行一次余额拉取。
策略:
- 401/403 → 先按重试次数确认(可能瞬时),仍失败 → auth_error(调用方禁用账号并通知)
- 其他 4xx → 直接失败(不重试)
- 401/403 → auth_error不重试,由调用方禁用账号并通知)
- 其他 4xx → 直接失败(配置问题,不重试)
- 网络异常 / 5xx → 重试 retry_count 次,间隔 2s
- JSON 解析/余额提取失败 → 直接失败
- JSON 解析/路径提取失败 → 直接失败
"""
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"
url, headers, body = _build_request(platform, api_key)
method = platform["method"]
attempts = retry_count + 1
for attempt in range(attempts):
t0 = time.time()
try:
if method == "GET":
resp = requests.get(url, headers=headers, timeout=timeout)
else:
resp = requests.post(url, headers=headers, data=body, timeout=timeout)
logger.debug("请求完成 %s -> %s%.0fms", url, resp.status_code, (time.time() - t0) * 1000)
except requests.RequestException as exc:
logger.warning("请求异常(%s/%s) %s: %s%.0fms", attempt + 1, attempts, url, exc, (time.time() - t0) * 1000)
logger.warning("请求异常(%s/%s) %s: %s", attempt + 1, attempts, url, exc)
if attempt < attempts - 1:
time.sleep(2)
continue
@@ -94,8 +118,8 @@ def fetch_balance(
return FetchResult(ok=False, status_code=resp.status_code,
error="响应不是合法 JSON")
try:
balance = provider.extract_balance(data)
except Exception as exc:
balance = extract_balance(data, platform["balance_path"])
except ValueError 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} 次后仍失败")
+13 -5
View File
@@ -8,10 +8,14 @@ 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 | None = Field(default=None, min_length=1, max_length=16)
icon: str | None = Field(default=None, 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)
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)
@@ -24,10 +28,14 @@ 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, max_length=16)
currency: str | None = Field(default=None, min_length=1, 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)
+8 -23
View File
@@ -18,7 +18,6 @@ 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")
@@ -29,15 +28,17 @@ _ACCOUNT_FIELDS = [
"last_check_at", "note",
]
_PLATFORM_FIELDS = [
"platform_id", "provider_id", "platform_name", "currency", "icon",
"interval_seconds", "retry_count", "timeout_seconds", "platform_enabled", "note",
"platform_id", "platform_name", "currency", "icon", "method", "url",
"headers", "body", "balance_path", "interval_seconds",
"retry_count", "timeout_seconds", "platform_enabled", "note",
]
_ACCOUNT_SQL = """
SELECT a.*,
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
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
FROM accounts a JOIN platforms p ON p.id = a.platform_id
"""
@@ -137,25 +138,13 @@ 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(provider, account["api_key"], int(retry), int(timeout))
result = fetch_balance(platform, account["api_key"], int(retry), int(timeout))
with db.get_conn() as conn:
if result.ok:
@@ -182,13 +171,9 @@ class Monitor:
)
if result.auth_error:
logger.warning("认证失败已禁用: %s / %s%s", platform["name"], account["name"], result.error)
notify_disabled(account, platform, cfg)
elif result.ok:
logger.info("检查成功: %s / %s 余额 %.4f %s", platform["name"], account["name"], result.balance, platform["currency"])
evaluate(account, platform, result.balance, cfg)
else:
logger.warning("检查失败: %s / %s -> %s", platform["name"], account["name"], result.error)
# 调度:无论成功失败,按间隔排下一次
with self._lock:
-30
View File
@@ -1,30 +0,0 @@
"""平台适配器注册表:新增平台 = 加一个文件 + 在这里登记。"""
from __future__ import annotations
from app.providers.base import BalanceProvider
from app.providers.deepseek import DeepSeekProvider
from app.providers.openrouter import OpenRouterProvider
PROVIDERS: dict[str, type[BalanceProvider]] = {
"deepseek": DeepSeekProvider,
"openrouter": OpenRouterProvider,
}
def get_provider(provider_id: str) -> BalanceProvider | None:
cls = PROVIDERS.get(provider_id)
return cls() if cls else None
def list_providers() -> list[dict]:
return [
{
"id": p.id,
"name": p.name,
"currency": p.currency,
"icon": p.icon,
"description": p.description,
}
for p in (cls() for cls in PROVIDERS.values())
]
-39
View File
@@ -1,39 +0,0 @@
"""平台适配器基类。
多平台支持通过继承 BalanceProvider 实现:
1. 新建 app/providers/<name>.py
2. 继承 BalanceProvider,实现 build_request / extract_balance
3. 在 __init__.py 的 PROVIDERS 注册表中登记
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class BalanceProvider(ABC):
"""一个平台的余额获取实现。"""
#: 唯一标识(存入 platforms.provider_id
id: str = ""
#: 平台显示名
name: str = ""
#: 默认货币单位
currency: str = "USD"
#: @lobehub/icons 键(前端品牌色映射)
icon: str = ""
#: 简要说明(前端展示)
description: str = ""
#: 请求方法
method: str = "GET"
@abstractmethod
def build_request(self, api_key: str) -> tuple[str, dict, str | None]:
"""根据 api_key 构建请求,返回 (url, headers, body)。
body 为 None 表示无请求体;POST 平台返回 JSON 字符串。
"""
@abstractmethod
def extract_balance(self, data: object) -> float:
"""从响应 JSON 中提取余额(数字),失败抛异常由上层记录。"""
-28
View File
@@ -1,28 +0,0 @@
"""DeepSeek 开放平台余额。
接口:GET https://api.deepseek.com/user/balance
响应:{"is_available": true, "balance_infos": [{"currency": "CNY", "total_balance": "1.34", ...}]}
"""
from __future__ import annotations
from app.expr import evaluate_balance
from app.providers.base import BalanceProvider
class DeepSeekProvider(BalanceProvider):
id = "deepseek"
name = "DeepSeek"
currency = "CNY"
icon = "DeepSeek"
description = "DeepSeek 开放平台余额(GET /user/balanceBearer 认证)"
def build_request(self, api_key: str) -> tuple[str, dict, str | None]:
return (
"https://api.deepseek.com/user/balance",
{"Accept": "application/json", "Authorization": f"Bearer {api_key}"},
None,
)
def extract_balance(self, data: object) -> float:
return evaluate_balance(data, "balance_infos[0].total_balance")
-29
View File
@@ -1,29 +0,0 @@
"""OpenRouter 余额。
接口:GET https://openrouter.ai/api/v1/credits
响应:{"data": {"total_credits": 100.5, "total_usage": 25.75}}
余额取剩余可用:total_credits - total_usage
"""
from __future__ import annotations
from app.expr import evaluate_balance
from app.providers.base import BalanceProvider
class OpenRouterProvider(BalanceProvider):
id = "openrouter"
name = "OpenRouter"
currency = "USD"
icon = "OpenRouter"
description = "OpenRouter 余额(GET /api/v1/creditsBearer 认证,剩余 = total_credits - total_usage"
def build_request(self, api_key: str) -> tuple[str, dict, str | None]:
return (
"https://openrouter.ai/api/v1/credits",
{"Authorization": f"Bearer {api_key}"},
None,
)
def extract_balance(self, data: object) -> float:
return evaluate_balance(data, "data.total_credits - data.total_usage")
+90 -273
View File
@@ -6,23 +6,8 @@
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"];
/* ---------- 工具 ---------- */
@@ -69,15 +54,6 @@
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();
@@ -130,15 +106,6 @@
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) {
@@ -153,16 +120,11 @@
async function refresh() {
try {
const [acc, plat, hist, provs] = await Promise.all([
api("/accounts"), api("/platforms"), api("/history"), api("/providers"),
]);
const [acc, plat] = await Promise.all([api("/accounts"), api("/platforms")]);
accounts = acc;
platforms = plat;
historyCache = hist || {};
providersList = provs || [];
renderAccounts(!hasRendered);
if (!document.getElementById("view-platforms").classList.contains("hidden")) renderPlatforms(!hasRendered);
hasRendered = true;
renderAccounts();
if (!document.getElementById("view-platforms").classList.contains("hidden")) renderPlatforms();
const d = new Date();
document.getElementById("refresh-time").textContent =
d.toLocaleTimeString("zh-CN", { hour12: false });
@@ -186,143 +148,50 @@
return { cls: "pending", text: "待检查" };
}
function accountCard(a, idx, animate = true) {
function accountCard(a, idx) {
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 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>`;
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";
return `
<div class="${cardCls}" style="${animStyle}">
<div class="card-head">
${badgeHtml(plat.icon || "", b.color, b.abbr)}
<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="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>
${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="threshold-bar" title="阈值 ${th ?? "—"} ${escapeHtml(plat.currency || "")}">
<div class="${fillCls}" style="width:${bal === null ? 0 : pct}%"></div>
</div>
<div class="card-meta">
<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>
<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>` : ""}
</div>
${a.last_error ? `<div class="card-error" title="${escapeHtml(a.last_error)}">${ICONS.alert}${escapeHtml(a.last_error)}</div>` : ""}
<div class="card-actions">
${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>
<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>
</div>
</div>`;
}
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) {
function renderAccounts() {
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);
@@ -339,43 +208,10 @@
document.getElementById("stat-disabled").textContent = stats.disabled;
document.getElementById("stat-pending").textContent = stats.pending;
// 同步平台筛选下拉
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);
});
grid.innerHTML = accounts.map(accountCard).join("");
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
@@ -386,35 +222,33 @@
/* ---------- 渲染:平台 ---------- */
function renderPlatforms(animate = true) {
function renderPlatforms() {
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="${rowCls}" style="${animStyle}">
${badgeHtml(p.icon || "", b.color, b.abbr)}
<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="platform-info">
<div class="platform-name">${escapeHtml(p.name)}
${p.enabled ? "" : '<span class="badge-pill disabled">已停用</span>'}
</div>
<div class="platform-meta">
${prov ? escapeHtml(prov.name) : "未知提供方"} ·
${escapeHtml(p.method)} ${escapeHtml(p.url)} ·
提取 ${escapeHtml(p.balance_path)} ·
${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" data-act="edit" data-id="${p.id}">编辑</button>
<button class="btn sm danger" data-act="del" data-id="${p.id}">删除</button>
</div>
</div>`;
}).join("");
@@ -422,12 +256,11 @@
/* ---------- 模态框 ---------- */
function openModal(html, onMount, onClose) {
function openModal(html, onMount) {
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);
@@ -436,9 +269,7 @@
}
function closeModal(mask) {
if (!mask) return;
if (mask._onClose) mask._onClose();
mask.remove();
if (mask) mask.remove();
}
function confirmDialog(text, onOk) {
@@ -455,23 +286,39 @@
mask.querySelector("[data-ok]").onclick = () => { closeModal(mask); onOk(); };
}
/* 平台监控参数表单(提供方由代码内置,不可改) */
/* 平台表单 */
function platformForm(p) {
const isEdit = !!p;
const v = p || {};
const prov = providersList.find((x) => x.id === v.provider_id);
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}';
openModal(`
<div class="modal">
<h3>${escapeHtml(v.name || "平台")} · 监控参数</h3>
<h3>${isEdit ? "编辑平台" : "添加平台"}</h3>
<div class="form-grid">
<div class="form-row full">
<label>平台提供方(代码内置)</label>
<input value="${prov ? escapeHtml(prov.name + " · " + (prov.description || "")) : escapeHtml(v.provider_id || "")}" disabled>
<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>
<div class="form-row full"><label>URL *(支持 {{apiKey}}</label><input id="pf-url" value="${escapeHtml(v.url || "")}" placeholder="https://api.openai.com/v1/dashboard/billing/credit_grants?api_key={{apiKey}}"></div>
<div class="form-row full"><label>HeadersJSON,值支持 {{apiKey}}</label><textarea id="pf-headers">${escapeHtml(headersStr)}</textarea></div>
<div class="form-row full"><label>BodyPOST 时使用,JSON 模板,支持 {{apiKey}}</label><textarea id="pf-body" placeholder='{"api_key": "{{apiKey}}"}'>${escapeHtml(v.body || "")}</textarea></div>
<div class="form-row"><label>余额提取路径 *</label><input id="pf-path" value="${escapeHtml(v.balance_path || "")}" placeholder="data.balance 或 data[0].balance"></div>
<div class="form-row"><label>监控间隔(秒,留空用全局)</label><input id="pf-interval" type="number" min="10" value="${v.interval_seconds ?? ""}" placeholder="全局 ${settings ? settings.global_interval_seconds : 300}s"></div>
<div class="form-row"><label>重试次数(留空用全局 ${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>
@@ -481,17 +328,28 @@
mask.querySelector("[data-cancel]").onclick = () => closeModal(mask);
mask.querySelector("#pf-save").onclick = async () => {
const payload = {
interval_seconds: numOrNull("#pf-interval"),
retry_count: numOrNull("#pf-retry"),
timeout_seconds: numOrNull("#pf-timeout"),
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"),
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 {
await api("/platforms/" + v.id, { method: "PUT", body: JSON.stringify(payload) });
if (isEdit) await api("/platforms/" + p.id, { method: "PUT", body: JSON.stringify(payload) });
else await api("/platforms", { method: "POST", body: JSON.stringify(payload) });
closeModal(mask);
toast("平台监控参数已更新");
toast(isEdit ? "平台已更新" : "平台已添加");
await refresh();
renderPlatforms();
switchView("platforms");
} catch (e) { err("#pf-error", e.message); }
};
});
@@ -546,7 +404,7 @@
});
}
/* 历史弹窗:纯表格(时间 + 余额),无图表 */
/* 历史弹窗 */
async function showHistory(accountId) {
const acc = accounts.find((a) => a.id === accountId);
if (!acc) return;
@@ -563,15 +421,12 @@
body.innerHTML = `<div style="padding:20px 0;text-align:center">暂无记录(检查成功后自动记录)</div>`;
return;
}
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>`;
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}`;
} catch (e) {
mask.querySelector("#hist-body").textContent = "加载失败:" + e.message;
}
@@ -602,6 +457,7 @@
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");
@@ -613,18 +469,6 @@
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);
}
@@ -668,51 +512,19 @@
const id = Number(btn.dataset.id);
const act = btn.dataset.act;
if (act === "edit") accountForm(accounts.find((a) => a.id === id));
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") {
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") {
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}`);
}
btn.disabled = true;
try { await api(`/accounts/${id}/check`, { method: "POST" }); toast("已开始检查"); }
catch (err2) { toast(err2.message, "err"); btn.disabled = false; }
setTimeout(refresh, 1500);
} 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);
}
}
@@ -730,6 +542,11 @@
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"); }
});
}
}
-20
View File
@@ -1,20 +0,0 @@
/* 品牌图标(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>"
};
+2 -15
View File
@@ -54,18 +54,6 @@
<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>
@@ -75,7 +63,8 @@
<!-- 平台视图 -->
<section id="view-platforms" class="view hidden">
<div class="view-head">
<h2>平台(代码内置,自动同步)</h2>
<h2>平台配置</h2>
<button id="add-platform-btn" class="btn primary">+ 添加平台</button>
</div>
<div id="platform-list" class="platform-list"></div>
</section>
@@ -138,8 +127,6 @@
<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>
-237
View File
@@ -235,8 +235,6 @@ 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; }
@@ -358,41 +356,6 @@ 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; }
@@ -444,203 +407,3 @@ 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 canvaschart-box 固定高度canvas 绝对定位
避免 Chart.js responsive 尺寸与容器高度互相反馈导致无限变高 */
.chart-box {
position: relative;
height: 36px;
}
.card-chart {
position: absolute;
inset: 0;
width: 100% !important;
height: 100% !important;
display: block;
}
.spark-title {
font-size: 10px;
color: var(--text-3);
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.window-switch {
display: flex;
gap: 2px;
justify-content: flex-end;
margin-top: 3px;
}
.win-btn {
font-family: inherit;
font-size: 9.5px;
font-weight: 600;
letter-spacing: 0.02em;
padding: 1px 5px;
border-radius: 4px;
border: none;
background: transparent;
color: var(--text-3);
cursor: pointer;
transition: color 120ms ease, background 120ms ease, transform 100ms var(--ease-out);
}
.win-btn:hover { color: var(--text); background: var(--bg-elev-2); }
.win-btn:active { transform: scale(0.92); }
.win-btn.active { color: var(--accent); background: var(--accent-soft); }
/* 禁用卡片:原因提示保留可读性 */
.account-card.disabled .balance-value { color: var(--text-3); }
.account-card.pending { border-color: var(--border-strong); }
File diff suppressed because one or more lines are too long.
-1
View File
@@ -23,7 +23,6 @@ def send_message(bot_token: str, chat_id: str, text: str) -> bool:
timeout=10,
)
if resp.status_code == 200:
logger.info("Telegram 消息已发送(%d 字符)", len(text))
return True
logger.error("Telegram 发送失败 status=%s body=%s", resp.status_code, resp.text[:200])
except requests.RequestException as exc:
-10
View File
@@ -1,10 +0,0 @@
{
"port": 8000,
"password": "换成你的管理密码",
"global_interval_seconds": 300,
"retry_count": 2,
"timeout_seconds": 10,
"max_workers": 8,
"telegram_bot_token": "",
"telegram_chat_id": ""
}
+10
View File
@@ -0,0 +1,10 @@
{
"port": 8000,
"password": "admin123",
"global_interval_seconds": 300,
"retry_count": 5,
"timeout_seconds": 30,
"max_workers": 8,
"telegram_bot_token": "8644232754:AAGmeVIrIooApWD97QSaPWAS5DEb_187XpE",
"telegram_chat_id": "6028494474"
}
+1 -15
View File
@@ -6,33 +6,19 @@ import logging
import uvicorn
from app import db
from app.api import create_app
from app.config import load_config
from app.providers import list_providers
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("monitor.main")
def main() -> None:
cfg = load_config()
app = create_app(cfg)
tg = "已配置" if cfg.telegram_bot_token and cfg.telegram_chat_id else "未配置"
with db.get_conn() as conn:
total = conn.execute("SELECT COUNT(*) FROM accounts").fetchone()[0]
enabled = conn.execute("SELECT COUNT(*) FROM accounts WHERE enabled=1").fetchone()[0]
logger.info("========== AI Balance Monitor 启动 ==========")
logger.info("端口 %s | 全局间隔 %ss | 重试 %s | 超时 %ss | 并发 %s", cfg.port, cfg.global_interval_seconds, cfg.retry_count, cfg.timeout_seconds, cfg.max_workers)
logger.info("Telegram 提醒: %s", tg)
logger.info("内置平台: %s", ", ".join(p["name"] for p in list_providers()))
logger.info("账号: %s 个(启用 %s| 数据库: %s", total, enabled, db.DB_PATH)
logger.info("面板地址: http://127.0.0.1:%s", cfg.port)
print(f"AI Balance Monitor 已启动: http://127.0.0.1:{cfg.port}")
uvicorn.run(app, host="0.0.0.0", port=cfg.port, log_level="warning")
+1 -1
View File
@@ -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, provider_id, name) VALUES (1, 'deepseek', 'OpenAI')")
conn.execute("INSERT INTO platforms (id, name, url, balance_path) VALUES (1, 'OpenAI', 'http://x', 'b')")
conn.execute(
"""INSERT INTO accounts (id, platform_id, name, api_key, threshold, enabled, alert_armed)
VALUES (1, 1, '主账号', 'a2tva2V5', 20.0, ?, ?)""",
+11 -79
View File
@@ -45,57 +45,39 @@ class TestAuth:
PLATFORM_PAYLOAD = {
"provider_id": "deepseek", "name": "DeepSeek-Test",
"name": "OpenAI", "currency": "USD", "icon": "OpenAI", "method": "GET",
"url": "https://x.test?key={{apiKey}}",
"headers": {"Authorization": "Bearer {{apiKey}}"},
"body": "", "balance_path": "data.balance",
"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()
created = next(p for p in lst if p["id"] == pid)
assert created["provider_id"] == "deepseek"
assert created["currency"] == "CNY" # 默认取 provider 的货币
assert len(lst) == 1 and lst[0]["account_count"] == 0 and lst[0]["url"] == PLATFORM_PAYLOAD["url"]
upd = client.put(f"/api/platforms/{pid}", json={"currency": "USD", "interval_seconds": 300}, headers=h)
upd = client.put(f"/api/platforms/{pid}", json={"currency": "CNY", "interval_seconds": 300}, headers=h)
assert upd.status_code == 200
assert next(p for p in client.get("/api/platforms", headers=h).json() if p["id"] == pid)["currency"] == "USD"
assert client.get("/api/platforms", headers=h).json()[0]["currency"] == "CNY"
assert client.delete(f"/api/platforms/{pid}", headers=h).status_code == 200
assert all(p["id"] != pid for p in client.get("/api/platforms", headers=h).json())
assert 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, 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)
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
def test_delete_cascades_accounts(self, client, test_db):
from app import db
@@ -118,7 +100,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"] == "DeepSeek-Test" and acc["currency"] == "CNY"
assert acc["platform_name"] == "OpenAI" and acc["currency"] == "USD"
def test_key_stored_base64(self, client, test_db):
from app import db
@@ -162,56 +144,6 @@ 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):
-134
View File
@@ -1,134 +0,0 @@
"""余额表达式引擎测试:运算符、函数、兼容性与安全。"""
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'")
+18 -47
View File
@@ -68,32 +68,13 @@ class FakeResponse:
return self._json
class FakeProvider:
"""测试用 providerURL 带 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 + '"}')
PLATFORM_GET = {
"method": "GET",
"url": "https://x.test/api?key={{apiKey}}",
"headers": {"Authorization": "Bearer {{apiKey}}"},
"body": "",
"balance_path": "data.balance",
}
class TestFetchBalance:
@@ -107,11 +88,12 @@ class TestFetchBalance:
return FakeResponse(200, json_data={"data": {"balance": 42.5}})
monkeypatch.setattr("requests.get", fake_get)
result = fetch_balance(FakeProvider(), "sk-1", retry_count=2, timeout=10)
result = fetch_balance(PLATFORM_GET, "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):
@@ -119,21 +101,10 @@ class TestFetchBalance:
return FakeResponse(200, json_data={"data": {"balance": 1}})
monkeypatch.setattr("requests.post", fake_post)
result = fetch_balance(FakePostProvider(), "sk-2", retry_count=2, timeout=10)
result = fetch_balance(platform, "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 = []
@@ -142,7 +113,7 @@ class TestFetchBalance:
return FakeResponse(401, text="unauthorized")
monkeypatch.setattr("requests.get", fake_get)
result = fetch_balance(FakeProvider(), "bad", retry_count=2, timeout=10)
result = fetch_balance(PLATFORM_GET, "bad", retry_count=2, timeout=10)
assert not result.ok and result.auth_error
assert len(calls) == 3 # 401 也按重试次数确认后再判定
@@ -154,7 +125,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(FakeProvider(), "k", retry_count=2, timeout=10)
result = fetch_balance(PLATFORM_GET, "k", retry_count=2, timeout=10)
assert result.ok and result.balance == 6.6
assert len(calls) == 2 # 瞬时 401 重试后成功,不误判禁用
@@ -166,13 +137,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(FakeProvider(), "k", retry_count=2, timeout=10)
result = fetch_balance(PLATFORM_GET, "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(FakeProvider(), "k", retry_count=2, timeout=10)
result = fetch_balance(PLATFORM_GET, "k", retry_count=2, timeout=10)
assert not result.ok and not result.auth_error
def test_network_error_retries(self, monkeypatch):
@@ -186,7 +157,7 @@ class TestFetchBalance:
return FakeResponse(200, json_data={"data": {"balance": 8}})
monkeypatch.setattr("requests.get", fake_get)
result = fetch_balance(FakeProvider(), "k", retry_count=2, timeout=10)
result = fetch_balance(PLATFORM_GET, "k", retry_count=2, timeout=10)
assert result.ok and result.balance == 8
def test_other_4xx_no_retry(self, monkeypatch):
@@ -197,19 +168,19 @@ class TestFetchBalance:
return FakeResponse(404, text="not found")
monkeypatch.setattr("requests.get", fake_get)
result = fetch_balance(FakeProvider(), "k", retry_count=2, timeout=10)
result = fetch_balance(PLATFORM_GET, "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(FakeProvider(), "k", retry_count=0, timeout=10)
result = fetch_balance(PLATFORM_GET, "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(FakeProvider(), "k", retry_count=0, timeout=10)
result = fetch_balance(PLATFORM_GET, "k", retry_count=0, timeout=10)
assert not result.ok
assert "JSON" in result.error
+4 -3
View File
@@ -12,7 +12,8 @@ def _row(**overrides):
"threshold": 0, "enabled": 1, "alert_armed": 1,
"last_balance": None, "last_status": "pending", "last_error": "",
"last_check_at": None, "note": "",
"provider_id": "deepseek", "platform_name": "P", "currency": "USD", "icon": "",
"platform_name": "P", "currency": "USD", "icon": "", "method": "GET",
"url": "http://x", "headers": "{}", "body": "", "balance_path": "b",
"interval_seconds": None, "retry_count": None, "timeout_seconds": None,
"platform_enabled": 1, "platform_note": "",
}
@@ -31,8 +32,8 @@ class TestSplit:
assert platform["name"] == "P"
assert platform["id"] == 2
assert platform["enabled"] == 1
assert platform["provider_id"] == "deepseek"
assert platform["currency"] == "USD"
assert platform["url"] == "http://x"
assert platform["balance_path"] == "b"
def test_plain_key_passthrough(self):
"""未编码的 key(历史数据)原样使用,不抛错。"""
-76
View File
@@ -1,76 +0,0 @@
"""内置平台适配器测试。"""
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"