diff --git a/app/api.py b/app/api.py index f0ba78b..7cf4d4b 100644 --- a/app/api.py +++ b/app/api.py @@ -3,7 +3,6 @@ from __future__ import annotations import base64 -import json import logging import secrets import sqlite3 @@ -27,6 +26,7 @@ from app.models import ( SettingsUpdate, ) from app.monitor import Monitor +from app.providers import get_provider, list_providers as list_providers_svc logger = logging.getLogger("monitor.api") @@ -130,36 +130,40 @@ def create_app(cfg: Config | None = None) -> FastAPI: # ---------- 平台 ---------- + @app.get("/api/providers", dependencies=[Depends(require_auth)]) + def list_providers(): + """代码内置的平台适配器列表(新增平台 = 代码扩展)。""" + return list_providers_svc() + @app.get("/api/platforms", dependencies=[Depends(require_auth)]) def list_platforms(): with db.get_conn() as conn: rows = conn.execute( - """SELECT p.*, (SELECT COUNT(*) FROM accounts a WHERE a.platform_id = p.id) AS account_count + """SELECT p.id, p.name, p.currency, p.icon, p.provider_id, + p.interval_seconds, p.retry_count, p.timeout_seconds, + p.enabled, p.note, p.created_at, p.updated_at, + (SELECT COUNT(*) FROM accounts a WHERE a.platform_id = p.id) AS account_count FROM platforms p ORDER BY p.id""" ).fetchall() - result = [] - for r in rows: - d = dict(r) - try: - d["headers"] = json.loads(d["headers"]) if d["headers"] else {} - except (ValueError, TypeError): - d["headers"] = {} - result.append(d) - return result + return [dict(r) for r in rows] @app.post("/api/platforms", dependencies=[Depends(require_auth)]) def create_platform(body: PlatformCreate): + provider = get_provider(body.provider_id) + if provider is None: + raise HTTPException(status_code=400, detail=f"不支持的平台提供方: {body.provider_id}") + currency = body.currency or provider.currency + icon = body.icon or provider.icon try: with db.get_conn() as conn: cur = conn.execute( - """INSERT INTO platforms (name, currency, icon, method, url, headers, body, - balance_path, interval_seconds, retry_count, timeout_seconds, enabled, note) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + """INSERT INTO platforms (provider_id, name, currency, icon, + interval_seconds, retry_count, timeout_seconds, enabled, note) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( - body.name, body.currency, body.icon, body.method, body.url, - __import__("json").dumps(body.headers, ensure_ascii=False), - body.body, body.balance_path, body.interval_seconds, - body.retry_count, body.timeout_seconds, int(body.enabled), body.note, + body.provider_id, body.name, currency, icon, + body.interval_seconds, body.retry_count, body.timeout_seconds, + int(body.enabled), body.note, ), ) pid = cur.lastrowid @@ -172,22 +176,22 @@ def create_app(cfg: Config | None = None) -> FastAPI: @app.put("/api/platforms/{pid}", dependencies=[Depends(require_auth)]) def update_platform(pid: int, body: PlatformUpdate): fields = {} + if body.provider_id is not None: + provider = get_provider(body.provider_id) + if provider is None: + raise HTTPException(status_code=400, detail=f"不支持的平台提供方: {body.provider_id}") + fields["provider_id"] = body.provider_id + # 换 provider 时若未显式给货币/图标,则重置为 provider 默认 + if "currency" not in body.model_fields_set: + fields["currency"] = provider.currency + if "icon" not in body.model_fields_set: + fields["icon"] = provider.icon if body.name is not None: fields["name"] = body.name if body.currency is not None: fields["currency"] = body.currency if body.icon is not None: fields["icon"] = body.icon - if body.method is not None: - fields["method"] = body.method - if body.url is not None: - fields["url"] = body.url - if body.headers is not None: - fields["headers"] = __import__("json").dumps(body.headers, ensure_ascii=False) - if body.body is not None: - fields["body"] = body.body - if body.balance_path is not None: - fields["balance_path"] = body.balance_path if body.interval_seconds is not None: fields["interval_seconds"] = body.interval_seconds if body.retry_count is not None: diff --git a/app/db.py b/app/db.py index 0ec02fe..f9b3101 100644 --- a/app/db.py +++ b/app/db.py @@ -11,14 +11,10 @@ DB_PATH = DATA_DIR / "monitor.db" SCHEMA = """ CREATE TABLE IF NOT EXISTS platforms ( id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL DEFAULT '', name TEXT NOT NULL UNIQUE, currency TEXT NOT NULL DEFAULT 'USD', icon TEXT NOT NULL DEFAULT '', - method TEXT NOT NULL DEFAULT 'GET', - url TEXT NOT NULL, - headers TEXT NOT NULL DEFAULT '{}', - body TEXT NOT NULL DEFAULT '', - balance_path TEXT NOT NULL, interval_seconds INTEGER, retry_count INTEGER, timeout_seconds INTEGER, diff --git a/app/expr.py b/app/expr.py new file mode 100644 index 0000000..3ee3d1c --- /dev/null +++ b/app/expr.py @@ -0,0 +1,236 @@ +"""受限的余额表达式引擎。 + +在 JSON 提取路径基础上支持: +- 运算符:+ - * / // % ** 一元负号、括号 +- 函数(白名单):float / int / abs / round / min / max / sum / len +- 路径写法与原来完全兼容:data.balance、data[0].x、$.data[0].x + +示例: + data.balance / 100 # 分转元 + float(data.balance) # 字符串转数字 + data.granted + data.topped_up # 多字段求和 + sum(data[0].balances) # 数组求和 + round(data.balance, 2) * 0.9 + +安全性:无 eval、无任意变量访问,函数名严格白名单,路径只支持 +dict 键 / list 索引访问。 +""" + +from __future__ import annotations + +import re + +_PATH_SEG_RE = re.compile(r"([^.\[]+)|\[(-?\d+)\]") + + +class ExprError(ValueError): + """表达式语法错误 / 求值错误。""" + + +# ---------- 路径提取(返回原始值,不强制数字) ---------- + +def extract_path(data: object, path: str) -> object: + p = path.strip() + if p.startswith("$"): + p = p[1:] + cur: object = data + for key, idx in _PATH_SEG_RE.findall(p): + if key: + if not isinstance(cur, dict) or key not in cur: + raise ExprError(f"路径不存在: {path}(在 {key!r} 处)") + cur = cur[key] + if idx != "": + n = int(idx) + if not isinstance(cur, list) or n >= len(cur): + raise ExprError(f"数组索引越界: {path}(索引 {n})") + cur = cur[n] + return cur + + +def to_number(value: object, src: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float, str)): + raise ExprError(f"余额不是数字: {src} -> {value!r}") + try: + return float(value) + except (TypeError, ValueError): + raise ExprError(f"余额不是数字: {src} -> {value!r}") + + +# ---------- 词法 ---------- + +_TOKEN_RE = re.compile(r""" + (?P\d+(?:\.\d+)?) + | (?P\$?\.?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*|\[-?\d+\])*) + | (?P//|\*\*|[+\-*/%(),]) + | (?P\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() diff --git a/app/fetcher.py b/app/fetcher.py index 6030640..6970df7 100644 --- a/app/fetcher.py +++ b/app/fetcher.py @@ -2,18 +2,18 @@ from __future__ import annotations -import json import logging -import re import time from dataclasses import dataclass import requests +from app.expr import extract_path, to_number +from app.providers.base import BalanceProvider + logger = logging.getLogger("monitor.fetcher") PLACEHOLDER = "{{apiKey}}" -_TOKEN_RE = re.compile(r"([^.\[]+)|\[(\d+)\]") @dataclass @@ -33,57 +33,31 @@ def render_template(text: str, api_key: str) -> str: def extract_balance(data: object, path: str) -> float: - """按点路径/数组索引提取余额,如 data.balance、data[0].balance、$.data[0].balance。 + """按点路径/数组索引提取余额(兼容旧写法,返回 float)。 - 取到的值必须是数字或可转数字的字符串,否则抛 ValueError。 + 新写法请用 evaluate_balance(支持运算符与函数)。 """ - p = path.strip() - if p.startswith("$"): - p = p[1:] - cur: object = data - for key, idx in _TOKEN_RE.findall(p): - if key: - if not isinstance(cur, dict) or key not in cur: - raise ValueError(f"路径不存在: {path}(在 {key!r} 处)") - cur = cur[key] - if idx != "": - n = int(idx) - if not isinstance(cur, list) or n >= len(cur): - raise ValueError(f"数组索引越界: {path}(索引 {n})") - cur = cur[n] - if isinstance(cur, bool) or not isinstance(cur, (int, float, str)): - raise ValueError(f"余额不是数字: {path} -> {cur!r}") - try: - return float(cur) - except (TypeError, ValueError): - raise ValueError(f"余额不是数字: {path} -> {cur!r}") + return to_number(extract_path(data, path), path) -def _build_request(platform: dict, api_key: str) -> tuple[str, dict, str | None]: - url = render_template(platform["url"], api_key) - headers_raw = platform.get("headers") or {} - if isinstance(headers_raw, str): - headers_raw = json.loads(headers_raw) if headers_raw.strip() else {} - headers = json.loads(render_template(json.dumps(headers_raw), api_key)) - body = None - if platform["method"] == "POST" and platform.get("body"): - body = render_template(platform["body"], api_key) - if not any(k.lower() == "content-type" for k in headers): - headers["Content-Type"] = "application/json" - return url, headers, body - - -def fetch_balance(platform: dict, api_key: str, retry_count: int, timeout: int) -> FetchResult: +def fetch_balance( + provider: BalanceProvider, + api_key: str, + retry_count: int, + timeout: int, +) -> FetchResult: """执行一次余额拉取。 策略: - - 401/403 → auth_error(不重试,由调用方禁用账号并通知) - - 其他 4xx → 直接失败(配置问题,不重试) + - 401/403 → 先按重试次数确认(可能瞬时),仍失败 → auth_error(调用方禁用账号并通知) + - 其他 4xx → 直接失败(不重试) - 网络异常 / 5xx → 重试 retry_count 次,间隔 2s - - JSON 解析/路径提取失败 → 直接失败 + - JSON 解析/余额提取失败 → 直接失败 """ - url, headers, body = _build_request(platform, api_key) - method = platform["method"] + url, headers, body = provider.build_request(api_key) + method = provider.method + if method == "POST" and body and not any(k.lower() == "content-type" for k in headers): + headers["Content-Type"] = "application/json" attempts = retry_count + 1 for attempt in range(attempts): try: @@ -118,8 +92,8 @@ def fetch_balance(platform: dict, api_key: str, retry_count: int, timeout: int) return FetchResult(ok=False, status_code=resp.status_code, error="响应不是合法 JSON") try: - balance = extract_balance(data, platform["balance_path"]) - except ValueError as exc: + balance = provider.extract_balance(data) + except Exception as exc: return FetchResult(ok=False, status_code=resp.status_code, error=str(exc)) return FetchResult(ok=True, balance=balance, status_code=resp.status_code) return FetchResult(ok=False, error=f"网络错误,重试 {retry_count} 次后仍失败") diff --git a/app/models.py b/app/models.py index 63c0f47..92ed440 100644 --- a/app/models.py +++ b/app/models.py @@ -8,14 +8,10 @@ from pydantic import BaseModel, Field, model_validator # ---------- 平台 ---------- class PlatformBase(BaseModel): + provider_id: str = Field(min_length=1, max_length=64) name: str = Field(min_length=1, max_length=64) - currency: str = Field(default="USD", min_length=1, max_length=16) - icon: str = Field(default="", max_length=64) - method: str = Field(default="GET", pattern="^(GET|POST)$") - url: str = Field(min_length=1) - headers: dict = Field(default_factory=dict) - body: str = Field(default="") - balance_path: str = Field(min_length=1) + currency: str | None = Field(default=None, min_length=1, max_length=16) + icon: str | None = Field(default=None, max_length=64) interval_seconds: int | None = Field(default=None, ge=10) retry_count: int | None = Field(default=None, ge=0, le=10) timeout_seconds: int | None = Field(default=None, ge=1, le=120) @@ -28,14 +24,10 @@ class PlatformCreate(PlatformBase): class PlatformUpdate(BaseModel): + provider_id: str | None = Field(default=None, min_length=1, max_length=64) name: str | None = Field(default=None, min_length=1, max_length=64) - currency: str | None = Field(default=None, min_length=1, max_length=16) + currency: str | None = Field(default=None, max_length=16) icon: str | None = Field(default=None, max_length=64) - method: str | None = Field(default=None, pattern="^(GET|POST)$") - url: str | None = Field(default=None, min_length=1) - headers: dict | None = None - body: str | None = None - balance_path: str | None = Field(default=None, min_length=1) interval_seconds: int | None = Field(default=None, ge=10) retry_count: int | None = Field(default=None, ge=0, le=10) timeout_seconds: int | None = Field(default=None, ge=1, le=120) diff --git a/app/monitor.py b/app/monitor.py index 6e013d8..4af0562 100644 --- a/app/monitor.py +++ b/app/monitor.py @@ -18,6 +18,7 @@ from app import db from app.alert import evaluate, notify_disabled from app.config import Config from app.fetcher import fetch_balance +from app.providers import get_provider logger = logging.getLogger("monitor.scheduler") @@ -28,17 +29,15 @@ _ACCOUNT_FIELDS = [ "last_check_at", "note", ] _PLATFORM_FIELDS = [ - "platform_id", "platform_name", "currency", "icon", "method", "url", - "headers", "body", "balance_path", "interval_seconds", - "retry_count", "timeout_seconds", "platform_enabled", "note", + "platform_id", "provider_id", "platform_name", "currency", "icon", + "interval_seconds", "retry_count", "timeout_seconds", "platform_enabled", "note", ] _ACCOUNT_SQL = """ SELECT a.*, - p.name AS platform_name, p.currency, p.icon, p.method, p.url, - p.headers, p.body, p.balance_path, p.interval_seconds, - p.retry_count, p.timeout_seconds, p.enabled AS platform_enabled, - p.note AS platform_note + p.provider_id, p.name AS platform_name, p.currency, p.icon, + p.interval_seconds, p.retry_count, p.timeout_seconds, + p.enabled AS platform_enabled, p.note AS platform_note FROM accounts a JOIN platforms p ON p.id = a.platform_id """ @@ -138,13 +137,25 @@ class Monitor: def _check_one(self, row: dict) -> None: cfg = self.cfg account, platform = _split(row) + provider = get_provider(platform["provider_id"]) + if provider is None: + logger.warning("平台 %s 的 provider 不存在: %r", platform["name"], platform["provider_id"]) + with db.get_conn() as conn: + conn.execute( + "UPDATE accounts SET last_status='error', last_error=?, " + "last_check_at=datetime('now','localtime') WHERE id=?", + (f"provider 不存在: {platform['provider_id']}", account["id"]), + ) + with self._lock: + self._next_check[account["id"]] = time.time() + _account_interval(platform, cfg) + return retry = platform.get("retry_count") if retry is None: retry = cfg.retry_count timeout = platform.get("timeout_seconds") if timeout is None: timeout = cfg.timeout_seconds - result = fetch_balance(platform, account["api_key"], int(retry), int(timeout)) + result = fetch_balance(provider, account["api_key"], int(retry), int(timeout)) with db.get_conn() as conn: if result.ok: diff --git a/app/providers/__init__.py b/app/providers/__init__.py new file mode 100644 index 0000000..709a001 --- /dev/null +++ b/app/providers/__init__.py @@ -0,0 +1,28 @@ +"""平台适配器注册表:新增平台 = 加一个文件 + 在这里登记。""" + +from __future__ import annotations + +from app.providers.base import BalanceProvider +from app.providers.deepseek import DeepSeekProvider + +PROVIDERS: dict[str, type[BalanceProvider]] = { + "deepseek": DeepSeekProvider, +} + + +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()) + ] diff --git a/app/providers/base.py b/app/providers/base.py new file mode 100644 index 0000000..25dab0a --- /dev/null +++ b/app/providers/base.py @@ -0,0 +1,39 @@ +"""平台适配器基类。 + +多平台支持通过继承 BalanceProvider 实现: +1. 新建 app/providers/.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 中提取余额(数字),失败抛异常由上层记录。""" diff --git a/app/providers/deepseek.py b/app/providers/deepseek.py new file mode 100644 index 0000000..5ff7bea --- /dev/null +++ b/app/providers/deepseek.py @@ -0,0 +1,28 @@ +"""DeepSeek 开放平台余额。 + +接口:GET https://api.deepseek.com/user/balance +响应:{"is_available": true, "balance_infos": [{"currency": "CNY", "total_balance": "1.34", ...}]} +""" + +from __future__ import annotations + +from app.expr import evaluate_balance +from app.providers.base import BalanceProvider + + +class DeepSeekProvider(BalanceProvider): + id = "deepseek" + name = "DeepSeek" + currency = "CNY" + icon = "DeepSeek" + description = "DeepSeek 开放平台余额(GET /user/balance,Bearer 认证)" + + def build_request(self, api_key: str) -> tuple[str, dict, str | None]: + return ( + "https://api.deepseek.com/user/balance", + {"Accept": "application/json", "Authorization": f"Bearer {api_key}"}, + None, + ) + + def extract_balance(self, data: object) -> float: + return evaluate_balance(data, "balance_infos[0].total_balance") diff --git a/app/static/app.js b/app/static/app.js index ec00b25..cfd9f90 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -6,6 +6,7 @@ let token = localStorage.getItem(TOKEN_KEY) || ""; let accounts = []; let platforms = []; + let providersList = []; let settings = null; let refreshTimer = null; let filterStatus = "all"; @@ -141,10 +142,13 @@ async function refresh() { try { - const [acc, plat, hist] = await Promise.all([api("/accounts"), api("/platforms"), api("/history")]); + const [acc, plat, hist, provs] = await Promise.all([ + api("/accounts"), api("/platforms"), api("/history"), api("/providers"), + ]); accounts = acc; platforms = plat; historyCache = hist || {}; + providersList = provs || []; renderAccounts(); if (!document.getElementById("view-platforms").classList.contains("hidden")) renderPlatforms(); const d = new Date(); @@ -347,6 +351,7 @@ } list.innerHTML = platforms.map((p, i) => { const b = brandStyle(p.icon || ""); + const prov = providersList.find((x) => x.id === p.provider_id); return `
${escapeHtml(b.abbr)}
@@ -355,11 +360,11 @@ ${p.enabled ? "" : '已停用'}
- ${escapeHtml(p.method)} ${escapeHtml(p.url)} · - 提取 ${escapeHtml(p.balance_path)} · + ${prov ? escapeHtml(prov.name) : "未知提供方"} · ${escapeHtml(p.currency)} · 间隔 ${p.interval_seconds || "全局"}s · ${p.account_count} 个账号 + ${prov && prov.description ? `
${escapeHtml(prov.description)}
` : ""}
@@ -407,35 +412,28 @@ function platformForm(p) { const isEdit = !!p; const v = p || {}; - let savedHeaders = v.headers || {}; - if (typeof savedHeaders === "string") { - try { savedHeaders = JSON.parse(savedHeaders); } catch (_) { savedHeaders = {}; } - } - const headersStr = Object.keys(savedHeaders).length ? JSON.stringify(savedHeaders, null, 2) : '{\n "Authorization": "Bearer {{apiKey}}"\n}'; + const provOpts = providersList.length + ? providersList.map((x) => ``).join("") + : ''; + const prov = providersList.find((x) => x.id === v.provider_id); openModal(`