feat: 统一全站自定义下拉组件
This commit is contained in:
parent
8cbf055d98
commit
a7762aac63
@ -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();
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
186
tests/test_static_frontend.py
Normal file
186
tests/test_static_frontend.py
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user