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:
|
def create_app(cfg: Config | None = None) -> FastAPI:
|
||||||
cfg = cfg or load_config()
|
cfg = cfg or load_config()
|
||||||
db.init_db()
|
db.init_db()
|
||||||
|
db.sync_platforms()
|
||||||
monitor = Monitor(cfg)
|
monitor = Monitor(cfg)
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|||||||
@@ -75,5 +75,30 @@ def init_db() -> None:
|
|||||||
conn.executescript(SCHEMA)
|
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]:
|
def rows_to_dicts(rows: list[sqlite3.Row]) -> list[dict]:
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
+10
-44
@@ -346,7 +346,7 @@
|
|||||||
function renderPlatforms() {
|
function renderPlatforms() {
|
||||||
const list = document.getElementById("platform-list");
|
const list = document.getElementById("platform-list");
|
||||||
if (platforms.length === 0) {
|
if (platforms.length === 0) {
|
||||||
list.innerHTML = `<div class="empty-hint">还没有平台,点击右上角「+ 添加平台」创建。</div>`;
|
list.innerHTML = `<div class="empty-hint">暂无平台(代码内置平台会在启动时自动同步)。</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = platforms.map((p, i) => {
|
list.innerHTML = platforms.map((p, i) => {
|
||||||
@@ -369,8 +369,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="platform-actions">
|
<div class="platform-actions">
|
||||||
<button class="btn sm" data-act="toggle" data-id="${p.id}">${p.enabled ? "停用" : "启用"}</button>
|
<button class="btn sm" data-act="toggle" data-id="${p.id}">${p.enabled ? "停用" : "启用"}</button>
|
||||||
<button class="btn sm" data-act="edit" data-id="${p.id}">编辑</button>
|
<button class="btn sm" data-act="edit" data-id="${p.id}">监控参数</button>
|
||||||
<button class="btn sm danger" data-act="del" data-id="${p.id}">删除</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
@@ -408,32 +407,23 @@
|
|||||||
mask.querySelector("[data-ok]").onclick = () => { closeModal(mask); onOk(); };
|
mask.querySelector("[data-ok]").onclick = () => { closeModal(mask); onOk(); };
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 平台表单 */
|
/* 平台监控参数表单(提供方由代码内置,不可改) */
|
||||||
function platformForm(p) {
|
function platformForm(p) {
|
||||||
const isEdit = !!p;
|
|
||||||
const v = 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);
|
const prov = providersList.find((x) => x.id === v.provider_id);
|
||||||
openModal(`
|
openModal(`
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h3>${isEdit ? "编辑平台" : "添加平台"}</h3>
|
<h3>${escapeHtml(v.name || "平台")} · 监控参数</h3>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-row full">
|
<div class="form-row full">
|
||||||
<label>平台提供方 *(代码内置)</label>
|
<label>平台提供方(代码内置)</label>
|
||||||
<select id="pf-provider">${provOpts}</select>
|
<input value="${prov ? escapeHtml(prov.name + " · " + (prov.description || "")) : escapeHtml(v.provider_id || "")}" disabled>
|
||||||
<div class="form-hint" id="pf-prov-desc">${prov ? escapeHtml(prov.description) : ""}</div>
|
|
||||||
</div>
|
</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>监控间隔(秒,留空用全局)</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.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"><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 class="form-row full"><label>备注</label><input id="pf-note" value="${escapeHtml(v.note || "")}"></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="form-hint">余额获取方式由代码内置的平台适配器决定,无需配置请求地址与提取路径。</p>
|
|
||||||
<p class="modal-error" id="pf-error"></p>
|
<p class="modal-error" id="pf-error"></p>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn" data-cancel>取消</button>
|
<button class="btn" data-cancel>取消</button>
|
||||||
@@ -441,37 +431,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>`, (mask) => {
|
</div>`, (mask) => {
|
||||||
mask.querySelector("[data-cancel]").onclick = () => closeModal(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 () => {
|
mask.querySelector("#pf-save").onclick = async () => {
|
||||||
const providerId = val("#pf-provider");
|
|
||||||
const payload = {
|
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"),
|
interval_seconds: numOrNull("#pf-interval"),
|
||||||
retry_count: numOrNull("#pf-retry"),
|
retry_count: numOrNull("#pf-retry"),
|
||||||
timeout_seconds: numOrNull("#pf-timeout"),
|
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 {
|
try {
|
||||||
if (isEdit) await api("/platforms/" + p.id, { method: "PUT", body: JSON.stringify(payload) });
|
await api("/platforms/" + v.id, { method: "PUT", body: JSON.stringify(payload) });
|
||||||
else await api("/platforms", { method: "POST", body: JSON.stringify(payload) });
|
|
||||||
closeModal(mask);
|
closeModal(mask);
|
||||||
toast(isEdit ? "平台已更新" : "平台已添加");
|
toast("平台监控参数已更新");
|
||||||
await refresh();
|
await refresh();
|
||||||
switchView("platforms");
|
renderPlatforms();
|
||||||
} catch (e) { err("#pf-error", e.message); }
|
} 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("login-password").addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); });
|
||||||
document.getElementById("logout-btn").onclick = logout;
|
document.getElementById("logout-btn").onclick = logout;
|
||||||
document.querySelectorAll(".tab").forEach((t) => t.addEventListener("click", () => switchView(t.dataset.view)));
|
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 = () => {
|
document.getElementById("add-account-btn").onclick = () => {
|
||||||
if (platforms.length === 0) {
|
if (platforms.length === 0) {
|
||||||
toast("请先在「平台」页添加平台", "err");
|
toast("请先在「平台」页添加平台", "err");
|
||||||
@@ -708,11 +679,6 @@
|
|||||||
await refresh();
|
await refresh();
|
||||||
renderPlatforms();
|
renderPlatforms();
|
||||||
} catch (err2) { toast(err2.message, "err"); }
|
} 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">
|
<section id="view-platforms" class="view hidden">
|
||||||
<div class="view-head">
|
<div class="view-head">
|
||||||
<h2>平台配置</h2>
|
<h2>平台(代码内置,自动同步)</h2>
|
||||||
<button id="add-platform-btn" class="btn primary">+ 添加平台</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="platform-list" class="platform-list"></div>
|
<div id="platform-list" class="platform-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+15
-5
@@ -52,21 +52,31 @@ PLATFORM_PAYLOAD = {
|
|||||||
|
|
||||||
|
|
||||||
class TestPlatforms:
|
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):
|
def test_crud_flow(self, client):
|
||||||
h = _auth(client)
|
h = _auth(client)
|
||||||
pid = client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h).json()["id"]
|
pid = client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h).json()["id"]
|
||||||
|
|
||||||
lst = client.get("/api/platforms", headers=h).json()
|
lst = client.get("/api/platforms", headers=h).json()
|
||||||
assert len(lst) == 1 and lst[0]["account_count"] == 0
|
created = next(p for p in lst if p["id"] == pid)
|
||||||
assert lst[0]["provider_id"] == "deepseek"
|
assert created["provider_id"] == "deepseek"
|
||||||
assert lst[0]["currency"] == "CNY" # 默认取 provider 的货币
|
assert created["currency"] == "CNY" # 默认取 provider 的货币
|
||||||
|
|
||||||
upd = client.put(f"/api/platforms/{pid}", json={"currency": "USD", "interval_seconds": 300}, headers=h)
|
upd = client.put(f"/api/platforms/{pid}", json={"currency": "USD", "interval_seconds": 300}, headers=h)
|
||||||
assert upd.status_code == 200
|
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.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):
|
def test_duplicate_name_409(self, client):
|
||||||
h = _auth(client)
|
h = _auth(client)
|
||||||
|
|||||||
Reference in New Issue
Block a user