This commit is contained in:
XOF
2025-11-20 12:24:05 +08:00
commit f28bdc751f
164 changed files with 64248 additions and 0 deletions

65
web/static/js/api.js Normal file
View 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

File diff suppressed because it is too large Load Diff

View 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
};

View 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
};

View 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
};

View 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
View 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 = "");
});
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

2245
web/static/js/keys_status.js Normal file

File diff suppressed because it is too large Load Diff

View 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
View 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

File diff suppressed because it is too large Load Diff

View 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);
};
}
}