feat: 统一全站自定义下拉组件

This commit is contained in:
3127647737@qq.com 2026-07-17 15:43:46 +08:00
parent 8cbf055d98
commit a7762aac63
4 changed files with 593 additions and 146 deletions

View File

@ -336,6 +336,7 @@ function resetPrivateState() {
state.catalog = { instances: [], groups: [], instancesLoaded: false, groupsLoaded: false }; state.catalog = { instances: [], groups: [], instancesLoaded: false, groupsLoaded: false };
state.selected = { instanceId: null, groupId: null, runId: null }; state.selected = { instanceId: null, groupId: null, runId: null };
state.groupMemberDraft = new Set(); state.groupMemberDraft = new Set();
resetCustomSelectValues();
state.pendingAction = null; state.pendingAction = null;
state.pendingDelete = null; state.pendingDelete = null;
state.pending.clear(); state.pending.clear();
@ -640,102 +641,218 @@ async function fetchGroupCatalog(force = false) {
async function ensureCatalogs(force = false) { async function ensureCatalogs(force = false) {
await Promise.all([fetchInstanceCatalog(force), fetchGroupCatalog(force)]); await Promise.all([fetchInstanceCatalog(force), fetchGroupCatalog(force)]);
populateResourceSelects(); return populateResourceSelects();
} }
function populateSelect(select, items, firstLabel, selectedValue = null) { const INSTANCE_STATUS_OPTIONS = [
if (!select) return; { value: "enabled", label: "已启用", icon: "check-circle-2", tone: "success" },
const previous = selectedValue === null ? select.value : String(selectedValue || ""); { value: "disabled", label: "已停用", icon: "server-off", tone: "muted" },
select.innerHTML = `<option value="">${escapeHtml(firstLabel)}</option>${items.map((item) => `<option value="${escapeHtml(item.id)}">${escapeHtml(item.display_name || item.name || item.id)}</option>`).join("")}`; ];
if ($(`option[value="${CSS.escape(previous)}"]`, select)) select.value = previous;
}
function instanceGroupSelectElements() { const GROUP_STATUS_OPTIONS = [
const root = $("#instance-group-select"); { value: "enabled", label: "计划已启用", icon: "timer-reset", tone: "success" },
{ value: "disabled", label: "计划已暂停", icon: "hourglass", tone: "muted" },
];
const HISTORY_STATUS_OPTIONS = [
{ value: "queued", label: "排队中", icon: "hourglass", tone: "muted" },
{ value: "running", label: "进行中", icon: "loader-circle", tone: "info" },
{ value: "needs_attention", label: "待处理", icon: "triangle-alert", tone: "warning" },
{ value: "cleanup_pending", label: "待清理", icon: "trash-2", tone: "warning" },
{ value: "succeeded", label: "已成功", icon: "check-circle-2", tone: "success" },
{ value: "failed", label: "已失败", icon: "octagon-x", tone: "danger" },
{ value: "cancelled", label: "已放弃", icon: "x", tone: "muted" },
];
function customSelectElements(target) {
const root = target?.matches?.("[data-custom-select]")
? target
: target?.closest?.("[data-custom-select]");
if (!root) return null; if (!root) return null;
return { return {
root, root,
input: $('input[name="group_id"]', root), input: $('input[type="hidden"]', root),
trigger: $(".custom-select-trigger", root), trigger: $(".custom-select-trigger", root),
label: $("#instance-group-select-value", root), label: $("[data-custom-select-label]", root),
menu: $(".custom-select-menu", root), menu: $("[data-custom-select-menu]", root),
}; };
} }
function instanceGroupOptionMarkup(group, selectedValue) { function customSelectOptionMarkup(option, selectedValue) {
const value = group ? String(group.id) : ""; const value = String(option.value ?? option.id ?? "");
const label = group?.name || "未分组"; const label = String(option.label ?? option.display_name ?? option.name ?? value);
const detail = option.detail ? String(option.detail) : "";
const toneClass = {
success: "is-success",
info: "is-info",
warning: "is-warning",
danger: "is-danger",
muted: "is-muted",
}[option.tone] || "";
const selected = value === selectedValue; const selected = value === selectedValue;
const detail = group
? (group.enabled ? `${Number(group.interval_minutes || 60)} 分钟周期` : "计划已停用")
: "不跟随组计划";
return `<button class="custom-select-option" type="button" role="option" tabindex="-1" data-value="${escapeHtml(value)}" data-label="${escapeHtml(label)}" aria-selected="${selected}"> return `<button class="custom-select-option" type="button" role="option" tabindex="-1" data-value="${escapeHtml(value)}" data-label="${escapeHtml(label)}" aria-selected="${selected}">
<span class="custom-select-option-icon ${group?.enabled ? "is-enabled" : ""}">${icon(group ? "boxes" : "minus")}</span> <span class="custom-select-option-icon ${toneClass}">${icon(option.icon || "check")}</span>
<span class="custom-select-option-copy"><strong>${escapeHtml(label)}</strong><small>${escapeHtml(detail)}</small></span> <span class="custom-select-option-copy"><strong>${escapeHtml(label)}</strong>${detail ? `<small>${escapeHtml(detail)}</small>` : ""}</span>
<span class="custom-select-option-check">${icon("check")}</span> <span class="custom-select-option-check">${icon("check")}</span>
</button>`; </button>`;
} }
function syncInstanceGroupSelection(value, { emitChange = false } = {}) { function syncCustomSelectSelection(target, value, { emitChange = false } = {}) {
const elements = instanceGroupSelectElements(); const elements = customSelectElements(target);
if (!elements) return; if (!elements) return "";
const requested = String(value || ""); const requested = String(value || "");
const options = $$(".custom-select-option", elements.menu); const options = $$(".custom-select-option", elements.menu);
const selected = options.find((option) => option.dataset.value === requested) || options[0]; const selected = options.find((option) => option.dataset.value === requested) || options[0];
const resolved = selected?.dataset.value || ""; const resolved = selected?.dataset.value || "";
const label = selected?.dataset.label || "";
elements.input.value = resolved; elements.input.value = resolved;
elements.label.textContent = selected?.dataset.label || "未分组"; elements.label.textContent = label;
elements.label.title = label;
const accessibleLabel = elements.trigger.dataset.customSelectName
|| elements.trigger.getAttribute("aria-label");
if (accessibleLabel) {
elements.trigger.dataset.customSelectName = accessibleLabel;
elements.trigger.setAttribute("aria-label", `${accessibleLabel}${label}`);
}
options.forEach((option) => option.setAttribute("aria-selected", String(option === selected))); options.forEach((option) => option.setAttribute("aria-selected", String(option === selected)));
if (emitChange) elements.input.dispatchEvent(new Event("change", { bubbles: true })); if (emitChange) elements.input.dispatchEvent(new Event("change", { bubbles: true }));
return resolved;
} }
function populateInstanceGroupSelect(items, selectedValue = null) { function populateCustomSelect(target, options, selectedValue = null) {
const elements = instanceGroupSelectElements(); const elements = customSelectElements(target);
if (!elements) return; if (!elements) return "";
const previous = selectedValue === null ? elements.input.value : String(selectedValue || ""); const previous = selectedValue === null ? elements.input.value : String(selectedValue || "");
elements.menu.innerHTML = [ const focusedOption = elements.root.contains(document.activeElement)
instanceGroupOptionMarkup(null, previous), ? document.activeElement.closest?.(".custom-select-option")
...items.map((group) => instanceGroupOptionMarkup(group, previous)), : null;
].join(""); const focusedValue = focusedOption?.dataset.value;
syncInstanceGroupSelection(previous); elements.menu.innerHTML = options
.map((option) => customSelectOptionMarkup(option, previous))
.join("");
const resolved = syncCustomSelectSelection(elements.root, previous);
refreshIcons(); refreshIcons();
if (focusedValue !== undefined) {
window.requestAnimationFrame(() => {
const currentOptions = $$(".custom-select-option", elements.menu);
const option = currentOptions.find((item) => item.dataset.value === focusedValue)
|| currentOptions.find((item) => item.getAttribute("aria-selected") === "true");
option?.focus();
});
}
return resolved;
} }
function setInstanceGroupSelectOpen(open, focusTarget = null) { function setCustomSelectOpen(target, open, focusTarget = null) {
const elements = instanceGroupSelectElements(); const elements = customSelectElements(target);
if (!elements) return; if (!elements) return;
if (open) {
$$('[data-custom-select].is-open').forEach((root) => {
if (root !== elements.root) setCustomSelectOpen(root, false);
});
}
elements.root.classList.toggle("is-open", open); elements.root.classList.toggle("is-open", open);
elements.trigger.setAttribute("aria-expanded", String(open)); elements.trigger.setAttribute("aria-expanded", String(open));
elements.menu.hidden = !open; elements.menu.hidden = !open;
if (!open) {
delete elements.root.dataset.typeaheadQuery;
delete elements.root.dataset.typeaheadAt;
}
if (!open || !focusTarget) return; if (!open || !focusTarget) return;
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
const options = $$(".custom-select-option", elements.menu); const options = $$(".custom-select-option", elements.menu);
const target = focusTarget === "last" const option = focusTarget === "last"
? options.at(-1) ? options.at(-1)
: options.find((option) => option.getAttribute("aria-selected") === "true") || options[0]; : focusTarget === "first"
target?.focus(); ? options[0]
: options.find((item) => item.getAttribute("aria-selected") === "true") || options[0];
option?.focus();
}); });
} }
function selectInstanceGroupOption(option) { function closeCustomSelects() {
if (!option) return; $$('[data-custom-select].is-open').forEach((root) => setCustomSelectOpen(root, false));
syncInstanceGroupSelection(option.dataset.value, { emitChange: true });
setInstanceGroupSelectOpen(false);
instanceGroupSelectElements()?.trigger.focus();
} }
function handleInstanceGroupSelectKeydown(event) { function selectCustomSelectOption(option) {
const elements = instanceGroupSelectElements(); const elements = customSelectElements(option);
if (!elements) return; if (!elements || !option) return;
const options = $$(".custom-select-option", elements.menu); syncCustomSelectSelection(elements.root, option.dataset.value, { emitChange: true });
if (event.target === elements.trigger && ["ArrowDown", "ArrowUp"].includes(event.key)) { setCustomSelectOpen(elements.root, false);
event.preventDefault(); elements.trigger.focus();
setInstanceGroupSelectOpen(true, event.key === "ArrowUp" ? "last" : "selected"); }
function handleCustomSelectClick(event) {
const option = event.target.closest?.(".custom-select-option");
if (option) {
selectCustomSelectOption(option);
return; return;
} }
const trigger = event.target.closest?.(".custom-select-trigger");
if (trigger) {
const root = trigger.closest("[data-custom-select]");
const open = !root.classList.contains("is-open");
setCustomSelectOpen(root, open, open ? "selected" : null);
return;
}
if (!event.target.closest?.("[data-custom-select]")) closeCustomSelects();
}
function focusCustomSelectByText(root, options, currentIndex, key) {
const now = Date.now();
const previousAt = Number(root.dataset.typeaheadAt || 0);
const prefix = now - previousAt < 700 ? root.dataset.typeaheadQuery || "" : "";
const query = `${prefix}${key}`.toLocaleLowerCase("zh-CN");
root.dataset.typeaheadQuery = query;
root.dataset.typeaheadAt = String(now);
const ordered = [
...options.slice(currentIndex + 1),
...options.slice(0, currentIndex + 1),
];
const match = ordered.find((option) =>
String(option.dataset.label || "").toLocaleLowerCase("zh-CN").startsWith(query)
);
match?.focus();
}
function handleCustomSelectKeydown(event) {
const root = event.target.closest?.("[data-custom-select]");
if (!root) {
if (event.key === "Escape") closeCustomSelects();
return;
}
const elements = customSelectElements(root);
const options = $$(".custom-select-option", elements.menu);
if (event.target === elements.trigger) {
const focusTarget = {
ArrowDown: "selected",
ArrowUp: "last",
Home: "first",
End: "last",
}[event.key];
if (focusTarget) {
event.preventDefault();
setCustomSelectOpen(root, true, focusTarget);
return;
}
if (
event.key.length === 1
&& event.key.trim()
&& !event.altKey
&& !event.ctrlKey
&& !event.metaKey
) {
event.preventDefault();
setCustomSelectOpen(root, true);
window.requestAnimationFrame(() => {
focusCustomSelectByText(root, options, -1, event.key);
});
return;
}
}
const currentIndex = options.indexOf(document.activeElement); const currentIndex = options.indexOf(document.activeElement);
if (currentIndex < 0) { if (currentIndex < 0) {
if (event.key === "Escape") setInstanceGroupSelectOpen(false); if (event.key === "Escape") setCustomSelectOpen(root, false);
return; return;
} }
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) { if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
@ -746,20 +863,104 @@ function handleInstanceGroupSelectKeydown(event) {
if (event.key === "Home") nextIndex = 0; if (event.key === "Home") nextIndex = 0;
if (event.key === "End") nextIndex = options.length - 1; if (event.key === "End") nextIndex = options.length - 1;
options[nextIndex]?.focus(); options[nextIndex]?.focus();
} else if (
event.key.length === 1
&& event.key.trim()
&& !event.altKey
&& !event.ctrlKey
&& !event.metaKey
) {
event.preventDefault();
focusCustomSelectByText(root, options, currentIndex, event.key);
} else if (event.key === "Escape") { } else if (event.key === "Escape") {
event.preventDefault(); event.preventDefault();
setInstanceGroupSelectOpen(false); setCustomSelectOpen(root, false);
elements.trigger.focus(); elements.trigger.focus();
} else if (event.key === "Tab") { } else if (event.key === "Tab") {
setInstanceGroupSelectOpen(false); window.setTimeout(() => setCustomSelectOpen(root, false), 0);
} }
} }
function populateInstanceGroupSelect(items, selectedValue = null) {
const options = [
{ value: "", label: "未分组", detail: "不跟随组计划", icon: "minus", tone: "muted" },
...items.map((group) => ({
value: group.id,
label: group.name,
detail: group.enabled ? `${Number(group.interval_minutes || 60)} 分钟周期` : "计划已停用",
icon: "boxes",
tone: group.enabled ? "success" : "muted",
})),
];
return populateCustomSelect($("#instance-form").elements.group_id, options, selectedValue);
}
function initializeCustomSelects() {
populateCustomSelect($("#instance-group-filter"), [
{ value: "", label: "全部分组", icon: "boxes", tone: "muted" },
], state.instances.groupId);
populateCustomSelect($("#instance-status-filter"), [
{ value: "", label: "全部状态", icon: "activity", tone: "muted" },
...INSTANCE_STATUS_OPTIONS,
], state.instances.status);
populateCustomSelect($("#group-status-filter"), [
{ value: "", label: "全部状态", icon: "timer-reset", tone: "muted" },
...GROUP_STATUS_OPTIONS,
], state.groups.status);
populateCustomSelect($("#history-instance-filter"), [
{ value: "", label: "全部实例", icon: "server", tone: "muted" },
], state.history.instanceId);
populateCustomSelect($("#history-group-filter"), [
{ value: "", label: "全部分组", icon: "boxes", tone: "muted" },
], state.history.groupId);
populateCustomSelect($("#history-status-filter"), [
{ value: "", label: "全部结果", icon: "activity", tone: "muted" },
...HISTORY_STATUS_OPTIONS,
], state.history.status);
populateInstanceGroupSelect([], "");
}
function resetCustomSelectValues() {
$$('[data-custom-select]').forEach((root) => syncCustomSelectSelection(root, ""));
closeCustomSelects();
}
function populateResourceSelects() { function populateResourceSelects() {
populateSelect($("#instance-group-filter"), state.catalog.groups, "全部分组", state.instances.groupId); const previousInstanceGroupId = state.instances.groupId;
populateSelect($("#history-instance-filter"), state.catalog.instances, "全部实例", state.history.instanceId); const previousHistoryInstanceId = state.history.instanceId;
populateSelect($("#history-group-filter"), state.catalog.groups, "全部分组", state.history.groupId); const previousHistoryGroupId = state.history.groupId;
const groupOptions = state.catalog.groups.map((group) => ({
value: group.id,
label: group.name,
icon: "boxes",
tone: group.enabled ? "success" : "muted",
}));
state.instances.groupId = populateCustomSelect($("#instance-group-filter"), [
{ value: "", label: "全部分组", icon: "boxes", tone: "muted" },
...groupOptions,
], state.instances.groupId);
state.history.instanceId = populateCustomSelect($("#history-instance-filter"), [
{ value: "", label: "全部实例", icon: "server", tone: "muted" },
...state.catalog.instances.map((instance) => ({
value: instance.id,
label: instance.display_name,
icon: "server",
tone: instance.enabled ? "success" : "muted",
})),
], state.history.instanceId);
state.history.groupId = populateCustomSelect($("#history-group-filter"), [
{ value: "", label: "全部分组", icon: "boxes", tone: "muted" },
...groupOptions,
], state.history.groupId);
populateInstanceGroupSelect(state.catalog.groups); populateInstanceGroupSelect(state.catalog.groups);
const instanceFilterReset = state.instances.groupId !== previousInstanceGroupId;
const historyFilterReset = (
state.history.instanceId !== previousHistoryInstanceId
|| state.history.groupId !== previousHistoryGroupId
);
if (instanceFilterReset) state.instances.page = 1;
if (historyFilterReset) state.history.page = 1;
return { instanceFilterReset, historyFilterReset };
} }
function instanceQuerySignature() { function instanceQuerySignature() {
@ -883,7 +1084,7 @@ async function openInstanceDialog(instanceId = null) {
form.elements[name].value = instance?.[name] ?? ""; form.elements[name].value = instance?.[name] ?? "";
}); });
populateInstanceGroupSelect(state.catalog.groups, instance?.group_id || ""); populateInstanceGroupSelect(state.catalog.groups, instance?.group_id || "");
setInstanceGroupSelectOpen(false); setCustomSelectOpen(form.elements.group_id, false);
$("#instance-dialog-title").textContent = instance ? `编辑 ${instance.display_name}` : "新增实例"; $("#instance-dialog-title").textContent = instance ? `编辑 ${instance.display_name}` : "新增实例";
$("#delete-instance").classList.toggle("hidden", !instance); $("#delete-instance").classList.toggle("hidden", !instance);
$("#test-instance-dialog").classList.toggle("hidden", !instance); $("#test-instance-dialog").classList.toggle("hidden", !instance);
@ -1367,12 +1568,17 @@ function mapFormErrors(form, error) {
async function loadHistory(page = state.history.page) { async function loadHistory(page = state.history.page) {
state.history.page = page; state.history.page = page;
if (state.loading.has("history")) return; if (state.loading.has("history")) return;
const requestSignature = [page, state.history.instanceId, state.history.groupId, state.history.status].join("|"); let requestSignature = [page, state.history.instanceId, state.history.groupId, state.history.status].join("|");
state.loading.add("history"); state.loading.add("history");
const firstLoad = !state.loaded.has("history"); const firstLoad = !state.loaded.has("history");
if (firstLoad) $("#history-loading").classList.remove("hidden"); if (firstLoad) $("#history-loading").classList.remove("hidden");
try { try {
await ensureCatalogs(false); const catalogChanges = await ensureCatalogs(false);
if (catalogChanges.historyFilterReset) {
page = 1;
state.history.page = 1;
}
requestSignature = [page, state.history.instanceId, state.history.groupId, state.history.status].join("|");
const params = new URLSearchParams({ page: String(page), per_page: "20" }); const params = new URLSearchParams({ page: String(page), per_page: "20" });
if (state.history.instanceId) params.set("instance_id", state.history.instanceId); if (state.history.instanceId) params.set("instance_id", state.history.instanceId);
if (state.history.groupId) params.set("group_id", state.history.groupId); if (state.history.groupId) params.set("group_id", state.history.groupId);
@ -1598,19 +1804,6 @@ function bindEvents() {
$("#instances-prev").addEventListener("click", () => { state.instances.page = Math.max(1, state.instances.page - 1); loadInstances(); }); $("#instances-prev").addEventListener("click", () => { state.instances.page = Math.max(1, state.instances.page - 1); loadInstances(); });
$("#instances-next").addEventListener("click", () => { state.instances.page += 1; loadInstances(); }); $("#instances-next").addEventListener("click", () => { state.instances.page += 1; loadInstances(); });
$("#instance-form").addEventListener("submit", saveInstance); $("#instance-form").addEventListener("submit", saveInstance);
const instanceGroupSelect = $("#instance-group-select");
$(".custom-select-trigger", instanceGroupSelect).addEventListener("click", () => {
const isOpen = instanceGroupSelect.classList.contains("is-open");
setInstanceGroupSelectOpen(!isOpen, isOpen ? null : "selected");
});
$(".custom-select-menu", instanceGroupSelect).addEventListener("click", (event) => {
selectInstanceGroupOption(event.target.closest(".custom-select-option"));
});
instanceGroupSelect.addEventListener("keydown", handleInstanceGroupSelectKeydown);
document.addEventListener("click", (event) => {
if (!instanceGroupSelect.contains(event.target)) setInstanceGroupSelectOpen(false);
});
$("#instance-dialog").addEventListener("close", () => setInstanceGroupSelectOpen(false));
$("#test-instance-dialog").addEventListener("click", (event) => testInstance(state.selected.instanceId, event.currentTarget)); $("#test-instance-dialog").addEventListener("click", (event) => testInstance(state.selected.instanceId, event.currentTarget));
$("#delete-instance").addEventListener("click", () => requestDelete("instance", state.selected.instanceId)); $("#delete-instance").addEventListener("click", () => requestDelete("instance", state.selected.instanceId));
@ -1651,11 +1844,17 @@ function bindEvents() {
$("#password-form").addEventListener("submit", changePassword); $("#password-form").addEventListener("submit", changePassword);
$("#logout-button").addEventListener("click", logout); $("#logout-button").addEventListener("click", logout);
document.addEventListener("click", handleCustomSelectClick);
document.addEventListener("keydown", handleCustomSelectKeydown);
$$('[data-close-dialog]').forEach((button) => button.addEventListener("click", () => button.closest("dialog").close())); $$('[data-close-dialog]').forEach((button) => button.addEventListener("click", () => button.closest("dialog").close()));
$$("dialog").forEach((dialog) => dialog.addEventListener("click", (event) => { if (event.target === dialog) dialog.close(); })); $$("dialog").forEach((dialog) => {
dialog.addEventListener("click", (event) => { if (event.target === dialog) dialog.close(); });
dialog.addEventListener("close", closeCustomSelects);
});
} }
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
initializeCustomSelects();
refreshIcons(); refreshIcons();
bindEvents(); bindEvents();
bootstrap(); bootstrap();

View File

@ -150,8 +150,16 @@
</header> </header>
<div class="resource-toolbar" aria-label="实例筛选"> <div class="resource-toolbar" aria-label="实例筛选">
<label class="input-shell search-shell"><i data-lucide="search"></i><input id="instance-search" type="search" placeholder="搜索名称、实例或域名" aria-label="搜索实例"></label> <label class="input-shell search-shell"><i data-lucide="search"></i><input id="instance-search" type="search" placeholder="搜索名称、实例或域名" aria-label="搜索实例"></label>
<label class="select-shell"><i data-lucide="boxes"></i><select id="instance-group-filter" aria-label="按实例组筛选"><option value="">全部分组</option></select><i data-lucide="chevron-down"></i></label> <div class="custom-select custom-select-compact" data-custom-select>
<label class="select-shell"><i data-lucide="activity"></i><select id="instance-status-filter" aria-label="按状态筛选"><option value="">全部状态</option><option value="enabled">已启用</option><option value="disabled">已停用</option></select><i data-lucide="chevron-down"></i></label> <input id="instance-group-filter" type="hidden" value="">
<button class="custom-select-trigger" type="button" aria-haspopup="listbox" aria-expanded="false" aria-controls="instance-group-filter-options" aria-label="按实例组筛选"><i data-lucide="boxes"></i><span class="custom-select-value" data-custom-select-label>全部分组</span><i class="custom-select-chevron" data-lucide="chevron-down"></i></button>
<div id="instance-group-filter-options" class="custom-select-menu" data-custom-select-menu role="listbox" aria-label="按实例组筛选" hidden></div>
</div>
<div class="custom-select custom-select-compact" data-custom-select>
<input id="instance-status-filter" type="hidden" value="">
<button class="custom-select-trigger" type="button" aria-haspopup="listbox" aria-expanded="false" aria-controls="instance-status-filter-options" aria-label="按状态筛选"><i data-lucide="activity"></i><span class="custom-select-value" data-custom-select-label>全部状态</span><i class="custom-select-chevron" data-lucide="chevron-down"></i></button>
<div id="instance-status-filter-options" class="custom-select-menu" data-custom-select-menu role="listbox" aria-label="按状态筛选" hidden></div>
</div>
<button id="refresh-instances" class="icon-button" type="button" aria-label="刷新实例" title="刷新实例"><i data-lucide="refresh-cw"></i></button> <button id="refresh-instances" class="icon-button" type="button" aria-label="刷新实例" title="刷新实例"><i data-lucide="refresh-cw"></i></button>
</div> </div>
<div id="instances-loading" class="resource-loading hidden"><div class="skeleton skeleton-list"></div></div> <div id="instances-loading" class="resource-loading hidden"><div class="skeleton skeleton-list"></div></div>
@ -167,9 +175,13 @@
<div><p class="eyebrow">AUTOMATION</p><h1>实例组</h1></div> <div><p class="eyebrow">AUTOMATION</p><h1>实例组</h1></div>
<button class="button button-primary" data-create-group type="button" aria-label="新增分组" title="新增分组"><i data-lucide="plus"></i><span>新增分组</span></button> <button class="button button-primary" data-create-group type="button" aria-label="新增分组" title="新增分组"><i data-lucide="plus"></i><span>新增分组</span></button>
</header> </header>
<div class="resource-toolbar" aria-label="实例组筛选"> <div class="resource-toolbar group-toolbar" aria-label="实例组筛选">
<label class="input-shell search-shell"><i data-lucide="search"></i><input id="group-search" type="search" placeholder="搜索实例组" aria-label="搜索实例组"></label> <label class="input-shell search-shell"><i data-lucide="search"></i><input id="group-search" type="search" placeholder="搜索实例组" aria-label="搜索实例组"></label>
<label class="select-shell"><i data-lucide="timer-reset"></i><select id="group-status-filter" aria-label="按计划状态筛选"><option value="">全部状态</option><option value="enabled">计划已启用</option><option value="disabled">计划已暂停</option></select><i data-lucide="chevron-down"></i></label> <div class="custom-select custom-select-compact" data-custom-select>
<input id="group-status-filter" type="hidden" value="">
<button class="custom-select-trigger" type="button" aria-haspopup="listbox" aria-expanded="false" aria-controls="group-status-filter-options" aria-label="按计划状态筛选"><i data-lucide="timer-reset"></i><span class="custom-select-value" data-custom-select-label>全部状态</span><i class="custom-select-chevron" data-lucide="chevron-down"></i></button>
<div id="group-status-filter-options" class="custom-select-menu" data-custom-select-menu role="listbox" aria-label="按计划状态筛选" hidden></div>
</div>
<button id="refresh-groups" class="icon-button" type="button" aria-label="刷新分组" title="刷新分组"><i data-lucide="refresh-cw"></i></button> <button id="refresh-groups" class="icon-button" type="button" aria-label="刷新分组" title="刷新分组"><i data-lucide="refresh-cw"></i></button>
</div> </div>
<div id="groups-loading" class="resource-loading hidden"><div class="skeleton skeleton-list"></div></div> <div id="groups-loading" class="resource-loading hidden"><div class="skeleton skeleton-list"></div></div>
@ -182,9 +194,21 @@
<section id="view-history" class="view" data-view-panel="history"> <section id="view-history" class="view" data-view-panel="history">
<header class="page-header"><div><p class="eyebrow">AUDIT TRAIL</p><h1>轮换记录</h1></div><button id="refresh-history" class="icon-button" type="button" aria-label="刷新记录" title="刷新记录"><i data-lucide="refresh-cw"></i></button></header> <header class="page-header"><div><p class="eyebrow">AUDIT TRAIL</p><h1>轮换记录</h1></div><button id="refresh-history" class="icon-button" type="button" aria-label="刷新记录" title="刷新记录"><i data-lucide="refresh-cw"></i></button></header>
<div class="resource-toolbar history-toolbar" aria-label="记录筛选"> <div class="resource-toolbar history-toolbar" aria-label="记录筛选">
<label class="select-shell"><i data-lucide="server"></i><select id="history-instance-filter" aria-label="按实例筛选"><option value="">全部实例</option></select><i data-lucide="chevron-down"></i></label> <div class="custom-select custom-select-compact" data-custom-select>
<label class="select-shell"><i data-lucide="boxes"></i><select id="history-group-filter" aria-label="按实例组筛选"><option value="">全部分组</option></select><i data-lucide="chevron-down"></i></label> <input id="history-instance-filter" type="hidden" value="">
<label class="select-shell"><i data-lucide="activity"></i><select id="history-status-filter" aria-label="按结果筛选"><option value="">全部结果</option><option value="running">进行中</option><option value="needs_attention">待处理</option><option value="cleanup_pending">待清理</option><option value="succeeded">已成功</option><option value="failed">已失败</option></select><i data-lucide="chevron-down"></i></label> <button class="custom-select-trigger" type="button" aria-haspopup="listbox" aria-expanded="false" aria-controls="history-instance-filter-options" aria-label="按实例筛选"><i data-lucide="server"></i><span class="custom-select-value" data-custom-select-label>全部实例</span><i class="custom-select-chevron" data-lucide="chevron-down"></i></button>
<div id="history-instance-filter-options" class="custom-select-menu" data-custom-select-menu role="listbox" aria-label="按实例筛选" hidden></div>
</div>
<div class="custom-select custom-select-compact" data-custom-select>
<input id="history-group-filter" type="hidden" value="">
<button class="custom-select-trigger" type="button" aria-haspopup="listbox" aria-expanded="false" aria-controls="history-group-filter-options" aria-label="按实例组筛选"><i data-lucide="boxes"></i><span class="custom-select-value" data-custom-select-label>全部分组</span><i class="custom-select-chevron" data-lucide="chevron-down"></i></button>
<div id="history-group-filter-options" class="custom-select-menu" data-custom-select-menu role="listbox" aria-label="按实例组筛选" hidden></div>
</div>
<div class="custom-select custom-select-compact" data-custom-select>
<input id="history-status-filter" type="hidden" value="">
<button class="custom-select-trigger" type="button" aria-haspopup="listbox" aria-expanded="false" aria-controls="history-status-filter-options" aria-label="按结果筛选"><i data-lucide="activity"></i><span class="custom-select-value" data-custom-select-label>全部结果</span><i class="custom-select-chevron" data-lucide="chevron-down"></i></button>
<div id="history-status-filter-options" class="custom-select-menu" data-custom-select-menu role="listbox" aria-label="按结果筛选" hidden></div>
</div>
</div> </div>
<div id="history-loading" class="resource-loading hidden"><div class="skeleton skeleton-list"></div></div> <div id="history-loading" class="resource-loading hidden"><div class="skeleton skeleton-list"></div></div>
<section class="history-summary" aria-label="记录摘要"><div><span>全部任务</span><strong id="history-total">0</strong></div><div><span>成功率</span><strong id="history-rate">--</strong></div><div><span>最近成功</span><strong id="history-latest">--</strong></div></section> <section class="history-summary" aria-label="记录摘要"><div><span>全部任务</span><strong id="history-total">0</strong></div><div><span>成功率</span><strong id="history-rate">--</strong></div><div><span>最近成功</span><strong id="history-latest">--</strong></div></section>
@ -210,14 +234,14 @@
<label class="field"><span>显示名称</span><span class="input-shell"><i data-lucide="tag"></i><input name="display_name" maxlength="80" placeholder="新加坡代理 01" required></span><small class="field-error" data-error-for="display_name"></small></label> <label class="field"><span>显示名称</span><span class="input-shell"><i data-lucide="tag"></i><input name="display_name" maxlength="80" placeholder="新加坡代理 01" required></span><small class="field-error" data-error-for="display_name"></small></label>
<div class="field"> <div class="field">
<span id="instance-group-label">所属实例组</span> <span id="instance-group-label">所属实例组</span>
<div id="instance-group-select" class="custom-select"> <div id="instance-group-select" class="custom-select custom-select-rich" data-custom-select>
<input name="group_id" type="hidden" value=""> <input name="group_id" type="hidden" value="">
<button class="custom-select-trigger" type="button" aria-haspopup="listbox" aria-expanded="false" aria-controls="instance-group-options" aria-labelledby="instance-group-label instance-group-select-value"> <button class="custom-select-trigger" type="button" aria-haspopup="listbox" aria-expanded="false" aria-controls="instance-group-options" aria-labelledby="instance-group-label instance-group-select-value">
<i data-lucide="boxes"></i> <i data-lucide="boxes"></i>
<span id="instance-group-select-value">未分组</span> <span id="instance-group-select-value" class="custom-select-value" data-custom-select-label>未分组</span>
<i class="custom-select-chevron" data-lucide="chevron-down"></i> <i class="custom-select-chevron" data-lucide="chevron-down"></i>
</button> </button>
<div id="instance-group-options" class="custom-select-menu" role="listbox" aria-labelledby="instance-group-label" hidden></div> <div id="instance-group-options" class="custom-select-menu" data-custom-select-menu role="listbox" aria-labelledby="instance-group-label" hidden></div>
</div> </div>
<small class="field-error" data-error-for="group_id"></small> <small class="field-error" data-error-for="group_id"></small>
</div> </div>

View File

@ -1181,16 +1181,6 @@ input:checked + .toggle::after { transform: translateX(16px); }
} }
/* Multi-instance control plane */ /* Multi-instance control plane */
select {
font: inherit;
letter-spacing: 0;
}
select:focus-visible {
outline: 2px solid rgba(71, 118, 231, 0.62);
outline-offset: 2px;
}
.sidebar-action { .sidebar-action {
display: flex; display: flex;
width: 100%; width: 100%;
@ -1355,61 +1345,14 @@ select:focus-visible {
grid-template-columns: repeat(3, minmax(150px, 1fr)); grid-template-columns: repeat(3, minmax(150px, 1fr));
} }
.group-toolbar {
grid-template-columns: minmax(260px, 1fr) minmax(150px, 190px) 40px;
}
.search-shell { .search-shell {
max-width: 460px; max-width: 460px;
} }
.select-shell {
display: grid;
width: 100%;
min-height: 44px;
grid-template-columns: 16px minmax(0, 1fr) 14px;
align-items: center;
gap: 8px;
padding: 0 11px;
border: 1px solid var(--line);
border-radius: 7px;
background: rgba(255, 255, 255, 0.72);
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.select-shell:focus-within {
border-color: rgba(71, 118, 231, 0.5);
background: #fff;
box-shadow: 0 0 0 3px rgba(71, 118, 231, 0.1);
}
.select-shell svg {
width: 15px;
height: 15px;
color: var(--ink-faint);
}
.select-shell select {
width: 100%;
min-width: 0;
height: 42px;
padding: 0;
border: 0;
outline: 0;
appearance: none;
color: var(--ink);
background: transparent;
font-size: 11px;
font-weight: 600;
text-overflow: ellipsis;
}
.field-select {
min-height: 44px;
}
.field-select select {
height: 42px;
font-size: 13px;
font-weight: 500;
}
.custom-select { .custom-select {
position: relative; position: relative;
width: 100%; width: 100%;
@ -1457,7 +1400,7 @@ select:focus-visible {
color: var(--ink-faint); color: var(--ink-faint);
} }
#instance-group-select-value { .custom-select-value {
overflow: hidden; overflow: hidden;
font-size: 13px; font-size: 13px;
font-weight: 550; font-weight: 550;
@ -1492,6 +1435,8 @@ select:focus-visible {
border-radius: 8px; border-radius: 8px;
background: rgba(251, 252, 250, 0.96); background: rgba(251, 252, 250, 0.96);
box-shadow: 0 18px 48px rgba(33, 42, 33, 0.16); box-shadow: 0 18px 48px rgba(33, 42, 33, 0.16);
scrollbar-color: rgba(29, 33, 29, 0.22) transparent;
scrollbar-width: thin;
backdrop-filter: blur(22px) saturate(1.12); backdrop-filter: blur(22px) saturate(1.12);
-webkit-backdrop-filter: blur(22px) saturate(1.12); -webkit-backdrop-filter: blur(22px) saturate(1.12);
animation: custom-select-enter 150ms ease both; animation: custom-select-enter 150ms ease both;
@ -1501,6 +1446,19 @@ select:focus-visible {
display: none; display: none;
} }
.custom-select-menu::-webkit-scrollbar {
width: 6px;
}
.custom-select-menu::-webkit-scrollbar-thumb {
border-radius: 3px;
background: rgba(29, 33, 29, 0.22);
}
.custom-select-menu::-webkit-scrollbar-track {
background: transparent;
}
@keyframes custom-select-enter { @keyframes custom-select-enter {
from { opacity: 0; transform: translateY(-4px) scale(0.99); } from { opacity: 0; transform: translateY(-4px) scale(0.99); }
to { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 1; transform: translateY(0) scale(1); }
@ -1544,11 +1502,31 @@ select:focus-visible {
background: rgba(29, 33, 29, 0.055); background: rgba(29, 33, 29, 0.055);
} }
.custom-select-option-icon.is-enabled { .custom-select-option-icon.is-success {
color: #4f6c13; color: #4f6c13;
background: rgba(184, 235, 54, 0.24); background: rgba(184, 235, 54, 0.24);
} }
.custom-select-option-icon.is-info {
color: #3156ad;
background: #dfe8ff;
}
.custom-select-option-icon.is-warning {
color: #795410;
background: #fff0ca;
}
.custom-select-option-icon.is-danger {
color: #9a352c;
background: rgba(217, 79, 67, 0.13);
}
.custom-select-option-icon.is-muted {
color: var(--ink-faint);
background: rgba(29, 33, 29, 0.055);
}
.custom-select-option-icon svg, .custom-select-option-icon svg,
.custom-select-option-check svg { .custom-select-option-check svg {
width: 15px; width: 15px;
@ -1591,6 +1569,46 @@ select:focus-visible {
transform: scale(1); transform: scale(1);
} }
.custom-select-compact .custom-select-trigger {
grid-template-columns: 15px minmax(0, 1fr) 14px;
gap: 8px;
padding: 0 11px;
}
.custom-select-compact .custom-select-value {
font-size: 11px;
font-weight: 620;
}
.custom-select-compact .custom-select-menu {
min-width: max(100%, 180px);
max-height: min(260px, calc(100dvh - 150px));
gap: 2px;
padding: 5px;
}
.custom-select-compact .custom-select-option {
min-height: 42px;
grid-template-columns: 28px minmax(0, 1fr) 15px;
gap: 8px;
padding: 5px 7px;
}
.custom-select-compact .custom-select-option-icon {
width: 28px;
height: 28px;
border-radius: 6px;
}
.custom-select-compact .custom-select-option-copy strong {
font-size: 11px;
font-weight: 650;
}
.custom-select-compact .custom-select-option-copy small {
display: none;
}
.resource-loading { .resource-loading {
margin-bottom: 13px; margin-bottom: 13px;
} }
@ -2013,6 +2031,14 @@ select:focus-visible {
.history-toolbar { .history-toolbar {
grid-template-columns: repeat(2, minmax(160px, 1fr)); grid-template-columns: repeat(2, minmax(160px, 1fr));
} }
.group-toolbar {
grid-template-columns: minmax(230px, 1fr) minmax(140px, 170px) 40px;
}
.history-toolbar .custom-select:last-child {
grid-column: 1 / -1;
}
} }
@media (max-width: 760px) { @media (max-width: 760px) {
@ -2020,11 +2046,19 @@ select:focus-visible {
grid-template-columns: repeat(2, minmax(0, 1fr)) 40px; grid-template-columns: repeat(2, minmax(0, 1fr)) 40px;
} }
.resource-toolbar.group-toolbar {
grid-template-columns: minmax(0, 1fr) 40px;
}
.resource-toolbar .search-shell { .resource-toolbar .search-shell {
max-width: none; max-width: none;
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.custom-select-compact .custom-select-menu {
min-width: 100%;
}
.history-toolbar { .history-toolbar {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@ -2072,7 +2106,11 @@ select:focus-visible {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 40px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 40px;
} }
.resource-toolbar .select-shell { .resource-toolbar.group-toolbar {
grid-template-columns: minmax(0, 1fr) 40px;
}
.resource-toolbar .custom-select {
min-width: 0; min-width: 0;
} }

View File

@ -0,0 +1,186 @@
from __future__ import annotations
import shutil
import subprocess
from dataclasses import dataclass, field
from html.parser import HTMLParser
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
INDEX_HTML = PROJECT_ROOT / "app" / "static" / "index.html"
APP_JS = PROJECT_ROOT / "app" / "static" / "app.js"
FILTER_INPUT_IDS = {
"instance-group-filter",
"instance-status-filter",
"group-status-filter",
"history-instance-filter",
"history-group-filter",
"history-status-filter",
}
VOID_ELEMENTS = {
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
}
@dataclass
class HtmlNode:
tag: str
attrs: dict[str, str | None]
children: list[HtmlNode] = field(default_factory=list)
class HtmlTreeParser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.root = HtmlNode("document", {})
self._stack = [self.root]
def handle_starttag(
self, tag: str, attrs: list[tuple[str, str | None]]
) -> None:
self._append_node(tag, attrs, push=tag not in VOID_ELEMENTS)
def handle_startendtag(
self, tag: str, attrs: list[tuple[str, str | None]]
) -> None:
self._append_node(tag, attrs, push=False)
def handle_endtag(self, tag: str) -> None:
for index in range(len(self._stack) - 1, 0, -1):
if self._stack[index].tag == tag:
del self._stack[index:]
return
def _append_node(
self,
tag: str,
attrs: list[tuple[str, str | None]],
*,
push: bool,
) -> None:
node = HtmlNode(tag, dict(attrs))
self._stack[-1].children.append(node)
if push:
self._stack.append(node)
def parse_index() -> HtmlNode:
parser = HtmlTreeParser()
parser.feed(INDEX_HTML.read_text(encoding="utf-8"))
parser.close()
return parser.root
def descendants(node: HtmlNode) -> list[HtmlNode]:
result: list[HtmlNode] = []
pending = list(reversed(node.children))
while pending:
current = pending.pop()
result.append(current)
pending.extend(reversed(current.children))
return result
def has_class(node: HtmlNode, class_name: str) -> bool:
return class_name in (node.attrs.get("class") or "").split()
def test_all_native_selects_are_replaced_by_custom_selects() -> None:
nodes = descendants(parse_index())
assert not [node for node in nodes if node.tag == "select"]
assert len([node for node in nodes if "data-custom-select" in node.attrs]) == 7
def test_every_custom_select_has_scoped_hidden_input_and_valid_aria() -> None:
nodes = descendants(parse_index())
roots = [node for node in nodes if "data-custom-select" in node.attrs]
controlled_ids: list[str] = []
hidden_inputs: list[HtmlNode] = []
for index, root in enumerate(roots, start=1):
scoped_nodes = descendants(root)
scoped_hidden = [
node
for node in scoped_nodes
if node.tag == "input"
and (node.attrs.get("type") or "").lower() == "hidden"
]
triggers = [
node
for node in scoped_nodes
if node.tag == "button" and has_class(node, "custom-select-trigger")
]
listboxes = [
node for node in scoped_nodes if node.attrs.get("role") == "listbox"
]
description = root.attrs.get("id") or f"custom select #{index}"
assert len(scoped_hidden) == 1, description
assert len(triggers) == 1, description
assert len(listboxes) == 1, description
hidden_inputs.extend(scoped_hidden)
trigger = triggers[0]
listbox = listboxes[0]
controls = trigger.attrs.get("aria-controls")
listbox_id = listbox.attrs.get("id")
assert trigger.attrs.get("aria-haspopup") == "listbox", description
assert trigger.attrs.get("aria-expanded") in {"true", "false"}, description
assert controls, description
assert controls == listbox_id, description
assert "data-custom-select-menu" in listbox.attrs, description
controlled_ids.append(controls)
assert len(controlled_ids) == len(set(controlled_ids)) == 7
assert {
node.attrs["id"] for node in hidden_inputs if node.attrs.get("id")
} == FILTER_INPUT_IDS
assert len(
[node for node in hidden_inputs if node.attrs.get("name") == "group_id"]
) == 1
def test_shared_option_template_keeps_listbox_option_aria_contract() -> None:
source = APP_JS.read_text(encoding="utf-8")
marker = "function customSelectOptionMarkup("
start = source.index(marker)
end = source.index("\nfunction ", start + len(marker))
function_source = source[start:end]
assert source.count(marker) == 1
assert source.count("customSelectOptionMarkup(") >= 2
assert 'role="option"' in function_source
assert 'aria-selected="${selected}"' in function_source
assert 'tabindex="-1"' in function_source
def test_app_javascript_has_valid_syntax() -> None:
node = shutil.which("node")
assert node is not None, "Node.js is required to validate app/static/app.js"
result = subprocess.run(
[node, "--check", str(APP_JS)],
capture_output=True,
text=True,
timeout=30,
check=False,
)
assert result.returncode == 0, result.stderr or result.stdout