feat: 平台自动同步代码内置 provider,移除手动添加/删除平台 - 启动时 sync_platforms:注册表中的 provider 自动生成为平台记录 - 前端移除添加平台按钮与删除入口,平台列表只读 + 停用/启用 + 监控参数编辑 - 平台编辑表单仅保留间隔/重试/超时/备注(提供方锁定) - 测试更新:内置平台自动同步用例,共 93 个

This commit is contained in:
2026-08-05 07:37:30 +08:00
parent bdeb353bc2
commit 86ef5aa412
5 changed files with 52 additions and 51 deletions
+1
View File
@@ -101,6 +101,7 @@ def _downsample(rows: list[sqlite3.Row], limit: int) -> list[sqlite3.Row]:
def create_app(cfg: Config | None = None) -> FastAPI:
cfg = cfg or load_config()
db.init_db()
db.sync_platforms()
monitor = Monitor(cfg)
@asynccontextmanager
+25
View File
@@ -75,5 +75,30 @@ def init_db() -> None:
conn.executescript(SCHEMA)
def sync_platforms() -> None:
"""把代码内置的 provider 自动同步为平台记录(缺失时创建)。
平台 = 代码注册表,无需用户手动添加;删除也会在下一次启动时重建。
"""
from app.providers import list_providers
with get_conn() as conn:
existing = {
r["provider_id"]
for r in conn.execute(
"SELECT provider_id FROM platforms WHERE provider_id != ''"
).fetchall()
}
for p in list_providers():
if p["id"] in existing:
continue
conn.execute(
"INSERT INTO platforms (provider_id, name, currency, icon, enabled) "
"VALUES (?, ?, ?, ?, 1)",
(p["id"], p["name"], p["currency"], p["icon"]),
)
existing.add(p["id"])
def rows_to_dicts(rows: list[sqlite3.Row]) -> list[dict]:
return [dict(r) for r in rows]
+10 -44
View File
@@ -346,7 +346,7 @@
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) => {
@@ -369,8 +369,7 @@
</div>
<div class="platform-actions">
<button class="btn sm" data-act="toggle" data-id="${p.id}">${p.enabled ? "停用" : "启用"}</button>
<button class="btn sm" data-act="edit" data-id="${p.id}">编辑</button>
<button class="btn sm danger" data-act="del" data-id="${p.id}">删除</button>
<button class="btn sm" data-act="edit" data-id="${p.id}">监控参数</button>
</div>
</div>`;
}).join("");
@@ -408,32 +407,23 @@
mask.querySelector("[data-ok]").onclick = () => { closeModal(mask); onOk(); };
}
/* 平台表单 */
/* 平台监控参数表单(提供方由代码内置,不可改) */
function platformForm(p) {
const isEdit = !!p;
const v = p || {};
const provOpts = providersList.length
? providersList.map((x) => `<option value="${x.id}" ${v.provider_id === x.id ? "selected" : ""}>${escapeHtml(x.name)}</option>`).join("")
: '<option value="">加载中…</option>';
const prov = providersList.find((x) => x.id === v.provider_id);
openModal(`
<div class="modal">
<h3>${isEdit ? "编辑平台" : "添加平台"}</h3>
<h3>${escapeHtml(v.name || "平台")} · 监控参数</h3>
<div class="form-grid">
<div class="form-row full">
<label>平台提供方 *(代码内置)</label>
<select id="pf-provider">${provOpts}</select>
<div class="form-hint" id="pf-prov-desc">${prov ? escapeHtml(prov.description) : ""}</div>
<label>平台提供方(代码内置)</label>
<input value="${prov ? escapeHtml(prov.name + " · " + (prov.description || "")) : escapeHtml(v.provider_id || "")}" disabled>
</div>
<div class="form-row"><label>名称 *</label><input id="pf-name" value="${escapeHtml(v.name || "")}"></div>
<div class="form-row"><label>货币单位</label><input id="pf-currency" value="${escapeHtml(v.currency || "")}" placeholder="默认来自提供方"></div>
<div class="form-row"><label>图标键(@lobehub/icons</label><input id="pf-icon" value="${escapeHtml(v.icon || "")}" placeholder="默认来自提供方"></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">余额获取方式由代码内置的平台适配器决定,无需配置请求地址与提取路径。</p>
<p class="modal-error" id="pf-error"></p>
<div class="modal-actions">
<button class="btn" data-cancel>取消</button>
@@ -441,37 +431,19 @@
</div>
</div>`, (mask) => {
mask.querySelector("[data-cancel]").onclick = () => closeModal(mask);
const provSel = mask.querySelector("#pf-provider");
provSel.onchange = () => {
const x = providersList.find((q) => q.id === provSel.value);
if (!x) return;
mask.querySelector("#pf-prov-desc").textContent = x.description || "";
if (!mask.querySelector("#pf-name").value || !isEdit) mask.querySelector("#pf-name").value = x.name;
if (!mask.querySelector("#pf-currency").value) mask.querySelector("#pf-currency").value = x.currency;
if (!mask.querySelector("#pf-icon").value) mask.querySelector("#pf-icon").value = x.icon;
};
mask.querySelector("#pf-save").onclick = async () => {
const providerId = val("#pf-provider");
const payload = {
provider_id: providerId,
name: val("#pf-name"),
currency: val("#pf-currency") || null,
icon: val("#pf-icon") || null,
note: val("#pf-note"),
interval_seconds: numOrNull("#pf-interval"),
retry_count: numOrNull("#pf-retry"),
timeout_seconds: numOrNull("#pf-timeout"),
enabled: p ? p.enabled : true,
note: val("#pf-note"),
};
if (!providerId) { err("#pf-error", "请选择平台提供方"); return; }
if (!payload.name) { err("#pf-error", "名称必填"); return; }
try {
if (isEdit) await api("/platforms/" + p.id, { method: "PUT", body: JSON.stringify(payload) });
else await api("/platforms", { method: "POST", body: JSON.stringify(payload) });
await api("/platforms/" + v.id, { method: "PUT", body: JSON.stringify(payload) });
closeModal(mask);
toast(isEdit ? "平台已更新" : "平台已添加");
toast("平台监控参数已更新");
await refresh();
switchView("platforms");
renderPlatforms();
} catch (e) { err("#pf-error", e.message); }
};
});
@@ -579,7 +551,6 @@
document.getElementById("login-password").addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); });
document.getElementById("logout-btn").onclick = logout;
document.querySelectorAll(".tab").forEach((t) => t.addEventListener("click", () => switchView(t.dataset.view)));
document.getElementById("add-platform-btn").onclick = () => platformForm(null);
document.getElementById("add-account-btn").onclick = () => {
if (platforms.length === 0) {
toast("请先在「平台」页添加平台", "err");
@@ -708,11 +679,6 @@
await refresh();
renderPlatforms();
} catch (err2) { toast(err2.message, "err"); }
} else if (act === "del") {
confirmDialog(`确定删除平台「${escapeHtml(p ? p.name : id)}」?<br>其下 ${p ? p.account_count : 0} 个账号及历史记录将一并删除,此操作不可恢复。`, async () => {
try { await api("/platforms/" + id, { method: "DELETE" }); toast("平台已删除"); await refresh(); switchView("platforms"); }
catch (err2) { toast(err2.message, "err"); }
});
}
}
+1 -2
View File
@@ -75,8 +75,7 @@
<!-- 平台视图 -->
<section id="view-platforms" class="view hidden">
<div class="view-head">
<h2>平台配置</h2>
<button id="add-platform-btn" class="btn primary">+ 添加平台</button>
<h2>平台(代码内置,自动同步)</h2>
</div>
<div id="platform-list" class="platform-list"></div>
</section>
+15 -5
View File
@@ -52,21 +52,31 @@ PLATFORM_PAYLOAD = {
class TestPlatforms:
def test_builtin_platforms_auto_synced(self, client):
"""代码内置 provider 启动时自动同步为平台记录,无需手动添加。"""
h = _auth(client)
provs = client.get("/api/providers", headers=h).json()
plats = client.get("/api/platforms", headers=h).json()
for p in provs:
assert any(x["provider_id"] == p["id"] for x in plats), p["id"]
dp = next(x for x in plats if x["provider_id"] == "deepseek")
assert dp["name"] == "DeepSeek" and dp["currency"] == "CNY"
def test_crud_flow(self, client):
h = _auth(client)
pid = client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h).json()["id"]
lst = client.get("/api/platforms", headers=h).json()
assert len(lst) == 1 and lst[0]["account_count"] == 0
assert lst[0]["provider_id"] == "deepseek"
assert lst[0]["currency"] == "CNY" # 默认取 provider 的货币
created = next(p for p in lst if p["id"] == pid)
assert created["provider_id"] == "deepseek"
assert created["currency"] == "CNY" # 默认取 provider 的货币
upd = client.put(f"/api/platforms/{pid}", json={"currency": "USD", "interval_seconds": 300}, headers=h)
assert upd.status_code == 200
assert client.get("/api/platforms", headers=h).json()[0]["currency"] == "USD"
assert next(p for p in client.get("/api/platforms", headers=h).json() if p["id"] == pid)["currency"] == "USD"
assert client.delete(f"/api/platforms/{pid}", headers=h).status_code == 200
assert client.get("/api/platforms", headers=h).json() == []
assert all(p["id"] != pid for p in client.get("/api/platforms", headers=h).json())
def test_duplicate_name_409(self, client):
h = _auth(client)