feat(accounts): add explicit route binding workflow

This commit is contained in:
phamnazage-jpg
2026-05-29 19:07:01 +08:00
parent ad94feab72
commit 649eb13f30
8 changed files with 768 additions and 210 deletions

View File

@@ -343,6 +343,13 @@
line-height: 1.6;
word-break: break-word;
}
.binding-box {
margin-top: 16px;
padding: 16px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.84);
}
.empty {
padding: 18px;
border-radius: 18px;
@@ -483,7 +490,16 @@
</select>
</label>
</div>
<div class="field-grid two" style="margin-top:12px;">
<div class="field-grid three" style="margin-top:12px;">
<label>
binding_state
<select id="filter-binding-state">
<option value="">全部归属状态</option>
<option value="assigned">assigned</option>
<option value="unassigned">unassigned</option>
<option value="conflict">conflict</option>
</select>
</label>
<label>
搜索
<input id="filter-query" type="text" placeholder="provider / logical_group / host_account / fingerprint">
@@ -520,7 +536,9 @@
当前显式使用的动作接口是:
<code>/api/provider-accounts/{account_id}/enable</code>
<code>/api/provider-accounts/{account_id}/disable</code>
<code>/api/provider-accounts/{account_id}/retire</code>
<code>/api/provider-accounts/{account_id}/retire</code>
<code>/api/provider-accounts/{account_id}/binding-candidates</code>
<code>/api/provider-accounts/{account_id}/binding</code>
</p>
<div class="field-grid" style="margin-top:12px;">
<label>
@@ -535,6 +553,32 @@
</div>
<div class="statusbar" id="action-status">请选择左侧一条帐号记录。</div>
<div class="binding-box">
<h2 style="margin:0 0 8px; font-size:20px;">显式整理归属</h2>
<p class="panel-desc">
当帐号因为同一 <code>shadow_host_id + shadow_group_id</code> 对应多条 route 而显示为
<code>conflict</code> 时,直接在这里挑一条 route 绑定;也可以清空 binding保留为未归属。
</p>
<div class="field-grid two" style="margin-top:12px;">
<label>
route 候选
<select id="binding-route-select" disabled>
<option value="">请先选择帐号</option>
</select>
</label>
<label>
当前 binding_state
<input id="binding-state-view" type="text" readonly value="-">
</label>
</div>
<div class="actions">
<button class="ghost" id="refresh-binding-btn" type="button" disabled>刷新候选 route</button>
<button class="secondary" id="apply-binding-btn" type="button" disabled>绑定到所选 route</button>
<button class="ghost" id="clear-binding-btn" type="button" disabled>清空 route 归属</button>
</div>
<div class="statusbar" id="binding-status">选择左侧一条帐号后,这里会加载 route 候选。</div>
</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>
@@ -549,6 +593,7 @@
const state = {
accounts: [],
selectedAccountID: 0,
bindingCandidates: [],
};
const apiBaseInput = document.getElementById("api-base");
@@ -561,6 +606,7 @@
const routeFilterInput = document.getElementById("filter-route-id");
const shadowGroupFilterInput = document.getElementById("filter-shadow-group-id");
const statusFilterInput = document.getElementById("filter-status");
const bindingStateFilterInput = document.getElementById("filter-binding-state");
const queryFilterInput = document.getElementById("filter-query");
const limitFilterInput = document.getElementById("filter-limit");
const actionReasonInput = document.getElementById("action-reason");
@@ -573,6 +619,9 @@
const detailPanel = document.getElementById("detail-panel");
const detailGrid = document.getElementById("detail-grid");
const detailJSON = document.getElementById("detail-json");
const bindingRouteSelect = document.getElementById("binding-route-select");
const bindingStateView = document.getElementById("binding-state-view");
const bindingStatus = document.getElementById("binding-status");
const metricApiRoot = document.getElementById("metric-api-root");
const metricTotal = document.getElementById("metric-total");
@@ -582,6 +631,9 @@
const enableButton = document.getElementById("enable-btn");
const disableButton = document.getElementById("disable-btn");
const retireButton = document.getElementById("retire-btn");
const refreshBindingButton = document.getElementById("refresh-binding-btn");
const applyBindingButton = document.getElementById("apply-binding-btn");
const clearBindingButton = document.getElementById("clear-binding-btn");
function readConfig() {
try {
@@ -603,6 +655,7 @@
routeID: routeFilterInput.value.trim(),
shadowGroupID: shadowGroupFilterInput.value.trim(),
accountStatus: statusFilterInput.value,
bindingState: bindingStateFilterInput.value,
query: queryFilterInput.value.trim(),
limit: limitFilterInput.value.trim(),
};
@@ -621,6 +674,7 @@
routeFilterInput.value = config.routeID || "";
shadowGroupFilterInput.value = config.shadowGroupID || "";
statusFilterInput.value = config.accountStatus || "";
bindingStateFilterInput.value = config.bindingState || "";
queryFilterInput.value = config.query || "";
limitFilterInput.value = config.limit || "200";
}
@@ -707,6 +761,7 @@
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 (bindingStateFilterInput.value) params.set("binding_state", bindingStateFilterInput.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();
@@ -718,19 +773,27 @@
try {
const payload = await requestJSON(buildListQuery(), { headers: authHeaders() });
state.accounts = Array.isArray(payload.provider_accounts) ? payload.provider_accounts : [];
state.bindingCandidates = [];
if (!state.accounts.some((item) => item.id === state.selectedAccountID)) {
state.selectedAccountID = state.accounts[0]?.id || 0;
}
renderMetrics();
renderCatalog();
renderDetail();
if (state.selectedAccountID) {
await loadBindingCandidates();
} else {
renderBindingCandidates();
}
setStatus(tableStatus, `已加载 ${state.accounts.length} 条帐号资产记录。`, "success");
} catch (error) {
state.accounts = [];
state.selectedAccountID = 0;
state.bindingCandidates = [];
renderMetrics();
renderCatalog();
renderDetail();
renderBindingCandidates();
setStatus(tableStatus, `读取帐号资产失败:${error.message}`, "danger");
}
}
@@ -780,6 +843,7 @@
<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>
<span class="badge ${statusClass(account.account_status)}">binding: ${escapeHTML(account.binding_state || "unassigned")} / candidates: ${escapeHTML(account.binding_candidate_count || 0)}</span>
</div>
<div class="meta-list">
<span>route_name: <code>${escapeHTML(account.route_name || "-")}</code></span>
@@ -791,6 +855,7 @@
state.selectedAccountID = account.id;
renderCatalog();
renderDetail();
loadBindingCandidates();
});
accountsCatalog.appendChild(card);
});
@@ -804,9 +869,12 @@
enableButton.disabled = !hasSelection;
disableButton.disabled = !hasSelection;
retireButton.disabled = !hasSelection;
refreshBindingButton.disabled = !hasSelection;
clearBindingButton.disabled = !hasSelection;
if (!account) {
detailGrid.innerHTML = "";
detailJSON.textContent = "{}";
bindingStateView.value = "-";
setStatus(actionStatus, "请选择左侧一条帐号记录。");
return;
}
@@ -825,6 +893,8 @@
["host_account_id", account.host_account_id],
["key_fingerprint", account.key_fingerprint],
["account_status", account.account_status],
["binding_state", account.binding_state || "unassigned"],
["binding_candidate_count", String(account.binding_candidate_count || 0)],
["last_probe_status", account.last_probe_status || "-"],
["last_probe_at", account.last_probe_at || "-"],
["disabled_reason", account.disabled_reason || "-"],
@@ -837,9 +907,56 @@
</div>
`).join("");
detailJSON.textContent = JSON.stringify(account, null, 2);
bindingStateView.value = account.binding_state || "unassigned";
setStatus(actionStatus, `当前选中帐号 #${account.id},操作只会修改插件 provider_accounts 库存状态。`);
}
async function loadBindingCandidates() {
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
if (!account) {
state.bindingCandidates = [];
renderBindingCandidates();
return;
}
setStatus(bindingStatus, `正在读取帐号 #${account.id} 的 route 候选…`);
try {
const payload = await requestJSON(`/api/provider-accounts/${encodeURIComponent(account.id)}/binding-candidates`, { headers: authHeaders() });
state.bindingCandidates = Array.isArray(payload.candidate_routes) ? payload.candidate_routes : [];
if (payload.provider_account) {
state.accounts = state.accounts.map((item) => item.id === account.id ? payload.provider_account : item);
}
renderCatalog();
renderDetail();
renderBindingCandidates();
setStatus(bindingStatus, `已加载 ${state.bindingCandidates.length} 条 route 候选。`, "success");
} catch (error) {
state.bindingCandidates = [];
renderBindingCandidates();
setStatus(bindingStatus, `读取 route 候选失败:${error.message}`, "danger");
}
}
function renderBindingCandidates() {
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
const hasSelection = Boolean(account);
bindingRouteSelect.disabled = !hasSelection;
applyBindingButton.disabled = !hasSelection;
if (!hasSelection) {
bindingRouteSelect.innerHTML = '<option value="">请先选择帐号</option>';
bindingStateView.value = "-";
return;
}
const options = ['<option value="">请选择一个 route</option>'];
state.bindingCandidates.forEach((route) => {
const selected = route.route_id === account.route_id ? " selected" : "";
options.push(`<option value="${escapeHTML(route.route_id)}"${selected}>${escapeHTML(route.route_id)} / ${escapeHTML(route.logical_group_id)} / ${escapeHTML(route.name || "-")}</option>`);
});
if (!state.bindingCandidates.length) {
options.push('<option value="">当前 shadow binding 下没有候选 route</option>');
}
bindingRouteSelect.innerHTML = options.join("");
}
async function updateAccountStatus(action) {
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
if (!account) {
@@ -865,6 +982,37 @@
}
}
async function updateAccountBinding(mode) {
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
if (!account) {
setStatus(bindingStatus, "请先选择一条帐号记录。", "warn");
return;
}
let payload = {};
if (mode === "assign") {
const routeID = bindingRouteSelect.value.trim();
if (!routeID) {
setStatus(bindingStatus, "请先选择要绑定的 route。", "warn");
return;
}
payload = { route_id: routeID };
} else {
payload = { clear: true };
}
try {
const response = await requestJSON(`/api/provider-accounts/${encodeURIComponent(account.id)}/binding`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify(payload),
});
const updated = response.provider_account;
setStatus(bindingStatus, `帐号 #${updated.id} 已更新归属binding_state=${updated.binding_state || "unassigned"} route=${updated.route_id || "-"}`, "success");
await loadAccounts();
} catch (error) {
setStatus(bindingStatus, `更新帐号归属失败:${error.message}`, "danger");
}
}
function clearFilters() {
hostFilterInput.value = "";
providerFilterInput.value = "";
@@ -872,6 +1020,7 @@
routeFilterInput.value = "";
shadowGroupFilterInput.value = "";
statusFilterInput.value = "";
bindingStateFilterInput.value = "";
queryFilterInput.value = "";
limitFilterInput.value = "200";
}
@@ -922,6 +1071,9 @@
enableButton.addEventListener("click", () => updateAccountStatus("enable"));
disableButton.addEventListener("click", () => updateAccountStatus("disable"));
retireButton.addEventListener("click", () => updateAccountStatus("retire"));
refreshBindingButton.addEventListener("click", loadBindingCandidates);
applyBindingButton.addEventListener("click", () => updateAccountBinding("assign"));
clearBindingButton.addEventListener("click", () => updateAccountBinding("clear"));
hydrateConfig();
refreshSession();