From a7762aac6371b9ee183330ddfc0b261697f6b6c4 Mon Sep 17 00:00:00 2001 From: "3127647737@qq.com" <3127647737@qq.com> Date: Fri, 17 Jul 2026 15:43:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=E5=85=A8=E7=AB=99?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E4=B8=8B=E6=8B=89=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/static/app.js | 343 +++++++++++++++++++++++++++------- app/static/index.html | 44 ++++- app/static/styles.css | 166 +++++++++------- tests/test_static_frontend.py | 186 ++++++++++++++++++ 4 files changed, 593 insertions(+), 146 deletions(-) create mode 100644 tests/test_static_frontend.py diff --git a/app/static/app.js b/app/static/app.js index f616700..3e1f950 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -336,6 +336,7 @@ function resetPrivateState() { state.catalog = { instances: [], groups: [], instancesLoaded: false, groupsLoaded: false }; state.selected = { instanceId: null, groupId: null, runId: null }; state.groupMemberDraft = new Set(); + resetCustomSelectValues(); state.pendingAction = null; state.pendingDelete = null; state.pending.clear(); @@ -640,102 +641,218 @@ async function fetchGroupCatalog(force = false) { async function ensureCatalogs(force = false) { await Promise.all([fetchInstanceCatalog(force), fetchGroupCatalog(force)]); - populateResourceSelects(); + return populateResourceSelects(); } -function populateSelect(select, items, firstLabel, selectedValue = null) { - if (!select) return; - const previous = selectedValue === null ? select.value : String(selectedValue || ""); - select.innerHTML = `${items.map((item) => ``).join("")}`; - if ($(`option[value="${CSS.escape(previous)}"]`, select)) select.value = previous; -} +const INSTANCE_STATUS_OPTIONS = [ + { value: "enabled", label: "已启用", icon: "check-circle-2", tone: "success" }, + { value: "disabled", label: "已停用", icon: "server-off", tone: "muted" }, +]; -function instanceGroupSelectElements() { - const root = $("#instance-group-select"); +const GROUP_STATUS_OPTIONS = [ + { 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; return { root, - input: $('input[name="group_id"]', root), + input: $('input[type="hidden"]', root), trigger: $(".custom-select-trigger", root), - label: $("#instance-group-select-value", root), - menu: $(".custom-select-menu", root), + label: $("[data-custom-select-label]", root), + menu: $("[data-custom-select-menu]", root), }; } -function instanceGroupOptionMarkup(group, selectedValue) { - const value = group ? String(group.id) : ""; - const label = group?.name || "未分组"; +function customSelectOptionMarkup(option, selectedValue) { + const value = String(option.value ?? option.id ?? ""); + 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 detail = group - ? (group.enabled ? `${Number(group.interval_minutes || 60)} 分钟周期` : "计划已停用") - : "不跟随组计划"; return ``; } -function syncInstanceGroupSelection(value, { emitChange = false } = {}) { - const elements = instanceGroupSelectElements(); - if (!elements) return; +function syncCustomSelectSelection(target, value, { emitChange = false } = {}) { + const elements = customSelectElements(target); + if (!elements) return ""; const requested = String(value || ""); const options = $$(".custom-select-option", elements.menu); const selected = options.find((option) => option.dataset.value === requested) || options[0]; const resolved = selected?.dataset.value || ""; + const label = selected?.dataset.label || ""; 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))); if (emitChange) elements.input.dispatchEvent(new Event("change", { bubbles: true })); + return resolved; } -function populateInstanceGroupSelect(items, selectedValue = null) { - const elements = instanceGroupSelectElements(); - if (!elements) return; +function populateCustomSelect(target, options, selectedValue = null) { + const elements = customSelectElements(target); + if (!elements) return ""; const previous = selectedValue === null ? elements.input.value : String(selectedValue || ""); - elements.menu.innerHTML = [ - instanceGroupOptionMarkup(null, previous), - ...items.map((group) => instanceGroupOptionMarkup(group, previous)), - ].join(""); - syncInstanceGroupSelection(previous); + const focusedOption = elements.root.contains(document.activeElement) + ? document.activeElement.closest?.(".custom-select-option") + : null; + const focusedValue = focusedOption?.dataset.value; + elements.menu.innerHTML = options + .map((option) => customSelectOptionMarkup(option, previous)) + .join(""); + const resolved = syncCustomSelectSelection(elements.root, previous); 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) { - const elements = instanceGroupSelectElements(); +function setCustomSelectOpen(target, open, focusTarget = null) { + const elements = customSelectElements(target); 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.trigger.setAttribute("aria-expanded", String(open)); elements.menu.hidden = !open; + if (!open) { + delete elements.root.dataset.typeaheadQuery; + delete elements.root.dataset.typeaheadAt; + } if (!open || !focusTarget) return; window.requestAnimationFrame(() => { const options = $$(".custom-select-option", elements.menu); - const target = focusTarget === "last" + const option = focusTarget === "last" ? options.at(-1) - : options.find((option) => option.getAttribute("aria-selected") === "true") || options[0]; - target?.focus(); + : focusTarget === "first" + ? options[0] + : options.find((item) => item.getAttribute("aria-selected") === "true") || options[0]; + option?.focus(); }); } -function selectInstanceGroupOption(option) { - if (!option) return; - syncInstanceGroupSelection(option.dataset.value, { emitChange: true }); - setInstanceGroupSelectOpen(false); - instanceGroupSelectElements()?.trigger.focus(); +function closeCustomSelects() { + $$('[data-custom-select].is-open').forEach((root) => setCustomSelectOpen(root, false)); } -function handleInstanceGroupSelectKeydown(event) { - const elements = instanceGroupSelectElements(); - if (!elements) return; - const options = $$(".custom-select-option", elements.menu); - if (event.target === elements.trigger && ["ArrowDown", "ArrowUp"].includes(event.key)) { - event.preventDefault(); - setInstanceGroupSelectOpen(true, event.key === "ArrowUp" ? "last" : "selected"); +function selectCustomSelectOption(option) { + const elements = customSelectElements(option); + if (!elements || !option) return; + syncCustomSelectSelection(elements.root, option.dataset.value, { emitChange: true }); + setCustomSelectOpen(elements.root, false); + elements.trigger.focus(); +} + +function handleCustomSelectClick(event) { + const option = event.target.closest?.(".custom-select-option"); + if (option) { + selectCustomSelectOption(option); 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); if (currentIndex < 0) { - if (event.key === "Escape") setInstanceGroupSelectOpen(false); + if (event.key === "Escape") setCustomSelectOpen(root, false); return; } if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) { @@ -746,20 +863,104 @@ function handleInstanceGroupSelectKeydown(event) { if (event.key === "Home") nextIndex = 0; if (event.key === "End") nextIndex = options.length - 1; 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") { event.preventDefault(); - setInstanceGroupSelectOpen(false); + setCustomSelectOpen(root, false); elements.trigger.focus(); } 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() { - populateSelect($("#instance-group-filter"), state.catalog.groups, "全部分组", state.instances.groupId); - populateSelect($("#history-instance-filter"), state.catalog.instances, "全部实例", state.history.instanceId); - populateSelect($("#history-group-filter"), state.catalog.groups, "全部分组", state.history.groupId); + const previousInstanceGroupId = state.instances.groupId; + const previousHistoryInstanceId = state.history.instanceId; + 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); + 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() { @@ -883,7 +1084,7 @@ async function openInstanceDialog(instanceId = null) { form.elements[name].value = instance?.[name] ?? ""; }); populateInstanceGroupSelect(state.catalog.groups, instance?.group_id || ""); - setInstanceGroupSelectOpen(false); + setCustomSelectOpen(form.elements.group_id, false); $("#instance-dialog-title").textContent = instance ? `编辑 ${instance.display_name}` : "新增实例"; $("#delete-instance").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) { state.history.page = page; 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"); const firstLoad = !state.loaded.has("history"); if (firstLoad) $("#history-loading").classList.remove("hidden"); 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" }); if (state.history.instanceId) params.set("instance_id", state.history.instanceId); 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-next").addEventListener("click", () => { state.instances.page += 1; loadInstances(); }); $("#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)); $("#delete-instance").addEventListener("click", () => requestDelete("instance", state.selected.instanceId)); @@ -1651,11 +1844,17 @@ function bindEvents() { $("#password-form").addEventListener("submit", changePassword); $("#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())); - $$("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", () => { + initializeCustomSelects(); refreshIcons(); bindEvents(); bootstrap(); diff --git a/app/static/index.html b/app/static/index.html index 29c7f52..d695639 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -150,8 +150,16 @@
@@ -167,9 +175,13 @@AUTOMATION