feat(accounts): add explicit route binding workflow
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user