feat: 平台自动同步代码内置 provider,移除手动添加/删除平台 - 启动时 sync_platforms:注册表中的 provider 自动生成为平台记录 - 前端移除添加平台按钮与删除入口,平台列表只读 + 停用/启用 + 监控参数编辑 - 平台编辑表单仅保留间隔/重试/超时/备注(提供方锁定) - 测试更新:内置平台自动同步用例,共 93 个
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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"); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user