mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(channels): 渠道控制台对齐参考 ChannelCard —— 账号池/模型开关/供应商治理/备份 + 全量 UI
用户要求与示例插件 dsh-our-free-model 的 WebUI 全面对齐。参考前端的
核心是 ChannelCard(client.js 3627 行起):一个渠道可挂**多账号池**,
账号可逐个启用/停用/测试/删除/排序,模型可逐个开关,供应商整体开关,
账号备份导出/导入,自动签到开关。此前是单凭据 + 平表登录,差距大。
## 后端(channels.lua 新增 19 个 RPC,对齐参考 channel-pack 契约)
账号池(对齐参考 account.*):
account_add/list/update/delete/test/refresh/reorder —— 一平台多账号,
每账号一条上游源 channels-<plat>-<aid>,凭据走保险库(0600 加密落盘)。
complete_login 带 account_id 时走池路径回填凭据。
模型开关(对齐 model.*):
model_list/set_disabled/set_all_disabled —— 黑名单存 state,开关即重同步
该平台所有账号源(关掉模型=该账号源里去掉该模型)。
供应商治理(对齐 provider.*):
provider_status(账号数/模型数/closed 态)+ provider_set_enabled
(关闭=全关模型+停用全部账号;打开=清黑名单+启用全部账号)。
一键领取(对齐 credits.claimAll):
credits_claim_all —— 逐账号 claim_daily 收集结果,返回 {claimed, results}。
备份(对齐 backup.*):
backup_export/import —— 账号池+凭据+模型黑名单+自动签到开关打包,导入
恢复凭据(走保险库)+重建全部账号源。
自动签到开关(对齐 usage.autoCheckin):
auto_checkin —— 读写 state.settings.auto_checkin,关闭时 auto_claim 跳过。
auto_claim 升级为账号池驱动(逐账号 claim + 尊重 CHANNEL_CREDITS 能力表)。
**修一个真 bug**:__llmsproxy_secrets.set 返回 (true, nil) 成功 / (nil, err) 失败,
我多值赋给单变量拿到第一个返回值,误判为失败 → 改为 local _, serr = set(...)。
## WebUI(整个重写,对齐 ChannelCard 交互)
渠道网格(搜索/筛选全部·已接入·待接入/汇总条已接入N·账号总数)
→ 渠道详情(账号池列表:昵称/启用态/上次领取摘要 + 测试/刷新/停用/优先/删除;
模型折叠:逐个开关 + 全部启用/关闭 + 计数;添加账号=登录(占位账号先入池,
login 完成回填凭据,两步式对齐参考 account.create);一键领取额度(结果
摘要展示成功数+失败原因);供应商整体开关;自动签到开关;导出/导入备份)。
登录区仍覆盖 device-poll/callback/paste/manual/vnc 全部流程。
node --check 过内联 JS;凭据只经 complete_login 落保险库,不回显。
## 行为测试(channels_pool_test.go)
TestChannelsAccountPoolBehavior 5 个子测试全过:
账号增删改(含返回 account_id、改名、删空)
模型开关同步上游源(3模型→关1→2→全关→closed=true→全开恢复)
enabled开关同步上游源(停用删源、启用重建源)
供应商整体开关(关→无源+closed;开→源重建)
备份导出含凭据明文
## 已知边界(如实说明,未假装对齐)
- 余额逐账号展示:参考有 credits.balances(真实查上游余额),本环境各平台余额
端点形状差异大且多数需真实凭据,未编造假余额;账号行只显示上次领取摘要。
- usage.tokenLedger / cline.requestLog(用量账本/请求日志页):参考有,本插件
内核侧已有 usage 记录但未暴露给插件,暂缺这两页(其余控制台能力已对齐)。
回归:全量 8 包(core/lua/gateway/config/types/scheduler/provider/e2e)通过。
This commit is contained in:
196
internal/core/channels_pool_test.go
Normal file
196
internal/core/channels_pool_test.go
Normal file
@ -0,0 +1,196 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// channels_pool_test:对齐参考 account.* / model.* / provider.* 契约的行为测试。
|
||||
// 账号池增删改、上游源随 enabled/模型开关同步、供应商整体开关、备份导出。
|
||||
|
||||
func poolCore(t *testing.T) (*Core, *lua.Plugins) {
|
||||
t.Helper()
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "remove_source", "status", "probe_all", "reset_health"}, false)
|
||||
return c, ps
|
||||
}
|
||||
|
||||
// listSourcesViaKernel 直查内核 list_sources(actionListSources 返回 []map)。
|
||||
func listSourcesViaKernel(t *testing.T, c *Core) []map[string]interface{} {
|
||||
t.Helper()
|
||||
out, err := c.Invoke(nil, "list_sources", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("list_sources: %v", err)
|
||||
}
|
||||
if ms, ok := out.([]map[string]interface{}); ok {
|
||||
return ms
|
||||
}
|
||||
raw, _ := out.([]interface{})
|
||||
var srcs []map[string]interface{}
|
||||
for _, s := range raw {
|
||||
if m, ok := s.(map[string]interface{}); ok {
|
||||
srcs = append(srcs, m)
|
||||
}
|
||||
}
|
||||
return srcs
|
||||
}
|
||||
|
||||
func invoke(t *testing.T, ps *lua.Plugins, fn, body string) map[string]interface{} {
|
||||
t.Helper()
|
||||
out, err := ps.InvokeHandler("channels", fn, &lua.HandlerRequest{Method: "POST", Body: body})
|
||||
if err != nil {
|
||||
t.Fatalf("invoke %s: %v", fn, err)
|
||||
}
|
||||
return out.JSON.(map[string]interface{})
|
||||
}
|
||||
|
||||
// asArr 规整桥接后的数组。Lua 空表过桥是 map[string]interface{}(无法与空数组
|
||||
// 区分,见 vm.go luaTableToGoValue);内核 list_sources 的 models 过桥是 []string。
|
||||
func asArr(v interface{}) []interface{} {
|
||||
switch a := v.(type) {
|
||||
case []interface{}:
|
||||
return a
|
||||
case []string:
|
||||
out := make([]interface{}, len(a))
|
||||
for i, s := range a {
|
||||
out[i] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func srcHasAccount(srcs []map[string]interface{}, needle string) (map[string]interface{}, bool) {
|
||||
for _, sm := range srcs {
|
||||
if name, _ := sm["name"].(string); strings.Contains(name, needle) {
|
||||
return sm, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// TestChannelsAccountPoolBehavior:账号池 CRUD + 上游源同步 + 模型/供应商开关 + 备份。
|
||||
func TestChannelsAccountPoolBehavior(t *testing.T) {
|
||||
c, ps := poolCore(t)
|
||||
|
||||
t.Run("账号增删改", func(t *testing.T) {
|
||||
m := invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"我的号"}`)
|
||||
if m["ok"] != true {
|
||||
t.Fatalf("account_add: %v", m)
|
||||
}
|
||||
aid, _ := m["account_id"].(string)
|
||||
if aid == "" {
|
||||
t.Fatalf("account_add 应返回 account_id: %v", m)
|
||||
}
|
||||
m = invoke(t, ps, "account_list", `{"platform":"minimax"}`)
|
||||
accts := asArr(m["accounts"])
|
||||
if len(accts) != 1 {
|
||||
t.Fatalf("应有1个账号: %d", len(accts))
|
||||
}
|
||||
a := accts[0].(map[string]interface{})
|
||||
if a["nickname"] != "我的号" || a["enabled"] != true {
|
||||
t.Fatalf("账号字段: %v", a)
|
||||
}
|
||||
invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"nickname":"改名"}}`)
|
||||
m = invoke(t, ps, "account_list", `{"platform":"minimax"}`)
|
||||
if asArr(m["accounts"])[0].(map[string]interface{})["nickname"] != "改名" {
|
||||
t.Fatalf("改名失败")
|
||||
}
|
||||
invoke(t, ps, "account_delete", `{"platform":"minimax","account_id":"`+aid+`"}`)
|
||||
m = invoke(t, ps, "account_list", `{"platform":"minimax"}`)
|
||||
if n := len(asArr(m["accounts"])); n != 0 {
|
||||
t.Fatalf("删除后应为0, 实得 %d", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("模型开关同步上游源", func(t *testing.T) {
|
||||
m := invoke(t, ps, "account_add", `{"platform":"minimax","credential":"MM-KEY"}`)
|
||||
aid, _ := m["account_id"].(string)
|
||||
sm, ok := srcHasAccount(listSourcesViaKernel(t, c), aid)
|
||||
if !ok {
|
||||
t.Fatal("带凭据账号应已同步源")
|
||||
}
|
||||
if n := len(asArr(sm["models"])); n != 3 {
|
||||
t.Fatalf("源应有3模型, 实得 %d", n)
|
||||
}
|
||||
invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`)
|
||||
sm, _ = srcHasAccount(listSourcesViaKernel(t, c), aid)
|
||||
if n := len(asArr(sm["models"])); n != 2 {
|
||||
t.Fatalf("关1模型后源应2模型, 实得 %d", n)
|
||||
}
|
||||
invoke(t, ps, "model_set_all_disabled", `{"platform":"minimax","disabled":true}`)
|
||||
m = invoke(t, ps, "provider_status", `{}`)
|
||||
for _, s := range asArr(m["statuses"]) {
|
||||
x := s.(map[string]interface{})
|
||||
if x["id"] == "minimax" && x["closed"] != true {
|
||||
t.Fatalf("全关模型后应 closed=true: %v", x)
|
||||
}
|
||||
}
|
||||
invoke(t, ps, "model_set_all_disabled", `{"platform":"minimax","disabled":false}`)
|
||||
})
|
||||
|
||||
t.Run("enabled开关同步上游源", func(t *testing.T) {
|
||||
m := invoke(t, ps, "account_list", `{"platform":"minimax"}`)
|
||||
accts := asArr(m["accounts"])
|
||||
if len(accts) == 0 {
|
||||
t.Fatal("上个用例应留有账号")
|
||||
}
|
||||
aid := accts[0].(map[string]interface{})["id"].(string)
|
||||
before := len(listSourcesViaKernel(t, c))
|
||||
invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":false}}`)
|
||||
if n := len(listSourcesViaKernel(t, c)); n != before-1 {
|
||||
t.Fatalf("停用账号应删源: 前 %d 后 %d", before, n)
|
||||
}
|
||||
invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":true}}`)
|
||||
if n := len(listSourcesViaKernel(t, c)); n != before {
|
||||
t.Fatalf("启用账号应重建源: 前 %d 后 %d", before, n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("供应商整体开关", func(t *testing.T) {
|
||||
invoke(t, ps, "provider_set_enabled", `{"platform":"minimax","enabled":false}`)
|
||||
if _, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax"); ok {
|
||||
t.Fatal("关闭渠道后不应有 minimax 源")
|
||||
}
|
||||
m := invoke(t, ps, "provider_status", `{}`)
|
||||
for _, s := range asArr(m["statuses"]) {
|
||||
x := s.(map[string]interface{})
|
||||
if x["id"] == "minimax" && x["closed"] != true {
|
||||
t.Fatalf("关闭后应 closed: %v", x)
|
||||
}
|
||||
}
|
||||
invoke(t, ps, "provider_set_enabled", `{"platform":"minimax","enabled":true}`)
|
||||
if _, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax"); !ok {
|
||||
t.Fatal("开启渠道后应有 minimax 源")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("备份导出含凭据", func(t *testing.T) {
|
||||
invoke(t, ps, "account_add", `{"platform":"cline","credential":"CL-KEY","nickname":"cl1"}`)
|
||||
exp := invoke(t, ps, "backup_export", `{}`)
|
||||
payload, _ := exp["payload"].(map[string]interface{})
|
||||
if payload == nil {
|
||||
t.Fatal("backup_export 应返回 payload")
|
||||
}
|
||||
creds, _ := payload["credentials"].(map[string]interface{})
|
||||
found := false
|
||||
for _, v := range creds {
|
||||
if v == "CL-KEY" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("备份应含凭据明文: %v", creds)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st
|
||||
|
||||
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
|
||||
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
|
||||
plugin.exports = { "set_endpoints" }
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "model_list", "model_set_disabled", "model_set_all_disabled", "provider_status", "provider_set_enabled", "credits_claim_all", "backup_export", "backup_import", "auto_checkin" }
|
||||
|
||||
|
||||
|
||||
@ -639,6 +639,115 @@ local function accountOf(id)
|
||||
return plugin.state.accounts[id]
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- 账号池(对齐参考 account.* RPC 契约):一个平台可挂多个账号
|
||||
-- ============================================================================
|
||||
-- plugin.state.pool[provider] = { [accountId] = 条目 },条目字段对齐参考
|
||||
-- AccountEntry:{ id, provider, nickname, enabled, credentialRef, refreshable,
|
||||
-- createdAt, expiresAt }。凭据在保险库(credentialRef 即保险库键),不入 state。
|
||||
--
|
||||
-- 兼容:旧的单账号路径(state.accounts)保留 —— e2e 与既有端点仍走它。
|
||||
local function poolTable()
|
||||
if not plugin.state.pool then plugin.state.pool = {} end
|
||||
return plugin.state.pool
|
||||
end
|
||||
|
||||
local function poolAccounts(provider)
|
||||
local t = poolTable()[provider]
|
||||
if not t then t = {} poolTable()[provider] = t end
|
||||
return t
|
||||
end
|
||||
|
||||
local function poolList(provider)
|
||||
local out = {}
|
||||
for _, a in pairs(poolAccounts(provider)) do out[#out+1] = a end
|
||||
table.sort(out, function(x, y)
|
||||
local px, py = x.priority or 0, y.priority or 0
|
||||
if px ~= py then return px > py end -- priority 大者优先(参考 reorder 语义)
|
||||
return (x.createdAt or 0) < (y.createdAt or 0)
|
||||
end)
|
||||
return out
|
||||
end
|
||||
|
||||
local function poolGet(provider, accountId)
|
||||
return poolAccounts(provider)[accountId]
|
||||
end
|
||||
|
||||
local function poolAdd(provider, entry)
|
||||
poolAccounts(provider)[entry.id] = entry
|
||||
end
|
||||
|
||||
local function poolRemove(provider, accountId)
|
||||
poolAccounts(provider)[accountId] = nil
|
||||
end
|
||||
|
||||
-- 模型黑名单(对齐参考 model.setDisabled):provider → { [modelId] = true }
|
||||
local function disabledModelsOf(provider)
|
||||
if not plugin.state.modelDisabled then plugin.state.modelDisabled = {} end
|
||||
local m = plugin.state.modelDisabled[provider]
|
||||
if not m then m = {} plugin.state.modelDisabled[provider] = m end
|
||||
return m
|
||||
end
|
||||
|
||||
-- 渠道积分能力表(对齐参考 CHANNEL_CREDITS):声明各渠道有没有领取/余额。
|
||||
local CHANNEL_CREDITS = {
|
||||
codearts = { claim = true },
|
||||
buddy = { claim = true, status = true },
|
||||
lobsterai = { claim = true },
|
||||
qoder = { claim = true },
|
||||
trae = { claim = true },
|
||||
cline = { unit = "dollars" },
|
||||
loomy = { claim = true },
|
||||
raccoon = {},
|
||||
minimax = { claim = true, status = true },
|
||||
zcode = { claim = true, unit = "tokens" },
|
||||
gemini = { unit = "quota", quota = true },
|
||||
}
|
||||
|
||||
-- ---- 账号级 source 同步 ----
|
||||
-- 每个账号一条上游源:channels-<provider>-<accountId>。模型开关即改写
|
||||
-- 这条源的 models 列表(remove+add 重建;内核 add_source 同名覆盖,但显式
|
||||
-- 先 remove 更诚实,避免旧模型残留)。
|
||||
local function accountSourceName(provider, accountId)
|
||||
return "channels-" .. provider .. "-" .. accountId
|
||||
end
|
||||
|
||||
-- syncAccountSource 把某账号的上游源与(平台模型目录 - 黑名单)对齐。
|
||||
-- 无凭据(占位账号)不动源。
|
||||
local function syncAccountSource(provider, accountId)
|
||||
local plat = platformById(provider)
|
||||
local acct = poolGet(provider, accountId)
|
||||
if not plat or not acct then return end
|
||||
local tok = __llmsproxy_secrets.get(acct.credentialRef)
|
||||
if not tok or tok == "" then return end
|
||||
local name = accountSourceName(provider, accountId)
|
||||
local models = {}
|
||||
local disabled = disabledModelsOf(provider)
|
||||
for _, m in ipairs(plat.models or {}) do
|
||||
if not disabled[m] then
|
||||
models[#models+1] = { id = m, kind = "chat", priority = 20 }
|
||||
end
|
||||
end
|
||||
-- 重建:先 remove(容错:源可能不存在)再 add
|
||||
__llmsproxy_kernel.call("remove_source", { name = name })
|
||||
local r, aerr = __llmsproxy_kernel.call("add_source", {
|
||||
source = {
|
||||
name = name,
|
||||
base_url = plat.base_url,
|
||||
adapter = plat.adapter or "openai",
|
||||
api_key = tok,
|
||||
max_concurrent = 2,
|
||||
models = models,
|
||||
},
|
||||
})
|
||||
if aerr then logEvent("接入源失败 " .. name .. ": " .. aerr)
|
||||
else logEvent("已同步源 " .. name .. "(" .. #models .. " 模型)") end
|
||||
end
|
||||
|
||||
local function dropAccountSource(provider, accountId)
|
||||
__llmsproxy_kernel.call("remove_source", { name = accountSourceName(provider, accountId) })
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- 登录
|
||||
-- ============================================================================
|
||||
@ -772,6 +881,7 @@ function plugin.start_login(req)
|
||||
expires_at = now + SESSION_TTL,
|
||||
}
|
||||
sessions()[sess.id] = sess
|
||||
sess.account_id = args and args.account_id -- 账号池路径:登录完成回填这个池账号
|
||||
logEvent("开始登录 " .. plat.name .. " (" .. plat.flow .. ")")
|
||||
|
||||
-- 响应里只带一次 nonce(回调校验要用),存侧不回显
|
||||
@ -968,6 +1078,32 @@ function plugin.complete_login(req)
|
||||
local token = args and args.credential
|
||||
if not token or token == "" then return { status = 400, json = { error = "缺少凭据" } } end
|
||||
|
||||
-- 账号池路径:带 account_id 时写入池条目(对齐参考 account.create 的
|
||||
-- 占位账号语义 —— 先有账号行,凭据随后填入)。
|
||||
local aid = args.account_id
|
||||
if aid and aid ~= "" then
|
||||
local acct = poolGet(plat.id, aid)
|
||||
if not acct then return { status = 400, json = { error = "账号不存在: " .. tostring(aid) } } end
|
||||
local ref = acct.credentialRef
|
||||
local _, serr = __llmsproxy_secrets.set(ref, token)
|
||||
if serr then return { status = 400, json = { error = serr } } end
|
||||
acct.nickname = args.account or acct.nickname or aid
|
||||
acct.expiresAt = args.expires_at or acct.expiresAt
|
||||
acct.refreshable = args.refreshable or acct.refreshable or false
|
||||
poolAdd(plat.id, acct)
|
||||
-- 终结该账号的未决会话
|
||||
for id, sess in pairs(sessions()) do
|
||||
if sess.platform == plat.id and sess.state == "pending"
|
||||
and (sess.account_id == aid or sess.pool_account_id == aid) then
|
||||
sess.state = "completed"
|
||||
end
|
||||
end
|
||||
logEvent("账号凭据已入池 " .. plat.id .. "/" .. aid)
|
||||
syncAccountSource(plat.id, aid)
|
||||
return { status = 200, json = { ok = true, account_id = aid, source = accountSourceName(plat.id, aid) } }
|
||||
end
|
||||
|
||||
-- 兼容旧路径:单账号模式(e2e 与既有端点仍走它)。
|
||||
local ok, err = __llmsproxy_secrets.set("channels:" .. plat.id, token)
|
||||
if err then return { status = 400, json = { error = err } } end
|
||||
|
||||
@ -1023,7 +1159,15 @@ function plugin.claim_daily(req)
|
||||
if not plat.checkin or not plat.checkin.url then
|
||||
return { status = 400, json = { error = plat.name .. " 不支持每日签到(平台未声明 checkin)" } }
|
||||
end
|
||||
local tok = __llmsproxy_secrets.get("channels:" .. plat.id)
|
||||
-- 账号池路径:claim 某个池账号;否则旧路径单凭据。
|
||||
local tok, acctEntry
|
||||
if args.account_id and args.account_id ~= "" then
|
||||
acctEntry = poolGet(plat.id, args.account_id)
|
||||
if not acctEntry then return { status = 400, json = { error = "账号不存在" } } end
|
||||
tok = __llmsproxy_secrets.get(acctEntry.credentialRef)
|
||||
else
|
||||
tok = __llmsproxy_secrets.get("channels:" .. plat.id)
|
||||
end
|
||||
if not tok then return { status = 400, json = { error = "未登录" } } end
|
||||
|
||||
-- 平台可自定义领取请求形状;默认 Bearer + POST {}。
|
||||
@ -1047,7 +1191,12 @@ function plugin.claim_daily(req)
|
||||
return { status = 400, json = { error = err } }
|
||||
end
|
||||
|
||||
local acct = plugin.state.accounts[plat.id] or {}
|
||||
local acct
|
||||
if acctEntry then
|
||||
acct = acctEntry
|
||||
else
|
||||
acct = plugin.state.accounts[plat.id] or {}
|
||||
end
|
||||
acct.last_claim = os.time and os.time() or 0
|
||||
acct.last_claim_status = resp.status
|
||||
-- 平台可提供解析器把响应转成可读摘要(余额/获得额度)
|
||||
@ -1055,7 +1204,11 @@ function plugin.claim_daily(req)
|
||||
local ok, summary = pcall(plat.claim_result_fn, resp.body)
|
||||
if ok and summary then acct.last_claim_summary = summary end
|
||||
end
|
||||
plugin.state.accounts[plat.id] = acct
|
||||
if acctEntry then
|
||||
poolAdd(plat.id, acctEntry)
|
||||
else
|
||||
plugin.state.accounts[plat.id] = acct
|
||||
end
|
||||
logEvent("领取返回 " .. tostring(resp.status))
|
||||
return { status = 200, json = { ok = true, upstream_status = resp.status, body = resp.body, summary = acct.last_claim_summary } }
|
||||
end
|
||||
@ -1071,11 +1224,22 @@ plugin.schedule = {
|
||||
}
|
||||
|
||||
function plugin.auto_claim()
|
||||
-- 自动签到开关(对齐参考 usage.autoCheckin):关闭时不做任何领取。
|
||||
plugin.state.settings = plugin.state.settings or {}
|
||||
if plugin.state.settings.auto_checkin == false then return end
|
||||
for _, plat in ipairs(PLATFORMS) do
|
||||
local acct = accountOf(plat.id)
|
||||
if acct and acct.enabled then
|
||||
local cap = CHANNEL_CREDITS[plat.id]
|
||||
if not cap or not cap.claim then goto continue end
|
||||
local legacy = accountOf(plat.id)
|
||||
if legacy and legacy.enabled then
|
||||
plugin.claim_daily({ platform = plat.id })
|
||||
end
|
||||
for _, a in ipairs(poolList(plat.id)) do
|
||||
if a.enabled ~= false and __llmsproxy_secrets.get(a.credentialRef) then
|
||||
plugin.claim_daily({ platform = plat.id, account_id = a.id })
|
||||
end
|
||||
end
|
||||
::continue::
|
||||
end
|
||||
end
|
||||
|
||||
@ -1099,6 +1263,39 @@ plugin.http = {
|
||||
-- OAuth 回调落点:平台重定向回来带 code。
|
||||
{ path = "channels/callback", method = "GET", handler = "oauth_callback",
|
||||
public = false, roles = { "admin" }, description = "OAuth 回调" },
|
||||
-- 账号池 / 模型开关 / 供应商治理 / 备份(对齐参考 channel-pack RPC 面)
|
||||
{ path = "channels/account/add", method = "POST", handler = "account_add",
|
||||
roles = { "admin" }, description = "向平台添加账号(占位或带凭据)" },
|
||||
{ path = "channels/account/list", method = "POST", handler = "account_list",
|
||||
roles = { "admin" }, description = "列平台账号池" },
|
||||
{ path = "channels/account/update", method = "POST", handler = "account_update",
|
||||
roles = { "admin" }, description = "改账号昵称/启用态" },
|
||||
{ path = "channels/account/delete", method = "POST", handler = "account_delete",
|
||||
roles = { "admin" }, description = "删除账号(含凭据与上游源)" },
|
||||
{ path = "channels/account/test", method = "POST", handler = "account_test",
|
||||
roles = { "admin" }, description = "测账号可达" },
|
||||
{ path = "channels/account/refresh", method = "POST", handler = "account_refresh",
|
||||
roles = { "admin" }, description = "刷新账号(重同步源)" },
|
||||
{ path = "channels/account/reorder", method = "POST", handler = "account_reorder",
|
||||
roles = { "admin" }, description = "调账号优先级" },
|
||||
{ path = "channels/model/list", method = "POST", handler = "model_list",
|
||||
roles = { "admin" }, description = "列平台模型与开关态" },
|
||||
{ path = "channels/model/set_disabled", method = "POST", handler = "model_set_disabled",
|
||||
roles = { "admin" }, description = "开关单个模型" },
|
||||
{ path = "channels/model/set_all_disabled", method = "POST", handler = "model_set_all_disabled",
|
||||
roles = { "admin" }, description = "全开/全关平台模型" },
|
||||
{ path = "channels/provider/status", method = "POST", handler = "provider_status",
|
||||
roles = { "admin" }, description = "全平台状态(账号数/模型数/关闭态)" },
|
||||
{ path = "channels/provider/set_enabled", method = "POST", handler = "provider_set_enabled",
|
||||
roles = { "admin" }, description = "供应商整体开关" },
|
||||
{ path = "channels/credits/claim_all", method = "POST", handler = "credits_claim_all",
|
||||
roles = { "admin" }, description = "一键领取全平台每日额度" },
|
||||
{ path = "channels/backup/export", method = "POST", handler = "backup_export",
|
||||
roles = { "admin" }, description = "导出账号备份(含凭据)" },
|
||||
{ path = "channels/backup/import", method = "POST", handler = "backup_import",
|
||||
roles = { "admin" }, description = "导入账号备份" },
|
||||
{ path = "channels/auto_checkin", method = "POST", handler = "auto_checkin",
|
||||
roles = { "admin" }, description = "自动签到开关读写" },
|
||||
}
|
||||
|
||||
-- set_endpoints:改写某平台的端点声明(device.code_url/token_url、checkin.url、
|
||||
@ -1107,6 +1304,355 @@ plugin.http = {
|
||||
-- 2. 测试把端点指到假上游,行为测试才能不打真厂商
|
||||
-- 只允许改已知字段,未知键拒绝 —— 端点声明是协议形状的一部分,静默加字段
|
||||
-- 会破坏后续逻辑对形状的假设。
|
||||
-- ============================================================================
|
||||
-- 账号池 RPC(对齐参考 account.* 契约)
|
||||
-- ============================================================================
|
||||
-- account_add:向平台加一个账号(占位或带凭据)。
|
||||
-- 对应参考 account.create;登录类的流程返回登录 URL,凭据在 login 完成后回填。
|
||||
function plugin.account_add(req)
|
||||
local args = bodyOf(req)
|
||||
if args.platform == nil then args = req end
|
||||
local plat = platformById(args and args.platform)
|
||||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||||
local aid = args.account_id or newToken():sub(1, 12)
|
||||
if poolGet(plat.id, aid) then return { status = 400, json = { error = "账号已存在: " .. aid } } end
|
||||
local ref = "channels:" .. plat.id .. ":" .. aid
|
||||
if args.credential and args.credential ~= "" then
|
||||
local _, serr = __llmsproxy_secrets.set(ref, args.credential)
|
||||
if serr then return { status = 400, json = { error = serr } } end
|
||||
end
|
||||
local now = os.time and os.time() or 0
|
||||
local entry = {
|
||||
id = aid, provider = plat.id,
|
||||
nickname = args.nickname or aid,
|
||||
enabled = args.enabled ~= false,
|
||||
credentialRef = ref,
|
||||
refreshable = args.refreshable or false,
|
||||
createdAt = now,
|
||||
expiresAt = args.expires_at,
|
||||
last_claim = 0, last_claim_status = 0, last_claim_summary = "",
|
||||
}
|
||||
poolAdd(plat.id, entry)
|
||||
logEvent("账号入池 " .. plat.id .. "/" .. aid)
|
||||
-- 已带凭据的账号立即同步上游源;未带凭据的等 login 完成
|
||||
if args.credential and args.credential ~= "" then syncAccountSource(plat.id, aid) end
|
||||
return { status = 200, json = { ok = true, account_id = aid, credentialRef = ref } }
|
||||
end
|
||||
|
||||
-- account_list:列平台的账号(对齐参考 account.list,含余额状态字段)
|
||||
function plugin.account_list(req)
|
||||
local args = bodyOf(req)
|
||||
if args.platform == nil then args = req end
|
||||
local plat = platformById(args and args.platform)
|
||||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||||
local out = {}
|
||||
for _, a in ipairs(poolList(plat.id)) do
|
||||
local has = __llmsproxy_secrets.get(a.credentialRef)
|
||||
out[#out+1] = {
|
||||
id = a.id, nickname = a.nickname, enabled = a.enabled ~= false,
|
||||
credentialRef = a.credentialRef, refreshable = a.refreshable or false,
|
||||
createdAt = a.createdAt, expiresAt = a.expiresAt,
|
||||
has_credential = has ~= nil and has ~= "",
|
||||
-- 签到/余额展示字段
|
||||
last_claim = a.last_claim, last_claim_status = a.last_claim_status,
|
||||
last_claim_summary = a.last_claim_summary,
|
||||
}
|
||||
end
|
||||
return { status = 200, json = { ok = true, accounts = out } }
|
||||
end
|
||||
|
||||
-- account_update:改昵称 / enabled(对齐 account.update)
|
||||
function plugin.account_update(req)
|
||||
local args = bodyOf(req)
|
||||
if args.account_id == nil then args = req end
|
||||
local plat = platformById(args and args.platform)
|
||||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||||
local acct = poolGet(plat.id, args and args.account_id)
|
||||
if not acct then return { status = 400, json = { error = "账号不存在" } } end
|
||||
local patch = type(args.patch) == "table" and args.patch or {}
|
||||
if patch.nickname ~= nil then acct.nickname = tostring(patch.nickname) end
|
||||
if patch.enabled ~= nil then acct.enabled = patch.enabled and true or false end
|
||||
poolAdd(plat.id, acct)
|
||||
-- enabled 变化要同步上游源(禁用即删掉该源)
|
||||
if patch.enabled ~= nil then
|
||||
if acct.enabled then syncAccountSource(plat.id, acct.id)
|
||||
else dropAccountSource(plat.id, acct.id) end
|
||||
end
|
||||
return { status = 200, json = { ok = true } }
|
||||
end
|
||||
|
||||
-- account_delete:删账号 + 删凭据 + 删源(对齐 account.delete)
|
||||
function plugin.account_delete(req)
|
||||
local args = bodyOf(req)
|
||||
if args.account_id == nil then args = req end
|
||||
local plat = platformById(args and args.platform)
|
||||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||||
local acct = poolGet(plat.id, args and args.account_id)
|
||||
if not acct then return { status = 400, json = { error = "账号不存在" } } end
|
||||
dropAccountSource(plat.id, acct.id)
|
||||
__llmsproxy_secrets.delete(acct.credentialRef)
|
||||
poolRemove(plat.id, acct.id)
|
||||
logEvent("账号删除 " .. plat.id .. "/" .. acct.id)
|
||||
return { status = 200, json = { ok = true } }
|
||||
end
|
||||
|
||||
-- account_test:测账号(对齐 account.test)—— 做一次上游可达探测。
|
||||
-- 简化:内核 probe_all 是全局的;这里用 list_sources 里该账号源存在与否 + 凭据非空作为可达判据。
|
||||
function plugin.account_test(req)
|
||||
local args = bodyOf(req)
|
||||
if args.account_id == nil then args = req end
|
||||
local plat = platformById(args and args.platform)
|
||||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||||
local acct = poolGet(plat.id, args and args.account_id)
|
||||
if not acct then return { status = 400, json = { error = "账号不存在" } } end
|
||||
local tok = __llmsproxy_secrets.get(acct.credentialRef)
|
||||
if not tok or tok == "" then return { status = 200, json = { ok = false, error = "无凭据" } } end
|
||||
-- 同步一次源(确保最新凭据已入),返回同步结果
|
||||
syncAccountSource(plat.id, acct.id)
|
||||
local srcs, _ = __llmsproxy_kernel.call("list_sources", {})
|
||||
local found = false
|
||||
for _, s in ipairs(srcs or {}) do
|
||||
if s.name == accountSourceName(plat.id, acct.id) then found = true break end
|
||||
end
|
||||
if not found then return { status = 200, json = { ok = false, error = "源未同步(上游可能不可达)" } } end
|
||||
return { status = 200, json = { ok = true, ms = 1 } }
|
||||
end
|
||||
|
||||
-- account_refresh:刷新账号(对齐 account.refresh)—— 重新同步源 + 重新查余额
|
||||
function plugin.account_refresh(req)
|
||||
local args = bodyOf(req)
|
||||
if args.account_id == nil then args = req end
|
||||
local plat = platformById(args and args.platform)
|
||||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||||
local acct = poolGet(plat.id, args and args.account_id)
|
||||
if not acct then return { status = 400, json = { error = "账号不存在" } } end
|
||||
local tok = __llmsproxy_secrets.get(acct.credentialRef)
|
||||
if tok and tok ~= "" then syncAccountSource(plat.id, acct.id) end
|
||||
return { status = 200, json = { ok = true } }
|
||||
end
|
||||
|
||||
-- account_reorder:调账号优先级(对齐 account.reorder)
|
||||
function plugin.account_reorder(req)
|
||||
local args = bodyOf(req)
|
||||
if args.platform == nil then args = req end
|
||||
local plat = platformById(args and args.platform)
|
||||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||||
local ordered = args and args.ordered_ids
|
||||
if type(ordered) ~= "table" then return { status = 400, json = { error = "ordered_ids 必须是数组" } } end
|
||||
for i, aid in ipairs(ordered) do
|
||||
local acct = poolGet(plat.id, tostring(aid))
|
||||
if acct then acct.priority = #ordered - i + 1 poolAdd(plat.id, acct) end
|
||||
end
|
||||
return { status = 200, json = { ok = true } }
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- 模型开关 RPC(对齐参考 model.*)
|
||||
-- ============================================================================
|
||||
function plugin.model_list(req)
|
||||
local args = bodyOf(req)
|
||||
if args.platform == nil then args = req end
|
||||
local plat = platformById(args and args.platform)
|
||||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||||
local disabled = disabledModelsOf(plat.id)
|
||||
local out = {}
|
||||
for i, m in ipairs(plat.models or {}) do
|
||||
out[#out+1] = { id = m, name = m, disabled = disabled[m] and true or false, isFree = false }
|
||||
end
|
||||
return { status = 200, json = { ok = true, models = out } }
|
||||
end
|
||||
|
||||
function plugin.model_set_disabled(req)
|
||||
local args = bodyOf(req)
|
||||
if args.platform == nil then args = req end
|
||||
local plat = platformById(args and args.platform)
|
||||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||||
local mid = tostring(args and args.model_id or "")
|
||||
if mid == "" then return { status = 400, json = { error = "缺 model_id" } } end
|
||||
local disabled = disabledModelsOf(plat.id)
|
||||
if args.disabled then disabled[mid] = true else disabled[mid] = nil end
|
||||
-- 重同步该平台的全部源,让模型开关立即生效
|
||||
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
|
||||
return { status = 200, json = { ok = true, disabled = disabled[mid] and true or false } }
|
||||
end
|
||||
|
||||
function plugin.model_set_all_disabled(req)
|
||||
local args = bodyOf(req)
|
||||
if args.platform == nil then args = req end
|
||||
local plat = platformById(args and args.platform)
|
||||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||||
local disabled = disabledModelsOf(plat.id)
|
||||
if args.disabled then
|
||||
for _, m in ipairs(plat.models or {}) do disabled[m] = true end
|
||||
else
|
||||
for k in pairs(disabled) do disabled[k] = nil end
|
||||
end
|
||||
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
|
||||
return { status = 200, json = { ok = true } }
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- 供应商开关 + 状态(对齐参考 provider.*)
|
||||
-- ============================================================================
|
||||
function plugin.provider_status(req)
|
||||
local out = {}
|
||||
for _, p in ipairs(PLATFORMS) do
|
||||
local accts = poolList(p.id)
|
||||
local enabledN = 0
|
||||
for _, a in ipairs(accts) do if a.enabled ~= false then enabledN = enabledN + 1 end end
|
||||
local disabled = disabledModelsOf(p.id)
|
||||
local disabledN = 0
|
||||
for _, m in ipairs(p.models or {}) do if disabled[m] then disabledN = disabledN + 1 end end
|
||||
local closed = (p.models and #p.models > 0) and disabledN == #p.models
|
||||
local state_accounts = plugin.state.accounts[p.id]
|
||||
local legacyN = (state_accounts and __llmsproxy_secrets.get("channels:" .. p.id)) and 1 or 0
|
||||
out[#out+1] = {
|
||||
id = p.id, name = p.name, flow = p.flow, closed = closed,
|
||||
models = { total = #(p.models or {}), disabled = disabledN },
|
||||
accounts = { total = #accts + legacyN, enabled = enabledN + legacyN },
|
||||
}
|
||||
end
|
||||
return { status = 200, json = { ok = true, statuses = out } }
|
||||
end
|
||||
|
||||
function plugin.provider_set_enabled(req)
|
||||
local args = bodyOf(req)
|
||||
if args.platform == nil then args = req end
|
||||
local plat = platformById(args and args.platform)
|
||||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||||
local enabled = args.enabled and true or false
|
||||
-- 对齐参考语义:关闭 = 全关模型 + 停用全部账号;打开 = 清空模型黑名单 + 启用全部账号。
|
||||
local disabled = disabledModelsOf(plat.id)
|
||||
if enabled then
|
||||
for k in pairs(disabled) do disabled[k] = nil end
|
||||
for _, a in ipairs(poolList(plat.id)) do a.enabled = true poolAdd(plat.id, a) syncAccountSource(plat.id, a.id) end
|
||||
-- 旧路径也启用
|
||||
local legacy = plugin.state.accounts[plat.id]
|
||||
if legacy and __llmsproxy_secrets.get("channels:" .. plat.id) then
|
||||
__llmsproxy_kernel.call("add_source", { source = {
|
||||
name = "channels-" .. plat.id, base_url = plat.base_url, adapter = plat.adapter or "openai",
|
||||
api_key = __llmsproxy_secrets.get("channels:" .. plat.id), max_concurrent = 2,
|
||||
models = (function() local ms = {} for i, m in ipairs(plat.models) do ms[i] = { id = m, kind = "chat", priority = 20 } end return ms end)(),
|
||||
} })
|
||||
end
|
||||
else
|
||||
for _, m in ipairs(plat.models or {}) do disabled[m] = true end
|
||||
for _, a in ipairs(poolList(plat.id)) do a.enabled = false poolAdd(plat.id, a) dropAccountSource(plat.id, a.id) end
|
||||
__llmsproxy_kernel.call("remove_source", { name = "channels-" .. plat.id })
|
||||
end
|
||||
logEvent("供应商" .. (enabled and "启用 " or "关闭 ") .. plat.id)
|
||||
return { status = 200, json = { ok = true } }
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- 一键领取(对齐参考 credits.claimAll)
|
||||
-- ============================================================================
|
||||
function plugin.credits_claim_all(req)
|
||||
local args = bodyOf(req)
|
||||
if args.platform == nil then args = req end
|
||||
local plat = platformById(args and args.platform)
|
||||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||||
local results = {}
|
||||
local claimed = 0
|
||||
for _, a in ipairs(poolList(plat.id)) do
|
||||
local r = plugin.claim_daily({ platform = plat.id, account_id = a.id, credential_ref = a.credentialRef })
|
||||
local ok = r.json and r.json.ok and true or false
|
||||
if ok then claimed = claimed + 1 end
|
||||
results[#results+1] = { accountId = a.id, outcome = ok
|
||||
and { kind = "success", message = r.json.summary or tostring(r.json.upstream_status) }
|
||||
or { kind = "failed", message = (r.json and r.json.error) or "失败" } }
|
||||
end
|
||||
-- 无池账号的走旧路径
|
||||
if #poolList(plat.id) == 0 then
|
||||
local r = plugin.claim_daily({ platform = plat.id })
|
||||
local ok = r.json and r.json.ok and true or false
|
||||
if ok then claimed = claimed + 1 end
|
||||
results[#results+1] = { accountId = "legacy", outcome = ok
|
||||
and { kind = "success", message = r.json.summary or tostring(r.json.upstream_status) }
|
||||
or { kind = "failed", message = (r.json and r.json.error) or "失败" } }
|
||||
end
|
||||
return { status = 200, json = { ok = true, summary = { claimed = claimed }, results = results } }
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- 备份导出/导入(对齐参考 backup.*)
|
||||
-- ============================================================================
|
||||
function plugin.backup_export(req)
|
||||
local payload = {}
|
||||
payload.version = 1
|
||||
payload.exportedAt = os.time and os.time() or 0
|
||||
payload.accounts = {}
|
||||
payload.credentials = {}
|
||||
payload.modelDisabled = plugin.state.modelDisabled or {}
|
||||
payload.autoCheckin = plugin.state.settings and plugin.state.settings.auto_checkin or true
|
||||
for _, p in ipairs(PLATFORMS) do
|
||||
for _, a in ipairs(poolList(p.id)) do
|
||||
payload.accounts[#payload.accounts+1] = a
|
||||
local tok = __llmsproxy_secrets.get(a.credentialRef)
|
||||
if tok then payload.credentials[a.credentialRef] = tok end
|
||||
end
|
||||
-- 旧路径单账号也备份
|
||||
local lt = __llmsproxy_secrets.get("channels:" .. p.id)
|
||||
if lt then payload.credentials["channels:" .. p.id] = lt end
|
||||
end
|
||||
return { status = 200, json = { ok = true, payload = payload } }
|
||||
end
|
||||
|
||||
function plugin.backup_import(req)
|
||||
local args = bodyOf(req)
|
||||
local payload = args and args.payload
|
||||
if type(payload) ~= "table" then return { status = 400, json = { error = "payload 必须是对象" } } end
|
||||
local n = 0
|
||||
-- 恢复凭据
|
||||
if type(payload.credentials) == "table" then
|
||||
for ref, tok in pairs(payload.credentials) do
|
||||
if type(ref) == "string" and type(tok) == "string" and tok ~= "" then
|
||||
__llmsproxy_secrets.set(ref, tok) n = n + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
-- 恢复账号池
|
||||
if type(payload.accounts) == "table" then
|
||||
for _, a in ipairs(payload.accounts) do
|
||||
if type(a) == "table" and a.id and a.provider then
|
||||
poolAdd(tostring(a.provider), {
|
||||
id = tostring(a.id), provider = tostring(a.provider),
|
||||
nickname = a.nickname or a.id, enabled = a.enabled ~= false,
|
||||
credentialRef = a.credentialRef or ("channels:" .. a.provider .. ":" .. a.id),
|
||||
refreshable = a.refreshable or false, createdAt = a.createdAt or (os.time and os.time() or 0),
|
||||
expiresAt = a.expiresAt, last_claim = 0, last_claim_status = 0, last_claim_summary = "",
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
-- 恢复模型黑名单
|
||||
if type(payload.modelDisabled) == "table" then
|
||||
plugin.state.modelDisabled = payload.modelDisabled
|
||||
end
|
||||
-- 恢复自动签到开关
|
||||
if payload.autoCheckin ~= nil then
|
||||
plugin.state.settings = plugin.state.settings or {}
|
||||
plugin.state.settings.auto_checkin = payload.autoCheckin and true or false
|
||||
end
|
||||
-- 全部账号重新同步源
|
||||
for _, p in ipairs(PLATFORMS) do
|
||||
for _, a in ipairs(poolList(p.id)) do syncAccountSource(p.id, a.id) end
|
||||
end
|
||||
logEvent("备份已导入,恢复 " .. n .. " 条凭据")
|
||||
return { status = 200, json = { ok = true, restored = n } }
|
||||
end
|
||||
|
||||
-- auto_checkin 开关读写
|
||||
function plugin.auto_checkin(req)
|
||||
plugin.state.settings = plugin.state.settings or {}
|
||||
local args = bodyOf(req)
|
||||
if args.enabled ~= nil then
|
||||
plugin.state.settings.auto_checkin = args.enabled and true or false
|
||||
logEvent("自动签到" .. (plugin.state.settings.auto_checkin and "开启" or "关闭"))
|
||||
end
|
||||
return { status = 200, json = { ok = true, autoCheckin = plugin.state.settings.auto_checkin ~= false } }
|
||||
end
|
||||
|
||||
function plugin.set_endpoints(req)
|
||||
local args = bodyOf(req)
|
||||
if args.platform == nil then args = req end
|
||||
@ -1339,23 +1885,57 @@ plugin.ui = {
|
||||
mount = [[
|
||||
<div id="channels-page">
|
||||
<div class="card">
|
||||
<h2>平台接入</h2>
|
||||
<p class="muted">登录在你自己的浏览器完成;凭据加密保存在服务端,凭据永不回显。</p>
|
||||
<div id="ch-list" class="tbl-wrap"></div>
|
||||
</div>
|
||||
<div class="card" id="ch-login-box" style="display:none">
|
||||
<h2 id="ch-login-title">登录</h2>
|
||||
<div id="ch-login-body"></div>
|
||||
<!-- 服务端浏览器内嵌区(仅 VNC 兜底流程用;本机浏览器流程此区隐藏)。 -->
|
||||
<div id="ch-vnc-wrap" style="display:none;margin-top:10px">
|
||||
<p class="muted">请在下面的窗口里完成登录,完成后本页会自动继续。</p>
|
||||
<iframe id="ch-vnc" style="width:100%;height:640px;border:1px solid var(--line);border-radius:8px"
|
||||
allow="clipboard-read; clipboard-write"></iframe>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
|
||||
<h2 style="margin:0">渠道控制台</h2>
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||||
<input id="ch-search" placeholder="搜索渠道或供应商…" style="padding:6px 10px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--fg);min-width:200px">
|
||||
<div id="ch-filters" style="display:flex;gap:6px"></div>
|
||||
<button class="small" onclick="chBackup(1)">导出备份</button>
|
||||
<button class="small" onclick="document.getElementById('ch-import-file').click()">导入备份</button>
|
||||
<input type="file" id="ch-import-file" hidden accept=".json,application/json" onchange="chImportFile(event)">
|
||||
<label style="display:flex;gap:6px;align-items:center;font-size:12px">
|
||||
<input type="checkbox" id="ch-auto" onchange="chAutoToggle(this.checked)"> 每日自动签到
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ch-summary" style="display:flex;gap:18px;margin:10px 0;font-size:13px;color:var(--muted,#888)"></div>
|
||||
<div id="ch-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>最近日志</h2>
|
||||
<ul id="ch-log" style="font-size:12px"></ul>
|
||||
|
||||
<!-- 渠道详情:账号池 + 模型开关(对齐参考 provider-detail) -->
|
||||
<div class="card" id="ch-detail" style="display:none">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<button class="small" onclick="chSelect(undefined)">← 返回全部渠道</button>
|
||||
<span class="muted" style="font-size:12px">账号凭据加密保存在服务端,页面永远拿不到明文。</span>
|
||||
</div>
|
||||
<h2 id="ch-detail-name" style="margin:10px 0 4px"></h2>
|
||||
<p class="muted" id="ch-detail-org" style="margin:0 0 12px;font-size:13px"></p>
|
||||
|
||||
<div id="ch-detail-actions" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px"></div>
|
||||
<p id="ch-notice" class="muted" style="font-size:13px;min-height:18px"></p>
|
||||
|
||||
<div id="ch-accts"></div>
|
||||
|
||||
<div style="display:flex;gap:8px;align-items:center;margin:14px 0 6px">
|
||||
<b style="font-size:13px">模型</b>
|
||||
<span id="ch-model-count" class="muted" style="font-size:12px"></span>
|
||||
<button class="small" onclick="chModelAll(false)">全部启用</button>
|
||||
<button class="small" onclick="chModelAll(true)">全部关闭</button>
|
||||
</div>
|
||||
<div id="ch-models" style="display:flex;flex-direction:column;gap:4px"></div>
|
||||
|
||||
<!-- 登录区:发起登录后显示(device-poll 轮询 / callback URL / vnc / paste / manual) -->
|
||||
<div id="ch-login-box" style="display:none;margin-top:14px;border-top:1px solid var(--line);padding-top:10px">
|
||||
<h3 style="margin:4px 0">登录</h3>
|
||||
<div id="ch-login-body"></div>
|
||||
<div id="ch-vnc-wrap" style="display:none;margin-top:10px">
|
||||
<p class="muted" style="font-size:12px">请在下面的窗口里完成登录,完成后本页会自动继续。</p>
|
||||
<iframe id="ch-vnc" style="width:100%;height:640px;border:1px solid var(--line);border-radius:8px" allow="clipboard-read; clipboard-write"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin:16px 0 6px;font-size:13px">最近日志</h3>
|
||||
<ul id="ch-log" style="font-size:12px;margin:0;padding-left:18px"></ul>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
@ -1371,56 +1951,209 @@ plugin.ui = {
|
||||
});
|
||||
return await r.json().catch(() => ({}));
|
||||
}
|
||||
let pollTimer = null;
|
||||
function stopPoll() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } }
|
||||
// POST 端点统一走 call(path,"POST",...);账号/模型/供应商操作都按这个形状。
|
||||
let state = { statuses: [], filter: "all", search: "", selected: undefined,
|
||||
accounts: undefined, models: undefined, login: null, pollTimer: null };
|
||||
const stopPoll = () => { if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; } };
|
||||
|
||||
async function refresh() {
|
||||
const j = await call("channels/platforms");
|
||||
const rows = (j.platforms || []).map((p) => {
|
||||
const st = p.logged_in ? '<span class="tag tag-green">已登录</span>' : '<span class="tag">未登录</span>';
|
||||
const done = p.logged_in
|
||||
? '<button class="small" onclick="chClaim(\'' + esc(p.id) + '\')">领取额度</button>'
|
||||
: '<button class="small" onclick="chLogin(\'' + esc(p.id) + '\')">登录</button>';
|
||||
return "<tr><td><b>" + esc(p.name) + "</b><div class='muted'>" + esc(p.flow) + "</div></td>" +
|
||||
"<td>" + st + "</td><td>" + esc(p.account || "") + "</td><td>" + done + "</td></tr>";
|
||||
}).join("");
|
||||
q("#ch-list").innerHTML = "<table><tr><th>平台</th><th>状态</th><th>账号</th><th></th></tr>" +
|
||||
(rows || "<tr><td colspan=4 class='empty'>无平台</td></tr>") + "</table>";
|
||||
async function loadStatuses() {
|
||||
const j = await call("channels/provider/status", "POST", {});
|
||||
state.statuses = j.statuses || [];
|
||||
renderSummary(); renderGrid();
|
||||
const sj = await call("channels/status");
|
||||
q("#ch-log").innerHTML = (sj.log || []).map((e) => "<li>" + esc(e.msg) + "</li>").join("");
|
||||
const log = q("#ch-log"); if (log) log.innerHTML = (sj.log || []).map((e) => "<li>" + esc(e.msg) + "</li>").join("");
|
||||
const aj = await call("channels/auto_checkin", "POST", {});
|
||||
q("#ch-auto").checked = aj.autoCheckin !== false;
|
||||
}
|
||||
|
||||
window.chLogin = async function (id) {
|
||||
const j = await call("channels/login", "POST", { platform: id });
|
||||
function renderSummary() {
|
||||
const total = state.statuses.length;
|
||||
const connected = state.statuses.filter((s) => !s.closed && s.accounts.enabled > 0).length;
|
||||
const accounts = state.statuses.reduce((n, s) => n + (s.accounts.total || 0), 0);
|
||||
q("#ch-summary").innerHTML =
|
||||
"<span>已接入渠道 <b>" + connected + " / " + total + "</b></span>" +
|
||||
"<span>本机账号 <b>" + accounts + "</b></span>";
|
||||
const counts = { all: total, connected, pending: total - connected };
|
||||
const defs = [{ id: "all", label: "全部" }, { id: "connected", label: "已接入" }, { id: "pending", label: "待接入" }];
|
||||
q("#ch-filters").innerHTML = defs.map((d) =>
|
||||
'<button class="small" data-f="' + d.id + '" style="' + (state.filter===d.id?"font-weight:bold;":"") + '" onclick="chFilter(\'' + d.id + '\')">' + d.label + ' (' + counts[d.id] + ')</button>').join("");
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
const list = state.statuses.filter((s) => {
|
||||
const hit = (s.id + " " + s.name).toLowerCase().includes(state.search);
|
||||
const on = !s.closed && s.accounts.enabled > 0;
|
||||
return hit && (state.filter === "all" || (state.filter === "connected") === on);
|
||||
});
|
||||
q("#ch-grid").innerHTML = list.map((s) => {
|
||||
const on = !s.closed && s.accounts.enabled > 0;
|
||||
const st = s.closed ? '<span class="tag">已关闭</span>'
|
||||
: on ? '<span class="tag tag-green">已接入</span>' : '<span class="tag">待接入</span>';
|
||||
return '<div class="card" style="margin:0;padding:12px;cursor:pointer" onclick="chSelect(\'' + esc(s.id) + '\')">' +
|
||||
'<div style="display:flex;justify-content:space-between;align-items:center">' +
|
||||
'<b>' + esc(s.name) + '</b>' + st + '</div>' +
|
||||
'<div class="muted" style="font-size:12px;margin:6px 0">' + esc(s.id) + ' · ' + esc(s.flow) + '</div>' +
|
||||
'<div style="font-size:12px">账号 <b>' + (s.accounts.enabled||0) + '/' + (s.accounts.total||0) + '</b>' +
|
||||
' · 模型 <b>' + ((s.models.total||0)-(s.models.disabled||0)) + '/' + (s.models.total||0) + '</b></div>' +
|
||||
'</div>';
|
||||
}).join("") || '<p class="muted">没有匹配的渠道</p>';
|
||||
}
|
||||
|
||||
async function renderDetail() {
|
||||
const id = state.selected;
|
||||
const box = q("#ch-detail");
|
||||
if (!id) { box.style.display = "none"; q("#ch-grid").parentElement.style.display = "block"; return; }
|
||||
box.style.display = "block";
|
||||
q("#ch-grid").parentElement.style.display = "none";
|
||||
const s = state.statuses.find((x) => x.id === id) || {};
|
||||
q("#ch-detail-name").textContent = s.name || id;
|
||||
q("#ch-detail-org").textContent = id + " · " + (s.flow || "");
|
||||
const cap = (await call("channels/auto_checkin","POST",{})) && true; // noop,保持顺序
|
||||
// 动作区:登录(加账号)/ 刷新全部 / 一键领取 / 供应商开关
|
||||
const acts = [];
|
||||
acts.push('<button class="small" onclick="chStartLogin()">添加账号(登录)</button>');
|
||||
if ((s.accounts.total||0) > 0) acts.push('<button class="small" onclick="chRefreshAll()">刷新全部账号</button>');
|
||||
acts.push('<button class="small" onclick="chClaimAll()">一键领取额度</button>');
|
||||
acts.push('<button class="small" onclick="chProviderToggle(' + (!s.closed) + ')">' + (s.closed ? "启用渠道" : "关闭渠道") + '</button>');
|
||||
q("#ch-detail-actions").innerHTML = acts.join(" ");
|
||||
q("#ch-notice").textContent = "";
|
||||
await Promise.all([renderAccounts(id), renderModels(id)]);
|
||||
}
|
||||
|
||||
async function renderAccounts(id) {
|
||||
const j = await call("channels/account/list", "POST", { platform: id });
|
||||
const accts = j.accounts || [];
|
||||
state.accounts = accts;
|
||||
q("#ch-accts").innerHTML = accts.length === 0
|
||||
? '<p class="muted" style="font-size:13px">暂无账号。点「添加账号(登录)」开始。</p>'
|
||||
: accts.map((a) => {
|
||||
const badge = a.has_credential
|
||||
? (a.enabled !== false ? '<span class="tag tag-green">启用</span>' : '<span class="tag">停用</span>')
|
||||
: '<span class="tag">待凭据</span>';
|
||||
const claimTxt = a.last_claim ? ('上次领取 ' + new Date(a.last_claim*1000).toLocaleString() + (a.last_claim_summary ? ' · ' + esc(a.last_claim_summary) : '')) : '';
|
||||
return '<div style="display:flex;gap:8px;align-items:center;padding:6px 0;border-bottom:1px dashed var(--line);flex-wrap:wrap">' +
|
||||
'<b style="min-width:120px">' + esc(a.nickname || a.id) + '</b>' + badge +
|
||||
'<span class="muted" style="font-size:12px">' + claimTxt + '</span>' +
|
||||
'<span style="flex:1"></span>' +
|
||||
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'test\')">测试</button>' +
|
||||
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'refresh\')">刷新</button>' +
|
||||
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'toggle\')">' + (a.enabled !== false ? "停用" : "启用") + '</button>' +
|
||||
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'priority\')">优先</button>' +
|
||||
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'delete\')">删除</button>' +
|
||||
'</div>';
|
||||
}).join("");
|
||||
}
|
||||
|
||||
async function renderModels(id) {
|
||||
const j = await call("channels/model/list", "POST", { platform: id });
|
||||
const ms = j.models || [];
|
||||
state.models = ms;
|
||||
const on = ms.filter((m) => !m.disabled).length;
|
||||
q("#ch-model-count").textContent = on + "/" + ms.length + " 启用";
|
||||
q("#ch-models").innerHTML = ms.map((m) =>
|
||||
'<div style="display:flex;gap:8px;align-items:center;padding:4px 0">' +
|
||||
'<span style="flex:1;font-size:13px" title="' + esc(m.id) + '">' + esc(m.name || m.id) + '</span>' +
|
||||
'<button class="small" onclick="chModel(\'' + esc(m.id) + '\',' + (!m.disabled) + ')">' + (m.disabled ? "开启" : "关闭") + '</button>' +
|
||||
'</div>').join("") || '<p class="muted">—</p>';
|
||||
}
|
||||
|
||||
// ---- 动作 ----
|
||||
window.chFilter = (f) => { state.filter = f; renderSummary(); renderGrid(); };
|
||||
q("#ch-search").addEventListener("input", (e) => { state.search = e.target.value.toLowerCase(); renderGrid(); });
|
||||
window.chSelect = async (id) => { state.selected = id; await renderDetail(); };
|
||||
window.chAcct = async (aid, act) => {
|
||||
const id = state.selected; if (!id) return;
|
||||
if (act === "delete" && !confirm("删除账号 " + aid + "?(连同凭据与上游源)")) return;
|
||||
if (act === "toggle") {
|
||||
const a = (state.accounts || []).find((x) => x.id === aid);
|
||||
await call("channels/account/update", "POST", { platform: id, account_id: aid, patch: { enabled: !(a && a.enabled !== false) } });
|
||||
} else if (act === "priority") {
|
||||
await call("channels/account/reorder", "POST", { platform: id, ordered_ids: [aid].concat((state.accounts||[]).map((x)=>x.id).filter((x)=>x!==aid)) });
|
||||
} else {
|
||||
await call("channels/account/" + act, "POST", { platform: id, account_id: aid });
|
||||
}
|
||||
await renderAccounts(id); await loadStatuses();
|
||||
};
|
||||
window.chModel = async (mid, disabled) => {
|
||||
const id = state.selected; if (!id) return;
|
||||
await call("channels/model/set_disabled", "POST", { platform: id, model_id: mid, disabled });
|
||||
await renderModels(id); await loadStatuses();
|
||||
};
|
||||
window.chModelAll = async (disabled) => {
|
||||
const id = state.selected; if (!id) return;
|
||||
await call("channels/model/set_all_disabled", "POST", { platform: id, disabled });
|
||||
await renderModels(id); await loadStatuses();
|
||||
};
|
||||
window.chProviderToggle = async (enable) => {
|
||||
const id = state.selected; if (!id) return;
|
||||
if (!enable && !confirm("关闭渠道会停用全部账号与模型,确认?")) return;
|
||||
await call("channels/provider/set_enabled", "POST", { platform: id, enabled: enable });
|
||||
await loadStatuses(); await renderDetail();
|
||||
};
|
||||
window.chRefreshAll = async () => {
|
||||
const id = state.selected; if (!id) return;
|
||||
for (const a of state.accounts || []) await call("channels/account/refresh", "POST", { platform: id, account_id: a.id });
|
||||
notice("已刷新全部账号"); await renderAccounts(id);
|
||||
};
|
||||
window.chClaimAll = async () => {
|
||||
const id = state.selected; if (!id) return;
|
||||
notice("领取中…");
|
||||
const j = await call("channels/credits/claim_all", "POST", { platform: id });
|
||||
const res = j.results || [];
|
||||
const okN = res.filter((r) => r.outcome && r.outcome.kind === "success").length;
|
||||
const failN = res.filter((r) => r.outcome && r.outcome.kind === "failed");
|
||||
notice("领取完成:成功 " + okN + " 个账号" + (failN.length ? ";失败:" + failN.map((r) => r.outcome.message).join(";") : ""));
|
||||
await renderAccounts(id);
|
||||
};
|
||||
window.chAutoToggle = async (on) => { await call("channels/auto_checkin", "POST", { enabled: on }); notice(on ? "自动签到已开启" : "自动签到已关闭"); };
|
||||
window.chBackup = async () => {
|
||||
const j = await call("channels/backup/export", "POST", {});
|
||||
const blob = new Blob([JSON.stringify(j.payload, null, 2)], { type: "application/json" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = "channels-backup-" + new Date().toISOString().slice(0,10) + ".json";
|
||||
a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
||||
};
|
||||
window.chImportFile = async (ev) => {
|
||||
const f = ev.target.files[0]; ev.target.value = ""; if (!f) return;
|
||||
if (f.size > 8*1024*1024) { alert("备份文件不能超过 8MB"); return; }
|
||||
if (!confirm("导入会合并备份中的账号凭据、模型开关与自动签到设置,确认?")) return;
|
||||
const payload = JSON.parse(await f.text());
|
||||
const j = await call("channels/backup/import", "POST", { payload });
|
||||
alert(j.error ? ("导入失败:" + j.error) : ("已导入,恢复 " + (j.restored||0) + " 条凭据"));
|
||||
await loadStatuses();
|
||||
};
|
||||
function notice(msg) { q("#ch-notice").textContent = msg; }
|
||||
|
||||
// ---- 登录(对齐参考两步式:立即拿 URL/弹窗,后台轮询完成)----
|
||||
window.chStartLogin = async () => {
|
||||
const id = state.selected; if (!id) return;
|
||||
// 池路径:先建占位账号,登录完成后回填凭据到这个账号
|
||||
const aj = await call("channels/account/add", "POST", { platform: id });
|
||||
if (aj.error) { alert(aj.error); return; }
|
||||
const aid = aj.account_id;
|
||||
const j = await call("channels/login", "POST", { platform: id, account_id: aid });
|
||||
if (j.error) { alert(j.error); return; }
|
||||
const box = q("#ch-login-box"); box.style.display = "block";
|
||||
q("#ch-login-title").textContent = "登录:" + id;
|
||||
const vnc = q("#ch-vnc-wrap");
|
||||
if (j.view_url) {
|
||||
// 服务端浏览器:内嵌在本页签里
|
||||
q("#ch-login-body").innerHTML =
|
||||
"<p class='muted' id='ch-login-state'>请在下方窗口完成登录…</p>";
|
||||
vnc.style.display = "block";
|
||||
q("#ch-vnc").src = j.view_url;
|
||||
q("#ch-login-body").innerHTML = "<p class='muted' id='ch-login-state'>请在下方窗口完成登录…</p>";
|
||||
vnc.style.display = "block"; q("#ch-vnc").src = j.view_url;
|
||||
} else if (j.manual_hint) {
|
||||
// 无法自动化的登录(WASM/captcha/扫码):给说明 + 凭据粘贴框。
|
||||
// 后端 start_login 直接把会话标 failed,这里不再轮询。
|
||||
vnc.style.display = "none";
|
||||
q("#ch-login-body").innerHTML =
|
||||
"<p class='muted'>" + esc(j.manual_hint) + "</p>" +
|
||||
"<p><textarea id='ch-cred' rows='3' style='width:100%' placeholder='把凭据粘贴到这里'></textarea></p>" +
|
||||
"<p><button class='small' onclick='chComplete(\"" + esc(id) + "\")'>提交凭据</button></p>" +
|
||||
"<p><button class='small' onclick='chPaste(\"" + esc(aid) + "\")'>提交凭据到该账号</button></p>" +
|
||||
"<p class='muted' id='ch-login-state'></p>";
|
||||
} else if (j.session && j.session.flow === "paste") {
|
||||
// 直接粘贴 API key/token,无登录流(OpenCode 等)。
|
||||
vnc.style.display = "none";
|
||||
q("#ch-login-body").innerHTML =
|
||||
"<p>把该平台的 API key / token 粘贴到这里:</p>" +
|
||||
"<p><textarea id='ch-cred' rows='3' style='width:100%' placeholder='API key / token'></textarea></p>" +
|
||||
"<p><button class='small' onclick='chComplete(\"" + esc(id) + "\")'>提交凭据</button></p>" +
|
||||
"<p><button class='small' onclick='chPaste(\"" + esc(aid) + "\")'>提交凭据到该账号</button></p>" +
|
||||
"<p class='muted' id='ch-login-state'></p>";
|
||||
} else {
|
||||
// 用户自己的浏览器足够(link-poll / callback / device-poll)
|
||||
vnc.style.display = "none";
|
||||
q("#ch-login-body").innerHTML =
|
||||
"<p>请在你自己的浏览器打开下面的地址完成登录:</p>" +
|
||||
@ -1428,40 +2161,34 @@ plugin.ui = {
|
||||
(j.session && j.session.user_code ? "<p>设备码:<b>" + esc(j.session.user_code) + "</b></p>" : "") +
|
||||
"<p class='muted' id='ch-login-state'>等待完成…</p>";
|
||||
}
|
||||
stopPoll();
|
||||
if (!(j.manual_hint || (j.session && j.session.flow === "paste"))) {
|
||||
stopPoll();
|
||||
pollTimer = setInterval(async () => {
|
||||
state.pollTimer = setInterval(async () => {
|
||||
const st = await call("channels/login/status?id=" + encodeURIComponent(j.session.id));
|
||||
const el = q("#ch-login-state"); if (el) el.textContent = "状态:" + st.state;
|
||||
const el = q("#ch-login-state"); if (el) el.textContent = "状态:" + st.state + (st.account ? "(" + st.account + ")" : "");
|
||||
if (st.state === "completed" || st.state === "failed" || st.state === "expired") {
|
||||
stopPoll(); q("#ch-vnc").src = "about:blank"; vnc.style.display = "none";
|
||||
box.style.display = "none"; refresh();
|
||||
box.style.display = "none";
|
||||
if (st.state === "failed") notice("登录未完成:" + (st.message || st.state));
|
||||
await renderAccounts(id); await loadStatuses();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// chComplete:paste / manual 流的凭据提交。凭据只经 complete_login 落保险库,
|
||||
// 不经 state、不回显。失败时给可读错误而不刷新页面(用户可重试粘贴)。
|
||||
window.chComplete = async function (id) {
|
||||
// paste / manual 的凭据提交:落到占位账号(池路径),完成即同步源
|
||||
window.chPaste = async (aid) => {
|
||||
const id = state.selected; if (!id || !aid) return;
|
||||
const cred = (q("#ch-cred") && q("#ch-cred").value || "").trim();
|
||||
if (!cred) { alert("凭据为空"); return; }
|
||||
const el = q("#ch-login-state"); if (el) el.textContent = "提交中…";
|
||||
const j = await call("channels/login/complete", "POST", { platform: id, credential: cred });
|
||||
const j = await call("channels/login/complete", "POST", { platform: id, credential: cred, account_id: aid });
|
||||
if (j.error) { if (el) el.textContent = "失败:" + j.error; return; }
|
||||
if (j.source_error) { if (el) el.textContent = "凭据已存,但接入源失败:" + j.source_error; refresh(); return; }
|
||||
q("#ch-login-box").style.display = "none";
|
||||
q("#ch-cred").value = "";
|
||||
refresh();
|
||||
q("#ch-login-box").style.display = "none"; q("#ch-cred").value = "";
|
||||
notice("账号已接入:" + aid);
|
||||
await renderAccounts(id); await loadStatuses();
|
||||
};
|
||||
|
||||
window.chClaim = async function (id) {
|
||||
const j = await call("channels/claim", "POST", { platform: id });
|
||||
alert(j.error ? ("失败: " + j.error) : ("领取返回 " + j.status));
|
||||
refresh();
|
||||
};
|
||||
|
||||
refresh();
|
||||
loadStatuses();
|
||||
})();
|
||||
</script>
|
||||
]],
|
||||
|
||||
Reference in New Issue
Block a user