feat: AI 余额监控服务(平台/账号 CRUD、并发轮询、Telegram 阈值提醒)

This commit is contained in:
2026-08-04 23:51:06 +08:00
commit 17d2496a65
20 changed files with 2690 additions and 0 deletions
View File
+81
View File
@@ -0,0 +1,81 @@
"""阈值提醒状态机:低于阈值提醒一次,恢复后重新武装。
状态(accounts.alert_armed):
- 1 = 已武装,低于阈值时可提醒
- 0 = 已提醒,等待余额恢复到阈值以上
发送成功才切换状态;Telegram 失败则保持原状态,下次检查再试。
"""
from __future__ import annotations
import logging
from datetime import datetime
from app import db
from app.config import Config
from app.telegram import send_message
logger = logging.getLogger("monitor.alert")
def _fmt(v: float | None) -> str:
return "" if v is None else f"{v:g}"
def build_alert_message(kind: str, account: dict, platform: dict, balance: float | None) -> str:
pname = platform["name"]
aname = account["name"]
cur = platform["currency"]
th = account["threshold"]
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if kind == "below":
return (f"⚠️ 余额不足提醒\n"
f"平台:{pname}\n账号:{aname}\n"
f"当前余额:{_fmt(balance)} {cur}\n阈值:{_fmt(th)} {cur}\n"
f"时间:{now}")
if kind == "recovered":
return (f"✅ 余额已恢复\n"
f"平台:{pname}\n账号:{aname}\n"
f"当前余额:{_fmt(balance)} {cur}\n阈值:{_fmt(th)} {cur}\n"
f"时间:{now}")
if kind == "disabled":
return (f"🚫 账号已自动禁用\n"
f"平台:{pname}\n账号:{aname}\n"
f"原因:{account.get('last_error') or '认证失败'}")
return ""
def evaluate(account: dict, platform: dict, balance: float, cfg: Config) -> None:
"""拉取成功后调用:按状态机决定是否发送提醒,发送成功才更新武装状态。"""
threshold = account["threshold"]
armed = bool(account["alert_armed"])
below = balance < threshold
if below and armed:
kind = "below"
elif not below and not armed:
kind = "recovered"
else:
return
message = build_alert_message(kind, account, platform, balance)
if send_message(cfg.telegram_bot_token, cfg.telegram_chat_id, message):
new_armed = 0 if kind == "below" else 1
with db.get_conn() as conn:
conn.execute("UPDATE accounts SET alert_armed = ? WHERE id = ?", (new_armed, account["id"]))
conn.execute(
"INSERT INTO alert_log (account_id, type, message) VALUES (?, ?, ?)",
(account["id"], kind, message),
)
logger.info("已发送%s提醒: account=%s", kind, account["id"])
else:
logger.warning("提醒发送失败,保持原状态稍后重试: account=%s kind=%s", account["id"], kind)
def notify_disabled(account: dict, platform: dict, cfg: Config) -> None:
"""认证失败禁用账号后通知用户(不参与状态机)。"""
message = build_alert_message("disabled", account, platform, None)
if send_message(cfg.telegram_bot_token, cfg.telegram_chat_id, message):
with db.get_conn() as conn:
conn.execute(
"INSERT INTO alert_log (account_id, type, message) VALUES (?, ?, ?)",
(account["id"], "disabled", message),
)
+325
View File
@@ -0,0 +1,325 @@
"""FastAPI 应用:认证、平台/账号 CRUD、设置、静态前端。"""
from __future__ import annotations
import base64
import logging
import secrets
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import Depends, FastAPI, Header, HTTPException, Query
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import ValidationError
from app import db
from app.config import Config, load_config, save_config
from app.models import (
AccountCreate,
AccountUpdate,
LoginRequest,
PasswordChange,
PlatformCreate,
PlatformUpdate,
SettingsUpdate,
)
from app.monitor import Monitor
logger = logging.getLogger("monitor.api")
STATIC_DIR = Path(__file__).resolve().parent / "static"
# 内存 token 表:{token: 过期时间戳},重启即失效(单用户场景足够)
_tokens: dict[str, float] = {}
TOKEN_TTL = 7 * 24 * 3600
def _b64(v: str) -> str:
return base64.b64encode(v.encode("utf-8")).decode("ascii")
def _unb64(v: str) -> str:
try:
return base64.b64decode(v.encode("ascii")).decode("utf-8")
except Exception:
return v
def _account_out(row: dict) -> dict:
out = dict(row)
out["api_key"] = _unb64(out["api_key"]) # 前端展示/编辑需要明文(本地工具约定)
return out
def require_auth(authorization: str | None = Header(default=None)) -> None:
if authorization is None or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未登录")
token = authorization.removeprefix("Bearer ").strip()
ts = _tokens.get(token)
if ts is None:
raise HTTPException(status_code=401, detail="登录已失效")
if ts < __import__("time").time():
_tokens.pop(token, None)
raise HTTPException(status_code=401, detail="登录已失效")
def create_app(cfg: Config | None = None) -> FastAPI:
cfg = cfg or load_config()
db.init_db()
monitor = Monitor(cfg)
@asynccontextmanager
async def lifespan(app: FastAPI):
monitor.start()
yield
monitor.stop()
app = FastAPI(title="AI Balance Monitor", lifespan=lifespan)
# ---------- 认证 ----------
@app.post("/api/login")
def login(body: LoginRequest):
import time
if body.password != cfg.password:
raise HTTPException(status_code=401, detail="密码错误")
token = secrets.token_urlsafe(32)
_tokens[token] = time.time() + TOKEN_TTL
return {"token": token}
@app.post("/api/logout")
def logout(authorization: str | None = Header(default=None)):
if authorization and authorization.startswith("Bearer "):
_tokens.pop(authorization.removeprefix("Bearer ").strip(), None)
return {"ok": True}
# ---------- 平台 ----------
@app.get("/api/platforms", dependencies=[Depends(require_auth)])
def list_platforms():
with db.get_conn() as conn:
rows = conn.execute(
"""SELECT p.*, (SELECT COUNT(*) FROM accounts a WHERE a.platform_id = p.id) AS account_count
FROM platforms p ORDER BY p.id"""
).fetchall()
return [dict(r) for r in rows]
@app.post("/api/platforms", dependencies=[Depends(require_auth)])
def create_platform(body: PlatformCreate):
try:
with db.get_conn() as conn:
cur = conn.execute(
"""INSERT INTO platforms (name, currency, icon, method, url, headers, body,
balance_path, interval_seconds, retry_count, timeout_seconds, enabled, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
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
except Exception as exc:
if "UNIQUE" in str(exc):
raise HTTPException(status_code=409, detail="平台名称已存在")
raise HTTPException(status_code=400, detail=str(exc))
return {"id": pid}
@app.put("/api/platforms/{pid}", dependencies=[Depends(require_auth)])
def update_platform(pid: int, body: PlatformUpdate):
fields = {}
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:
fields["retry_count"] = body.retry_count
if body.timeout_seconds is not None:
fields["timeout_seconds"] = body.timeout_seconds
if body.enabled is not None:
fields["enabled"] = int(body.enabled)
if body.note is not None:
fields["note"] = body.note
if not fields:
raise HTTPException(status_code=400, detail="没有可更新的字段")
try:
with db.get_conn() as conn:
cur = conn.execute(
"UPDATE platforms SET "
+ ", ".join(f"{k}=?" for k in fields)
+ ", updated_at=datetime('now','localtime') WHERE id=?",
(*fields.values(), pid),
)
except Exception as exc:
if "UNIQUE" in str(exc):
raise HTTPException(status_code=409, detail="平台名称已存在")
raise HTTPException(status_code=400, detail=str(exc))
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="平台不存在")
return {"id": pid}
@app.delete("/api/platforms/{pid}", dependencies=[Depends(require_auth)])
def delete_platform(pid: int):
with db.get_conn() as conn:
cur = conn.execute("DELETE FROM platforms WHERE id=?", (pid,))
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="平台不存在")
return {"ok": True}
# ---------- 账号 ----------
@app.get("/api/accounts", dependencies=[Depends(require_auth)])
def list_accounts(
platform_id: int | None = Query(default=None),
include_disabled: bool = Query(default=True),
):
sql = """SELECT a.*, p.name AS platform_name, p.currency, p.icon,
p.interval_seconds AS platform_interval
FROM accounts a JOIN platforms p ON p.id = a.platform_id"""
params: list = []
if platform_id is not None:
sql += " WHERE a.platform_id = ?"
params.append(platform_id)
if not include_disabled:
sql += " AND a.enabled = 1" if "WHERE" in sql else " WHERE a.enabled = 1"
sql += " ORDER BY a.id"
with db.get_conn() as conn:
rows = conn.execute(sql, params).fetchall()
return [_account_out(dict(r)) for r in rows]
@app.post("/api/accounts", dependencies=[Depends(require_auth)])
def create_account(body: AccountCreate):
with db.get_conn() as conn:
plat = conn.execute("SELECT id FROM platforms WHERE id=?", (body.platform_id,)).fetchone()
if plat is None:
raise HTTPException(status_code=404, detail="平台不存在")
try:
with db.get_conn() as conn:
cur = conn.execute(
"""INSERT INTO accounts (platform_id, name, api_key, threshold, enabled, note)
VALUES (?, ?, ?, ?, ?, ?)""",
(body.platform_id, body.name, _b64(body.api_key),
body.threshold, int(body.enabled), body.note),
)
aid = cur.lastrowid
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"id": aid}
@app.put("/api/accounts/{aid}", dependencies=[Depends(require_auth)])
def update_account(aid: int, body: AccountUpdate):
fields = {}
if body.platform_id is not None:
fields["platform_id"] = body.platform_id
if body.name is not None:
fields["name"] = body.name
if body.api_key is not None:
fields["api_key"] = _b64(body.api_key)
if body.threshold is not None:
fields["threshold"] = body.threshold
if body.enabled is not None:
fields["enabled"] = int(body.enabled)
if body.note is not None:
fields["note"] = body.note
if not fields:
raise HTTPException(status_code=400, detail="没有可更新的字段")
if "platform_id" in fields:
with db.get_conn() as conn:
plat = conn.execute("SELECT id FROM platforms WHERE id=?", (fields["platform_id"],)).fetchone()
if plat is None:
raise HTTPException(status_code=404, detail="平台不存在")
with db.get_conn() as conn:
cur = conn.execute(
"UPDATE accounts SET "
+ ", ".join(f"{k}=?" for k in fields)
+ ", updated_at=datetime('now','localtime') WHERE id=?",
(*fields.values(), aid),
)
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="账号不存在")
return {"id": aid}
@app.delete("/api/accounts/{aid}", dependencies=[Depends(require_auth)])
def delete_account(aid: int):
with db.get_conn() as conn:
cur = conn.execute("DELETE FROM accounts WHERE id=?", (aid,))
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="账号不存在")
return {"ok": True}
@app.post("/api/accounts/{aid}/check", dependencies=[Depends(require_auth)])
def check_account_now(aid: int):
if not monitor.check_now(aid):
raise HTTPException(status_code=409, detail="账号未启用或正在检查中")
return {"ok": True}
@app.get("/api/accounts/{aid}/history", dependencies=[Depends(require_auth)])
def account_history(aid: int, limit: int = Query(default=30, ge=1, le=200)):
with db.get_conn() as conn:
rows = conn.execute(
"SELECT balance, checked_at FROM balance_history WHERE account_id=? "
"ORDER BY id DESC LIMIT ?",
(aid, limit),
).fetchall()
return [dict(r) for r in reversed(rows)]
# ---------- 设置 ----------
@app.get("/api/settings", dependencies=[Depends(require_auth)])
def get_settings():
return {
"port": cfg.port,
"global_interval_seconds": cfg.global_interval_seconds,
"retry_count": cfg.retry_count,
"timeout_seconds": cfg.timeout_seconds,
"max_workers": cfg.max_workers,
"telegram_bot_token": cfg.telegram_bot_token,
"telegram_chat_id": cfg.telegram_chat_id,
}
@app.put("/api/settings", dependencies=[Depends(require_auth)])
def update_settings(body: SettingsUpdate):
changed = False
for field in ("global_interval_seconds", "retry_count", "timeout_seconds",
"max_workers", "telegram_bot_token", "telegram_chat_id"):
value = getattr(body, field)
if value is not None and getattr(cfg, field) != value:
setattr(cfg, field, value)
changed = True
if changed:
save_config(cfg)
return {"ok": True}
@app.post("/api/settings/password", dependencies=[Depends(require_auth)])
def change_password(body: PasswordChange):
if body.old_password != cfg.password:
raise HTTPException(status_code=401, detail="旧密码错误")
cfg.password = body.new_password
save_config(cfg)
return {"ok": True}
# ---------- 静态前端 ----------
@app.get("/")
def index():
return FileResponse(STATIC_DIR / "index.html")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
return app
+46
View File
@@ -0,0 +1,46 @@
"""配置加载与保存:config.json 是唯一配置来源。"""
from __future__ import annotations
import json
import threading
from dataclasses import dataclass, asdict
from pathlib import Path
CONFIG_PATH = Path(__file__).resolve().parent.parent / "config.json"
_lock = threading.Lock()
@dataclass
class Config:
"""全局配置(运行时可改的字段会在界面设置页暴露)。"""
port: int = 8000
password: str = "admin123"
global_interval_seconds: int = 300
retry_count: int = 2
timeout_seconds: int = 10
max_workers: int = 8
telegram_bot_token: str = ""
telegram_chat_id: str = ""
def load_config() -> Config:
with _lock:
if not CONFIG_PATH.exists():
cfg = Config()
save_config(cfg)
return cfg
raw = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
known = {f: getattr(Config, f) for f in Config.__dataclass_fields__}
data = {k: v for k, v in raw.items() if k in known}
return Config(**data)
def save_config(cfg: Config) -> None:
with _lock:
CONFIG_PATH.write_text(
json.dumps(asdict(cfg), ensure_ascii=False, indent=2),
encoding="utf-8",
)
+83
View File
@@ -0,0 +1,83 @@
"""SQLite 数据层:连接短生命周期(每次操作新建),线程安全。"""
from __future__ import annotations
import sqlite3
from pathlib import Path
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
DB_PATH = DATA_DIR / "monitor.db"
SCHEMA = """
CREATE TABLE IF NOT EXISTS platforms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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,
enabled INTEGER NOT NULL DEFAULT 1,
note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform_id INTEGER NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
name TEXT NOT NULL,
api_key TEXT NOT NULL,
threshold REAL NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
alert_armed INTEGER NOT NULL DEFAULT 1,
last_balance REAL,
last_status TEXT NOT NULL DEFAULT 'pending',
last_error TEXT NOT NULL DEFAULT '',
last_check_at TEXT,
note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE TABLE IF NOT EXISTS balance_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
balance REAL NOT NULL,
checked_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE TABLE IF NOT EXISTS alert_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
type TEXT NOT NULL,
message TEXT NOT NULL,
sent_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE INDEX IF NOT EXISTS idx_accounts_platform ON accounts(platform_id);
CREATE INDEX IF NOT EXISTS idx_history_account ON balance_history(account_id, id);
CREATE INDEX IF NOT EXISTS idx_alert_account ON alert_log(account_id);
"""
def get_conn() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def init_db() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
with get_conn() as conn:
conn.executescript(SCHEMA)
def rows_to_dicts(rows: list[sqlite3.Row]) -> list[dict]:
return [dict(r) for r in rows]
+118
View File
@@ -0,0 +1,118 @@
"""余额获取:模板渲染 → HTTP 请求(带重试)→ JSON 路径提取。"""
from __future__ import annotations
import json
import logging
import re
import time
from dataclasses import dataclass
import requests
logger = logging.getLogger("monitor.fetcher")
PLACEHOLDER = "{{apiKey}}"
_TOKEN_RE = re.compile(r"([^.\[]+)|\[(\d+)\]")
@dataclass
class FetchResult:
"""一次拉取的结果。"""
ok: bool
balance: float | None = None
auth_error: bool = False
status_code: int | None = None
error: str = ""
def render_template(text: str, api_key: str) -> str:
"""把模板中的 {{apiKey}} 替换为账号密钥。"""
return text.replace(PLACEHOLDER, api_key)
def extract_balance(data: object, path: str) -> float:
"""按点路径/数组索引提取余额,如 data.balance、data[0].balance、$.data[0].balance。
取到的值必须是数字或可转数字的字符串,否则抛 ValueError。
"""
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 _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)
return url, headers, body
def fetch_balance(platform: dict, api_key: str, retry_count: int, timeout: int) -> FetchResult:
"""执行一次余额拉取。
策略:
- 401/403 → auth_error(不重试,由调用方禁用账号并通知)
- 其他 4xx → 直接失败(配置问题,不重试)
- 网络异常 / 5xx → 重试 retry_count 次,间隔 2s
- JSON 解析/路径提取失败 → 直接失败
"""
url, headers, body = _build_request(platform, api_key)
method = platform["method"]
attempts = retry_count + 1
for attempt in range(attempts):
try:
if method == "GET":
resp = requests.get(url, headers=headers, timeout=timeout)
else:
resp = requests.post(url, headers=headers, data=body, timeout=timeout)
except requests.RequestException as exc:
logger.warning("请求异常(%s/%s) %s: %s", attempt + 1, attempts, url, exc)
if attempt < attempts - 1:
time.sleep(2)
continue
if resp.status_code in (401, 403):
return FetchResult(ok=False, auth_error=True, status_code=resp.status_code,
error=f"HTTP {resp.status_code}(认证失败)")
if resp.status_code >= 500:
logger.warning("服务端错误(%s/%s) %s status=%s", attempt + 1, attempts, url, resp.status_code)
if attempt < attempts - 1:
time.sleep(2)
continue
if resp.status_code >= 400:
return FetchResult(ok=False, status_code=resp.status_code,
error=f"HTTP {resp.status_code}: {resp.text[:120]}")
try:
data = resp.json()
except ValueError:
return FetchResult(ok=False, status_code=resp.status_code,
error="响应不是合法 JSON")
try:
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} 次后仍失败")
+90
View File
@@ -0,0 +1,90 @@
"""API 请求/响应模型(Pydantic)。"""
from __future__ import annotations
from pydantic import BaseModel, Field, model_validator
# ---------- 平台 ----------
class PlatformBase(BaseModel):
name: str = Field(min_length=1, max_length=64)
currency: str = Field(default="USD", min_length=1, max_length=16)
icon: str = Field(default="", max_length=64)
method: str = Field(default="GET", pattern="^(GET|POST)$")
url: str = Field(min_length=1)
headers: dict = Field(default_factory=dict)
body: str = Field(default="")
balance_path: str = Field(min_length=1)
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)
enabled: bool = True
note: str = Field(default="", max_length=200)
class PlatformCreate(PlatformBase):
pass
class PlatformUpdate(BaseModel):
name: str | None = Field(default=None, 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)
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)
enabled: bool | None = None
note: str | None = Field(default=None, max_length=200)
# ---------- 账号 ----------
class AccountCreate(BaseModel):
platform_id: int
name: str = Field(min_length=1, max_length=64)
api_key: str = Field(min_length=1)
threshold: float = Field(default=0, ge=0)
enabled: bool = True
note: str = Field(default="", max_length=200)
class AccountUpdate(BaseModel):
platform_id: int | None = None
name: str | None = Field(default=None, min_length=1, max_length=64)
api_key: str | None = Field(default=None, min_length=1)
threshold: float | None = Field(default=None, ge=0)
enabled: bool | None = None
note: str | None = Field(default=None, max_length=200)
@model_validator(mode="after")
def _at_least_one(self) -> "AccountUpdate":
fields = self.model_fields_set
if not fields:
raise ValueError("至少需要提供一个字段")
return self
# ---------- 系统 ----------
class LoginRequest(BaseModel):
password: str
class SettingsUpdate(BaseModel):
global_interval_seconds: int | None = Field(default=None, ge=10, le=86400)
retry_count: int | None = Field(default=None, ge=0, le=10)
timeout_seconds: int | None = Field(default=None, ge=1, le=120)
max_workers: int | None = Field(default=None, ge=1, le=64)
telegram_bot_token: str | None = None
telegram_chat_id: str | None = None
class PasswordChange(BaseModel):
old_password: str
new_password: str = Field(min_length=4)
+191
View File
@@ -0,0 +1,191 @@
"""监控调度器:线程池并发轮询所有启用账号。
调度逻辑:
- 每个账号记录 next_check_at(epoch 秒),间隔取平台覆盖值或全局默认
- 调度线程每秒扫描一次,到期的账号且无进行中任务则提交线程池
- 任务完成后把 next_check_at 更新为 now + interval,避免重复执行
"""
from __future__ import annotations
import logging
import threading
import time
from concurrent.futures import Future, ThreadPoolExecutor
from app import db
from app.alert import evaluate, notify_disabled
from app.config import Config
from app.fetcher import fetch_balance
logger = logging.getLogger("monitor.scheduler")
# JOIN 查询后拆分为账号/平台两个 dict 用到的字段
_ACCOUNT_FIELDS = [
"id", "platform_id", "name", "api_key", "threshold", "enabled",
"alert_armed", "last_balance", "last_status", "last_error",
"last_check_at", "note",
]
_PLATFORM_FIELDS = [
"platform_id", "platform_name", "currency", "icon", "method", "url",
"headers", "body", "balance_path", "interval_seconds",
"retry_count", "timeout_seconds", "platform_enabled", "note",
]
_ACCOUNT_SQL = """
SELECT a.*,
p.name AS platform_name, p.currency, p.icon, p.method, p.url,
p.headers, p.body, p.balance_path, p.interval_seconds,
p.retry_count, p.timeout_seconds, p.enabled AS platform_enabled,
p.note AS platform_note
FROM accounts a JOIN platforms p ON p.id = a.platform_id
"""
def _split(row: dict) -> tuple[dict, dict]:
"""把 JOIN 行拆成 (account, platform) 两个 dict。"""
account = {k: row[k] for k in _ACCOUNT_FIELDS if k in row}
platform = {k: row[k] for k in _PLATFORM_FIELDS if k in row}
platform["name"] = platform.pop("platform_name")
platform["id"] = platform.pop("platform_id")
platform["enabled"] = platform.pop("platform_enabled")
return account, platform
def _account_interval(platform: dict, cfg: Config) -> float:
return float(platform.get("interval_seconds") or cfg.global_interval_seconds)
class Monitor:
def __init__(self, cfg: Config) -> None:
self.cfg = cfg
self._stop = threading.Event()
self._pool: ThreadPoolExecutor | None = None
self._thread: threading.Thread | None = None
self._next_check: dict[int, float] = {}
self._inflight: set[int] = set()
self._lock = threading.Lock()
# ---------- 生命周期 ----------
def start(self) -> None:
if self._thread is not None:
return
self._pool = ThreadPoolExecutor(max_workers=self.cfg.max_workers, thread_name_prefix="check")
self._thread = threading.Thread(target=self._loop, name="monitor-loop", daemon=True)
self._thread.start()
logger.info(
"监控调度已启动,全局间隔 %ss,并发 %s",
self.cfg.global_interval_seconds,
self.cfg.max_workers,
)
def stop(self) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=5)
if self._pool is not None:
self._pool.shutdown(wait=False, cancel_futures=True)
logger.info("监控调度已停止")
# ---------- 调度循环 ----------
def _loop(self) -> None:
while not self._stop.is_set():
try:
self._dispatch()
except Exception:
logger.exception("调度循环异常")
self._stop.wait(1)
def _dispatch(self) -> None:
with db.get_conn() as conn:
rows = conn.execute(
_ACCOUNT_SQL + " WHERE a.enabled = 1 AND p.enabled = 1"
).fetchall()
now = time.time()
for row in rows:
data = dict(row)
aid = data["id"]
with self._lock:
if aid in self._inflight:
continue
if now < self._next_check.get(aid, 0):
continue
self._inflight.add(aid)
assert self._pool is not None
future = self._pool.submit(self._check_one, data)
future.add_done_callback(lambda f, a=aid: self._on_done(a, f))
def _on_done(self, account_id: int, future: Future) -> None:
with self._lock:
self._inflight.discard(account_id)
if future.exception():
logger.error("账号 %s 检查任务异常: %s", account_id, future.exception())
# ---------- 单次检查 ----------
def _check_one(self, row: dict) -> None:
cfg = self.cfg
account, platform = _split(row)
retry = platform.get("retry_count")
if retry is None:
retry = cfg.retry_count
timeout = platform.get("timeout_seconds")
if timeout is None:
timeout = cfg.timeout_seconds
result = fetch_balance(platform, account["api_key"], int(retry), int(timeout))
with db.get_conn() as conn:
if result.ok:
conn.execute(
"UPDATE accounts SET last_balance=?, last_status='ok', last_error='', "
"last_check_at=datetime('now','localtime') WHERE id=?",
(result.balance, account["id"]),
)
conn.execute(
"INSERT INTO balance_history (account_id, balance) VALUES (?, ?)",
(account["id"], result.balance),
)
elif result.auth_error:
conn.execute(
"UPDATE accounts SET enabled=0, last_status='disabled', last_error=?, "
"last_check_at=datetime('now','localtime') WHERE id=?",
(result.error, account["id"]),
)
else:
conn.execute(
"UPDATE accounts SET last_status='error', last_error=?, "
"last_check_at=datetime('now','localtime') WHERE id=?",
(result.error, account["id"]),
)
if result.auth_error:
notify_disabled(account, platform, cfg)
elif result.ok:
evaluate(account, platform, result.balance, cfg)
# 调度:无论成功失败,按间隔排下一次
with self._lock:
self._next_check[account["id"]] = time.time() + _account_interval(platform, cfg)
# ---------- 手动触发 ----------
def check_now(self, account_id: int) -> bool:
"""API 手动立即检查;账号/平台未启用或已在检查中则返回 False。"""
with db.get_conn() as conn:
row = conn.execute(
_ACCOUNT_SQL + " WHERE a.id = ? AND a.enabled = 1 AND p.enabled = 1",
(account_id,),
).fetchone()
if row is None:
return False
data = dict(row)
with self._lock:
if account_id in self._inflight:
return False
self._inflight.add(account_id)
assert self._pool is not None
future = self._pool.submit(self._check_one, data)
future.add_done_callback(lambda f, a=account_id: self._on_done(a, f))
return True
+559
View File
@@ -0,0 +1,559 @@
/* AI Balance Monitor 前端逻辑 */
(() => {
"use strict";
const TOKEN_KEY = "abm_token";
let token = localStorage.getItem(TOKEN_KEY) || "";
let accounts = [];
let platforms = [];
let settings = null;
let refreshTimer = null;
/* ---------- 工具 ---------- */
async function api(path, opts = {}) {
const headers = Object.assign({ "Content-Type": "application/json" }, opts.headers || {});
if (token) headers["Authorization"] = "Bearer " + token;
const resp = await fetch("/api" + path, Object.assign({}, opts, { headers }));
if (resp.status === 401 && !path.startsWith("/login")) {
logout();
throw new Error("登录已失效");
}
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.detail || ("HTTP " + resp.status));
return data;
}
function toast(msg, kind = "ok") {
const el = document.createElement("div");
el.className = "toast " + kind;
el.innerHTML = `<span class="toast-dot"></span><span>${escapeHtml(msg)}</span>`;
document.getElementById("toast-root").appendChild(el);
requestAnimationFrame(() => el.classList.add("show"));
setTimeout(() => {
el.classList.remove("show");
setTimeout(() => el.remove(), 250);
}, 2600);
}
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
function fmtBalance(v) {
if (v === null || v === undefined) return null;
const n = Number(v);
if (!Number.isFinite(n)) return null;
return n >= 100 ? n.toFixed(2) : n >= 1 ? n.toFixed(3) : n.toFixed(4);
}
function fmtTime(s) {
if (!s) return "—";
return s;
}
/* ---------- 品牌图标映射(@lobehub/icons 键 → 品牌色) ---------- */
const BRAND_COLORS = {
OpenAI: "#10A37F", GPT: "#10A37F", AzureOpenAI: "#0078D4",
Anthropic: "#D97757", Claude: "#D97757",
Google: "#4285F4", Gemini: "#4285F4", GoogleAIStudio: "#4285F4",
DeepSeek: "#4D6BFE",
MoonshotAI: "#1E1E1E", Kimi: "#1E1E1E",
ZhipuAI: "#3859FF", ChatGLM: "#3859FF",
Qwen: "#615CED", Tongyi: "#615CED", Aliyun: "#FF6A00",
Groq: "#F55036",
MistralAI: "#FF7000", Mistral: "#FF7000",
Meta: "#0668E1", Llama: "#0668E1",
XAI: "#1B1B1B", XAIGrok: "#1B1B1B", Grok: "#1B1B1B",
OpenRouter: "#B35CFF",
Perplexity: "#1FB8CD",
Cohere: "#39594D",
HuggingFace: "#FFD21E",
TogetherAI: "#FFB1D9",
Replicate: "#F26E1E",
Baidu: "#2932E1", Qianfan: "#2932E1",
MiniMax: "#FFC80A",
SiliconFlow: "#3E9E6B",
FireworksAI: "#FF2D20",
Cerebras: "#00D1B2",
NVIDIA: "#76B900",
Volcengine: "#3370FF", Doubao: "#3370FF",
TencentCloud: "#006EFF", Hunyuan: "#006EFF",
};
function brandStyle(iconKey) {
const color = BRAND_COLORS[iconKey] || "#8A8F98";
const abbr = (iconKey || "").slice(0, 2).toUpperCase() || "AI";
return { color, abbr };
}
/* ---------- 视图/状态 ---------- */
function switchView(name) {
document.querySelectorAll(".view").forEach((v) => v.classList.add("hidden"));
document.getElementById("view-" + name).classList.remove("hidden");
document.querySelectorAll(".tab").forEach((t) => {
t.classList.toggle("active", t.dataset.view === name);
});
if (name === "platforms") renderPlatforms();
if (name === "settings") loadSettings();
}
async function refresh() {
try {
const [acc, plat] = await Promise.all([api("/accounts"), api("/platforms")]);
accounts = acc;
platforms = plat;
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 });
} catch (e) {
if (e.message !== "登录已失效") console.warn("刷新失败", e);
}
}
/* ---------- 渲染:账号 ---------- */
function statusInfo(a) {
if (!a.enabled) return { cls: "disabled", text: "已禁用" };
if (a.last_status === "disabled") return { cls: "disabled", text: "已禁用" };
if (a.last_status === "error") return { cls: "err", text: "异常" };
if (a.last_status === "ok") {
if (a.last_balance !== null && a.last_balance < a.threshold) {
return { cls: "warn", text: "低于阈值" };
}
return { cls: "ok", text: "正常" };
}
return { cls: "pending", text: "待检查" };
}
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 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="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>
</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>`}
</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="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>
<div class="card-actions">
<button class="btn sm" data-act="history" data-id="${a.id}">历史</button>
<button class="btn sm" data-act="check" data-id="${a.id}" ${!a.enabled ? "disabled" : ""}>立即检查</button>
<button class="btn sm" data-act="edit" data-id="${a.id}">编辑</button>
<button class="btn sm danger" data-act="del" data-id="${a.id}">删除</button>
</div>
</div>`;
}
function renderAccounts() {
const grid = document.getElementById("account-grid");
const stats = { total: accounts.length, ok: 0, below: 0, error: 0, disabled: 0, pending: 0 };
accounts.forEach((a) => {
const st = statusInfo(a);
if (st.cls === "ok") stats.ok++;
else if (st.cls === "warn") stats.below++;
else if (st.cls === "err") stats.error++;
else if (st.cls === "disabled") stats.disabled++;
else stats.pending++;
});
document.getElementById("stat-total").textContent = stats.total;
document.getElementById("stat-ok").textContent = stats.ok;
document.getElementById("stat-below").textContent = stats.below;
document.getElementById("stat-error").textContent = stats.error;
document.getElementById("stat-disabled").textContent = stats.disabled;
document.getElementById("stat-pending").textContent = stats.pending;
grid.innerHTML = accounts.map(accountCard).join("");
document.getElementById("empty-hint").classList.toggle("hidden", accounts.length > 0);
}
/* ---------- 渲染:平台 ---------- */
function renderPlatforms() {
const list = document.getElementById("platform-list");
if (platforms.length === 0) {
list.innerHTML = `<div class="empty-hint">还没有平台,点击右上角「+ 添加平台」创建。</div>`;
return;
}
list.innerHTML = platforms.map((p, i) => {
const b = brandStyle(p.icon || "");
return `
<div class="platform-row ${p.enabled ? "" : "platform-off"}" style="animation-delay:${Math.min(i, 10) * 35}ms">
<div class="badge" style="background:${b.color}">${escapeHtml(b.abbr)}</div>
<div class="platform-info">
<div class="platform-name">${escapeHtml(p.name)}
${p.enabled ? "" : '<span class="badge-pill disabled">已停用</span>'}
</div>
<div class="platform-meta">
${escapeHtml(p.method)} ${escapeHtml(p.url)} ·
提取 ${escapeHtml(p.balance_path)} ·
${escapeHtml(p.currency)} ·
间隔 ${p.interval_seconds || "全局"}s ·
${p.account_count} 个账号
</div>
</div>
<div class="platform-actions">
<button class="btn sm" data-act="toggle" data-id="${p.id}">${p.enabled ? "停用" : "启用"}</button>
<button class="btn sm" data-act="edit" data-id="${p.id}">编辑</button>
<button class="btn sm danger" data-act="del" data-id="${p.id}">删除</button>
</div>
</div>`;
}).join("");
}
/* ---------- 模态框 ---------- */
function openModal(html, onMount) {
const root = document.getElementById("modal-root");
const mask = document.createElement("div");
mask.className = "modal-mask";
mask.innerHTML = html;
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);
if (onMount) onMount(mask);
return mask;
}
function closeModal(mask) {
if (mask) mask.remove();
}
function confirmDialog(text, onOk) {
const mask = openModal(`
<div class="modal confirm-modal">
<h3>确认操作</h3>
<p class="confirm-text">${text}</p>
<div class="modal-actions">
<button class="btn" data-cancel>取消</button>
<button class="btn danger" data-ok>确认删除</button>
</div>
</div>`);
mask.querySelector("[data-cancel]").onclick = () => closeModal(mask);
mask.querySelector("[data-ok]").onclick = () => { closeModal(mask); onOk(); };
}
/* 平台表单 */
function platformForm(p) {
const isEdit = !!p;
const v = p || {};
const headersStr = Object.keys(v.headers || {}).length ? JSON.stringify(v.headers, null, 2) : '{\n "Authorization": "Bearer {{apiKey}}"\n}';
openModal(`
<div class="modal">
<h3>${isEdit ? "编辑平台" : "添加平台"}</h3>
<div class="form-grid">
<div class="form-row"><label>名称 *</label><input id="pf-name" value="${escapeHtml(v.name || "")}" placeholder="如 OpenAI"></div>
<div class="form-row"><label>货币单位 *</label><input id="pf-currency" value="${escapeHtml(v.currency || "USD")}"></div>
<div class="form-row"><label>图标键(@lobehub/icons</label><input id="pf-icon" value="${escapeHtml(v.icon || "")}" placeholder="如 OpenAI / DeepSeek"></div>
<div class="form-row">
<label>请求方法</label>
<select id="pf-method">
<option value="GET" ${(v.method || "GET") === "GET" ? "selected" : ""}>GET</option>
<option value="POST" ${v.method === "POST" ? "selected" : ""}>POST</option>
</select>
</div>
<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}} 占位,添加账号时自动替换。</p>
<p class="modal-error" id="pf-error"></p>
<div class="modal-actions">
<button class="btn" data-cancel>取消</button>
<button class="btn primary" id="pf-save">保存</button>
</div>
</div>`, (mask) => {
mask.querySelector("[data-cancel]").onclick = () => closeModal(mask);
mask.querySelector("#pf-save").onclick = async () => {
const payload = {
name: val("#pf-name"), currency: val("#pf-currency"), icon: val("#pf-icon"),
method: val("#pf-method"), url: val("#pf-url"), balance_path: val("#pf-path"),
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 = {};
try { headers = JSON.parse(val("#pf-headers") || "{}"); }
catch (e) { err("#pf-error", "Headers 不是合法 JSON"); return; }
payload.headers = headers;
if (!payload.name || !payload.url || !payload.balance_path) { err("#pf-error", "名称 / URL / 提取路径必填"); return; }
if (payload.method === "POST") {
try { JSON.parse(val("#pf-body") || "{}"); } catch (e) { err("#pf-error", "Body 不是合法 JSON"); return; }
}
payload.body = val("#pf-body");
try {
if (isEdit) await api("/platforms/" + p.id, { method: "PUT", body: JSON.stringify(payload) });
else await api("/platforms", { method: "POST", body: JSON.stringify(payload) });
closeModal(mask);
toast(isEdit ? "平台已更新" : "平台已添加");
await refresh();
switchView("platforms");
} catch (e) { err("#pf-error", e.message); }
};
});
}
/* 账号表单 */
function accountForm(a) {
const isEdit = !!a;
const v = a || {};
const opts = platforms.map((p) =>
`<option value="${p.id}" ${v.platform_id === p.id ? "selected" : ""}>${escapeHtml(p.name)}</option>`).join("");
openModal(`
<div class="modal">
<h3>${isEdit ? "编辑账号" : "添加账号"}</h3>
<div class="form-grid">
<div class="form-row"><label>所属平台 *</label><select id="ac-platform">${opts}</select></div>
<div class="form-row"><label>账号名称 *</label><input id="ac-name" value="${escapeHtml(v.name || "")}" placeholder="如 主账号"></div>
<div class="form-row full"><label>API Key *</label><input id="ac-key" type="password" value="${escapeHtml(v.api_key || "")}" placeholder="sk-..."></div>
<div class="form-row"><label>提醒阈值(余额低于此值提醒)</label><input id="ac-threshold" type="number" step="any" min="0" value="${v.threshold ?? 0}"></div>
<div class="form-row">
<label>启用监控</label>
<select id="ac-enabled">
<option value="1" ${v.enabled !== false ? "selected" : ""}>启用</option>
<option value="0" ${v.enabled === false ? "selected" : ""}>停用</option>
</select>
</div>
<div class="form-row full"><label>备注</label><input id="ac-note" value="${escapeHtml(v.note || "")}"></div>
</div>
<p class="modal-error" id="ac-error"></p>
<div class="modal-actions">
<button class="btn" data-cancel>取消</button>
<button class="btn primary" id="ac-save">保存</button>
</div>
</div>`, (mask) => {
mask.querySelector("[data-cancel]").onclick = () => closeModal(mask);
mask.querySelector("#ac-save").onclick = async () => {
const payload = {
platform_id: Number(val("#ac-platform")), name: val("#ac-name"),
api_key: val("#ac-key"), threshold: Number(val("#ac-threshold") || 0),
enabled: val("#ac-enabled") === "1", note: val("#ac-note"),
};
if (!payload.platform_id || !payload.name || !payload.api_key) { err("#ac-error", "平台 / 名称 / API Key 必填"); return; }
try {
if (isEdit) await api("/accounts/" + a.id, { method: "PUT", body: JSON.stringify(payload) });
else await api("/accounts", { method: "POST", body: JSON.stringify(payload) });
closeModal(mask);
toast(isEdit ? "账号已更新" : "账号已添加");
await refresh();
switchView("monitor");
} catch (e) { err("#ac-error", e.message); }
};
});
}
/* 历史弹窗 */
async function showHistory(accountId) {
const acc = accounts.find((a) => a.id === accountId);
if (!acc) return;
const plat = platforms.find((p) => p.id === acc.platform_id) || {};
const mask = openModal(`
<div class="modal history-modal">
<h3>余额历史 · ${escapeHtml(acc.name)}</h3>
<div id="hist-body" style="color:var(--text-3);font-size:12px">加载中…</div>
</div>`);
try {
const hist = await api(`/accounts/${accountId}/history?limit=30`);
const body = mask.querySelector("#hist-body");
if (hist.length === 0) {
body.innerHTML = `<div style="padding:20px 0;text-align:center">暂无记录(检查成功后自动记录)</div>`;
return;
}
const max = Math.max(...hist.map((h) => h.balance), 1e-9);
const bars = hist.map((h) =>
`<i title="${fmtBalance(h.balance)} ${escapeHtml(plat.currency || "")} @ ${escapeHtml(h.checked_at)}" style="height:${Math.max(6, (h.balance / max) * 100)}%"></i>`).join("");
const items = [...hist].reverse().map((h) =>
`<div class="history-item"><span>${escapeHtml(h.checked_at)}</span><span class="h-bal">${fmtBalance(h.balance)} ${escapeHtml(plat.currency || "")}</span></div>`).join("");
body.innerHTML = `<div class="spark">${bars}</div>${items}`;
} catch (e) {
mask.querySelector("#hist-body").textContent = "加载失败:" + e.message;
}
}
/* ---------- 设置 ---------- */
async function loadSettings() {
try {
settings = await api("/settings");
document.getElementById("set-interval").value = settings.global_interval_seconds;
document.getElementById("set-retry").value = settings.retry_count;
document.getElementById("set-timeout").value = settings.timeout_seconds;
document.getElementById("set-workers").value = settings.max_workers;
document.getElementById("set-token").value = settings.telegram_bot_token;
document.getElementById("set-chatid").value = settings.telegram_chat_id;
} catch (e) { toast(e.message, "err"); }
}
/* ---------- 事件 ---------- */
function val(id) { return document.getElementById(id).value.trim(); }
function numOrNull(id) { const s = val(id); return s === "" ? null : Number(s); }
function err(id, msg) { document.getElementById(id).textContent = msg; }
function bindEvents() {
document.getElementById("login-btn").onclick = doLogin;
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("save-settings-btn").onclick = saveSettings;
document.getElementById("change-pwd-btn").onclick = changePassword;
document.getElementById("account-grid").addEventListener("click", onAccountAction);
document.getElementById("platform-list").addEventListener("click", onPlatformAction);
}
async function doLogin() {
const pw = val("login-password");
if (!pw) return;
try {
const data = await api("/login", { method: "POST", body: JSON.stringify({ password: pw }) });
token = data.token;
localStorage.setItem(TOKEN_KEY, token);
document.getElementById("login-error").textContent = "";
document.getElementById("login-view").classList.add("hidden");
document.getElementById("app-view").classList.remove("hidden");
await refresh();
startRefreshTimer();
} catch (e) {
document.getElementById("login-error").textContent = e.message;
}
}
function logout() {
if (token) api("/logout", { method: "POST" }).catch(() => {});
token = "";
localStorage.removeItem(TOKEN_KEY);
stopRefreshTimer();
document.getElementById("app-view").classList.add("hidden");
document.getElementById("login-view").classList.remove("hidden");
document.getElementById("login-password").value = "";
}
function startRefreshTimer() {
stopRefreshTimer();
refreshTimer = setInterval(refresh, 10000);
}
function stopRefreshTimer() { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } }
async function onAccountAction(e) {
const btn = e.target.closest("[data-act]");
if (!btn) return;
const id = Number(btn.dataset.id);
const act = btn.dataset.act;
if (act === "edit") accountForm(accounts.find((a) => a.id === id));
else if (act === "del") {
const a = accounts.find((x) => x.id === id);
confirmDialog(`确定删除账号「${escapeHtml(a ? a.name : id)}」?其历史记录将一并删除,此操作不可恢复。`, async () => {
try { await api("/accounts/" + id, { method: "DELETE" }); toast("账号已删除"); await refresh(); }
catch (err2) { toast(err2.message, "err"); }
});
} else if (act === "check") {
btn.disabled = true;
try { await api(`/accounts/${id}/check`, { method: "POST" }); toast("已开始检查"); }
catch (err2) { toast(err2.message, "err"); btn.disabled = false; }
setTimeout(refresh, 1500);
} else if (act === "history") {
showHistory(id);
}
}
async function onPlatformAction(e) {
const btn = e.target.closest("[data-act]");
if (!btn) return;
const id = Number(btn.dataset.id);
const act = btn.dataset.act;
const p = platforms.find((x) => x.id === id);
if (act === "edit") platformForm(p);
else if (act === "toggle") {
try {
await api("/platforms/" + id, { method: "PUT", body: JSON.stringify({ enabled: !p.enabled }) });
toast(p.enabled ? "平台已停用" : "平台已启用");
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"); }
});
}
}
async function saveSettings() {
try {
await api("/settings", {
method: "PUT",
body: JSON.stringify({
global_interval_seconds: Number(val("set-interval")),
retry_count: Number(val("set-retry")),
timeout_seconds: Number(val("set-timeout")),
max_workers: Number(val("set-workers")),
telegram_bot_token: val("set-token"),
telegram_chat_id: val("set-chatid"),
}),
});
toast("设置已保存");
await loadSettings();
} catch (e) { toast(e.message, "err"); }
}
async function changePassword() {
try {
await api("/settings/password", {
method: "POST",
body: JSON.stringify({ old_password: val("set-oldpwd"), new_password: val("set-newpwd") }),
});
document.getElementById("set-oldpwd").value = "";
document.getElementById("set-newpwd").value = "";
toast("密码已修改");
} catch (e) { toast(e.message, "err"); }
}
/* ---------- 启动 ---------- */
function init() {
bindEvents();
if (token) {
document.getElementById("login-view").classList.add("hidden");
document.getElementById("app-view").classList.remove("hidden");
refresh().then(() => startRefreshTimer()).catch(() => logout());
}
}
init();
})();
+128
View File
@@ -0,0 +1,128 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Balance Monitor</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<!-- 登录视图 -->
<div id="login-view" class="login-view">
<div class="login-card">
<div class="login-logo"></div>
<h1>AI Balance Monitor</h1>
<p class="login-sub">多平台 AI 余额监控面板</p>
<input id="login-password" type="password" placeholder="管理密码" autocomplete="current-password">
<button id="login-btn" class="btn primary block">登录</button>
<p id="login-error" class="login-error"></p>
</div>
</div>
<!-- 主应用 -->
<div id="app-view" class="app-view hidden">
<header class="topbar">
<div class="brand">
<span class="brand-dot"></span>
<span class="brand-name">AI Balance Monitor</span>
</div>
<nav class="tabs">
<button class="tab active" data-view="monitor">监控</button>
<button class="tab" data-view="platforms">平台</button>
<button class="tab" data-view="settings">设置</button>
</nav>
<div class="topbar-right">
<span id="refresh-time" class="refresh-time"></span>
<button id="logout-btn" class="btn ghost sm">登出</button>
</div>
</header>
<main class="content">
<!-- 监控视图 -->
<section id="view-monitor" class="view">
<div class="stats-bar">
<div class="stat"><span id="stat-total">0</span><label>账号</label></div>
<div class="stat ok"><span id="stat-ok">0</span><label>正常</label></div>
<div class="stat warn"><span id="stat-below">0</span><label>低于阈值</label></div>
<div class="stat err"><span id="stat-error">0</span><label>异常</label></div>
<div class="stat disabled"><span id="stat-disabled">0</span><label>已禁用</label></div>
<div class="stat pending"><span id="stat-pending">0</span><label>待检查</label></div>
</div>
<div id="account-grid" class="account-grid"></div>
<div id="empty-hint" class="empty-hint hidden">
<p>还没有账号。先到「平台」页添加一个平台,再回来添加账号。</p>
</div>
</section>
<!-- 平台视图 -->
<section id="view-platforms" class="view hidden">
<div class="view-head">
<h2>平台配置</h2>
<button id="add-platform-btn" class="btn primary">+ 添加平台</button>
</div>
<div id="platform-list" class="platform-list"></div>
</section>
<!-- 设置视图 -->
<section id="view-settings" class="view hidden">
<div class="view-head"><h2>设置</h2></div>
<div class="settings-card">
<h3>监控</h3>
<div class="form-row">
<label>全局监控间隔(秒)</label>
<input id="set-interval" type="number" min="10">
</div>
<div class="form-row">
<label>默认重试次数</label>
<input id="set-retry" type="number" min="0" max="10">
</div>
<div class="form-row">
<label>默认超时(秒)</label>
<input id="set-timeout" type="number" min="1" max="120">
</div>
<div class="form-row">
<label>并发数</label>
<input id="set-workers" type="number" min="1" max="64">
</div>
</div>
<div class="settings-card">
<h3>Telegram 提醒</h3>
<div class="form-row">
<label>Bot Token</label>
<input id="set-token" type="password" placeholder="123456:ABC-DEF...">
</div>
<div class="form-row">
<label>Chat ID</label>
<input id="set-chatid" type="text" placeholder="如 123456789">
</div>
</div>
<div class="settings-card">
<h3>安全</h3>
<div class="form-row">
<label>旧密码</label>
<input id="set-oldpwd" type="password" autocomplete="current-password">
</div>
<div class="form-row">
<label>新密码(≥4 位)</label>
<input id="set-newpwd" type="password" autocomplete="new-password">
</div>
</div>
<div class="settings-actions">
<button id="save-settings-btn" class="btn primary">保存设置</button>
<button id="change-pwd-btn" class="btn">修改密码</button>
<span class="settings-hint">端口需修改 config.json 后重启生效。</span>
</div>
</section>
</main>
</div>
<!-- 模态框 -->
<div id="modal-root"></div>
<div id="toast-root"></div>
<script src="/static/app.js"></script>
</body>
</html>
+409
View File
@@ -0,0 +1,409 @@
/* ===== 设计基础 ===== */
:root {
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--radius: 12px;
--radius-sm: 8px;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
--bg: #f5f6f8;
--bg-elev: #ffffff;
--bg-elev-2: #f0f1f4;
--border: rgba(15, 23, 42, 0.08);
--border-strong: rgba(15, 23, 42, 0.14);
--text: #1a1d24;
--text-2: #5b6270;
--text-3: #9aa1ad;
--accent: #3b6ef6;
--accent-strong: #2f5ae0;
--accent-soft: rgba(59, 110, 246, 0.1);
--ok: #16a34a;
--ok-soft: rgba(22, 163, 74, 0.12);
--warn: #d97706;
--warn-soft: rgba(217, 119, 6, 0.13);
--err: #dc2626;
--err-soft: rgba(220, 38, 38, 0.11);
--disabled-soft: rgba(100, 116, 139, 0.14);
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05);
--shadow-md: 0 4px 16px rgba(15, 23, 42, 0.08);
--shadow-lg: 0 12px 40px rgba(15, 23, 42, 0.16);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0e1015;
--bg-elev: #171a21;
--bg-elev-2: #1e222b;
--border: rgba(255, 255, 255, 0.07);
--border-strong: rgba(255, 255, 255, 0.13);
--text: #e8eaef;
--text-2: #9aa1ad;
--text-3: #6b7280;
--accent: #5b8cff;
--accent-strong: #3b6ef6;
--accent-soft: rgba(91, 140, 255, 0.14);
--ok: #34d399;
--ok-soft: rgba(52, 211, 153, 0.13);
--warn: #fbbf24;
--warn-soft: rgba(251, 191, 36, 0.13);
--err: #f87171;
--err-soft: rgba(248, 113, 113, 0.13);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.5);
}
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.hidden { display: none !important; }
/* ===== 通用组件 ===== */
.btn {
font-family: inherit;
font-size: 13px;
font-weight: 500;
padding: 8px 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
background: var(--bg-elev);
color: var(--text);
cursor: pointer;
transition: transform 120ms var(--ease-out), background 150ms ease, border-color 150ms ease, opacity 150ms ease;
}
.btn:active { transform: scale(0.97); }
.btn:hover { background: var(--bg-elev-2); }
.btn.primary {
background: var(--accent);
border-color: transparent;
color: #fff;
}
.btn.primary:hover { background: var(--accent-strong); }
.btn.danger { color: var(--err); }
.btn.danger:hover { background: var(--err-soft); }
.btn.ghost { background: transparent; border-color: transparent; color: var(--text-2); }
.btn.ghost:hover { background: var(--bg-elev-2); color: var(--text); }
.btn.sm { padding: 5px 10px; font-size: 12px; }
.btn.block { width: 100%; padding: 10px; font-size: 14px; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
input, textarea, select {
font-family: inherit;
font-size: 13px;
color: var(--text);
background: var(--bg-elev);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding: 8px 10px;
width: 100%;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
textarea { resize: vertical; min-height: 72px; font-family: "Cascadia Code", Consolas, monospace; }
label { font-size: 12px; font-weight: 500; color: var(--text-2); margin-bottom: 5px; display: block; }
/* ===== 登录 ===== */
.login-view {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.login-card {
width: 320px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 16px;
padding: 32px 28px;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: 14px;
animation: card-in 260ms var(--ease-out);
}
.login-logo { font-size: 28px; text-align: center; color: var(--accent); }
.login-card h1 { font-size: 19px; text-align: center; letter-spacing: -0.02em; }
.login-sub { text-align: center; color: var(--text-3); font-size: 12px; margin-top: -8px; }
.login-error { color: var(--err); font-size: 12px; text-align: center; min-height: 16px; }
/* ===== 顶栏 ===== */
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 24px;
padding: 0 24px;
height: 52px;
background: color-mix(in srgb, var(--bg-elev) 72%, transparent);
backdrop-filter: blur(16px) saturate(160%);
-webkit-backdrop-filter: blur(16px) saturate(160%);
border-bottom: 1px solid var(--border);
}
.brand { display: flex; align-items: center; gap: 8px; font-weight: 600; letter-spacing: -0.01em; }
.brand-dot {
width: 10px; height: 10px; border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 4px var(--accent-soft);
}
.tabs { display: flex; gap: 4px; }
.tab {
font-family: inherit; font-size: 13px; font-weight: 500;
padding: 6px 14px; border: none; border-radius: 999px;
background: transparent; color: var(--text-2); cursor: pointer;
transition: background 150ms ease, color 150ms ease;
}
.tab:hover { background: var(--bg-elev-2); color: var(--text); }
.tab.active { background: var(--accent-soft); color: var(--accent); }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.refresh-time { font-size: 12px; color: var(--text-3); }
/* ===== 内容 ===== */
.content { max-width: 1080px; margin: 0 auto; padding: 24px; }
.view-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.view-head h2 { font-size: 17px; letter-spacing: -0.02em; }
/* 统计条 */
.stats-bar {
display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 20px;
}
.stat {
flex: 1; min-width: 90px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 16px;
display: flex; flex-direction: column; gap: 2px;
box-shadow: var(--shadow-sm);
}
.stat span { font-size: 22px; font-weight: 650; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.stat label { margin: 0; font-size: 11px; color: var(--text-3); }
.stat.ok span { color: var(--ok); }
.stat.warn span { color: var(--warn); }
.stat.err span { color: var(--err); }
.stat.disabled span { color: var(--text-2); }
.stat.pending span { color: var(--text-3); }
/* ===== 账号卡片 ===== */
.account-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 14px;
}
.account-card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: 10px;
opacity: 0;
transform: translateY(8px);
animation: card-in 300ms var(--ease-out) forwards;
transition: transform 180ms var(--ease-out), box-shadow 180ms var(--ease-out), border-color 180ms ease;
}
@media (hover: hover) and (pointer: fine) {
.account-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
}
.account-card.below { border-color: color-mix(in srgb, var(--warn) 55%, transparent); }
.account-card.error { border-color: color-mix(in srgb, var(--err) 55%, transparent); }
.account-card.disabled { opacity: 0.65; }
.card-top { display: flex; align-items: center; gap: 10px; }
.badge {
width: 34px; height: 34px; border-radius: 9px;
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 13px; color: #fff;
flex-shrink: 0;
letter-spacing: 0.02em;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.18);
}
.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; }
.balance-row { display: flex; align-items: baseline; gap: 6px; }
.balance-value { font-size: 26px; font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.balance-value.below { color: var(--warn); }
.balance-value.error { color: var(--err); }
.balance-currency { font-size: 13px; color: var(--text-2); font-weight: 500; }
.balance-empty { color: var(--text-3); font-size: 22px; font-weight: 600; }
.card-meta { font-size: 11.5px; color: var(--text-3); display: flex; flex-direction: column; gap: 2px; }
.card-meta .badge-pill { align-self: flex-start; }
.badge-pill {
font-size: 11px; font-weight: 500;
padding: 2px 8px; border-radius: 999px;
}
.badge-pill.ok { background: var(--ok-soft); color: var(--ok); }
.badge-pill.warn { background: var(--warn-soft); color: var(--warn); }
.badge-pill.err { background: var(--err-soft); color: var(--err); }
.badge-pill.disabled { background: var(--disabled-soft); color: var(--text-2); }
.badge-pill.pending { background: var(--bg-elev-2); color: var(--text-3); }
.card-actions { display: flex; gap: 6px; margin-top: 2px; }
/* 阈值条 */
.threshold-bar {
height: 4px; border-radius: 2px;
background: var(--bg-elev-2);
overflow: hidden;
}
.threshold-fill {
height: 100%; border-radius: 2px;
background: var(--ok);
transition: width 300ms var(--ease-out), background 200ms ease;
}
.threshold-fill.below { background: var(--warn); }
/* ===== 平台列表 ===== */
.platform-list { display: flex; flex-direction: column; gap: 10px; }
.platform-row {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
display: flex; align-items: center; gap: 12px;
box-shadow: var(--shadow-sm);
opacity: 0;
animation: card-in 260ms var(--ease-out) forwards;
}
.platform-info { flex: 1; min-width: 0; }
.platform-name { font-weight: 600; display: flex; align-items: center; gap: 8px; }
.platform-meta { font-size: 11.5px; color: var(--text-3); margin-top: 2px; }
.platform-actions { display: flex; gap: 6px; }
.platform-off { opacity: 0.55; }
/* ===== 设置 ===== */
.settings-card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px 20px;
margin-bottom: 14px;
box-shadow: var(--shadow-sm);
opacity: 0;
animation: card-in 260ms var(--ease-out) forwards;
}
.settings-card h3 { font-size: 13px; margin-bottom: 12px; color: var(--text-2); font-weight: 600; }
.form-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; max-width: 420px; }
.form-row:last-child { margin-bottom: 0; }
.settings-actions { display: flex; align-items: center; gap: 10px; margin-top: 4px; }
.settings-hint { font-size: 12px; color: var(--text-3); }
/* ===== 模态框 ===== */
.modal-mask {
position: fixed; inset: 0; z-index: 100;
background: rgba(10, 12, 18, 0.45);
display: flex; align-items: center; justify-content: center;
padding: 20px;
animation: fade-in 180ms ease;
backdrop-filter: blur(2px);
}
.modal {
width: 480px; max-width: 100%;
max-height: 86vh; overflow-y: auto;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: var(--shadow-lg);
padding: 22px 24px;
animation: modal-in 220ms var(--ease-out);
}
.modal h3 { font-size: 16px; letter-spacing: -0.02em; margin-bottom: 16px; }
.modal .form-row { max-width: none; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.modal-error { color: var(--err); font-size: 12px; min-height: 16px; margin-top: 8px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.form-grid .full { grid-column: 1 / -1; }
.form-hint { font-size: 11px; color: var(--text-3); margin-top: 4px; }
/* 历史弹窗 */
.history-modal .modal { width: 420px; }
.history-list { display: flex; flex-direction: column; gap: 6px; max-height: 300px; overflow-y: auto; }
.history-item {
display: flex; justify-content: space-between; align-items: center;
padding: 7px 10px; border-radius: var(--radius-sm);
background: var(--bg-elev-2);
font-size: 12.5px; font-variant-numeric: tabular-nums;
}
.history-item .h-bal { font-weight: 600; }
.spark { display: flex; align-items: flex-end; gap: 2px; height: 34px; margin: 10px 0 4px; }
.spark i {
flex: 1; background: var(--accent);
border-radius: 2px 2px 0 0;
opacity: 0.85;
min-height: 2px;
transition: opacity 150ms ease;
}
.spark i:hover { opacity: 1; }
/* 确认弹窗 */
.confirm-modal .modal { width: 340px; }
.confirm-text { color: var(--text-2); font-size: 13px; margin-bottom: 18px; line-height: 1.6; }
/* ===== Toast ===== */
#toast-root {
position: fixed; bottom: 20px; right: 20px; z-index: 200;
display: flex; flex-direction: column; gap: 8px;
}
.toast {
background: var(--bg-elev);
border: 1px solid var(--border-strong);
color: var(--text);
font-size: 13px;
padding: 10px 16px;
border-radius: 10px;
box-shadow: var(--shadow-md);
display: flex; align-items: center; gap: 8px;
opacity: 0;
transform: translateY(10px);
transition: opacity 220ms var(--ease-out), transform 220ms var(--ease-out);
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast.ok .toast-dot { background: var(--ok); }
.toast.err .toast-dot { background: var(--err); }
.toast-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
/* 空状态 */
.empty-hint {
text-align: center; color: var(--text-3);
padding: 60px 0; font-size: 13px;
}
/* ===== 动画 ===== */
@keyframes card-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modal-in {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
.account-card { opacity: 1; transform: none; }
}
+30
View File
@@ -0,0 +1,30 @@
"""Telegram Bot 消息发送(同步、带超时;失败不抛异常,返回 False)。"""
from __future__ import annotations
import logging
import requests
logger = logging.getLogger("monitor.telegram")
API_BASE = "https://api.telegram.org/bot{token}/sendMessage"
def send_message(bot_token: str, chat_id: str, text: str) -> bool:
"""发送一条消息。配置缺失或失败时记日志并返回 False。"""
if not bot_token or not chat_id:
logger.warning("Telegram 未配置(bot_token/chat_id 为空),跳过发送: %s", text[:60])
return False
try:
resp = requests.post(
API_BASE.format(token=bot_token),
json={"chat_id": chat_id, "text": text},
timeout=10,
)
if resp.status_code == 200:
return True
logger.error("Telegram 发送失败 status=%s body=%s", resp.status_code, resp.text[:200])
except requests.RequestException as exc:
logger.error("Telegram 请求异常: %s", exc)
return False