Initial commit
This commit is contained in:
65
web/static/js/api.js
Normal file
65
web/static/js/api.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// 全局Promise缓存,现在被封装在模块作用域内,不再污染全局
|
||||
const apiPromiseCache = new Map();
|
||||
|
||||
/**
|
||||
* 具备缓存、认证处理和自动JSON解析的apiFetch函数
|
||||
*/
|
||||
export async function apiFetch(url, options = {}) {
|
||||
// [修正] 不再使用 window.apiPromiseCache
|
||||
if (apiPromiseCache.has(url) && !options.noCache) {
|
||||
return apiPromiseCache.get(url);
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('bearerToken');
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const requestPromise = fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
}).then(response => {
|
||||
if (response.status === 401) {
|
||||
apiPromiseCache.delete(url);
|
||||
localStorage.removeItem('bearerToken');
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login?error=会话已过期,请重新登录。';
|
||||
}
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`API请求失败: ${response.status}`);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
|
||||
apiPromiseCache.set(url, requestPromise);
|
||||
return requestPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更安全的apiFetch包装器,直接返回解析后的JSON数据
|
||||
*/
|
||||
export async function apiFetchJson(url, options = {}) {
|
||||
const response = await apiFetch(url, options);
|
||||
return response.clone().json();
|
||||
}
|
||||
|
||||
export async function fetchVersionInfo() {
|
||||
console.log("Placeholder for fetchVersionInfo function.");
|
||||
// 示例: 如果您有一个/version的API端点
|
||||
/*
|
||||
fetch('/version')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
const versionElement = document.getElementById('system-version');
|
||||
if (versionElement) {
|
||||
versionElement.textContent = data.version || 'N/A';
|
||||
}
|
||||
});
|
||||
*/
|
||||
}
|
||||
4163
web/static/js/app.js
Normal file
4163
web/static/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
817
web/static/js/chunk-EZAP7GR4.js
Normal file
817
web/static/js/chunk-EZAP7GR4.js
Normal file
@@ -0,0 +1,817 @@
|
||||
// frontend/js/components/customSelect.js
|
||||
var CustomSelect = class _CustomSelect {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
this.trigger = this.container.querySelector(".custom-select-trigger");
|
||||
this.panel = this.container.querySelector(".custom-select-panel");
|
||||
if (!this.trigger || !this.panel) {
|
||||
console.warn("CustomSelect cannot initialize: missing .custom-select-trigger or .custom-select-panel.", this.container);
|
||||
return;
|
||||
}
|
||||
this.nativeSelect = this.container.querySelector("select");
|
||||
this.triggerText = this.trigger.querySelector("span");
|
||||
this.template = this.panel.querySelector(".custom-select-option-template");
|
||||
if (typeof _CustomSelect.openInstance === "undefined") {
|
||||
_CustomSelect.openInstance = null;
|
||||
_CustomSelect.initGlobalListener();
|
||||
}
|
||||
if (this.nativeSelect) {
|
||||
this.generateOptions();
|
||||
this.updateTriggerText();
|
||||
}
|
||||
this.bindEvents();
|
||||
}
|
||||
static initGlobalListener() {
|
||||
document.addEventListener("click", (event) => {
|
||||
if (_CustomSelect.openInstance && !_CustomSelect.openInstance.container.contains(event.target)) {
|
||||
_CustomSelect.openInstance.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
generateOptions() {
|
||||
this.panel.querySelectorAll(":scope > *:not(.custom-select-option-template)").forEach((child) => child.remove());
|
||||
Array.from(this.nativeSelect.options).forEach((option) => {
|
||||
let item;
|
||||
if (this.template) {
|
||||
item = this.template.cloneNode(true);
|
||||
item.classList.remove("custom-select-option-template");
|
||||
item.removeAttribute("hidden");
|
||||
} else {
|
||||
item = document.createElement("a");
|
||||
item.href = "#";
|
||||
item.className = "block px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-600";
|
||||
}
|
||||
item.classList.add("custom-select-option");
|
||||
item.textContent = option.textContent;
|
||||
item.dataset.value = option.value;
|
||||
if (option.selected) {
|
||||
item.classList.add("is-selected");
|
||||
}
|
||||
this.panel.appendChild(item);
|
||||
});
|
||||
}
|
||||
bindEvents() {
|
||||
this.trigger.addEventListener("click", (event) => {
|
||||
if (this.trigger.classList.contains("is-disabled")) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
if (_CustomSelect.openInstance && _CustomSelect.openInstance !== this) {
|
||||
_CustomSelect.openInstance.close();
|
||||
}
|
||||
this.toggle();
|
||||
});
|
||||
if (this.nativeSelect) {
|
||||
this.panel.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
const option = event.target.closest(".custom-select-option");
|
||||
if (option) {
|
||||
this.selectOption(option);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
selectOption(optionEl) {
|
||||
const selectedValue = optionEl.dataset.value;
|
||||
if (this.nativeSelect.value !== selectedValue) {
|
||||
this.nativeSelect.value = selectedValue;
|
||||
this.nativeSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
this.updateTriggerText();
|
||||
this.panel.querySelectorAll(".custom-select-option").forEach((el) => el.classList.remove("is-selected"));
|
||||
optionEl.classList.add("is-selected");
|
||||
this.close();
|
||||
}
|
||||
updateTriggerText() {
|
||||
if (!this.nativeSelect || !this.triggerText) return;
|
||||
const selectedOption = this.nativeSelect.options[this.nativeSelect.selectedIndex];
|
||||
if (selectedOption) {
|
||||
this.triggerText.textContent = selectedOption.textContent;
|
||||
}
|
||||
}
|
||||
toggle() {
|
||||
this.panel.classList.toggle("hidden");
|
||||
if (this.panel.classList.contains("hidden")) {
|
||||
if (_CustomSelect.openInstance === this) {
|
||||
_CustomSelect.openInstance = null;
|
||||
}
|
||||
} else {
|
||||
_CustomSelect.openInstance = this;
|
||||
}
|
||||
}
|
||||
open() {
|
||||
this.panel.classList.remove("hidden");
|
||||
_CustomSelect.openInstance = this;
|
||||
}
|
||||
close() {
|
||||
this.panel.classList.add("hidden");
|
||||
if (_CustomSelect.openInstance === this) {
|
||||
_CustomSelect.openInstance = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// frontend/js/components/ui.js
|
||||
var ModalManager = class {
|
||||
/**
|
||||
* Shows a generic modal by its ID.
|
||||
* @param {string} modalId The ID of the modal element to show.
|
||||
*/
|
||||
show(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.classList.remove("hidden");
|
||||
} else {
|
||||
console.error(`Modal with ID "${modalId}" not found.`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Hides a generic modal by its ID.
|
||||
* @param {string} modalId The ID of the modal element to hide.
|
||||
*/
|
||||
hide(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.classList.add("hidden");
|
||||
} else {
|
||||
console.error(`Modal with ID "${modalId}" not found.`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Shows a confirmation dialog. This is a versatile method for 'Are you sure?' style prompts.
|
||||
* It dynamically sets the title, message, and confirm action for a generic confirmation modal.
|
||||
* @param {object} options - The options for the confirmation modal.
|
||||
* @param {string} options.modalId - The ID of the confirmation modal element (e.g., 'resetModal', 'deleteConfirmModal').
|
||||
* @param {string} options.title - The title to display in the modal header.
|
||||
* @param {string} options.message - The message to display in the modal body. Can contain HTML.
|
||||
* @param {function} options.onConfirm - The callback function to execute when the confirm button is clicked.
|
||||
* @param {boolean} [options.disableConfirm=false] - Whether the confirm button should be initially disabled.
|
||||
*/
|
||||
showConfirm({ modalId, title, message, onConfirm, disableConfirm = false }) {
|
||||
const modalElement = document.getElementById(modalId);
|
||||
if (!modalElement) {
|
||||
console.error(`Confirmation modal with ID "${modalId}" not found.`);
|
||||
return;
|
||||
}
|
||||
const titleElement = modalElement.querySelector('[id$="ModalTitle"]');
|
||||
const messageElement = modalElement.querySelector('[id$="ModalMessage"]');
|
||||
const confirmButton = modalElement.querySelector('[id^="confirm"]');
|
||||
if (!titleElement || !messageElement || !confirmButton) {
|
||||
console.error(`Modal "${modalId}" is missing required child elements (title, message, or confirm button).`);
|
||||
return;
|
||||
}
|
||||
titleElement.textContent = title;
|
||||
messageElement.innerHTML = message;
|
||||
confirmButton.disabled = disableConfirm;
|
||||
const newConfirmButton = confirmButton.cloneNode(true);
|
||||
confirmButton.parentNode.replaceChild(newConfirmButton, confirmButton);
|
||||
newConfirmButton.onclick = () => onConfirm();
|
||||
this.show(modalId);
|
||||
}
|
||||
/**
|
||||
* Shows a result modal to indicate the outcome of an operation (success or failure).
|
||||
* @param {boolean} success - If true, displays a success icon and title; otherwise, shows failure indicators.
|
||||
* @param {string|Node} message - The message to display. Can be a simple string or a complex DOM Node for rich content.
|
||||
* @param {boolean} [autoReload=false] - If true, the page will automatically reload when the modal is closed.
|
||||
*/
|
||||
showResult(success, message, autoReload = false) {
|
||||
const modalElement = document.getElementById("resultModal");
|
||||
if (!modalElement) {
|
||||
console.error("Result modal with ID 'resultModal' not found.");
|
||||
return;
|
||||
}
|
||||
const titleElement = document.getElementById("resultModalTitle");
|
||||
const messageElement = document.getElementById("resultModalMessage");
|
||||
const iconElement = document.getElementById("resultIcon");
|
||||
const confirmButton = document.getElementById("resultModalConfirmBtn");
|
||||
if (!titleElement || !messageElement || !iconElement || !confirmButton) {
|
||||
console.error("Result modal is missing required child elements.");
|
||||
return;
|
||||
}
|
||||
titleElement.textContent = success ? "\u64CD\u4F5C\u6210\u529F" : "\u64CD\u4F5C\u5931\u8D25";
|
||||
if (success) {
|
||||
iconElement.innerHTML = '<i class="fas fa-check-circle text-success-500"></i>';
|
||||
iconElement.className = "text-6xl mb-3 text-success-500";
|
||||
} else {
|
||||
iconElement.innerHTML = '<i class="fas fa-times-circle text-danger-500"></i>';
|
||||
iconElement.className = "text-6xl mb-3 text-danger-500";
|
||||
}
|
||||
messageElement.innerHTML = "";
|
||||
if (typeof message === "string") {
|
||||
const messageDiv = document.createElement("div");
|
||||
messageDiv.innerText = message;
|
||||
messageElement.appendChild(messageDiv);
|
||||
} else if (message instanceof Node) {
|
||||
messageElement.appendChild(message);
|
||||
} else {
|
||||
const messageDiv = document.createElement("div");
|
||||
messageDiv.innerText = String(message);
|
||||
messageElement.appendChild(messageDiv);
|
||||
}
|
||||
confirmButton.onclick = () => this.closeResult(autoReload);
|
||||
this.show("resultModal");
|
||||
}
|
||||
/**
|
||||
* Closes the result modal.
|
||||
* @param {boolean} [reload=false] - If true, reloads the page after closing the modal.
|
||||
*/
|
||||
closeResult(reload = false) {
|
||||
this.hide("resultModal");
|
||||
if (reload) {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Shows and initializes the progress modal for long-running operations.
|
||||
* @param {string} title - The title to display for the progress modal.
|
||||
*/
|
||||
showProgress(title) {
|
||||
const modal = document.getElementById("progressModal");
|
||||
if (!modal) {
|
||||
console.error("Progress modal with ID 'progressModal' not found.");
|
||||
return;
|
||||
}
|
||||
const titleElement = document.getElementById("progressModalTitle");
|
||||
const statusText = document.getElementById("progressStatusText");
|
||||
const progressBar = document.getElementById("progressBar");
|
||||
const progressPercentage = document.getElementById("progressPercentage");
|
||||
const progressLog = document.getElementById("progressLog");
|
||||
const closeButton = document.getElementById("progressModalCloseBtn");
|
||||
const closeIcon = document.getElementById("closeProgressModalBtn");
|
||||
if (!titleElement || !statusText || !progressBar || !progressPercentage || !progressLog || !closeButton || !closeIcon) {
|
||||
console.error("Progress modal is missing required child elements.");
|
||||
return;
|
||||
}
|
||||
titleElement.textContent = title;
|
||||
statusText.textContent = "\u51C6\u5907\u5F00\u59CB...";
|
||||
progressBar.style.width = "0%";
|
||||
progressPercentage.textContent = "0%";
|
||||
progressLog.innerHTML = "";
|
||||
closeButton.disabled = true;
|
||||
closeIcon.disabled = true;
|
||||
this.show("progressModal");
|
||||
}
|
||||
/**
|
||||
* Updates the progress bar and status text within the progress modal.
|
||||
* @param {number} processed - The number of items that have been processed.
|
||||
* @param {number} total - The total number of items to process.
|
||||
* @param {string} status - The current status message to display.
|
||||
*/
|
||||
updateProgress(processed, total, status) {
|
||||
const modal = document.getElementById("progressModal");
|
||||
if (!modal || modal.classList.contains("hidden")) return;
|
||||
const progressBar = document.getElementById("progressBar");
|
||||
const progressPercentage = document.getElementById("progressPercentage");
|
||||
const statusText = document.getElementById("progressStatusText");
|
||||
const closeButton = document.getElementById("progressModalCloseBtn");
|
||||
const closeIcon = document.getElementById("closeProgressModalBtn");
|
||||
const percentage = total > 0 ? Math.round(processed / total * 100) : 0;
|
||||
progressBar.style.width = `${percentage}%`;
|
||||
progressPercentage.textContent = `${percentage}%`;
|
||||
statusText.textContent = status;
|
||||
if (processed === total) {
|
||||
closeButton.disabled = false;
|
||||
closeIcon.disabled = false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Adds a log entry to the progress modal's log area.
|
||||
* @param {string} message - The log message to append.
|
||||
* @param {boolean} [isError=false] - If true, styles the log entry as an error.
|
||||
*/
|
||||
addProgressLog(message, isError = false) {
|
||||
const progressLog = document.getElementById("progressLog");
|
||||
if (!progressLog) return;
|
||||
const logEntry = document.createElement("div");
|
||||
logEntry.textContent = message;
|
||||
logEntry.className = isError ? "text-danger-600" : "text-gray-700";
|
||||
progressLog.appendChild(logEntry);
|
||||
progressLog.scrollTop = progressLog.scrollHeight;
|
||||
}
|
||||
/**
|
||||
* Closes the progress modal.
|
||||
* @param {boolean} [reload=false] - If true, reloads the page after closing.
|
||||
*/
|
||||
closeProgress(reload = false) {
|
||||
this.hide("progressModal");
|
||||
if (reload) {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
};
|
||||
var UIPatterns = class {
|
||||
/**
|
||||
* Animates numerical values in elements from 0 to their target number.
|
||||
* The target number is read from the element's text content.
|
||||
* @param {string} selector - The CSS selector for the elements to animate (e.g., '.stat-value').
|
||||
* @param {number} [duration=1500] - The duration of the animation in milliseconds.
|
||||
*/
|
||||
animateCounters(selector = ".stat-value", duration = 1500) {
|
||||
const statValues = document.querySelectorAll(selector);
|
||||
statValues.forEach((valueElement) => {
|
||||
const finalValue = parseInt(valueElement.textContent, 10);
|
||||
if (isNaN(finalValue)) return;
|
||||
if (!valueElement.dataset.originalValue) {
|
||||
valueElement.dataset.originalValue = valueElement.textContent;
|
||||
}
|
||||
let startValue = 0;
|
||||
const startTime = performance.now();
|
||||
const updateCounter = (currentTime) => {
|
||||
const elapsedTime = currentTime - startTime;
|
||||
if (elapsedTime < duration) {
|
||||
const progress = elapsedTime / duration;
|
||||
const easeOutValue = 1 - Math.pow(1 - progress, 3);
|
||||
const currentValue = Math.floor(easeOutValue * finalValue);
|
||||
valueElement.textContent = currentValue;
|
||||
requestAnimationFrame(updateCounter);
|
||||
} else {
|
||||
valueElement.textContent = valueElement.dataset.originalValue;
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(updateCounter);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Toggles the visibility of a content section with a smooth height animation.
|
||||
* It expects a specific HTML structure where the header and content are within a common parent (e.g., a card).
|
||||
* The content element should have a `collapsed` class when hidden.
|
||||
* @param {HTMLElement} header - The header element that was clicked to trigger the toggle.
|
||||
*/
|
||||
toggleSection(header) {
|
||||
const card = header.closest(".stats-card");
|
||||
if (!card) return;
|
||||
const content = card.querySelector(".key-content");
|
||||
const toggleIcon = header.querySelector(".toggle-icon");
|
||||
if (!content || !toggleIcon) {
|
||||
console.error("Toggle section failed: Content or icon element not found.", { header });
|
||||
return;
|
||||
}
|
||||
const isCollapsed = content.classList.contains("collapsed");
|
||||
toggleIcon.classList.toggle("collapsed", !isCollapsed);
|
||||
if (isCollapsed) {
|
||||
content.classList.remove("collapsed");
|
||||
content.style.maxHeight = null;
|
||||
content.style.opacity = null;
|
||||
content.style.paddingTop = null;
|
||||
content.style.paddingBottom = null;
|
||||
content.style.overflow = "hidden";
|
||||
requestAnimationFrame(() => {
|
||||
const targetHeight = content.scrollHeight;
|
||||
content.style.maxHeight = `${targetHeight}px`;
|
||||
content.style.opacity = "1";
|
||||
content.style.paddingTop = "1rem";
|
||||
content.style.paddingBottom = "1rem";
|
||||
content.addEventListener("transitionend", function onExpansionEnd() {
|
||||
content.removeEventListener("transitionend", onExpansionEnd);
|
||||
if (!content.classList.contains("collapsed")) {
|
||||
content.style.maxHeight = "";
|
||||
content.style.overflow = "visible";
|
||||
}
|
||||
}, { once: true });
|
||||
});
|
||||
} else {
|
||||
const currentHeight = content.scrollHeight;
|
||||
content.style.maxHeight = `${currentHeight}px`;
|
||||
content.style.overflow = "hidden";
|
||||
requestAnimationFrame(() => {
|
||||
content.style.maxHeight = "0px";
|
||||
content.style.opacity = "0";
|
||||
content.style.paddingTop = "0";
|
||||
content.style.paddingBottom = "0";
|
||||
content.classList.add("collapsed");
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
var modalManager = new ModalManager();
|
||||
var uiPatterns = new UIPatterns();
|
||||
|
||||
// frontend/js/components/taskCenter.js
|
||||
var TaskCenterManager = class {
|
||||
constructor() {
|
||||
this.tasks = [];
|
||||
this.activePolls = /* @__PURE__ */ new Map();
|
||||
this.heartbeatInterval = null;
|
||||
this.MINIMUM_TASK_DISPLAY_TIME_MS = 800;
|
||||
this.hasUnreadCompletedTasks = false;
|
||||
this.isAnimating = false;
|
||||
this.countdownTimer = null;
|
||||
this.trigger = document.getElementById("task-hub-trigger");
|
||||
this.panel = document.getElementById("task-hub-panel");
|
||||
this.countdownBar = document.getElementById("task-hub-countdown-bar");
|
||||
this.countdownRing = document.getElementById("task-hub-countdown-ring");
|
||||
this.indicator = document.getElementById("task-hub-indicator");
|
||||
this.clearBtn = document.getElementById("task-hub-clear-btn");
|
||||
this.taskListContainer = document.getElementById("task-list-container");
|
||||
this.emptyState = document.getElementById("task-list-empty");
|
||||
}
|
||||
// [THE FINAL, DEFINITIVE VERSION]
|
||||
init() {
|
||||
if (!this.trigger || !this.panel) {
|
||||
console.warn("Task Center UI core elements not found. Initialization skipped.");
|
||||
return;
|
||||
}
|
||||
this.trigger.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
if (this.isAnimating) return;
|
||||
if (this.panel.classList.contains("hidden")) {
|
||||
this._handleUserInteraction();
|
||||
this.openPanel();
|
||||
} else {
|
||||
this.closePanel();
|
||||
}
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!this.panel.classList.contains("hidden") && !this.isAnimating && !this.panel.contains(event.target) && !this.trigger.contains(event.target)) {
|
||||
this.closePanel();
|
||||
}
|
||||
});
|
||||
this.trigger.addEventListener("mouseenter", this._stopCountdown.bind(this));
|
||||
this.panel.addEventListener("mouseenter", this._handleUserInteraction.bind(this));
|
||||
const handleMouseLeave = () => {
|
||||
if (!this.panel.classList.contains("hidden")) {
|
||||
this._startCountdown();
|
||||
}
|
||||
};
|
||||
this.panel.addEventListener("mouseleave", handleMouseLeave);
|
||||
this.trigger.addEventListener("mouseleave", handleMouseLeave);
|
||||
this.clearBtn?.addEventListener("click", this.clearCompletedTasks.bind(this));
|
||||
this.taskListContainer.addEventListener("click", (event) => {
|
||||
const toggleHeader = event.target.closest("[data-task-toggle]");
|
||||
if (!toggleHeader) return;
|
||||
this._handleUserInteraction();
|
||||
const taskItem = toggleHeader.closest(".task-list-item");
|
||||
const content = taskItem.querySelector("[data-task-content]");
|
||||
if (!content) return;
|
||||
const isCollapsed = content.classList.contains("collapsed");
|
||||
toggleHeader.classList.toggle("expanded", isCollapsed);
|
||||
if (isCollapsed) {
|
||||
content.classList.remove("collapsed");
|
||||
content.style.maxHeight = `${content.scrollHeight}px`;
|
||||
content.style.opacity = "1";
|
||||
content.addEventListener("transitionend", () => {
|
||||
if (!content.classList.contains("collapsed")) content.style.maxHeight = "none";
|
||||
}, { once: true });
|
||||
} else {
|
||||
content.style.maxHeight = `${content.scrollHeight}px`;
|
||||
requestAnimationFrame(() => {
|
||||
content.style.maxHeight = "0px";
|
||||
content.style.opacity = "0";
|
||||
content.classList.add("collapsed");
|
||||
});
|
||||
}
|
||||
});
|
||||
this._render();
|
||||
this._startHeartbeat();
|
||||
console.log("Task Center UI Initialized [Multi-Task Heartbeat Polling Architecture - IGNITED].");
|
||||
}
|
||||
async startTask(taskDefinition) {
|
||||
try {
|
||||
const initialTaskData = await taskDefinition.start();
|
||||
if (!initialTaskData || !initialTaskData.id) throw new Error("Task definition did not return a valid initial task object.");
|
||||
const newTask = {
|
||||
id: initialTaskData.id,
|
||||
definition: taskDefinition,
|
||||
data: initialTaskData,
|
||||
timestamp: /* @__PURE__ */ new Date(),
|
||||
startTime: Date.now()
|
||||
};
|
||||
if (!initialTaskData.is_running) {
|
||||
console.log(`[TaskCenter] Task ${newTask.id} completed synchronously. Skipping poll.`);
|
||||
taskDefinition.renderToastNarrative(newTask.data, {}, toastManager);
|
||||
this.tasks.unshift(newTask);
|
||||
this._render();
|
||||
this._handleTaskCompletion(newTask);
|
||||
return;
|
||||
}
|
||||
this.tasks.unshift(newTask);
|
||||
this.activePolls.set(newTask.id, newTask);
|
||||
this._render();
|
||||
this.openPanel();
|
||||
taskDefinition.renderToastNarrative(newTask.data, {}, toastManager);
|
||||
this._updateIndicatorState();
|
||||
} catch (error) {
|
||||
console.error("Failed to start task:", error);
|
||||
toastManager.show(`\u4EFB\u52A1\u542F\u52A8\u5931\u8D25: ${error.message}`, "error");
|
||||
}
|
||||
}
|
||||
_startHeartbeat() {
|
||||
if (this.heartbeatInterval) return;
|
||||
this.heartbeatInterval = setInterval(this._tick.bind(this), 1500);
|
||||
}
|
||||
_stopHeartbeat() {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
this.heartbeatInterval = null;
|
||||
}
|
||||
}
|
||||
async _tick() {
|
||||
if (this.activePolls.size === 0) {
|
||||
return;
|
||||
}
|
||||
for (const taskId of [...this.activePolls.keys()]) {
|
||||
const task = this.activePolls.get(taskId);
|
||||
if (!task) continue;
|
||||
try {
|
||||
const response = await task.definition.poll(taskId);
|
||||
if (!response.success || !response.data) throw new Error(response.message || "Polling failed");
|
||||
const oldData = { ...task.data };
|
||||
task.data = response.data;
|
||||
this._updateTaskItemInHistory(task.id, task.data);
|
||||
task.definition.renderToastNarrative(task.data, oldData, toastManager);
|
||||
if (!task.data.is_running) {
|
||||
this._handleTaskCompletion(task);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Polling for task ${taskId} failed:`, error);
|
||||
task.data.error = error.message;
|
||||
this._updateTaskItemInHistory(task.id, task.data);
|
||||
this._handleTaskCompletion(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
_handleTaskCompletion(task) {
|
||||
this.activePolls.delete(task.id);
|
||||
this._updateIndicatorState();
|
||||
const toastId = `task-${task.id}`;
|
||||
const finalize = async () => {
|
||||
await toastManager.dismiss(toastId, !task.data.error);
|
||||
this._updateTaskItemInDom(task);
|
||||
this.hasUnreadCompletedTasks = true;
|
||||
this._updateIndicatorState();
|
||||
if (task.data.error) {
|
||||
if (task.definition.onError) task.definition.onError(task.data);
|
||||
} else {
|
||||
if (task.definition.onSuccess) task.definition.onSuccess(task.data);
|
||||
}
|
||||
};
|
||||
const elapsedTime = Date.now() - task.startTime;
|
||||
const remainingTime = this.MINIMUM_TASK_DISPLAY_TIME_MS - elapsedTime;
|
||||
if (remainingTime > 0) {
|
||||
setTimeout(finalize, remainingTime);
|
||||
} else {
|
||||
finalize();
|
||||
}
|
||||
}
|
||||
// [REFACTORED for robustness]
|
||||
_updateIndicatorState() {
|
||||
const hasRunningTasks = this.activePolls.size > 0;
|
||||
const shouldBeVisible = hasRunningTasks || this.hasUnreadCompletedTasks;
|
||||
this.indicator.classList.toggle("hidden", !shouldBeVisible);
|
||||
}
|
||||
// [REFACTORED for robustness]
|
||||
clearCompletedTasks() {
|
||||
this.tasks = this.tasks.filter((task) => this.activePolls.has(task.id));
|
||||
this.hasUnreadCompletedTasks = false;
|
||||
this._render();
|
||||
}
|
||||
// [NEW SAFETY METHOD]
|
||||
_updateTaskItemInHistory(taskId, newData) {
|
||||
const taskInHistory = this.tasks.find((t) => t.id === taskId);
|
||||
if (taskInHistory) {
|
||||
taskInHistory.data = newData;
|
||||
}
|
||||
}
|
||||
// --- 渲染与DOM操作 ---
|
||||
_render() {
|
||||
this.taskListContainer.innerHTML = this.tasks.map((task) => this._createTaskItemHtml(task)).join("");
|
||||
const hasTasks = this.tasks.length > 0;
|
||||
this.taskListContainer.classList.toggle("hidden", !hasTasks);
|
||||
this.emptyState.classList.toggle("hidden", hasTasks);
|
||||
this._updateIndicatorState();
|
||||
}
|
||||
_createTaskItemHtml(task) {
|
||||
const innerHtml = task.definition.renderTaskCenterItem(task.data, task.timestamp, this._formatTimeAgo);
|
||||
return `<div class="task-list-item" data-task-id="${task.id}">${innerHtml}</div>`;
|
||||
}
|
||||
_updateTaskItemInDom(task) {
|
||||
const item = this.taskListContainer.querySelector(`[data-task-id="${task.id}"]`);
|
||||
if (item) {
|
||||
item.innerHTML = task.definition.renderTaskCenterItem(task.data, task.timestamp, this._formatTimeAgo);
|
||||
}
|
||||
}
|
||||
// --- 核心面板开关逻辑 ---
|
||||
openPanel() {
|
||||
if (this.isAnimating || !this.panel.classList.contains("hidden")) return;
|
||||
this.isAnimating = true;
|
||||
this.panel.classList.remove("hidden");
|
||||
this.panel.classList.add("animate-panel-in");
|
||||
setTimeout(() => {
|
||||
this.panel.classList.remove("animate-panel-in");
|
||||
this.isAnimating = false;
|
||||
this._startCountdown();
|
||||
}, 150);
|
||||
}
|
||||
closePanel() {
|
||||
if (this.isAnimating || this.panel.classList.contains("hidden")) return;
|
||||
this._stopCountdown();
|
||||
this.isAnimating = true;
|
||||
this.panel.classList.add("animate-panel-out");
|
||||
setTimeout(() => {
|
||||
this.panel.classList.remove("animate-panel-out");
|
||||
this.panel.classList.add("hidden");
|
||||
this.isAnimating = false;
|
||||
}, 50);
|
||||
}
|
||||
// --- [新增] 倒计时管理方法 ---
|
||||
/**
|
||||
* 启动或重启倒计时和进度条动画
|
||||
* @private
|
||||
*/
|
||||
_startCountdown() {
|
||||
this._stopCountdown();
|
||||
this.countdownBar.classList.add("w-full", "duration-[4950ms]");
|
||||
this.countdownRing.style.transition = "none";
|
||||
this.countdownRing.style.strokeDashoffset = "72.26";
|
||||
void this.countdownRing.offsetHeight;
|
||||
this.countdownRing.style.transition = "stroke-dashoffset 4.95s linear";
|
||||
this.countdownRing.style.strokeDashoffset = "0";
|
||||
this.countdownTimer = setTimeout(() => {
|
||||
this.closePanel();
|
||||
}, 4950);
|
||||
}
|
||||
/**
|
||||
* 停止倒计时并重置进度条
|
||||
* @private
|
||||
*/
|
||||
_stopCountdown() {
|
||||
if (this.countdownTimer) {
|
||||
clearTimeout(this.countdownTimer);
|
||||
this.countdownTimer = null;
|
||||
}
|
||||
this.countdownBar.classList.remove("w-full");
|
||||
this.countdownRing.style.transition = "none";
|
||||
this.countdownRing.style.strokeDashoffset = "72.26";
|
||||
}
|
||||
// [NEW] A central handler for any action that confirms the user has seen the panel.
|
||||
_handleUserInteraction() {
|
||||
this._stopCountdown();
|
||||
if (this.hasUnreadCompletedTasks) {
|
||||
this.hasUnreadCompletedTasks = false;
|
||||
this._updateIndicatorState();
|
||||
}
|
||||
}
|
||||
_formatTimeAgo(date) {
|
||||
if (!date) return "";
|
||||
const seconds = Math.floor((/* @__PURE__ */ new Date() - new Date(date)) / 1e3);
|
||||
if (seconds < 2) return "\u521A\u521A";
|
||||
if (seconds < 60) return `${seconds}\u79D2\u524D`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}\u5206\u949F\u524D`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}\u5C0F\u65F6\u524D`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}\u5929\u524D`;
|
||||
}
|
||||
};
|
||||
var ToastManager = class {
|
||||
constructor() {
|
||||
this.container = document.getElementById("toast-container");
|
||||
if (!this.container) {
|
||||
this.container = document.createElement("div");
|
||||
this.container.id = "toast-container";
|
||||
this.container.className = "fixed bottom-4 right-4 z-[100] w-full max-w-sm space-y-3";
|
||||
document.body.appendChild(this.container);
|
||||
}
|
||||
this.activeToasts = /* @__PURE__ */ new Map();
|
||||
}
|
||||
/**
|
||||
* 显示一个 Toast 通知
|
||||
* @param {string} message - The message to display.
|
||||
* @param {string} [type='info'] - 'info', 'success', or 'error'.
|
||||
* @param {number} [duration=4000] - Duration in milliseconds.
|
||||
*/
|
||||
show(message, type = "info", duration = 4e3) {
|
||||
const toastElement = this._createToastHtml(message, type);
|
||||
this.container.appendChild(toastElement);
|
||||
requestAnimationFrame(() => {
|
||||
toastElement.classList.remove("opacity-0", "translate-y-2");
|
||||
toastElement.classList.add("opacity-100", "translate-y-0");
|
||||
});
|
||||
setTimeout(() => {
|
||||
toastElement.classList.remove("opacity-100", "translate-y-0");
|
||||
toastElement.classList.add("opacity-0", "translate-y-2");
|
||||
toastElement.addEventListener("transitionend", () => toastElement.remove(), { once: true });
|
||||
}, duration);
|
||||
}
|
||||
// [NEW] 创建或更新一个带进度条的Toast
|
||||
showProgressToast(toastId, title, message, progress) {
|
||||
if (this.activeToasts.has(toastId)) {
|
||||
const toastElement = this.activeToasts.get(toastId);
|
||||
const messageEl = toastElement.querySelector(".toast-message");
|
||||
const progressBar = toastElement.querySelector(".toast-progress-bar");
|
||||
messageEl.textContent = `${message} - ${Math.round(progress)}%`;
|
||||
anime({
|
||||
targets: progressBar,
|
||||
width: `${progress}%`,
|
||||
duration: 400,
|
||||
easing: "easeOutQuad"
|
||||
});
|
||||
} else {
|
||||
const toastElement = this._createProgressToastHtml(toastId, title, message, progress);
|
||||
this.container.appendChild(toastElement);
|
||||
this.activeToasts.set(toastId, toastElement);
|
||||
requestAnimationFrame(() => {
|
||||
toastElement.classList.remove("opacity-0", "translate-x-full");
|
||||
toastElement.classList.add("opacity-100", "translate-x-0");
|
||||
});
|
||||
}
|
||||
}
|
||||
// [NEW] 移除一个进度Toast
|
||||
dismiss(toastId, success = null) {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.activeToasts.has(toastId)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const toastElement = this.activeToasts.get(toastId);
|
||||
const performFadeOut = () => {
|
||||
toastElement.classList.remove("opacity-100", "translate-x-0");
|
||||
toastElement.classList.add("opacity-0", "translate-x-full");
|
||||
toastElement.addEventListener("transitionend", () => {
|
||||
toastElement.remove();
|
||||
this.activeToasts.delete(toastId);
|
||||
resolve();
|
||||
}, { once: true });
|
||||
};
|
||||
if (success === null) {
|
||||
performFadeOut();
|
||||
} else {
|
||||
const iconContainer = toastElement.querySelector(".toast-icon");
|
||||
const messageEl = toastElement.querySelector(".toast-message");
|
||||
if (success) {
|
||||
const progressBar = toastElement.querySelector(".toast-progress-bar");
|
||||
messageEl.textContent = "\u5DF2\u5B8C\u6210";
|
||||
anime({
|
||||
targets: progressBar,
|
||||
width: "100%",
|
||||
duration: 300,
|
||||
easing: "easeOutQuad",
|
||||
complete: () => {
|
||||
iconContainer.innerHTML = `<i class="fas fa-check-circle text-white"></i>`;
|
||||
iconContainer.className = `toast-icon bg-green-500`;
|
||||
setTimeout(performFadeOut, 900);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
iconContainer.innerHTML = `<i class="fas fa-times-circle text-white"></i>`;
|
||||
iconContainer.className = `toast-icon bg-red-500`;
|
||||
messageEl.textContent = "\u5931\u8D25";
|
||||
setTimeout(performFadeOut, 1200);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
_createToastHtml(message, type) {
|
||||
const icons = {
|
||||
info: { class: "bg-blue-500", icon: "fa-info-circle" },
|
||||
success: { class: "bg-green-500", icon: "fa-check-circle" },
|
||||
error: { class: "bg-red-500", icon: "fa-exclamation-triangle" }
|
||||
};
|
||||
const typeInfo = icons[type] || icons.info;
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "toast-item opacity-0 translate-y-2 transition-all duration-300 ease-out";
|
||||
toast.innerHTML = `
|
||||
<div class="toast-icon ${typeInfo.class}">
|
||||
<i class="fas ${typeInfo.icon}"></i>
|
||||
</div>
|
||||
<div class="toast-content">
|
||||
<p class="toast-title">${this._capitalizeFirstLetter(type)}</p>
|
||||
<p class="toast-message">${message}</p>
|
||||
</div>
|
||||
`;
|
||||
return toast;
|
||||
}
|
||||
// [NEW] 创建带进度条Toast的HTML结构
|
||||
_createProgressToastHtml(toastId, title, message, progress) {
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "toast-item opacity-0 translate-x-full transition-all duration-300 ease-out";
|
||||
toast.dataset.toastId = toastId;
|
||||
toast.innerHTML = `
|
||||
<div class="toast-icon bg-blue-500">
|
||||
<i class="fas fa-spinner animate-spin"></i>
|
||||
</div>
|
||||
<div class="toast-content">
|
||||
<p class="toast-title">${title}</p>
|
||||
<p class="toast-message">${message} - ${Math.round(progress)}%</p>
|
||||
<div class="w-full bg-slate-200 dark:bg-zinc-700 rounded-full h-1 mt-1.5 overflow-hidden">
|
||||
<div class="toast-progress-bar bg-blue-500 h-1 rounded-full" style="width: ${progress}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return toast;
|
||||
}
|
||||
_capitalizeFirstLetter(string) {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
}
|
||||
};
|
||||
var taskCenterManager = new TaskCenterManager();
|
||||
var toastManager = new ToastManager();
|
||||
|
||||
export {
|
||||
CustomSelect,
|
||||
modalManager,
|
||||
uiPatterns,
|
||||
taskCenterManager,
|
||||
toastManager
|
||||
};
|
||||
83
web/static/js/chunk-PLQL6WIO.js
Normal file
83
web/static/js/chunk-PLQL6WIO.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// frontend/js/services/api.js
|
||||
var APIClientError = class extends Error {
|
||||
constructor(message, status, code, rawMessageFromServer) {
|
||||
super(message);
|
||||
this.name = "APIClientError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.rawMessageFromServer = rawMessageFromServer;
|
||||
}
|
||||
};
|
||||
var apiPromiseCache = /* @__PURE__ */ new Map();
|
||||
async function apiFetch(url, options = {}) {
|
||||
const isGetRequest = !options.method || options.method.toUpperCase() === "GET";
|
||||
const cacheKey = isGetRequest && !options.noCache ? url : null;
|
||||
if (cacheKey && apiPromiseCache.has(cacheKey)) {
|
||||
return apiPromiseCache.get(cacheKey);
|
||||
}
|
||||
const token = localStorage.getItem("bearerToken");
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers
|
||||
};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
const requestPromise = (async () => {
|
||||
try {
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
if (response.status === 401) {
|
||||
if (cacheKey) apiPromiseCache.delete(cacheKey);
|
||||
localStorage.removeItem("bearerToken");
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.href = "/login?error=\u4F1A\u8BDD\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55\u3002";
|
||||
}
|
||||
throw new APIClientError("Unauthorized", 401, "UNAUTHORIZED", "Session expired or token is invalid.");
|
||||
}
|
||||
if (!response.ok) {
|
||||
let errorData = null;
|
||||
let rawMessage = "";
|
||||
try {
|
||||
rawMessage = await response.text();
|
||||
if (rawMessage) {
|
||||
errorData = JSON.parse(rawMessage);
|
||||
}
|
||||
} catch (e) {
|
||||
errorData = { error: { code: "UNKNOWN_FORMAT", message: rawMessage || response.statusText } };
|
||||
}
|
||||
const code = errorData?.error?.code || "UNKNOWN_ERROR";
|
||||
const messageFromServer = errorData?.error?.message || rawMessage || "No message provided by server.";
|
||||
const error = new APIClientError(
|
||||
`API request failed: ${response.status}`,
|
||||
response.status,
|
||||
code,
|
||||
messageFromServer
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (cacheKey) apiPromiseCache.delete(cacheKey);
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
if (cacheKey) {
|
||||
apiPromiseCache.set(cacheKey, requestPromise);
|
||||
}
|
||||
return requestPromise;
|
||||
}
|
||||
async function apiFetchJson(url, options = {}) {
|
||||
try {
|
||||
const response = await apiFetch(url, options);
|
||||
const clonedResponse = response.clone();
|
||||
const jsonData = await clonedResponse.json();
|
||||
return jsonData;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
apiFetch,
|
||||
apiFetchJson
|
||||
};
|
||||
7
web/static/js/dashboard-CJJWKYPR.js
Normal file
7
web/static/js/dashboard-CJJWKYPR.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// frontend/js/pages/dashboard.js
|
||||
function init() {
|
||||
console.log("[Modern Frontend] Dashboard module loaded. Future logic will execute here.");
|
||||
}
|
||||
export {
|
||||
init as default
|
||||
};
|
||||
91
web/static/js/dashboard-chart.js
Normal file
91
web/static/js/dashboard-chart.js
Normal file
@@ -0,0 +1,91 @@
|
||||
// Filename: public/static/js/dashboard-chart.js (V2.0 - 兼容全局授权版)
|
||||
// export default function initializeDashboardChart() {
|
||||
// ========================================================================= //
|
||||
// Dashboard Chart Module //
|
||||
// ========================================================================= //
|
||||
|
||||
/** @type {import('chart.js').Chart | null} 专属的图表实例 */
|
||||
let historicalChartInstance = null;
|
||||
|
||||
// 使用 DOMContentLoaded 确保页面结构加载完毕后再执行
|
||||
document.addEventListener('DOMContentLoaded', main);
|
||||
|
||||
function main() {
|
||||
setupChartEventListeners();
|
||||
fetchChartData();
|
||||
// [新增] 监听来自主程序的刷新命令
|
||||
window.addEventListener('refresh-chart', () => fetchChartData(document.getElementById('chartGroupFilter')?.value));
|
||||
}
|
||||
|
||||
function setupChartEventListeners() {
|
||||
const chartGroupFilter = document.getElementById('chartGroupFilter');
|
||||
if (chartGroupFilter) {
|
||||
chartGroupFilter.addEventListener('change', (e) => fetchChartData(e.target.value));
|
||||
}
|
||||
}
|
||||
|
||||
function handleFilterChange(groupId) {
|
||||
fetchChartData(groupId);
|
||||
}
|
||||
|
||||
async function fetchChartData(groupId = '') {
|
||||
const url = groupId ? `/admin/dashboard/chart?group_id=${groupId}` : '/admin/dashboard/chart';
|
||||
try {
|
||||
const response = await apiFetch(url, { noCache: true }); // 图表数据总是获取最新的
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
const chartData = await response.json();
|
||||
renderHistoricalChart(chartData);
|
||||
} catch (error) {
|
||||
if (error.message !== 'Unauthorized') {
|
||||
console.error('Failed to fetch chart data:', error);
|
||||
renderHistoricalChart(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderHistoricalChart(chartData) {
|
||||
const canvas = document.getElementById('historicalChart');
|
||||
if (!canvas) return;
|
||||
|
||||
// [关键] 我们不再需要 setTimeout 检查!
|
||||
// 因为HTML的加载顺序保证了,当这个脚本执行时,`Chart` 对象必然存在。
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (historicalChartInstance) {
|
||||
historicalChartInstance.destroy();
|
||||
}
|
||||
|
||||
if (!chartData || !chartData.labels || chartData.labels.length === 0) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.font = "16px Inter, sans-serif";
|
||||
ctx.fillStyle = "#9ca3af";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("暂无图表数据", canvas.width / 2, canvas.height / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建新图表
|
||||
historicalChartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: chartData.labels,
|
||||
datasets: chartData.datasets.map(dataset => ({
|
||||
label: dataset.label, data: dataset.data, borderColor: dataset.color,
|
||||
backgroundColor: `${dataset.color}33`, pointBackgroundColor: dataset.color,
|
||||
pointBorderColor: '#fff', pointHoverBackgroundColor: '#fff',
|
||||
pointHoverBorderColor: dataset.color, fill: true, tension: 0.4
|
||||
}))
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: { beginAtZero: true, grid: { color: 'rgba(0, 0, 0, 0.05)' } },
|
||||
x: { grid: { display: false } }
|
||||
},
|
||||
plugins: {
|
||||
legend: { position: 'top', align: 'end', labels: { usePointStyle: true, boxWidth: 8, padding: 20 } }
|
||||
},
|
||||
interaction: { intersect: false, mode: 'index' }
|
||||
}
|
||||
});
|
||||
}
|
||||
439
web/static/js/dashboard.js
Normal file
439
web/static/js/dashboard.js
Normal file
@@ -0,0 +1,439 @@
|
||||
|
||||
// ========================================================================= //
|
||||
// 全局变量 //
|
||||
// ========================================================================= //
|
||||
|
||||
/** @type {number | null} 全局自动刷新定时器ID */
|
||||
let autoRefreshInterval = null;
|
||||
|
||||
/** @type {StatusGrid | null} API状态分布网格的实例 */
|
||||
let apiCanvasInstance = null;
|
||||
|
||||
/** @type {import('chart.js').Chart | null} 全局历史趋势图表实例 */
|
||||
let historicalChartInstance = null;
|
||||
|
||||
// 预热关键数据
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
apiFetch('/admin/dashboard/overview');
|
||||
apiFetch('/admin/keygroups');
|
||||
// ... dashboard页面的其他初始化代码 ...
|
||||
});
|
||||
|
||||
// ========================================================================= //
|
||||
// 主程序入口 //
|
||||
// ========================================================================= //
|
||||
|
||||
// 脚本位于<body>末尾,无需等待DOMContentLoaded,立即执行主程序。
|
||||
main();
|
||||
|
||||
/**
|
||||
* 主执行函数,负责编排核心UI渲染和非核心模块的异步加载。
|
||||
* [语法修正] 此函数必须是 async,以允许在其中使用 await。
|
||||
*/
|
||||
async function main() {
|
||||
// 阶段一:立即初始化并渲染所有核心UI
|
||||
initializeStaticFeatures();
|
||||
await hydrateCoreUIFromPrefetchedData(); // 等待核心UI所需数据加载并渲染完成
|
||||
|
||||
// (图表模块),并与之分离
|
||||
// 这个函数调用本身是同步的,但它内部会启动一个不会阻塞主流程的异步加载过程。
|
||||
loadChartModulesAndRender();
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================= //
|
||||
// 核心UI功能 //
|
||||
// ========================================================================= //
|
||||
|
||||
/**
|
||||
* 负责初始化所有静态的、非数据驱动的UI功能。
|
||||
*/
|
||||
function initializeStaticFeatures() {
|
||||
setupCoreEventListeners(); // [名称修正] 只监听核心UI相关的事件
|
||||
initializeDropdownMenu();
|
||||
initializeAutoRefreshControls();
|
||||
initStatItemAnimations();
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心数据“水合”函数。
|
||||
* 它的使命是使用由 base.html 中的“信使”脚本预取的数据,尽快填充核心UI。
|
||||
*/
|
||||
async function hydrateCoreUIFromPrefetchedData() {
|
||||
// 初始化Canvas网格实例
|
||||
if (document.getElementById('poolGridCanvas')) {
|
||||
apiCanvasInstance = new StatusGrid('poolGridCanvas');
|
||||
apiCanvasInstance.init();
|
||||
}
|
||||
|
||||
// 从缓存中等待并获取“概览”数据,然后更新统计卡片
|
||||
try {
|
||||
const overviewResponse = await apiFetch('/admin/dashboard/overview');
|
||||
const overviewData = await overviewResponse.json();
|
||||
updateStatCards(overviewData);
|
||||
} catch (error) {
|
||||
if (error.message !== 'Unauthorized') {
|
||||
console.error('Failed to hydrate overview data:', error);
|
||||
showNotification('渲染总览数据失败。', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 从缓存中等待并获取“分组”数据,然后填充下拉菜单
|
||||
try {
|
||||
const keygroupsResponse = await apiFetch('/admin/keygroups');
|
||||
const keygroupsData = await keygroupsResponse.json();
|
||||
populateSelectWithOptions(keygroupsData);
|
||||
} catch (error) {
|
||||
if (error.message !== 'Unauthorized') {
|
||||
console.error('Failed to hydrate keygroups data:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================= //
|
||||
// 二级火箭:图表模块功能 //
|
||||
// ========================================================================= //
|
||||
|
||||
/**
|
||||
* 动态加载Chart.js引擎,并在加载成功后,启动图表的渲染流程。
|
||||
* 这个过程是完全异步的,不会阻塞页面其它部分的交互。
|
||||
*/
|
||||
function loadChartModulesAndRender() {
|
||||
// 辅助函数:通过动态创建<script>标签来注入外部JS
|
||||
const injectScript = (src) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
};
|
||||
|
||||
// 1. 启动Chart.js引擎的后台下载
|
||||
injectScript('https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js')
|
||||
.then(() => {
|
||||
// 2. 当且仅当引擎加载成功后,才执行图表相关的初始化
|
||||
console.log("Chart.js engine loaded successfully. Fetching chart data...");
|
||||
setupChartEventListeners(); // 绑定图表专用的事件监听
|
||||
fetchAndRenderChart(); // 获取数据并渲染图表
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Failed to load Chart.js engine:", error);
|
||||
showNotification('图表引擎加载失败。', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 负责图表模块的数据获取与渲染。
|
||||
* @param {string} [groupId=''] - 可选的组ID,用于筛选数据。
|
||||
*/
|
||||
async function fetchAndRenderChart(groupId = '') {
|
||||
const url = groupId ? `/admin/dashboard/chart?group_id=${groupId}` : '/admin/dashboard/chart';
|
||||
try {
|
||||
// 图表数据总是获取最新的,不使用缓存
|
||||
const response = await apiFetch(url, { noCache: true });
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
const chartData = await response.json();
|
||||
|
||||
// --- 渲染逻辑 ---
|
||||
const canvas = document.getElementById('historicalChart');
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (historicalChartInstance) {
|
||||
historicalChartInstance.destroy();
|
||||
}
|
||||
|
||||
const noData = !chartData || !chartData.datasets || chartData.datasets.length === 0;
|
||||
|
||||
if (noData) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.font = "16px Inter, sans-serif";
|
||||
ctx.fillStyle = "#9ca3af";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("暂无图表数据", canvas.width / 2, canvas.height / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
historicalChartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: chartData.labels,
|
||||
datasets: chartData.datasets.map(dataset => ({
|
||||
label: dataset.label, data: dataset.data, borderColor: dataset.color,
|
||||
backgroundColor: `${dataset.color}33`, pointBackgroundColor: dataset.color,
|
||||
pointBorderColor: '#fff', pointHoverBackgroundColor: '#fff',
|
||||
pointHoverBorderColor: dataset.color, fill: true, tension: 0.4
|
||||
}))
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: { beginAtZero: true, grid: { color: 'rgba(0, 0, 0, 0.05)' } },
|
||||
x: { grid: { display: false } }
|
||||
},
|
||||
plugins: {
|
||||
legend: { position: 'top', align: 'end', labels: { usePointStyle: true, boxWidth: 8, padding: 20 } }
|
||||
},
|
||||
interaction: { intersect: false, mode: 'index' }
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error.message !== 'Unauthorized') {
|
||||
console.error('Failed to fetch chart data:', error);
|
||||
// 可以在此处调用渲染函数并传入null来显示错误信息
|
||||
showNotification('渲染图表失败,请检查控制台。', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================= //
|
||||
// 辅助函数与事件监听 //
|
||||
// ========================================================================= //
|
||||
|
||||
/**
|
||||
* [名称修正] 绑定所有与核心UI(非图表)相关的事件。
|
||||
*/
|
||||
function setupCoreEventListeners() {
|
||||
const autoRefreshToggle = document.getElementById('autoRefreshToggle');
|
||||
if (autoRefreshToggle) {
|
||||
autoRefreshToggle.addEventListener('change', handleAutoRefreshToggle);
|
||||
}
|
||||
const refreshButton = document.querySelector('button[title="手动刷新"]');
|
||||
if (refreshButton) {
|
||||
refreshButton.addEventListener('click', () => refreshPage(refreshButton));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [新增] 绑定图表专用的事件监听。此函数在Chart.js加载后才被调用。
|
||||
*/
|
||||
function setupChartEventListeners() {
|
||||
const chartGroupFilter = document.getElementById('chartGroupFilter');
|
||||
if (chartGroupFilter) {
|
||||
chartGroupFilter.addEventListener('change', (e) => fetchAndRenderChart(e.target.value));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [重构] 一个纯粹的UI填充函数,它只负责根据传入的数据渲染下拉菜单。
|
||||
* @param {object} result - 从 /admin/keygroups API 返回的完整JSON对象。
|
||||
*/
|
||||
function populateSelectWithOptions(result) {
|
||||
const groups = result?.data?.items || [];
|
||||
const selectElements = ['chartGroupFilter', 'poolGroupFilter'];
|
||||
|
||||
selectElements.forEach(selectId => {
|
||||
const selectElement = document.getElementById(selectId);
|
||||
if (!selectElement) return;
|
||||
const currentVal = selectElement.value;
|
||||
selectElement.innerHTML = '<option value="">所有分组</option>';
|
||||
groups.forEach(group => {
|
||||
const option = document.createElement('option');
|
||||
option.value = group.id;
|
||||
option.textContent = group.name.length > 20 ? group.name.substring(0, 20) + '...' : group.name;
|
||||
selectElement.appendChild(option);
|
||||
});
|
||||
selectElement.value = currentVal;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新所有统计卡片的数字。
|
||||
* @param {object} data - 从 /admin/dashboard/overview API 返回的数据对象。
|
||||
*/
|
||||
function updateStatCards(data) {
|
||||
const useAnimation = true; // 动画默认开启
|
||||
if (!data) return;
|
||||
|
||||
const updateFn = useAnimation ? animateValue : (id, val) => {
|
||||
const elem = document.getElementById(id);
|
||||
if (elem) elem.textContent = (val || 0).toLocaleString();
|
||||
};
|
||||
|
||||
if (data.key_count) {
|
||||
updateFn('stat-total-keys', data.key_count.value || 0);
|
||||
updateFn('stat-invalid-keys', data.key_count.sub_value || 0);
|
||||
}
|
||||
if (data.key_status_count) {
|
||||
updateFn('stat-valid-keys', data.key_status_count.ACTIVE || 0);
|
||||
updateFn('stat-cooldown-keys', data.key_status_count.COOLDOWN || 0);
|
||||
if (apiCanvasInstance) {
|
||||
apiCanvasInstance.updateData(data.key_status_count);
|
||||
}
|
||||
updateLegendCounts(data.key_status_count);
|
||||
}
|
||||
if (data.request_counts) {
|
||||
updateFn('stat-calls-1m', data.request_counts["1m"] || 0);
|
||||
updateFn('stat-calls-1h', data.request_counts["1h"] || 0);
|
||||
updateFn('stat-calls-1d', data.request_counts["1d"] || 0);
|
||||
updateFn('stat-calls-30d', data.request_counts["30d"] || 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新API状态图例下方的各项计数。
|
||||
* @param {object} counts - 包含各状态计数的对象。
|
||||
*/
|
||||
function updateLegendCounts(counts) {
|
||||
if (!counts) return;
|
||||
['active', 'pending', 'cooldown', 'disabled', 'banned'].forEach(field => {
|
||||
const elem = document.getElementById(`legend-${field}`);
|
||||
if(elem) elem.textContent = (counts[field.toUpperCase()] || 0).toLocaleString();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的刷新函数,供手动和自动刷新调用。
|
||||
*/
|
||||
async function refreshAllData() {
|
||||
// 强制刷新核心UI数据
|
||||
try {
|
||||
const overviewResponse = await apiFetch('/admin/dashboard/overview', { noCache: true });
|
||||
const overviewData = await overviewResponse.json();
|
||||
updateStatCards(overviewData); // 使用无动画的方式更新,追求速度
|
||||
} catch(e) { console.error("Failed to refresh overview", e); }
|
||||
|
||||
// 强制刷新图表数据
|
||||
if (typeof Chart !== 'undefined' && historicalChartInstance) { // 仅当图表已加载时才刷新
|
||||
fetchAndRenderChart(document.getElementById('chartGroupFilter')?.value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理手动刷新按钮的点击事件。
|
||||
* @param {HTMLElement} button - 被点击的刷新按钮。
|
||||
*/
|
||||
function refreshPage(button) {
|
||||
if (!button || button.disabled) return;
|
||||
button.disabled = true;
|
||||
const icon = button.querySelector('i');
|
||||
if (icon) icon.classList.add('fa-spin');
|
||||
|
||||
showNotification('正在刷新...', 'info', 1500);
|
||||
|
||||
refreshAllData().finally(() => {
|
||||
setTimeout(() => {
|
||||
button.disabled = false;
|
||||
if (icon) icon.classList.remove('fa-spin');
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理自动刷新开关的事件。
|
||||
* @param {Event} event - change事件对象。
|
||||
*/
|
||||
function handleAutoRefreshToggle(event) {
|
||||
const isEnabled = event.target.checked;
|
||||
localStorage.setItem("autoRefreshEnabled", isEnabled);
|
||||
if (isEnabled) {
|
||||
showNotification("自动刷新已开启 (30秒)", "info", 2000);
|
||||
refreshAllData(); // 立即执行一次
|
||||
autoRefreshInterval = setInterval(refreshAllData, 30000);
|
||||
} else {
|
||||
if (autoRefreshInterval) {
|
||||
showNotification("自动刷新已停止", "info", 2000);
|
||||
clearInterval(autoRefreshInterval);
|
||||
autoRefreshInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化自动刷新控件的状态(从localStorage读取)。
|
||||
*/
|
||||
function initializeAutoRefreshControls() {
|
||||
const toggle = document.getElementById("autoRefreshToggle");
|
||||
if (!toggle) return;
|
||||
const isEnabled = localStorage.getItem("autoRefreshEnabled") === "true";
|
||||
if (isEnabled) {
|
||||
toggle.checked = true;
|
||||
handleAutoRefreshToggle({ target: toggle });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化右上角下拉菜单的交互逻辑。
|
||||
*/
|
||||
function initializeDropdownMenu() {
|
||||
const dropdownButton = document.getElementById('dropdownMenuButton');
|
||||
const dropdownMenu = document.getElementById('dropdownMenu');
|
||||
const dropdownToggle = dropdownButton ? dropdownButton.closest('.dropdown-toggle') : null;
|
||||
if (!dropdownButton || !dropdownMenu || !dropdownToggle) return;
|
||||
dropdownButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
dropdownMenu.classList.toggle('show');
|
||||
});
|
||||
document.addEventListener('click', (event) => {
|
||||
if (dropdownMenu.classList.contains('show') && !dropdownToggle.contains(event.target)) {
|
||||
dropdownMenu.classList.remove('show');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示一个全局浮动通知。
|
||||
* @param {string} message - 通知内容。
|
||||
* @param {'info'|'success'|'error'} [type='info'] - 通知类型。
|
||||
* @param {number} [duration=3000] - 显示时长(毫秒)。
|
||||
*/
|
||||
function showNotification(message, type = 'info', duration = 3000) {
|
||||
const container = document.body;
|
||||
const styles = {
|
||||
info: { icon: 'fa-info-circle', color: 'blue-500' },
|
||||
success: { icon: 'fa-check-circle', color: 'green-500' },
|
||||
error: { icon: 'fa-times-circle', color: 'red-500' }
|
||||
};
|
||||
const style = styles[type] || styles.info;
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `fixed top-5 right-5 flex items-center bg-white text-gray-800 p-4 rounded-lg shadow-lg border-l-4 border-${style.color} z-[9999] animate-fade-in`;
|
||||
notification.innerHTML = `<i class="fas ${style.icon} text-${style.color} text-xl mr-3"></i><span class="font-semibold">${message}</span>`;
|
||||
container.appendChild(notification);
|
||||
setTimeout(() => {
|
||||
notification.classList.remove('animate-fade-in');
|
||||
notification.style.transition = 'opacity 0.5s ease-out, transform 0.5s ease-out';
|
||||
notification.style.opacity = '0';
|
||||
notification.style.transform = 'translateY(-20px)';
|
||||
setTimeout(() => notification.remove(), 500);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为统计卡片的数字添加动画效果。
|
||||
* @param {string} elementId - 目标元素ID。
|
||||
* @param {number} endValue - 最终要显示的数字。
|
||||
*/
|
||||
function animateValue(elementId, endValue) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) return;
|
||||
const startValue = parseInt(element.textContent.replace(/,/g, '') || '0');
|
||||
if (startValue === endValue) return;
|
||||
let startTime = null;
|
||||
const duration = 1200;
|
||||
const step = (timestamp) => {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const progress = Math.min((timestamp - startTime) / duration, 1);
|
||||
const easeOutValue = 1 - Math.pow(1 - progress, 3);
|
||||
const currentValue = Math.floor(easeOutValue * (endValue - startValue) + startValue);
|
||||
element.textContent = currentValue.toLocaleString();
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化统计项的悬浮动画(放大效果)。
|
||||
*/
|
||||
function initStatItemAnimations() {
|
||||
document.querySelectorAll(".stat-item").forEach(item => {
|
||||
item.addEventListener("mouseenter", () => item.style.transform = "scale(1.05)");
|
||||
item.addEventListener("mouseleave", () => item.style.transform = "");
|
||||
});
|
||||
}
|
||||
5403
web/static/js/keys-A2UAJYOX.js
Normal file
5403
web/static/js/keys-A2UAJYOX.js
Normal file
File diff suppressed because it is too large
Load Diff
5403
web/static/js/keys-W5FEUMQV.js
Normal file
5403
web/static/js/keys-W5FEUMQV.js
Normal file
File diff suppressed because it is too large
Load Diff
2245
web/static/js/keys_status.js
Normal file
2245
web/static/js/keys_status.js
Normal file
File diff suppressed because it is too large
Load Diff
106
web/static/js/logs-FGZ2SMPN.js
Normal file
106
web/static/js/logs-FGZ2SMPN.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
apiFetchJson
|
||||
} from "./chunk-PLQL6WIO.js";
|
||||
|
||||
// frontend/js/pages/logs/logList.js
|
||||
var LogList = class {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
if (!this.container) {
|
||||
console.error("LogList: container element (tbody) not found.");
|
||||
}
|
||||
}
|
||||
renderLoading() {
|
||||
if (!this.container) return;
|
||||
this.container.innerHTML = `<tr><td colspan="9" class="p-8 text-center text-muted-foreground"><i class="fas fa-spinner fa-spin mr-2"></i> \u52A0\u8F7D\u65E5\u5FD7\u4E2D...</td></tr>`;
|
||||
}
|
||||
render(logs) {
|
||||
if (!this.container) return;
|
||||
if (!logs || logs.length === 0) {
|
||||
this.container.innerHTML = `<tr><td colspan="9" class="p-8 text-center text-muted-foreground">\u6CA1\u6709\u627E\u5230\u76F8\u5173\u7684\u65E5\u5FD7\u8BB0\u5F55\u3002</td></tr>`;
|
||||
return;
|
||||
}
|
||||
const logsHtml = logs.map((log) => this.createLogRowHtml(log)).join("");
|
||||
this.container.innerHTML = logsHtml;
|
||||
}
|
||||
createLogRowHtml(log) {
|
||||
const groupName = log.GroupDisplayName || (log.GroupID ? `Group #${log.GroupID}` : "N/A");
|
||||
const apiKeyName = log.APIKeyName || (log.KeyID ? `Key #${log.KeyID}` : "N/A");
|
||||
const errorTag = log.IsSuccess ? `<span class="inline-flex items-center rounded-md bg-green-500/10 px-2 py-1 text-xs font-medium text-green-600">\u6210\u529F</span>` : `<span class="inline-flex items-center rounded-md bg-destructive/10 px-2 py-1 text-xs font-medium text-destructive">${log.ErrorCode || "\u5931\u8D25"}</span>`;
|
||||
const requestTime = new Date(log.RequestTime).toLocaleString();
|
||||
return `
|
||||
<tr class="border-b border-b-border transition-colors hover:bg-muted/80" data-log-id="${log.ID}">
|
||||
<td class="p-4 align-middle"><input type="checkbox" class="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500"></td>
|
||||
<td class="p-4 align-middle font-mono text-muted-foreground">#${log.ID}</td>
|
||||
<td class="p-4 align-middle font-medium font-mono">${apiKeyName}</td>
|
||||
<td class="p-4 align-middle">${groupName}</td>
|
||||
<td class="p-4 align-middle text-foreground">${log.ErrorMessage || (log.IsSuccess ? "" : "\u672A\u77E5\u9519\u8BEF")}</td>
|
||||
<td class="p-4 align-middle">${errorTag}</td>
|
||||
<td class="p-4 align-middle font-mono">${log.ModelName}</td>
|
||||
<td class="p-4 align-middle text-muted-foreground text-xs">${requestTime}</td>
|
||||
<td class="p-4 align-middle">
|
||||
<button class="btn btn-ghost btn-icon btn-sm" aria-label="\u67E5\u770B\u8BE6\u60C5">
|
||||
<i class="fas fa-ellipsis-h h-4 w-4"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
};
|
||||
var logList_default = LogList;
|
||||
|
||||
// frontend/js/pages/logs/index.js
|
||||
var LogsPage = class {
|
||||
constructor() {
|
||||
this.state = {
|
||||
logs: [],
|
||||
// [修正] 暂时将分页状态设为默认值,直到后端添加分页支持
|
||||
pagination: { page: 1, pages: 1, total: 0 },
|
||||
isLoading: true,
|
||||
filters: { page: 1, page_size: 20 }
|
||||
};
|
||||
this.elements = {
|
||||
tableBody: document.getElementById("logs-table-body")
|
||||
};
|
||||
this.initialized = !!this.elements.tableBody;
|
||||
if (this.initialized) {
|
||||
this.logList = new logList_default(this.elements.tableBody);
|
||||
}
|
||||
}
|
||||
async init() {
|
||||
if (!this.initialized) {
|
||||
console.error("LogsPage: Could not initialize. Essential container element 'logs-table-body' is missing.");
|
||||
return;
|
||||
}
|
||||
this.initEventListeners();
|
||||
await this.loadAndRenderLogs();
|
||||
}
|
||||
initEventListeners() {
|
||||
}
|
||||
async loadAndRenderLogs() {
|
||||
this.state.isLoading = true;
|
||||
this.logList.renderLoading();
|
||||
try {
|
||||
const url = `/admin/logs?page=${this.state.filters.page}&page_size=${this.state.filters.page_size}`;
|
||||
const responseData = await apiFetchJson(url);
|
||||
if (responseData && responseData.success && Array.isArray(responseData.data)) {
|
||||
this.state.logs = responseData.data;
|
||||
this.logList.render(this.state.logs);
|
||||
} else {
|
||||
console.error("API response for logs is incorrect:", responseData);
|
||||
this.logList.render([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load logs:", error);
|
||||
} finally {
|
||||
this.state.isLoading = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
function logs_default() {
|
||||
const page = new LogsPage();
|
||||
page.init();
|
||||
}
|
||||
export {
|
||||
logs_default as default
|
||||
};
|
||||
213
web/static/js/main.js
Normal file
213
web/static/js/main.js
Normal file
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
CustomSelect,
|
||||
modalManager,
|
||||
taskCenterManager,
|
||||
toastManager,
|
||||
uiPatterns
|
||||
} from "./chunk-EZAP7GR4.js";
|
||||
import {
|
||||
apiFetch,
|
||||
apiFetchJson
|
||||
} from "./chunk-PLQL6WIO.js";
|
||||
|
||||
// frontend/js/components/slidingTabs.js
|
||||
var SlidingTabs = class {
|
||||
/**
|
||||
* @param {HTMLElement} containerElement - The main container element with the `data-sliding-tabs-container` attribute.
|
||||
*/
|
||||
constructor(containerElement) {
|
||||
this.container = containerElement;
|
||||
this.indicator = this.container.querySelector("[data-tab-indicator]");
|
||||
this.tabs = this.container.querySelectorAll("[data-tab-item]");
|
||||
this.activeTab = this.container.querySelector(".tab-active");
|
||||
if (!this.indicator || this.tabs.length === 0) {
|
||||
console.error("SlidingTabs component is missing required elements (indicator or items).", this.container);
|
||||
return;
|
||||
}
|
||||
this.init();
|
||||
}
|
||||
init() {
|
||||
if (this.activeTab) {
|
||||
setTimeout(() => this.updateIndicator(this.activeTab), 50);
|
||||
}
|
||||
this.bindEvents();
|
||||
}
|
||||
updateIndicator(targetTab) {
|
||||
if (!targetTab) return;
|
||||
const containerRect = this.container.getBoundingClientRect();
|
||||
const targetRect = targetTab.getBoundingClientRect();
|
||||
const left = targetRect.left - containerRect.left;
|
||||
const width = targetRect.width;
|
||||
this.indicator.style.left = `${left}px`;
|
||||
this.indicator.style.width = `${width}px`;
|
||||
}
|
||||
bindEvents() {
|
||||
this.tabs.forEach((tab) => {
|
||||
tab.addEventListener("click", (e) => {
|
||||
if (this.activeTab) {
|
||||
this.activeTab.classList.remove("tab-active");
|
||||
}
|
||||
tab.classList.add("tab-active");
|
||||
this.activeTab = tab;
|
||||
this.updateIndicator(this.activeTab);
|
||||
});
|
||||
tab.addEventListener("mouseenter", () => {
|
||||
this.updateIndicator(tab);
|
||||
});
|
||||
});
|
||||
this.container.addEventListener("mouseleave", () => {
|
||||
this.updateIndicator(this.activeTab);
|
||||
});
|
||||
}
|
||||
};
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const allTabContainers = document.querySelectorAll("[data-sliding-tabs-container]");
|
||||
allTabContainers.forEach((container) => {
|
||||
new SlidingTabs(container);
|
||||
});
|
||||
});
|
||||
|
||||
// frontend/js/components/themeManager.js
|
||||
var themeManager = {
|
||||
// 用于存储图标的 SVG HTML
|
||||
icons: {},
|
||||
init: function() {
|
||||
this.html = document.documentElement;
|
||||
this.buttons = document.querySelectorAll(".theme-btn");
|
||||
this.cyclerBtn = document.getElementById("theme-cycler-btn");
|
||||
this.cyclerIconContainer = document.getElementById("theme-cycler-icon");
|
||||
if (!this.html || this.buttons.length === 0 || !this.cyclerBtn || !this.cyclerIconContainer) {
|
||||
console.warn("ThemeManager init failed: one or more required elements not found.");
|
||||
return;
|
||||
}
|
||||
this.mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
this.storeIcons();
|
||||
this.buttons.forEach((btn) => {
|
||||
btn.addEventListener("click", () => this.setTheme(btn.dataset.theme));
|
||||
});
|
||||
this.cyclerBtn.addEventListener("click", () => this.cycleTheme());
|
||||
this.mediaQuery.addEventListener("change", () => this.applyTheme());
|
||||
this.applyTheme();
|
||||
},
|
||||
// 从现有按钮中提取并存储 SVG 图标
|
||||
storeIcons: function() {
|
||||
this.buttons.forEach((btn) => {
|
||||
const theme = btn.dataset.theme;
|
||||
const svg = btn.querySelector("svg");
|
||||
if (theme && svg) {
|
||||
this.icons[theme] = svg.outerHTML;
|
||||
}
|
||||
});
|
||||
},
|
||||
// 循环切换主题的核心逻辑
|
||||
cycleTheme: function() {
|
||||
const themes = ["system", "light", "dark"];
|
||||
const currentTheme = this.getTheme();
|
||||
const currentIndex = themes.indexOf(currentTheme);
|
||||
const nextIndex = (currentIndex + 1) % themes.length;
|
||||
this.setTheme(themes[nextIndex]);
|
||||
},
|
||||
applyTheme: function() {
|
||||
let theme = this.getTheme();
|
||||
if (theme === "system") {
|
||||
theme = this.mediaQuery.matches ? "dark" : "light";
|
||||
}
|
||||
if (theme === "dark") {
|
||||
this.html.classList.add("dark");
|
||||
} else {
|
||||
this.html.classList.remove("dark");
|
||||
}
|
||||
this.updateButtons();
|
||||
this.updateCyclerIcon();
|
||||
},
|
||||
setTheme: function(theme) {
|
||||
localStorage.setItem("theme", theme);
|
||||
this.applyTheme();
|
||||
},
|
||||
getTheme: function() {
|
||||
return localStorage.getItem("theme") || "system";
|
||||
},
|
||||
updateButtons: function() {
|
||||
const currentTheme = this.getTheme();
|
||||
this.buttons.forEach((btn) => {
|
||||
if (btn.dataset.theme === currentTheme) {
|
||||
btn.classList.add("bg-white", "dark:bg-zinc-700");
|
||||
} else {
|
||||
btn.classList.remove("bg-white", "dark:bg-zinc-700");
|
||||
}
|
||||
});
|
||||
},
|
||||
// 更新移动端循环按钮的图标
|
||||
updateCyclerIcon: function() {
|
||||
if (this.cyclerIconContainer) {
|
||||
const currentTheme = this.getTheme();
|
||||
if (this.icons[currentTheme]) {
|
||||
this.cyclerIconContainer.innerHTML = this.icons[currentTheme];
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// frontend/js/layout/base.js
|
||||
function initActiveNav() {
|
||||
const currentPath = window.location.pathname;
|
||||
const navLinks = document.querySelectorAll(".nav-link");
|
||||
navLinks.forEach((link) => {
|
||||
const linkPath = link.getAttribute("href");
|
||||
if (linkPath && linkPath !== "/" && currentPath.startsWith(linkPath)) {
|
||||
const wrapper = link.closest(".nav-item-wrapper");
|
||||
if (wrapper) {
|
||||
wrapper.dataset.active = "true";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
function bridgeApiToGlobal() {
|
||||
window.apiFetch = apiFetch;
|
||||
window.apiFetchJson = apiFetchJson;
|
||||
console.log("[Bridge] apiFetch and apiFetchJson are now globally available.");
|
||||
}
|
||||
function initLayout() {
|
||||
console.log("[Init] Executing global layout JavaScript...");
|
||||
initActiveNav();
|
||||
themeManager.init();
|
||||
bridgeApiToGlobal();
|
||||
}
|
||||
var base_default = initLayout;
|
||||
|
||||
// frontend/js/main.js
|
||||
var pageModules = {
|
||||
// 键 'dashboard' 对应一个函数,该函数调用 import() 返回一个 Promise
|
||||
// esbuild 看到这个 import() 语法,就会自动将 dashboard.js 及其依赖打包成一个独立的 chunk 文件
|
||||
"dashboard": () => import("./dashboard-CJJWKYPR.js"),
|
||||
"keys": () => import("./keys-A2UAJYOX.js"),
|
||||
"logs": () => import("./logs-FGZ2SMPN.js")
|
||||
// 'settings': () => import('./pages/settings.js'), // 未来启用 settings 页面
|
||||
// 未来新增的页面,只需在这里添加一行映射,esbuild会自动处理
|
||||
};
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
base_default();
|
||||
const allTabContainers = document.querySelectorAll("[data-sliding-tabs-container]");
|
||||
allTabContainers.forEach((container) => new SlidingTabs(container));
|
||||
const allSelectContainers = document.querySelectorAll("[data-custom-select-container]");
|
||||
allSelectContainers.forEach((container) => new CustomSelect(container));
|
||||
taskCenterManager.init();
|
||||
const pageContainer = document.querySelector("[data-page-id]");
|
||||
if (pageContainer) {
|
||||
const pageId = pageContainer.dataset.pageId;
|
||||
if (pageId && pageModules[pageId]) {
|
||||
try {
|
||||
const pageModule = await pageModules[pageId]();
|
||||
if (pageModule.default && typeof pageModule.default === "function") {
|
||||
pageModule.default();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load module for page: ${pageId}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
window.modalManager = modalManager;
|
||||
window.taskCenterManager = taskCenterManager;
|
||||
window.toastManager = toastManager;
|
||||
window.uiPatterns = uiPatterns;
|
||||
3055
web/static/js/settings.js
Normal file
3055
web/static/js/settings.js
Normal file
File diff suppressed because it is too large
Load Diff
225
web/static/js/status-grid.js
Normal file
225
web/static/js/status-grid.js
Normal file
@@ -0,0 +1,225 @@
|
||||
// Filename: web/static/js/status-grid.js (V6.0 - 健壮初始化最终版)
|
||||
// export default function initializeStatusGrid() {
|
||||
class StatusGrid {
|
||||
constructor(canvasId) {
|
||||
this.canvas = document.getElementById(canvasId);
|
||||
if (!this.canvas) { return; }
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
|
||||
this.config = {
|
||||
cols: 100, rows: 4, gap: 2, cornerRadius: 2,
|
||||
colors: {
|
||||
'ACTIVE': '#22C55E', 'ACTIVE_BLINK': '#A7F3D0',
|
||||
'PENDING': '#9CA3AF', 'COOLDOWN': '#EAB308',
|
||||
'DISABLED': '#F97316', 'BANNED': '#EF4444',
|
||||
'EMPTY': '#F3F4F6',
|
||||
},
|
||||
blinkInterval: 100, blinksPerInterval: 2, blinkDuration: 200,
|
||||
statusOrder: ['ACTIVE', 'COOLDOWN', 'DISABLED', 'BANNED', 'PENDING']
|
||||
};
|
||||
this.state = {
|
||||
squares: [], squareSize: 0,
|
||||
devicePixelRatio: window.devicePixelRatio || 1,
|
||||
animationFrameId: null, blinkIntervalId: null,
|
||||
blinkingSquares: new Set()
|
||||
};
|
||||
|
||||
this.debouncedResize = this.debounce(this.resize.bind(this), 250);
|
||||
}
|
||||
init() {
|
||||
this.setupCanvas();
|
||||
// 初始化时,绘制一个完全为空的网格
|
||||
this.state.squares = Array(this.config.rows * this.config.cols).fill({ status: 'EMPTY' });
|
||||
this.drawFullGrid();
|
||||
window.addEventListener('resize', this.debouncedResize);
|
||||
}
|
||||
|
||||
/**
|
||||
* [灵魂重塑] 这是被彻底重写的核心函数
|
||||
* @param {object} keyStatusCounts - 例如 { "BANNED": 1, "DISABLED": 4, ... }
|
||||
*/
|
||||
updateData(keyStatusCounts) {
|
||||
if (!keyStatusCounts) return;
|
||||
|
||||
this.destroyAnimation();
|
||||
this.state.squares = [];
|
||||
this._activeIndices = null;
|
||||
this.state.blinkingSquares.clear();
|
||||
const totalKeys = Object.values(keyStatusCounts).reduce((s, c) => s + c, 0);
|
||||
const totalSquares = this.config.rows * this.config.cols;
|
||||
// 如果没有密钥,则显示为空白的网格
|
||||
if (totalKeys === 0) {
|
||||
this.init();
|
||||
return;
|
||||
}
|
||||
let statusMap = [];
|
||||
let calculatedSquares = 0;
|
||||
// 1. 严格按照比例,计算每个状态应该占据多少个“像素”
|
||||
for (const status of this.config.statusOrder) {
|
||||
const count = keyStatusCounts[status] || 0;
|
||||
if (count > 0) {
|
||||
const proportion = count / totalKeys;
|
||||
const squaresForStatus = Math.floor(proportion * totalSquares);
|
||||
for (let i = 0; i < squaresForStatus; i++) {
|
||||
statusMap.push(status);
|
||||
}
|
||||
calculatedSquares += squaresForStatus;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. [关键] 修正四舍五入的误差,将剩余的方块填满
|
||||
const remainingSquares = totalSquares - calculatedSquares;
|
||||
if (remainingSquares > 0) {
|
||||
// 将剩余方块,全部分配给数量最多的那个状态,以使其最不失真
|
||||
let largestStatus = this.config.statusOrder[0]; // 默认给第一个
|
||||
let maxCount = -1;
|
||||
for (const status in keyStatusCounts) {
|
||||
if (keyStatusCounts[status] > maxCount) {
|
||||
maxCount = keyStatusCounts[status];
|
||||
largestStatus = status;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < remainingSquares; i++) {
|
||||
statusMap.push(largestStatus);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 将最终的、按比例填充的地图,转化为内部的 square 对象
|
||||
this.state.squares = statusMap.map(status => ({
|
||||
status: status,
|
||||
isBlinking: false,
|
||||
blinkUntil: 0
|
||||
}));
|
||||
|
||||
// 4. [渲染修正] 直接、完整地重绘整个网格,然后启动动画
|
||||
this.drawFullGrid();
|
||||
this.startAnimationSystem();
|
||||
}
|
||||
// [渲染修正] 一个简单、直接、一次性绘制所有方块的函数
|
||||
drawFullGrid() {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
this.ctx.clearRect(0, 0, rect.width, rect.height);
|
||||
|
||||
// offsetX 和 offsetY 是在 setupCanvas 中计算的
|
||||
const offsetX = this.state.offsetX;
|
||||
const offsetY = this.state.offsetY;
|
||||
this.state.squares.forEach((square, i) => {
|
||||
const c = i % this.config.cols;
|
||||
const r = Math.floor(i / this.config.cols);
|
||||
|
||||
const x = offsetX + c * (this.state.squareSize + this.config.gap);
|
||||
const y = offsetY + r * (this.state.squareSize + this.config.gap);
|
||||
const color = this.config.colors[square.status];
|
||||
this.drawRoundedRect(x, y, this.state.squareSize, this.config.cornerRadius, color);
|
||||
});
|
||||
}
|
||||
|
||||
startAnimationSystem() {
|
||||
if(this.state.blinkIntervalId) clearInterval(this.state.blinkIntervalId);
|
||||
if(this.state.animationFrameId) cancelAnimationFrame(this.state.animationFrameId);
|
||||
|
||||
this.state.blinkIntervalId = setInterval(() => {
|
||||
const activeSquareIndices = this.getActiveSquareIndices();
|
||||
if (activeSquareIndices.length === 0) return;
|
||||
|
||||
for(let i = 0; i < this.config.blinksPerInterval; i++) {
|
||||
const randomIndex = activeSquareIndices[Math.floor(Math.random() * activeSquareIndices.length)];
|
||||
const square = this.state.squares[randomIndex];
|
||||
|
||||
if (square && !square.isBlinking) {
|
||||
square.isBlinking = true;
|
||||
square.blinkUntil = performance.now() + this.config.blinkDuration;
|
||||
this.state.blinkingSquares.add(randomIndex);
|
||||
}
|
||||
}
|
||||
}, this.config.blinkInterval);
|
||||
|
||||
const animationLoop = (timestamp) => {
|
||||
if (this.state.blinkingSquares.size > 0) {
|
||||
this.state.blinkingSquares.forEach(index => {
|
||||
const square = this.state.squares[index];
|
||||
if(!square) { // 防御性检查
|
||||
this.state.blinkingSquares.delete(index);
|
||||
return;
|
||||
}
|
||||
const c = index % this.config.cols;
|
||||
const r = Math.floor(index / this.config.cols);
|
||||
|
||||
this.drawSquare(square, c, r);
|
||||
|
||||
if (timestamp > square.blinkUntil) {
|
||||
square.isBlinking = false;
|
||||
this.state.blinkingSquares.delete(index);
|
||||
this.drawSquare(square, c, r);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.state.animationFrameId = requestAnimationFrame(animationLoop);
|
||||
};
|
||||
animationLoop();
|
||||
}
|
||||
|
||||
setupCanvas() {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
this.canvas.width = rect.width * this.state.devicePixelRatio;
|
||||
this.canvas.height = rect.height * this.state.devicePixelRatio;
|
||||
this.ctx.scale(this.state.devicePixelRatio, this.state.devicePixelRatio);
|
||||
const calculatedWidth = (rect.width - (this.config.cols - 1) * this.config.gap) / this.config.cols;
|
||||
const calculatedHeight = (rect.height - (this.config.rows - 1) * this.config.gap) / this.config.rows;
|
||||
this.state.squareSize = Math.max(1, Math.floor(Math.min(calculatedWidth, calculatedHeight)));
|
||||
const totalGridWidth = this.config.cols * this.state.squareSize + (this.config.cols - 1) * this.config.gap;
|
||||
const totalGridHeight = this.config.rows * this.state.squareSize + (this.config.rows - 1) * this.config.gap;
|
||||
this.state.offsetX = Math.floor((rect.width - totalGridWidth) / 2);
|
||||
this.state.offsetY = Math.floor((rect.height - totalGridHeight) / 2);
|
||||
}
|
||||
|
||||
getActiveSquareIndices() {
|
||||
if (!this._activeIndices) {
|
||||
this._activeIndices = [];
|
||||
this.state.squares.forEach((s, i) => {
|
||||
if (s.status === 'ACTIVE') this._activeIndices.push(i);
|
||||
});
|
||||
}
|
||||
return this._activeIndices;
|
||||
}
|
||||
|
||||
drawRoundedRect(x, y, size, radius, color) {
|
||||
this.ctx.fillStyle = color;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(x + radius, y);
|
||||
this.ctx.arcTo(x + size, y, x + size, y + size, radius);
|
||||
this.ctx.arcTo(x + size, y + size, x, y + size, radius);
|
||||
this.ctx.arcTo(x, y + size, x, y, radius);
|
||||
this.ctx.arcTo(x, y, x + size, y, radius);
|
||||
this.ctx.closePath();
|
||||
this.ctx.fill();
|
||||
}
|
||||
|
||||
drawSquare(square, c, r) {
|
||||
const x = this.state.offsetX + c * (this.state.squareSize + this.config.gap);
|
||||
const y = this.state.offsetY + r * (this.state.squareSize + this.config.gap);
|
||||
const color = square.isBlinking ? this.config.colors.ACTIVE_BLINK : this.config.colors[square.status];
|
||||
this.drawRoundedRect(x, y, this.state.squareSize, this.config.cornerRadius, color);
|
||||
}
|
||||
|
||||
destroyAnimation() {
|
||||
if(this.state.animationFrameId) cancelAnimationFrame(this.state.animationFrameId);
|
||||
if(this.state.blinkIntervalId) clearInterval(this.state.blinkIntervalId);
|
||||
this.state.animationFrameId = null;
|
||||
this.state.blinkIntervalId = null;
|
||||
}
|
||||
|
||||
resize() {
|
||||
this.destroyAnimation();
|
||||
this.init(); // 重新绘制空网格
|
||||
// 这里依赖dashboard.js在resize后重新获取并传入数据
|
||||
}
|
||||
|
||||
debounce(func, delay) {
|
||||
let timeout;
|
||||
return (...args) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(this, args), delay);
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user