@@ -0,0 +1,931 @@
<!doctype html>
< html lang = "zh-CN" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
< title > Provider Accounts Admin< / title >
< style >
: root {
--bg : #f3ede4 ;
--panel : rgba ( 255 , 252 , 246 , 0.94 ) ;
--ink : #201b17 ;
--muted : #685d54 ;
--line : rgba ( 32 , 27 , 23 , 0.12 ) ;
--accent : #0b6bcb ;
--accent-soft : rgba ( 11 , 107 , 203 , 0.12 ) ;
--success : #126b43 ;
--success-soft : rgba ( 18 , 107 , 67 , 0.1 ) ;
--warn : #9b6215 ;
--warn-soft : rgba ( 155 , 98 , 21 , 0.12 ) ;
--danger : #b23131 ;
--danger-soft : rgba ( 178 , 49 , 49 , 0.1 ) ;
--shadow : 0 26 px 72 px rgba ( 47 , 38 , 29 , 0.1 ) ;
--radius : 24 px ;
--radius-sm : 16 px ;
--font-sans : "IBM Plex Sans" , "Noto Sans SC" , "PingFang SC" , sans-serif ;
--font-mono : "IBM Plex Mono" , "JetBrains Mono" , monospace ;
}
* { box-sizing : border-box ; }
body {
margin : 0 ;
color : var ( - - ink ) ;
font-family : var ( - - font - sans ) ;
background :
radial-gradient ( circle at top left , rgba ( 11 , 107 , 203 , 0.16 ) , transparent 26 rem ) ,
radial-gradient ( circle at bottom right , rgba ( 18 , 107 , 67 , 0.12 ) , transparent 24 rem ) ,
var ( - - bg ) ;
}
a { color : inherit ; }
code , pre {
font-family : var ( - - font - mono ) ;
font-size : 12 px ;
}
. shell {
max-width : 1500 px ;
margin : 0 auto ;
padding : 34 px 20 px 64 px ;
}
. topnav {
display : flex ;
flex-wrap : wrap ;
gap : 10 px ;
margin-bottom : 18 px ;
}
. topnav a {
text-decoration : none ;
padding : 10 px 14 px ;
border-radius : 999 px ;
border : 1 px solid var ( - - line ) ;
background : rgba ( 255 , 255 , 255 , 0.78 ) ;
color : var ( - - muted ) ;
font-size : 13 px ;
font-weight : 700 ;
transition : transform 120 ms ease , background 120 ms ease ;
}
. topnav a : hover { transform : translateY ( -1 px ) ; background : #fff ; }
. topnav a . is-current {
background : var ( - - ink ) ;
border-color : var ( - - ink ) ;
color : #fff ;
}
. hero {
display : grid ;
grid-template-columns : 1.2 fr 0.8 fr ;
gap : 18 px ;
margin-bottom : 18 px ;
}
. card {
background : var ( - - panel ) ;
border : 1 px solid var ( - - line ) ;
border-radius : var ( - - radius ) ;
box-shadow : var ( - - shadow ) ;
}
. hero-card , . panel {
padding : 26 px ;
}
. hero-card {
position : relative ;
overflow : hidden ;
}
. hero-card :: after {
content : "" ;
position : absolute ;
right : -4 rem ;
bottom : -4 rem ;
width : 18 rem ;
height : 18 rem ;
border-radius : 999 px ;
background : linear-gradient ( 135 deg , rgba ( 11 , 107 , 203 , 0.18 ) , rgba ( 18 , 107 , 67 , 0.06 ) ) ;
filter : blur ( 10 px ) ;
}
. eyebrow {
display : inline-flex ;
align-items : center ;
padding : 8 px 12 px ;
border-radius : 999 px ;
background : var ( - - accent - soft ) ;
color : var ( - - accent ) ;
font-size : 12 px ;
font-weight : 800 ;
letter-spacing : 0.06 em ;
text-transform : uppercase ;
}
h1 {
margin : 18 px 0 10 px ;
font-size : clamp ( 32 px , 4 vw , 46 px ) ;
line-height : 1.02 ;
letter-spacing : -0.05 em ;
}
h2 {
margin : 0 0 8 px ;
font-size : 24 px ;
letter-spacing : -0.04 em ;
}
. hero-copy , . panel-desc {
color : var ( - - muted ) ;
line-height : 1.75 ;
font-size : 15 px ;
}
. hero-points {
display : flex ;
flex-wrap : wrap ;
gap : 10 px ;
margin : 18 px 0 0 ;
padding : 0 ;
list-style : none ;
}
. hero-points li {
padding : 8 px 12 px ;
border-radius : 999 px ;
border : 1 px solid var ( - - line ) ;
background : rgba ( 255 , 255 , 255 , 0.78 ) ;
font-size : 13 px ;
font-weight : 700 ;
}
. metrics {
display : grid ;
gap : 12 px ;
align-content : start ;
}
. metric {
border-radius : 20 px ;
border : 1 px solid var ( - - line ) ;
background : #fff ;
padding : 16 px ;
}
. metric-label {
color : var ( - - muted ) ;
font-size : 12 px ;
letter-spacing : 0.05 em ;
text-transform : uppercase ;
}
. metric-value {
margin-top : 8 px ;
font-size : 24 px ;
font-weight : 800 ;
letter-spacing : -0.04 em ;
word-break : break-word ;
}
. layout {
display : grid ;
grid-template-columns : 440 px minmax ( 0 , 1 fr ) ;
gap : 18 px ;
}
. stack {
display : grid ;
gap : 18 px ;
}
. field-grid {
display : grid ;
gap : 12 px ;
}
. field-grid . two {
grid-template-columns : 1 fr 1 fr ;
}
. field-grid . three {
grid-template-columns : repeat ( 3 , minmax ( 0 , 1 fr ) ) ;
}
label {
display : grid ;
gap : 7 px ;
color : var ( - - muted ) ;
font-size : 13 px ;
font-weight : 700 ;
}
input , select , textarea {
width : 100 % ;
border : 1 px solid var ( - - line ) ;
border-radius : 14 px ;
padding : 12 px 14 px ;
font : inherit ;
color : var ( - - ink ) ;
background : #fff ;
}
textarea {
min-height : 84 px ;
resize : vertical ;
}
. actions {
display : flex ;
flex-wrap : wrap ;
gap : 10 px ;
margin-top : 14 px ;
}
button {
border : 0 ;
cursor : pointer ;
border-radius : 999 px ;
padding : 12 px 18 px ;
font : inherit ;
font-weight : 800 ;
transition : transform 120 ms ease , opacity 120 ms ease , background 120 ms ease ;
}
button : hover { transform : translateY ( -1 px ) ; }
button : disabled { cursor : not-allowed ; opacity : 0.6 ; transform : none ; }
. primary { background : var ( - - ink ) ; color : #fff ; }
. secondary { background : var ( - - accent - soft ) ; color : var ( - - accent ) ; }
. ghost {
border : 1 px solid var ( - - line ) ;
background : transparent ;
color : var ( - - muted ) ;
}
. danger {
background : var ( - - danger - soft ) ;
color : var ( - - danger ) ;
border : 1 px solid rgba ( 178 , 49 , 49 , 0.2 ) ;
}
. statusbar {
margin-top : 16 px ;
min-height : 54 px ;
padding : 14 px 16 px ;
border-radius : 16 px ;
border : 1 px solid var ( - - line ) ;
background : #fff ;
display : flex ;
align-items : center ;
color : var ( - - muted ) ;
font-size : 14 px ;
line-height : 1.5 ;
white-space : pre-wrap ;
}
. catalog {
display : grid ;
gap : 12 px ;
}
. row-card {
border : 1 px solid var ( - - line ) ;
border-radius : 18 px ;
background : rgba ( 255 , 255 , 255 , 0.86 ) ;
padding : 16 px ;
cursor : pointer ;
transition : transform 120 ms ease , border-color 120 ms ease , box-shadow 120 ms ease ;
}
. row-card : hover {
transform : translateY ( -1 px ) ;
border-color : rgba ( 11 , 107 , 203 , 0.24 ) ;
box-shadow : 0 16 px 36 px rgba ( 47 , 38 , 29 , 0.08 ) ;
}
. row-card . is-selected {
border-color : rgba ( 11 , 107 , 203 , 0.34 ) ;
background : linear-gradient ( 180 deg , rgba ( 11 , 107 , 203 , 0.08 ) , rgba ( 255 , 255 , 255 , 0.96 ) ) ;
}
. row-heading {
display : flex ;
justify-content : space-between ;
gap : 10 px ;
align-items : start ;
}
. row-title {
font-size : 16 px ;
font-weight : 800 ;
letter-spacing : -0.03 em ;
word-break : break-word ;
}
. badge-row {
display : flex ;
flex-wrap : wrap ;
gap : 8 px ;
margin-top : 10 px ;
}
. badge {
display : inline-flex ;
align-items : center ;
gap : 6 px ;
padding : 6 px 10 px ;
border-radius : 999 px ;
font-size : 12 px ;
font-weight : 800 ;
}
. badge . active {
background : var ( - - success - soft ) ;
color : var ( - - success ) ;
}
. badge . disabled {
background : rgba ( 120 , 113 , 108 , 0.12 ) ;
color : var ( - - muted ) ;
}
. badge . deprecated {
background : var ( - - warn - soft ) ;
color : var ( - - warn ) ;
}
. badge . broken {
background : var ( - - danger - soft ) ;
color : var ( - - danger ) ;
}
. meta-list {
display : grid ;
gap : 8 px ;
margin-top : 14 px ;
color : var ( - - muted ) ;
font-size : 13 px ;
line-height : 1.6 ;
}
. detail-grid {
display : grid ;
grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ;
gap : 12 px ;
margin-top : 16 px ;
}
. detail-card {
border : 1 px solid var ( - - line ) ;
border-radius : 18 px ;
background : rgba ( 255 , 255 , 255 , 0.84 ) ;
padding : 16 px ;
}
. detail-card strong {
display : block ;
margin-bottom : 6 px ;
font-size : 14 px ;
}
. detail-card span , . detail-card code {
color : var ( - - muted ) ;
line-height : 1.6 ;
word-break : break-word ;
}
. empty {
padding : 18 px ;
border-radius : 18 px ;
border : 1 px dashed var ( - - line ) ;
color : var ( - - muted ) ;
background : rgba ( 255 , 255 , 255 , 0.58 ) ;
}
. raw-json {
margin-top : 16 px ;
background : #161311 ;
color : #f6efe8 ;
border-radius : 18 px ;
padding : 16 px ;
min-height : 180 px ;
overflow : auto ;
white-space : pre-wrap ;
word-break : break-word ;
}
@ media ( max-width : 1200px ) {
. hero , . layout , . field-grid . two , . field-grid . three , . detail-grid { grid-template-columns : 1 fr ; }
}
< / style >
< / head >
< body >
< main class = "shell" >
< nav class = "topnav" aria-label = "Admin Navigation" >
< a href = "/portal/admin/" > 管理首页< / a >
< a href = "/portal/admin/logical-groups.html" > 逻辑分组 / 路由< / a >
< a href = "/portal/admin/route-health.html" > Route 健康视图< / a >
< a href = "/portal/admin/accounts.html" class = "is-current" > 帐号资产< / a >
< a href = "/portal/admin/providers.html" > 新增模型 / 供应商目录< / a >
< a href = "/portal/admin/batch-import.html" > 导入供应商帐号< / a >
< a href = "/portal/" target = "_blank" rel = "noreferrer" > 用户 Portal< / a >
< / nav >
< section class = "hero" >
< article class = "card hero-card" >
< div class = "eyebrow" > Provider Accounts< / div >
< h1 > 把导入结果升级成可读、可筛选、可启停的帐号资产库存< / h1 >
< p class = "hero-copy" >
这页直接消费 < code > /api/provider-accounts< / code > 与三个启停动作,把每条供应商帐号摊开到
< code > provider / logical_group / route / shadow_group / shadow_host< / code > 维度。
当前首版明确只修改插件 SQLite 里的帐号资产状态,不假装已经联动修改宿主 account 记录。
< / p >
< ul class = "hero-points" >
< li > 默认 API Base: < code > /portal-admin-api< / code > < / li >
< li > 列表会先做一次 provider_accounts 回填< / li >
< li > 人工 disabled / deprecated 不会被列表刷新刷回 active< / li >
< / ul >
< / article >
< aside class = "card metrics" >
< div class = "metric" >
< div class = "metric-label" > API Root< / div >
< div class = "metric-value" id = "metric-api-root" > -< / div >
< / div >
< div class = "metric" >
< div class = "metric-label" > Accounts< / div >
< div class = "metric-value" id = "metric-total" > 0< / div >
< / div >
< div class = "metric" >
< div class = "metric-label" > Active / Disabled< / div >
< div class = "metric-value" id = "metric-live" > 0 / 0< / div >
< / div >
< div class = "metric" >
< div class = "metric-label" > Deprecated / Broken< / div >
< div class = "metric-value" id = "metric-dead" > 0 / 0< / div >
< / div >
< / aside >
< / section >
< section class = "layout" >
< div class = "stack" >
< article class = "card panel" >
< h2 > 连接与过滤< / h2 >
< p class = "panel-desc" >
这页默认优先走管理员 session, 也保留 Bearer token 兜底。过滤只影响读取列表,不会修改帐号状态。
< / p >
< div class = "field-grid" >
< label >
API Base
< input id = "api-base" type = "text" value = "/portal-admin-api" >
< / label >
< label >
Bearer Admin Token( 可选)
< input id = "admin-token" type = "password" placeholder = "未启用 session 时可填" >
< / label >
< / div >
< div class = "field-grid two" style = "margin-top:12px;" >
< label >
管理员用户名
< input id = "admin-username" type = "text" placeholder = "portal-admin" >
< / label >
< label >
管理员密码
< input id = "admin-password" type = "password" placeholder = "请输入当前实例管理员密码" >
< / label >
< / div >
< div class = "actions" >
< button class = "secondary" id = "admin-login-btn" type = "button" > 管理员登录< / button >
< button class = "ghost" id = "admin-logout-btn" type = "button" > 退出会话< / button >
< button class = "ghost" id = "save-config-btn" type = "button" > 保存本地配置< / button >
< button class = "ghost" id = "refresh-btn" type = "button" > 刷新帐号库存< / button >
< / div >
< div class = "statusbar" id = "session-status" > 正在检查管理员会话…< / div >
< div class = "field-grid three" style = "margin-top:18px;" >
< label >
host_id
< input id = "filter-host-id" type = "text" placeholder = "例如 remote43" >
< / label >
< label >
provider_id
< input id = "filter-provider-id" type = "text" placeholder = "例如 gpt-asxs-shadow-lab" >
< / label >
< label >
logical_group_id
< input id = "filter-logical-group-id" type = "text" placeholder = "例如 gpt-shared" >
< / label >
< / div >
< div class = "field-grid three" style = "margin-top:12px;" >
< label >
route_id
< input id = "filter-route-id" type = "text" placeholder = "例如 asxs-primary" >
< / label >
< label >
shadow_group_id
< input id = "filter-shadow-group-id" type = "text" placeholder = "例如 9" >
< / label >
< label >
account_status
< select id = "filter-status" >
< option value = "" > 全部状态< / option >
< option value = "active" > active< / option >
< option value = "disabled" > disabled< / option >
< option value = "deprecated" > deprecated< / option >
< option value = "broken" > broken< / option >
< / select >
< / label >
< / div >
< div class = "field-grid two" style = "margin-top:12px;" >
< label >
搜索
< input id = "filter-query" type = "text" placeholder = "provider / logical_group / host_account / fingerprint" >
< / label >
< label >
limit
< input id = "filter-limit" type = "number" min = "1" max = "500" value = "200" >
< / label >
< / div >
< div class = "actions" >
< button class = "primary" id = "apply-filters-btn" type = "button" > 应用过滤< / button >
< button class = "ghost" id = "clear-filters-btn" type = "button" > 清空过滤< / button >
< / div >
< div class = "statusbar" id = "table-status" > 帐号库存结果会显示在这里。< / div >
< / article >
< article class = "card panel" >
< h2 > 帐号资产清单< / h2 >
< p class = "panel-desc" >
选中一条帐号后,右侧会展示完整归属和当前启停操作。未补齐 route 的帐号不会被隐藏,而是明确显示为“未归属”。
< / p >
< div class = "catalog" id = "accounts-catalog" >
< div class = "empty" > 还没有帐号库存数据。< / div >
< / div >
< / article >
< / div >
< article class = "card panel" >
< h2 > 帐号归属详情< / h2 >
< p class = "panel-desc" >
这里回答三个问题:这条帐号属于谁、挂到哪条 route、当前是人工停用还是自动探测异常。所有启停动作都只改插件库存状态。
< / p >
< p class = "panel-desc" >
当前显式使用的动作接口是:
< code > /api/provider-accounts/{account_id}/enable< / code > 、
< code > /api/provider-accounts/{account_id}/disable< / code > 、
< code > /api/provider-accounts/{account_id}/retire< / code > 。
< / p >
< div class = "field-grid" style = "margin-top:12px;" >
< label >
状态变更原因
< textarea id = "action-reason" placeholder = "例如 manual_disable / quota_pause / provider_rotation" > < / textarea >
< / label >
< / div >
< div class = "actions" >
< button class = "secondary" id = "enable-btn" type = "button" disabled > 启用帐号< / button >
< button class = "ghost" id = "disable-btn" type = "button" disabled > 停用帐号< / button >
< button class = "danger" id = "retire-btn" type = "button" disabled > 标记退役< / button >
< / div >
< div class = "statusbar" id = "action-status" > 请选择左侧一条帐号记录。< / div >
< div id = "detail-empty" class = "empty" style = "margin-top:16px;" > 选择左侧一条帐号后,这里会显示 route / shadow group / logical group 归属详情。< / div >
< div id = "detail-panel" hidden >
< div class = "detail-grid" id = "detail-grid" > < / div >
< pre class = "raw-json" id = "detail-json" > {}< / pre >
< / div >
< / article >
< / section >
< / main >
< script >
const storageKey = "sub2api-provider-accounts-admin" ;
const state = {
accounts : [ ] ,
selectedAccountID : 0 ,
} ;
const apiBaseInput = document . getElementById ( "api-base" ) ;
const adminTokenInput = document . getElementById ( "admin-token" ) ;
const adminUsernameInput = document . getElementById ( "admin-username" ) ;
const adminPasswordInput = document . getElementById ( "admin-password" ) ;
const hostFilterInput = document . getElementById ( "filter-host-id" ) ;
const providerFilterInput = document . getElementById ( "filter-provider-id" ) ;
const logicalGroupFilterInput = document . getElementById ( "filter-logical-group-id" ) ;
const routeFilterInput = document . getElementById ( "filter-route-id" ) ;
const shadowGroupFilterInput = document . getElementById ( "filter-shadow-group-id" ) ;
const statusFilterInput = document . getElementById ( "filter-status" ) ;
const queryFilterInput = document . getElementById ( "filter-query" ) ;
const limitFilterInput = document . getElementById ( "filter-limit" ) ;
const actionReasonInput = document . getElementById ( "action-reason" ) ;
const sessionStatus = document . getElementById ( "session-status" ) ;
const tableStatus = document . getElementById ( "table-status" ) ;
const actionStatus = document . getElementById ( "action-status" ) ;
const accountsCatalog = document . getElementById ( "accounts-catalog" ) ;
const detailEmpty = document . getElementById ( "detail-empty" ) ;
const detailPanel = document . getElementById ( "detail-panel" ) ;
const detailGrid = document . getElementById ( "detail-grid" ) ;
const detailJSON = document . getElementById ( "detail-json" ) ;
const metricApiRoot = document . getElementById ( "metric-api-root" ) ;
const metricTotal = document . getElementById ( "metric-total" ) ;
const metricLive = document . getElementById ( "metric-live" ) ;
const metricDead = document . getElementById ( "metric-dead" ) ;
const enableButton = document . getElementById ( "enable-btn" ) ;
const disableButton = document . getElementById ( "disable-btn" ) ;
const retireButton = document . getElementById ( "retire-btn" ) ;
function readConfig ( ) {
try {
return JSON . parse ( localStorage . getItem ( storageKey ) || "{}" ) ;
} catch ( error ) {
console . warn ( "failed to parse config" , error ) ;
return { } ;
}
}
function writeConfig ( ) {
const payload = {
apiBase : apiBaseInput . value . trim ( ) ,
adminToken : adminTokenInput . value ,
adminUsername : adminUsernameInput . value . trim ( ) ,
hostID : hostFilterInput . value . trim ( ) ,
providerID : providerFilterInput . value . trim ( ) ,
logicalGroupID : logicalGroupFilterInput . value . trim ( ) ,
routeID : routeFilterInput . value . trim ( ) ,
shadowGroupID : shadowGroupFilterInput . value . trim ( ) ,
accountStatus : statusFilterInput . value ,
query : queryFilterInput . value . trim ( ) ,
limit : limitFilterInput . value . trim ( ) ,
} ;
localStorage . setItem ( storageKey , JSON . stringify ( payload ) ) ;
setStatus ( tableStatus , "已保存本地配置。" ) ;
}
function hydrateConfig ( ) {
const config = readConfig ( ) ;
apiBaseInput . value = config . apiBase || "/portal-admin-api" ;
adminTokenInput . value = config . adminToken || "" ;
adminUsernameInput . value = config . adminUsername || "" ;
hostFilterInput . value = config . hostID || "" ;
providerFilterInput . value = config . providerID || "" ;
logicalGroupFilterInput . value = config . logicalGroupID || "" ;
routeFilterInput . value = config . routeID || "" ;
shadowGroupFilterInput . value = config . shadowGroupID || "" ;
statusFilterInput . value = config . accountStatus || "" ;
queryFilterInput . value = config . query || "" ;
limitFilterInput . value = config . limit || "200" ;
}
function apiBase ( ) {
return ( apiBaseInput . value . trim ( ) || "/portal-admin-api" ) . replace ( /\/+$/ , "" ) ;
}
function authHeaders ( ) {
const token = adminTokenInput . value . trim ( ) ;
return token ? { Authorization : ` Bearer ${ token } ` } : { } ;
}
async function requestJSON ( path , options = { } ) {
const response = await fetch ( ` ${ apiBase ( ) } ${ path } ` , {
credentials : "include" ,
... options ,
headers : {
Accept : "application/json" ,
... ( options . headers || { } ) ,
} ,
} ) ;
const text = await response . text ( ) ;
const payload = text ? JSON . parse ( text ) : { } ;
if ( ! response . ok ) {
const message = payload ? . error ? . message || payload ? . message || response . statusText || "request failed" ;
throw new Error ( ` ${ response . status } ${ message } ` ) ;
}
return payload ;
}
async function refreshSession ( ) {
metricApiRoot . textContent = apiBase ( ) ;
try {
const payload = await requestJSON ( "/api/admin/session" , { headers : authHeaders ( ) } ) ;
if ( payload . authenticated ) {
setStatus ( sessionStatus , ` 管理员会话已建立: ${ payload . username || "unknown" } ( session) ` , "success" ) ;
} else if ( payload . login _enabled ) {
setStatus ( sessionStatus , ` 当前未登录。可用管理员用户名 ${ payload . username || "admin" } 建立 session, 或继续使用 Bearer token。 ` , "warn" ) ;
} else {
setStatus ( sessionStatus , "当前实例未启用管理员登录,只能使用 Bearer token。" , "warn" ) ;
}
} catch ( error ) {
setStatus ( sessionStatus , ` 检查管理员会话失败: ${ error . message } ` , "danger" ) ;
}
}
async function loginSession ( ) {
const username = adminUsernameInput . value . trim ( ) ;
const password = adminPasswordInput . value ;
if ( ! username || ! password ) {
setStatus ( sessionStatus , "请先输入管理员用户名和密码。" , "warn" ) ;
return ;
}
try {
const payload = await requestJSON ( "/api/admin/session/login" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" , ... authHeaders ( ) } ,
body : JSON . stringify ( { username , password } ) ,
} ) ;
setStatus ( sessionStatus , ` 管理员会话已建立: ${ payload . username || username } ` , "success" ) ;
} catch ( error ) {
setStatus ( sessionStatus , ` 管理员登录失败: ${ error . message } ` , "danger" ) ;
}
}
async function logoutSession ( ) {
try {
await requestJSON ( "/api/admin/session/logout" , {
method : "POST" ,
headers : authHeaders ( ) ,
} ) ;
setStatus ( sessionStatus , "管理员会话已退出。" , "warn" ) ;
} catch ( error ) {
setStatus ( sessionStatus , ` 退出会话失败: ${ error . message } ` , "danger" ) ;
}
}
function buildListQuery ( ) {
const params = new URLSearchParams ( ) ;
if ( hostFilterInput . value . trim ( ) ) params . set ( "host_id" , hostFilterInput . value . trim ( ) ) ;
if ( providerFilterInput . value . trim ( ) ) params . set ( "provider_id" , providerFilterInput . value . trim ( ) ) ;
if ( logicalGroupFilterInput . value . trim ( ) ) params . set ( "logical_group_id" , logicalGroupFilterInput . value . trim ( ) ) ;
if ( routeFilterInput . value . trim ( ) ) params . set ( "route_id" , routeFilterInput . value . trim ( ) ) ;
if ( shadowGroupFilterInput . value . trim ( ) ) params . set ( "shadow_group_id" , shadowGroupFilterInput . value . trim ( ) ) ;
if ( statusFilterInput . value ) params . set ( "account_status" , statusFilterInput . value ) ;
if ( queryFilterInput . value . trim ( ) ) params . set ( "q" , queryFilterInput . value . trim ( ) ) ;
if ( limitFilterInput . value . trim ( ) ) params . set ( "limit" , limitFilterInput . value . trim ( ) ) ;
const query = params . toString ( ) ;
return query ? ` /api/provider-accounts? ${ query } ` : "/api/provider-accounts" ;
}
async function loadAccounts ( ) {
setStatus ( tableStatus , "正在读取 provider_accounts…" ) ;
try {
const payload = await requestJSON ( buildListQuery ( ) , { headers : authHeaders ( ) } ) ;
state . accounts = Array . isArray ( payload . provider _accounts ) ? payload . provider _accounts : [ ] ;
if ( ! state . accounts . some ( ( item ) => item . id === state . selectedAccountID ) ) {
state . selectedAccountID = state . accounts [ 0 ] ? . id || 0 ;
}
renderMetrics ( ) ;
renderCatalog ( ) ;
renderDetail ( ) ;
setStatus ( tableStatus , ` 已加载 ${ state . accounts . length } 条帐号资产记录。 ` , "success" ) ;
} catch ( error ) {
state . accounts = [ ] ;
state . selectedAccountID = 0 ;
renderMetrics ( ) ;
renderCatalog ( ) ;
renderDetail ( ) ;
setStatus ( tableStatus , ` 读取帐号资产失败: ${ error . message } ` , "danger" ) ;
}
}
function renderMetrics ( ) {
metricApiRoot . textContent = apiBase ( ) ;
metricTotal . textContent = String ( state . accounts . length ) ;
const counts = { active : 0 , disabled : 0 , deprecated : 0 , broken : 0 } ;
state . accounts . forEach ( ( account ) => {
if ( Object . prototype . hasOwnProperty . call ( counts , account . account _status ) ) {
counts [ account . account _status ] += 1 ;
}
} ) ;
metricLive . textContent = ` ${ counts . active } / ${ counts . disabled } ` ;
metricDead . textContent = ` ${ counts . deprecated } / ${ counts . broken } ` ;
}
function statusClass ( status ) {
if ( status === "active" ) return "active" ;
if ( status === "disabled" ) return "disabled" ;
if ( status === "deprecated" ) return "deprecated" ;
return "broken" ;
}
function renderCatalog ( ) {
if ( ! state . accounts . length ) {
accountsCatalog . innerHTML = '<div class="empty">还没有匹配到帐号资产记录。</div>' ;
return ;
}
accountsCatalog . innerHTML = "" ;
state . accounts . forEach ( ( account ) => {
const card = document . createElement ( "button" ) ;
card . type = "button" ;
card . className = ` row-card ${ account . id === state . selectedAccountID ? " is-selected" : "" } ` ;
card . innerHTML = `
<div class="row-heading">
<div>
<div class="row-title"> ${ escapeHTML ( account . account _name || account . host _account _id ) } </div>
<div class="meta-list">
<span>provider: <code> ${ escapeHTML ( account . provider _id ) } </code></span>
<span>host_account_id: <code> ${ escapeHTML ( account . host _account _id ) } </code></span>
</div>
</div>
<span class="badge ${ statusClass ( account . account _status ) } "> ${ escapeHTML ( account . account _status ) } </span>
</div>
<div class="badge-row">
<span class="badge ${ statusClass ( account . account _status ) } "> ${ escapeHTML ( account . logical _group _id || "未归属 logical_group" ) } </span>
<span class="badge ${ statusClass ( account . account _status ) } "> ${ escapeHTML ( account . route _id || "未归属 route" ) } </span>
<span class="badge ${ statusClass ( account . account _status ) } ">shadow_group: ${ escapeHTML ( account . shadow _group _id || "-" ) } </span>
</div>
<div class="meta-list">
<span>route_name: <code> ${ escapeHTML ( account . route _name || "-" ) } </code></span>
<span>shadow_host_id: <code> ${ escapeHTML ( account . shadow _host _id || account . host _id || "-" ) } </code></span>
<span>last_probe_status: <code> ${ escapeHTML ( account . last _probe _status || "-" ) } </code></span>
</div>
` ;
card . addEventListener ( "click" , ( ) => {
state . selectedAccountID = account . id ;
renderCatalog ( ) ;
renderDetail ( ) ;
} ) ;
accountsCatalog . appendChild ( card ) ;
} ) ;
}
function renderDetail ( ) {
const account = state . accounts . find ( ( item ) => item . id === state . selectedAccountID ) ;
const hasSelection = Boolean ( account ) ;
detailEmpty . hidden = hasSelection ;
detailPanel . hidden = ! hasSelection ;
enableButton . disabled = ! hasSelection ;
disableButton . disabled = ! hasSelection ;
retireButton . disabled = ! hasSelection ;
if ( ! account ) {
detailGrid . innerHTML = "" ;
detailJSON . textContent = "{}" ;
setStatus ( actionStatus , "请选择左侧一条帐号记录。" ) ;
return ;
}
const cards = [
[ "帐号主键" , String ( account . id ) ] ,
[ "provider_id" , account . provider _id ] ,
[ "provider_name" , account . provider _name || "-" ] ,
[ "host_id" , account . host _id ] ,
[ "host_base_url" , account . host _base _url || "-" ] ,
[ "logical_group_id" , account . logical _group _id || "未归属" ] ,
[ "route_id" , account . route _id || "未归属" ] ,
[ "route_name" , account . route _name || "-" ] ,
[ "shadow_group_id" , account . shadow _group _id || "-" ] ,
[ "shadow_host_id" , account . shadow _host _id || "-" ] ,
[ "upstream_base_url_hint" , account . upstream _base _url _hint || "-" ] ,
[ "host_account_id" , account . host _account _id ] ,
[ "key_fingerprint" , account . key _fingerprint ] ,
[ "account_status" , account . account _status ] ,
[ "last_probe_status" , account . last _probe _status || "-" ] ,
[ "last_probe_at" , account . last _probe _at || "-" ] ,
[ "disabled_reason" , account . disabled _reason || "-" ] ,
[ "updated_at" , account . updated _at || "-" ] ,
] ;
detailGrid . innerHTML = cards . map ( ( [ label , value ] ) => `
<div class="detail-card">
<strong> ${ escapeHTML ( label ) } </strong>
<code> ${ escapeHTML ( value ) } </code>
</div>
` ) . join ( "" ) ;
detailJSON . textContent = JSON . stringify ( account , null , 2 ) ;
setStatus ( actionStatus , ` 当前选中帐号 # ${ account . id } ,操作只会修改插件 provider_accounts 库存状态。 ` ) ;
}
async function updateAccountStatus ( action ) {
const account = state . accounts . find ( ( item ) => item . id === state . selectedAccountID ) ;
if ( ! account ) {
setStatus ( actionStatus , "请先选择一条帐号记录。" , "warn" ) ;
return ;
}
const reason = actionReasonInput . value . trim ( ) ;
if ( ( action === "disable" || action === "retire" ) && ! reason ) {
setStatus ( actionStatus , "停用或退役请填写原因,避免后续看不懂为什么改状态。" , "warn" ) ;
return ;
}
try {
const payload = await requestJSON ( ` /api/provider-accounts/ ${ encodeURIComponent ( account . id ) } / ${ action } ` , {
method : "POST" ,
headers : { "Content-Type" : "application/json" , ... authHeaders ( ) } ,
body : JSON . stringify ( reason ? { reason } : { } ) ,
} ) ;
const updated = payload . provider _account ;
setStatus ( actionStatus , ` 帐号 # ${ updated . id } 已更新为 ${ updated . account _status } ${ updated . disabled _reason ? ` ( ${ updated . disabled _reason } ) ` : "" } 。 ` , "success" ) ;
await loadAccounts ( ) ;
} catch ( error ) {
setStatus ( actionStatus , ` 更新帐号状态失败: ${ error . message } ` , "danger" ) ;
}
}
function clearFilters ( ) {
hostFilterInput . value = "" ;
providerFilterInput . value = "" ;
logicalGroupFilterInput . value = "" ;
routeFilterInput . value = "" ;
shadowGroupFilterInput . value = "" ;
statusFilterInput . value = "" ;
queryFilterInput . value = "" ;
limitFilterInput . value = "200" ;
}
function setStatus ( element , message , tone = "" ) {
element . textContent = message ;
element . style . color = tone === "danger"
? "var(--danger)"
: tone === "warn"
? "var(--warn)"
: tone === "success"
? "var(--success)"
: "var(--muted)" ;
element . style . borderColor = tone === "danger"
? "rgba(178, 49, 49, 0.24)"
: tone === "warn"
? "rgba(155, 98, 21, 0.22)"
: tone === "success"
? "rgba(18, 107, 67, 0.22)"
: "var(--line)" ;
element . style . background = tone === "danger"
? "rgba(178, 49, 49, 0.08)"
: tone === "warn"
? "rgba(155, 98, 21, 0.08)"
: tone === "success"
? "rgba(18, 107, 67, 0.08)"
: "#fff" ;
}
function escapeHTML ( value ) {
return String ( value ? ? "" )
. replaceAll ( "&" , "&" )
. replaceAll ( "<" , "<" )
. replaceAll ( ">" , ">" )
. replaceAll ( '"' , """ )
. replaceAll ( "'" , "'" ) ;
}
document . getElementById ( "save-config-btn" ) . addEventListener ( "click" , writeConfig ) ;
document . getElementById ( "admin-login-btn" ) . addEventListener ( "click" , loginSession ) ;
document . getElementById ( "admin-logout-btn" ) . addEventListener ( "click" , logoutSession ) ;
document . getElementById ( "refresh-btn" ) . addEventListener ( "click" , loadAccounts ) ;
document . getElementById ( "apply-filters-btn" ) . addEventListener ( "click" , loadAccounts ) ;
document . getElementById ( "clear-filters-btn" ) . addEventListener ( "click" , ( ) => {
clearFilters ( ) ;
loadAccounts ( ) ;
} ) ;
enableButton . addEventListener ( "click" , ( ) => updateAccountStatus ( "enable" ) ) ;
disableButton . addEventListener ( "click" , ( ) => updateAccountStatus ( "disable" ) ) ;
retireButton . addEventListener ( "click" , ( ) => updateAccountStatus ( "retire" ) ) ;
hydrateConfig ( ) ;
refreshSession ( ) ;
loadAccounts ( ) ;
< / script >
< / body >
< / html >