4164 lines
186 KiB
JavaScript
4164 lines
186 KiB
JavaScript
(() => {
|
||
// 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/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();
|
||
|
||
// frontend/js/pages/dashboard.js
|
||
function init() {
|
||
console.log("[Modern Frontend] Dashboard module loaded. Future logic will execute here.");
|
||
}
|
||
|
||
// frontend/js/components/tagInput.js
|
||
var TagInput = class {
|
||
constructor(container, options = {}) {
|
||
if (!container) {
|
||
console.error("TagInput container not found.");
|
||
return;
|
||
}
|
||
this.container = container;
|
||
this.input = container.querySelector(".tag-input-new");
|
||
this.tags = [];
|
||
this.options = {
|
||
validator: /.+/,
|
||
validationMessage: "\u8F93\u5165\u683C\u5F0F\u65E0\u6548",
|
||
...options
|
||
};
|
||
this.copyBtn = document.createElement("button");
|
||
this.copyBtn.className = "tag-copy-btn";
|
||
this.copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
|
||
this.copyBtn.title = "\u590D\u5236\u6240\u6709";
|
||
this.container.appendChild(this.copyBtn);
|
||
this._initEventListeners();
|
||
}
|
||
_initEventListeners() {
|
||
this.container.addEventListener("click", (e) => {
|
||
if (e.target.closest(".tag-delete")) {
|
||
this._removeTag(e.target.closest(".tag-item"));
|
||
}
|
||
});
|
||
if (this.input) {
|
||
this.input.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter" || e.key === "," || e.key === " ") {
|
||
e.preventDefault();
|
||
const value = this.input.value.trim();
|
||
if (value) {
|
||
this._addTag(value);
|
||
this.input.value = "";
|
||
}
|
||
}
|
||
});
|
||
this.input.addEventListener("blur", () => {
|
||
const value = this.input.value.trim();
|
||
if (value) {
|
||
this._addTag(value);
|
||
this.input.value = "";
|
||
}
|
||
});
|
||
}
|
||
this.copyBtn.addEventListener("click", this._handleCopyAll.bind(this));
|
||
}
|
||
_addTag(raw_value) {
|
||
const value = raw_value.toLowerCase();
|
||
if (!this.options.validator.test(value)) {
|
||
console.warn(`Tag validation failed for value: "${value}". Rule: ${this.options.validator}`);
|
||
this.input.placeholder = this.options.validationMessage;
|
||
this.input.classList.add("input-error");
|
||
setTimeout(() => {
|
||
this.input.classList.remove("input-error");
|
||
this.input.placeholder = "\u6DFB\u52A0...";
|
||
}, 2e3);
|
||
return;
|
||
}
|
||
if (this.tags.includes(value)) return;
|
||
this.tags.push(value);
|
||
const tagEl = document.createElement("span");
|
||
tagEl.className = "tag-item";
|
||
tagEl.innerHTML = `<span class="tag-text">${value}</span><button class="tag-delete">×</button>`;
|
||
this.container.insertBefore(tagEl, this.input);
|
||
}
|
||
// 处理复制逻辑的专用方法
|
||
_handleCopyAll() {
|
||
const tagsString = this.tags.join(",");
|
||
if (!tagsString) {
|
||
this.copyBtn.innerHTML = "<span>\u65E0\u5185\u5BB9!</span>";
|
||
this.copyBtn.classList.add("none");
|
||
setTimeout(() => {
|
||
this.copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
|
||
this.copyBtn.classList.remove("copied");
|
||
}, 1500);
|
||
return;
|
||
}
|
||
navigator.clipboard.writeText(tagsString).then(() => {
|
||
this.copyBtn.innerHTML = "<span>\u5DF2\u590D\u5236!</span>";
|
||
this.copyBtn.classList.add("copied");
|
||
setTimeout(() => {
|
||
this.copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
|
||
this.copyBtn.classList.remove("copied");
|
||
}, 2e3);
|
||
}).catch((err) => {
|
||
console.error("Could not copy text: ", err);
|
||
this.copyBtn.innerHTML = "<span>\u5931\u8D25!</span>";
|
||
setTimeout(() => {
|
||
this.copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
|
||
}, 2e3);
|
||
});
|
||
}
|
||
_removeTag(tagEl) {
|
||
const value = tagEl.querySelector(".tag-text").textContent;
|
||
this.tags = this.tags.filter((t) => t !== value);
|
||
tagEl.remove();
|
||
}
|
||
getValues() {
|
||
return this.tags;
|
||
}
|
||
setValues(values) {
|
||
this.container.querySelectorAll(".tag-item").forEach((el) => el.remove());
|
||
this.tags = [];
|
||
if (Array.isArray(values)) {
|
||
values.filter((value) => value).forEach((value) => this._addTag(value));
|
||
}
|
||
}
|
||
};
|
||
|
||
// frontend/js/pages/keys/requestSettingsModal.js
|
||
var RequestSettingsModal = class {
|
||
constructor({ onSave }) {
|
||
this.modalId = "request-settings-modal";
|
||
this.modal = document.getElementById(this.modalId);
|
||
this.onSave = onSave;
|
||
if (!this.modal) {
|
||
throw new Error(`Modal with id "${this.modalId}" not found.`);
|
||
}
|
||
this.elements = {
|
||
saveBtn: document.getElementById("request-settings-save-btn"),
|
||
customHeadersContainer: document.getElementById("CUSTOM_HEADERS_container"),
|
||
addCustomHeaderBtn: document.getElementById("addCustomHeaderBtn"),
|
||
streamOptimizerEnabled: document.getElementById("STREAM_OPTIMIZER_ENABLED"),
|
||
streamingSettingsPanel: document.getElementById("streaming-settings-panel"),
|
||
streamMinDelay: document.getElementById("STREAM_MIN_DELAY"),
|
||
streamMaxDelay: document.getElementById("STREAM_MAX_DELAY"),
|
||
streamShortTextThresh: document.getElementById("STREAM_SHORT_TEXT_THRESHOLD"),
|
||
streamLongTextThresh: document.getElementById("STREAM_LONG_TEXT_THRESHOLD"),
|
||
streamChunkSize: document.getElementById("STREAM_CHUNK_SIZE"),
|
||
fakeStreamEnabled: document.getElementById("FAKE_STREAM_ENABLED"),
|
||
fakeStreamInterval: document.getElementById("FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS"),
|
||
toolsCodeExecutionEnabled: document.getElementById("TOOLS_CODE_EXECUTION_ENABLED"),
|
||
urlContextEnabled: document.getElementById("URL_CONTEXT_ENABLED"),
|
||
showSearchLink: document.getElementById("SHOW_SEARCH_LINK"),
|
||
showThinkingProcess: document.getElementById("SHOW_THINKING_PROCESS"),
|
||
safetySettingsContainer: document.getElementById("SAFETY_SETTINGS_container"),
|
||
addSafetySettingBtn: document.getElementById("addSafetySettingBtn"),
|
||
configOverrides: document.getElementById("group-config-overrides")
|
||
};
|
||
this._initEventListeners();
|
||
}
|
||
// --- 公共 API ---
|
||
/**
|
||
* 打開模態框並填充數據
|
||
* @param {object} data - 用於填充表單的數據
|
||
*/
|
||
open(data) {
|
||
this._populateForm(data);
|
||
modalManager.show(this.modalId);
|
||
}
|
||
/**
|
||
* 關閉模態框
|
||
*/
|
||
close() {
|
||
modalManager.hide(this.modalId);
|
||
}
|
||
// --- 內部事件與邏輯 ---
|
||
_initEventListeners() {
|
||
this.modal.addEventListener("click", (e) => {
|
||
const removeBtn = e.target.closest(".remove-btn");
|
||
if (removeBtn) {
|
||
removeBtn.parentElement.remove();
|
||
}
|
||
});
|
||
if (this.elements.addCustomHeaderBtn) {
|
||
this.elements.addCustomHeaderBtn.addEventListener("click", () => this.addCustomHeaderItem());
|
||
}
|
||
if (this.elements.addSafetySettingBtn) {
|
||
this.elements.addSafetySettingBtn.addEventListener("click", () => this.addSafetySettingItem());
|
||
}
|
||
if (this.elements.saveBtn) {
|
||
this.elements.saveBtn.addEventListener("click", this._handleSave.bind(this));
|
||
}
|
||
if (this.elements.streamOptimizerEnabled) {
|
||
this.elements.streamOptimizerEnabled.addEventListener("change", (e) => {
|
||
this._toggleStreamingPanel(e.target.checked);
|
||
});
|
||
}
|
||
const closeAction = () => {
|
||
modalManager.hide(this.modalId);
|
||
};
|
||
const closeTriggers = this.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
|
||
closeTriggers.forEach((trigger) => {
|
||
trigger.addEventListener("click", closeAction);
|
||
});
|
||
this.modal.addEventListener("click", (event) => {
|
||
if (event.target === this.modal) {
|
||
closeAction();
|
||
}
|
||
});
|
||
}
|
||
async _handleSave() {
|
||
const data = this._collectFormData();
|
||
if (this.onSave) {
|
||
try {
|
||
if (this.elements.saveBtn) {
|
||
this.elements.saveBtn.disabled = true;
|
||
this.elements.saveBtn.textContent = "Saving...";
|
||
}
|
||
await this.onSave(data);
|
||
this.close();
|
||
} catch (error) {
|
||
console.error("Failed to save request settings:", error);
|
||
alert(`\u4FDD\u5B58\u5931\u6557: ${error.message}`);
|
||
} finally {
|
||
if (this.elements.saveBtn) {
|
||
this.elements.saveBtn.disabled = false;
|
||
this.elements.saveBtn.textContent = "Save Changes";
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// --- 所有表單處理輔助方法 ---
|
||
_populateForm(data = {}) {
|
||
const isStreamOptimizerEnabled = !!data.stream_optimizer_enabled;
|
||
this._setToggle(this.elements.streamOptimizerEnabled, isStreamOptimizerEnabled);
|
||
this._toggleStreamingPanel(isStreamOptimizerEnabled);
|
||
this._setValue(this.elements.streamMinDelay, data.stream_min_delay);
|
||
this._setValue(this.elements.streamMaxDelay, data.stream_max_delay);
|
||
this._setValue(this.elements.streamShortTextThresh, data.stream_short_text_threshold);
|
||
this._setValue(this.elements.streamLongTextThresh, data.stream_long_text_threshold);
|
||
this._setValue(this.elements.streamChunkSize, data.stream_chunk_size);
|
||
this._setToggle(this.elements.fakeStreamEnabled, data.fake_stream_enabled);
|
||
this._setValue(this.elements.fakeStreamInterval, data.fake_stream_empty_data_interval_seconds);
|
||
this._setToggle(this.elements.toolsCodeExecutionEnabled, data.tools_code_execution_enabled);
|
||
this._setToggle(this.elements.urlContextEnabled, data.url_context_enabled);
|
||
this._setToggle(this.elements.showSearchLink, data.show_search_link);
|
||
this._setToggle(this.elements.showThinkingProcess, data.show_thinking_process);
|
||
this._setValue(this.elements.configOverrides, data.config_overrides);
|
||
this._populateKVItems(this.elements.customHeadersContainer, data.custom_headers, this.addCustomHeaderItem.bind(this));
|
||
this._clearContainer(this.elements.safetySettingsContainer);
|
||
if (data.safety_settings && typeof data.safety_settings === "object") {
|
||
for (const [key, value] of Object.entries(data.safety_settings)) {
|
||
this.addSafetySettingItem(key, value);
|
||
}
|
||
}
|
||
}
|
||
/**
|
||
* Collects all data from the form fields and returns it as an object.
|
||
* @returns {object} The collected request configuration data.
|
||
*/
|
||
collectFormData() {
|
||
return {
|
||
// Simple Toggles & Inputs
|
||
stream_optimizer_enabled: this.elements.streamOptimizerEnabled.checked,
|
||
stream_min_delay: parseInt(this.elements.streamMinDelay.value, 10),
|
||
stream_max_delay: parseInt(this.elements.streamMaxDelay.value, 10),
|
||
stream_short_text_threshold: parseInt(this.elements.streamShortTextThresh.value, 10),
|
||
stream_long_text_threshold: parseInt(this.elements.streamLongTextThresh.value, 10),
|
||
stream_chunk_size: parseInt(this.elements.streamChunkSize.value, 10),
|
||
fake_stream_enabled: this.elements.fakeStreamEnabled.checked,
|
||
fake_stream_empty_data_interval_seconds: parseInt(this.elements.fakeStreamInterval.value, 10),
|
||
tools_code_execution_enabled: this.elements.toolsCodeExecutionEnabled.checked,
|
||
url_context_enabled: this.elements.urlContextEnabled.checked,
|
||
show_search_link: this.elements.showSearchLink.checked,
|
||
show_thinking_process: this.elements.showThinkingProcess.checked,
|
||
config_overrides: this.elements.configOverrides.value,
|
||
// Dynamic & Complex Fields
|
||
custom_headers: this._collectKVItems(this.elements.customHeadersContainer),
|
||
safety_settings: this._collectSafetySettings(this.elements.safetySettingsContainer)
|
||
// TODO: Collect from Tag Inputs
|
||
// image_models: this.imageModelsInput.getValues(),
|
||
};
|
||
}
|
||
// 控制流式面板显示/隐藏的辅助函数
|
||
_toggleStreamingPanel(is_enabled) {
|
||
if (this.elements.streamingSettingsPanel) {
|
||
if (is_enabled) {
|
||
this.elements.streamingSettingsPanel.classList.remove("hidden");
|
||
} else {
|
||
this.elements.streamingSettingsPanel.classList.add("hidden");
|
||
}
|
||
}
|
||
}
|
||
/**
|
||
* Adds a new key-value pair item for Custom Headers.
|
||
* @param {string} [key=''] - The initial key.
|
||
* @param {string} [value=''] - The initial value.
|
||
*/
|
||
addCustomHeaderItem(key = "", value = "") {
|
||
const container = this.elements.customHeadersContainer;
|
||
const item = document.createElement("div");
|
||
item.className = "dynamic-kv-item";
|
||
item.innerHTML = `
|
||
<input type="text" class="modal-input text-xs bg-zinc-100 dark:bg-zinc-700/50" placeholder="Header Name" value="${key}">
|
||
<input type="text" class="modal-input text-xs" placeholder="Header Value" value="${value}">
|
||
<button type="button" class="remove-btn text-zinc-400 hover:text-red-500 transition-colors"><i class="fas fa-trash-alt"></i></button>
|
||
`;
|
||
container.appendChild(item);
|
||
}
|
||
/**
|
||
* Adds a new item for Safety Settings.
|
||
* @param {string} [category=''] - The initial category.
|
||
* @param {string} [threshold=''] - The initial threshold.
|
||
*/
|
||
addSafetySettingItem(category = "", threshold = "") {
|
||
const container = this.elements.safetySettingsContainer;
|
||
const item = document.createElement("div");
|
||
item.className = "safety-setting-item flex items-center gap-x-2";
|
||
const harmCategories = [
|
||
"HARM_CATEGORY_HARASSMENT",
|
||
"HARM_CATEGORY_HATE_SPEECH",
|
||
"HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||
"HARM_CATEGORY_DANGEROUS_CONTENT",
|
||
"HARM_CATEGORY_DANGEROUS_CONTENT",
|
||
"HARM_CATEGORY_CIVIC_INTEGRITY"
|
||
];
|
||
const harmThresholds = [
|
||
"BLOCK_OFF",
|
||
"BLOCK_NONE",
|
||
"BLOCK_LOW_AND_ABOVE",
|
||
"BLOCK_MEDIUM_AND_ABOVE",
|
||
"BLOCK_ONLY_HIGH"
|
||
];
|
||
const categorySelect = document.createElement("select");
|
||
categorySelect.className = "modal-input flex-grow";
|
||
harmCategories.forEach((cat) => {
|
||
const option = new Option(cat.replace("HARM_CATEGORY_", ""), cat);
|
||
if (cat === category) option.selected = true;
|
||
categorySelect.add(option);
|
||
});
|
||
const thresholdSelect = document.createElement("select");
|
||
thresholdSelect.className = "modal-input w-48";
|
||
harmThresholds.forEach((thr) => {
|
||
const option = new Option(thr.replace("BLOCK_", "").replace("_AND_ABOVE", "+"), thr);
|
||
if (thr === threshold) option.selected = true;
|
||
thresholdSelect.add(option);
|
||
});
|
||
const removeButton = document.createElement("button");
|
||
removeButton.type = "button";
|
||
removeButton.className = "remove-btn text-zinc-400 hover:text-red-500 transition-colors";
|
||
removeButton.innerHTML = `<i class="fas fa-trash-alt"></i>`;
|
||
item.appendChild(categorySelect);
|
||
item.appendChild(thresholdSelect);
|
||
item.appendChild(removeButton);
|
||
container.appendChild(item);
|
||
}
|
||
// --- Private Helper Methods for Form Handling ---
|
||
_setValue(element, value) {
|
||
if (element && value !== null && value !== void 0) {
|
||
element.value = value;
|
||
}
|
||
}
|
||
_setToggle(element, value) {
|
||
if (element) {
|
||
element.checked = !!value;
|
||
}
|
||
}
|
||
_clearContainer(container) {
|
||
if (container) {
|
||
const firstChild = container.firstElementChild;
|
||
const isTemplate = firstChild && (firstChild.tagName === "TEMPLATE" || firstChild.id === "kv-item-header");
|
||
let child = isTemplate ? firstChild.nextElementSibling : container.firstElementChild;
|
||
while (child) {
|
||
const next = child.nextElementSibling;
|
||
child.remove();
|
||
child = next;
|
||
}
|
||
}
|
||
}
|
||
_populateKVItems(container, items, addItemFn) {
|
||
this._clearContainer(container);
|
||
if (items && typeof items === "object") {
|
||
for (const [key, value] of Object.entries(items)) {
|
||
addItemFn(key, value);
|
||
}
|
||
}
|
||
}
|
||
_collectKVItems(container) {
|
||
const items = {};
|
||
container.querySelectorAll(".dynamic-kv-item").forEach((item) => {
|
||
const keyEl = item.querySelector(".dynamic-kv-key");
|
||
const valueEl = item.querySelector(".dynamic-kv-value");
|
||
if (keyEl && valueEl && keyEl.value) {
|
||
items[keyEl.value] = valueEl.value;
|
||
}
|
||
});
|
||
return items;
|
||
}
|
||
_collectSafetySettings(container) {
|
||
const items = {};
|
||
container.querySelectorAll(".safety-setting-item").forEach((item) => {
|
||
const categorySelect = item.querySelector("select:first-child");
|
||
const thresholdSelect = item.querySelector("select:last-of-type");
|
||
if (categorySelect && thresholdSelect && categorySelect.value) {
|
||
items[categorySelect.value] = thresholdSelect.value;
|
||
}
|
||
});
|
||
return items;
|
||
}
|
||
};
|
||
|
||
// 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;
|
||
}
|
||
}
|
||
|
||
// frontend/js/components/apiKeyManager.js
|
||
var ApiKeyManager = class {
|
||
constructor() {
|
||
}
|
||
// [新增] 开始一个向指定分组添加Keys的异步任务
|
||
/**
|
||
* Starts a task to add multiple API keys to a specific group.
|
||
* @param {number} groupId - The ID of the group.
|
||
* @param {string} keysText - A string of keys, separated by newlines.
|
||
* @returns {Promise<object>} A promise that resolves to the initial task status object.
|
||
*/
|
||
async addKeysToGroup(groupId, keysText, validate) {
|
||
const payload = {
|
||
key_group_id: groupId,
|
||
keys: keysText,
|
||
validate_on_import: validate
|
||
};
|
||
const response = await apiFetch(`/admin/keygroups/${groupId}/apikeys/bulk`, {
|
||
method: "POST",
|
||
body: JSON.stringify(payload),
|
||
noCache: true
|
||
});
|
||
return response.json();
|
||
}
|
||
// [新增] 查询一个指定任务的当前状态
|
||
/**
|
||
* Gets the current status of a background task.
|
||
* @param {string} taskId - The ID of the task.
|
||
* @returns {Promise<object>} A promise that resolves to the task status object.
|
||
*/
|
||
getTaskStatus(taskId, options = {}) {
|
||
return apiFetchJson(`/admin/tasks/${taskId}`, options);
|
||
}
|
||
/**
|
||
* Fetches a paginated and filtered list of keys.
|
||
* @param {string} type - The type of keys to fetch ('valid' or 'invalid').
|
||
* @param {number} [page=1] - The page number to retrieve.
|
||
* @param {number} [limit=10] - The number of keys per page.
|
||
* @param {string} [searchTerm=''] - A search term to filter keys.
|
||
* @param {number|null} [failCountThreshold=null] - A threshold for filtering by failure count.
|
||
* @returns {Promise<object>} A promise that resolves to the API response data.
|
||
*/
|
||
async fetchKeys(type, page = 1, limit = 10, searchTerm = "", failCountThreshold = null) {
|
||
const params = new URLSearchParams({
|
||
page,
|
||
limit,
|
||
status: type
|
||
});
|
||
if (searchTerm) params.append("search", searchTerm);
|
||
if (failCountThreshold !== null) params.append("fail_count_threshold", failCountThreshold);
|
||
return await apiFetch(`/api/keys?${params.toString()}`);
|
||
}
|
||
/**
|
||
* Starts a task to unlink multiple API keys from a specific group.
|
||
* @param {number} groupId - The ID of the group.
|
||
* @param {string} keysText - A string of keys, separated by newlines.
|
||
* @returns {Promise<object>} A promise that resolves to the initial task status object.
|
||
*/
|
||
async unlinkKeysFromGroup(groupId, keysInput) {
|
||
let keysAsText;
|
||
if (Array.isArray(keysInput)) {
|
||
keysAsText = keysInput.join("\n");
|
||
} else {
|
||
keysAsText = keysInput;
|
||
}
|
||
const payload = {
|
||
key_group_id: groupId,
|
||
keys: keysAsText
|
||
};
|
||
const response = await apiFetch(`/admin/keygroups/${groupId}/apikeys/bulk`, {
|
||
method: "DELETE",
|
||
body: JSON.stringify(payload),
|
||
noCache: true
|
||
});
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({ message: response.statusText }));
|
||
throw new Error(errorData.message || `Request failed with status ${response.status}`);
|
||
}
|
||
return response.json();
|
||
}
|
||
/**
|
||
* 更新一个Key在特定分组中的状态 (e.g., 'ACTIVE', 'DISABLED').
|
||
* @param {number} groupId - The ID of the group.
|
||
* @param {number} keyId - The ID of the API key (api_keys.id).
|
||
* @param {string} newStatus - The new operational status ('ACTIVE', 'DISABLED', etc.).
|
||
* @returns {Promise<object>} A promise that resolves to the updated mapping object.
|
||
*/
|
||
async updateKeyStatusInGroup(groupId, keyId, newStatus) {
|
||
const endpoint = `/admin/keygroups/${groupId}/apikeys/${keyId}`;
|
||
const payload = { status: newStatus };
|
||
return await apiFetchJson(endpoint, {
|
||
method: "PUT",
|
||
body: JSON.stringify(payload),
|
||
noCache: true
|
||
});
|
||
}
|
||
/**
|
||
* [MODIFIED] Fetches a paginated and filtered list of API key details for a specific group.
|
||
* @param {number} groupId - The ID of the group.
|
||
* @param {object} [params={}] - An object containing pagination and filter parameters.
|
||
* @param {number} [params.page=1] - The page number to fetch.
|
||
* @param {number} [params.limit=20] - The number of items per page.
|
||
* @param {string} [params.status] - An optional status to filter the keys by.
|
||
* @returns {Promise<object>} A promise that resolves to a pagination object.
|
||
*/
|
||
async getKeysForGroup(groupId, params = {}) {
|
||
const query = new URLSearchParams({
|
||
page: params.page || 1,
|
||
// Default to page 1 if not provided
|
||
limit: params.limit || 20
|
||
// Default to 20 per page if not provided
|
||
});
|
||
if (params.status) {
|
||
query.append("status", params.status);
|
||
}
|
||
if (params.keyword && params.keyword.trim() !== "") {
|
||
query.append("keyword", params.keyword.trim());
|
||
}
|
||
const url = `/admin/keygroups/${groupId}/apikeys?${query.toString()}`;
|
||
const responseData = await apiFetchJson(url, { noCache: true });
|
||
if (!responseData.success || typeof responseData.data !== "object" || !Array.isArray(responseData.data.items)) {
|
||
throw new Error(responseData.message || "Failed to fetch paginated keys for the group.");
|
||
}
|
||
return responseData.data;
|
||
}
|
||
/**
|
||
* 启动一个重新验证一个或多个Key的异步任务。
|
||
* @param {number} groupId - The ID of the group context for validation.
|
||
* @param {string[]} keyValues - An array of API key strings to revalidate.
|
||
* @returns {Promise<object>} A promise that resolves to the initial task status object.
|
||
*/
|
||
async revalidateKeys(groupId, keyValues) {
|
||
const payload = {
|
||
keys: keyValues.join("\n")
|
||
};
|
||
const url = `/admin/keygroups/${groupId}/apikeys/test`;
|
||
const responseData = await apiFetchJson(url, {
|
||
method: "POST",
|
||
body: JSON.stringify(payload),
|
||
noCache: true
|
||
});
|
||
if (!responseData.success || !responseData.data) {
|
||
throw new Error(responseData.message || "Failed to start revalidation task.");
|
||
}
|
||
return responseData.data;
|
||
}
|
||
/**
|
||
* Starts a generic bulk action task for an entire group based on filters.
|
||
* This single function replaces the need for separate cleanup, revalidate, and restore functions.
|
||
* @param {number} groupId The group ID.
|
||
* @param {object} payload The body of the request, defining the action and filters.
|
||
* @returns {Promise<object>} The initial task response with a task_id.
|
||
*/
|
||
async startGroupBulkActionTask(groupId, payload) {
|
||
const url = `/admin/keygroups/${groupId}/bulk-actions`;
|
||
const responseData = await apiFetchJson(url, {
|
||
method: "POST",
|
||
body: JSON.stringify(payload)
|
||
});
|
||
if (!responseData.success || !responseData.data) {
|
||
throw new Error(responseData.message || "\u672A\u80FD\u542F\u52A8\u5206\u7EC4\u6279\u91CF\u4EFB\u52A1\u3002");
|
||
}
|
||
return responseData.data;
|
||
}
|
||
/**
|
||
* [NEW] Fetches all keys for a group, filtered by status, for export purposes using the dedicated export API.
|
||
* @param {number} groupId The ID of the group.
|
||
* @param {string[]} statuses An array of statuses to filter by (e.g., ['active', 'cooldown']). Use ['all'] for everything.
|
||
* @returns {Promise<string[]>} A promise that resolves to an array of API key strings.
|
||
*/
|
||
async exportKeysForGroup(groupId, statuses = ["all"]) {
|
||
const params = new URLSearchParams();
|
||
statuses.forEach((status) => params.append("status", status));
|
||
const url = `/admin/keygroups/${groupId}/apikeys/export?${params.toString()}`;
|
||
const responseData = await apiFetchJson(url, { noCache: true });
|
||
if (!responseData.success || !Array.isArray(responseData.data)) {
|
||
throw new Error(responseData.message || "\u672A\u80FD\u83B7\u53D6\u7528\u4E8E\u5BFC\u51FA\u7684Key\u5217\u8868\u3002");
|
||
}
|
||
return responseData.data;
|
||
}
|
||
/** !!!以下为GB预置函数,未做对齐
|
||
* Verifies a single API key.
|
||
* @param {string} key - The API key to verify.
|
||
* @returns {Promise<object>} A promise that resolves to the API response data.
|
||
*/
|
||
async verifyKey(key) {
|
||
return await apiFetch(`/gemini/v1beta/verify-key/${key}`, { method: "POST" });
|
||
}
|
||
/**
|
||
* Verifies a batch of selected API keys.
|
||
* @param {string[]} keys - An array of API keys to verify.
|
||
* @returns {Promise<object>} A promise that resolves to the API response data.
|
||
*/
|
||
async verifySelectedKeys(keys) {
|
||
return await apiFetch(`/gemini/v1beta/verify-selected-keys`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ keys })
|
||
});
|
||
}
|
||
/**
|
||
* Resets the failure count for a single API key.
|
||
* @param {string} key - The API key whose failure count is to be reset.
|
||
* @returns {Promise<object>} A promise that resolves to the API response data.
|
||
*/
|
||
async resetFailCount(key) {
|
||
return await apiFetch(`/gemini/v1beta/reset-fail-count/${key}`, { method: "POST" });
|
||
}
|
||
/**
|
||
* Resets the failure count for a batch of selected API keys.
|
||
* @param {string[]} keys - An array of API keys to reset.
|
||
* @returns {Promise<object>} A promise that resolves to the API response data.
|
||
*/
|
||
async resetSelectedFailCounts(keys) {
|
||
return await apiFetch(`/gemini/v1beta/reset-selected-fail-counts`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ keys })
|
||
});
|
||
}
|
||
/**
|
||
* Deletes a single API key.
|
||
* @param {string} key - The API key to delete.
|
||
* @returns {Promise<object>} A promise that resolves to the API response data.
|
||
*/
|
||
async deleteKey(key) {
|
||
return await apiFetch(`/api/config/keys/${key}`, { method: "DELETE" });
|
||
}
|
||
/**
|
||
* Deletes a batch of selected API keys.
|
||
* @param {string[]} keys - An array of API keys to delete.
|
||
* @returns {Promise<object>} A promise that resolves to the API response data.
|
||
*/
|
||
async deleteSelectedKeys(keys) {
|
||
return await apiFetch("/api/config/keys/delete-selected", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ keys })
|
||
});
|
||
}
|
||
/**
|
||
* Fetches all keys, both valid and invalid.
|
||
* @returns {Promise<object>} A promise that resolves to an object containing 'valid_keys' and 'invalid_keys' arrays.
|
||
*/
|
||
async fetchAllKeys() {
|
||
return await apiFetch("/api/keys/all");
|
||
}
|
||
/**
|
||
* Fetches usage details for a specific key over the last 24 hours.
|
||
* @param {string} key - The API key to get details for.
|
||
* @returns {Promise<object>} A promise that resolves to the API response data.
|
||
*/
|
||
async getKeyUsageDetails(key) {
|
||
return await apiFetch(`/api/key-usage-details/${key}`);
|
||
}
|
||
/**
|
||
* Fetches API call statistics for a given period.
|
||
* @param {string} period - The time period for the stats (e.g., '1m', '1h', '24h').
|
||
* @returns {Promise<object>} A promise that resolves to the API response data.
|
||
*/
|
||
async getStatsDetails(period) {
|
||
return await apiFetch(`/api/stats/details?period=${period}`);
|
||
}
|
||
};
|
||
var apiKeyManager = new ApiKeyManager();
|
||
|
||
// frontend/js/utils/utils.js
|
||
function debounce(func, wait) {
|
||
let timeout;
|
||
const debounced = function(...args) {
|
||
const context = this;
|
||
const later = () => {
|
||
clearTimeout(timeout);
|
||
func.apply(context, args);
|
||
};
|
||
clearTimeout(timeout);
|
||
timeout = setTimeout(later, wait);
|
||
};
|
||
debounced.cancel = () => {
|
||
clearTimeout(timeout);
|
||
};
|
||
return debounced;
|
||
}
|
||
function isValidApiKeyFormat(key) {
|
||
const patterns = [
|
||
// Google Gemini API Key: AIzaSy + 33 characters (alphanumeric, _, -)
|
||
/^AIzaSy[\w-]{33}$/,
|
||
// OpenAI API Key (新格式): sk- + 48 alphanumeric characters
|
||
/^sk-[\w]{48}$/,
|
||
// Google AI Studio Key: gsk_ + alphanumeric & hyphens
|
||
/^gsk_[\w-]{40,}$/,
|
||
// Anthropic API Key (示例): sk-ant-api03- + long string
|
||
/^sk-ant-api\d{2}-[\w-]{80,}$/,
|
||
// Fallback for other potential "sk-" keys with a reasonable length
|
||
/^sk-[\w-]{20,}$/
|
||
];
|
||
return patterns.some((pattern) => pattern.test(key));
|
||
}
|
||
function escapeHTML(str) {
|
||
if (typeof str !== "string") {
|
||
return str;
|
||
}
|
||
return str.replace(/[&<>"']/g, function(match) {
|
||
return {
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
'"': """,
|
||
"'": "'"
|
||
}[match];
|
||
});
|
||
}
|
||
|
||
// frontend/js/pages/keys/addApiModal.js
|
||
var AddApiModal = class {
|
||
constructor({ onImportSuccess }) {
|
||
this.modalId = "add-api-modal";
|
||
this.onImportSuccess = onImportSuccess;
|
||
this.activeGroupId = null;
|
||
this.elements = {
|
||
modal: document.getElementById(this.modalId),
|
||
title: document.getElementById("add-api-modal-title"),
|
||
inputView: document.getElementById("add-api-input-view"),
|
||
textarea: document.getElementById("api-add-textarea"),
|
||
importBtn: document.getElementById("add-api-import-btn"),
|
||
validateCheckbox: document.getElementById("validate-on-import-checkbox")
|
||
};
|
||
if (!this.elements.modal) {
|
||
throw new Error(`Modal with id "${this.modalId}" not found.`);
|
||
}
|
||
this._initEventListeners();
|
||
}
|
||
open(activeGroupId) {
|
||
if (!activeGroupId) {
|
||
console.error("Cannot open AddApiModal: activeGroupId is required.");
|
||
return;
|
||
}
|
||
this.activeGroupId = activeGroupId;
|
||
this._reset();
|
||
modalManager.show(this.modalId);
|
||
}
|
||
_initEventListeners() {
|
||
this.elements.importBtn?.addEventListener("click", this._handleSubmit.bind(this));
|
||
const closeAction = () => {
|
||
this._reset();
|
||
modalManager.hide(this.modalId);
|
||
};
|
||
const closeTriggers = this.elements.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
|
||
closeTriggers.forEach((trigger) => trigger.addEventListener("click", closeAction));
|
||
this.elements.modal.addEventListener("click", (event) => {
|
||
if (event.target === this.elements.modal) closeAction();
|
||
});
|
||
}
|
||
async _handleSubmit(event) {
|
||
event.preventDefault();
|
||
const cleanedKeys = this._parseAndCleanKeys(this.elements.textarea.value);
|
||
if (cleanedKeys.length === 0) {
|
||
alert("\u6CA1\u6709\u68C0\u6D4B\u5230\u6709\u6548\u7684API Keys\u3002");
|
||
return;
|
||
}
|
||
this.elements.importBtn.disabled = true;
|
||
this.elements.importBtn.innerHTML = `<i class="fas fa-spinner fa-spin mr-2"></i>\u6B63\u5728\u542F\u52A8...`;
|
||
const addKeysTask = {
|
||
start: async () => {
|
||
const shouldValidate = this.elements.validateCheckbox.checked;
|
||
const response = await apiKeyManager.addKeysToGroup(this.activeGroupId, cleanedKeys.join("\n"), shouldValidate);
|
||
if (!response.success || !response.data) throw new Error(response.message || "\u542F\u52A8\u5BFC\u5165\u4EFB\u52A1\u5931\u8D25\u3002");
|
||
return response.data;
|
||
},
|
||
poll: async (taskId) => {
|
||
return await apiKeyManager.getTaskStatus(taskId, { noCache: true });
|
||
},
|
||
renderTaskCenterItem: (data, timestamp, formatTimeAgo) => {
|
||
const timeAgo = formatTimeAgo(timestamp);
|
||
let contentHtml = "";
|
||
if (!data.is_running && !data.error) {
|
||
const result = data.result || {};
|
||
const newlyLinked = result.newly_linked_count || 0;
|
||
const alreadyLinked = result.already_linked_count || 0;
|
||
const summaryTitle = `\u6279\u91CF\u94FE\u63A5 ${newlyLinked} Key\uFF0C\u5DF2\u8DF3\u8FC7 ${alreadyLinked}`;
|
||
contentHtml = `
|
||
<div class="task-item-main">
|
||
<div class="task-item-icon-summary text-green-500"><i class="fas fa-check-circle"></i></div>
|
||
<div class="task-item-content flex-grow">
|
||
<div class="flex justify-between items-center cursor-pointer" data-task-toggle>
|
||
<p class="task-item-title">${summaryTitle}</p>
|
||
<i class="fas fa-chevron-down task-toggle-icon"></i>
|
||
</div>
|
||
<div class="task-details-content collapsed" data-task-content>
|
||
<div class="task-details-body space-y-1">
|
||
<p class="flex justify-between"><span>\u6709\u6548\u8F93\u5165:</span> <span class="font-semibold">${data.total}</span></p>
|
||
<p class="flex justify-between"><span>\u5206\u7EC4\u4E2D\u5DF2\u5B58\u5728 (\u8DF3\u8FC7):</span> <span class="font-semibold">${alreadyLinked}</span></p>
|
||
<p class="flex justify-between font-bold"><span>\u65B0\u589E\u94FE\u63A5:</span> <span>${newlyLinked}</span></p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else if (!data.is_running && data.error) {
|
||
contentHtml = `
|
||
<div class="task-item-main">
|
||
<div class="task-item-icon-summary text-red-500"><i class="fas fa-times-circle"></i></div>
|
||
<div class="task-item-content flex-grow">
|
||
<p class="task-item-title">\u6279\u91CF\u6DFB\u52A0\u5931\u8D25</p>
|
||
<p class="task-item-status text-red-500 truncate" title="${data.error || "\u672A\u77E5\u9519\u8BEF"}">
|
||
${data.error || "\u672A\u77E5\u9519\u8BEF"}
|
||
</p>
|
||
</div>
|
||
</div>`;
|
||
} else {
|
||
contentHtml = `
|
||
<div class="task-item-main gap-3">
|
||
<div class="task-item-icon task-item-icon-running"><i class="fas fa-spinner animate-spin"></i></div>
|
||
<div class="task-item-content flex-grow">
|
||
<p class="task-item-title">\u6279\u91CF\u6DFB\u52A0 ${data.total} \u4E2AAPI Key</p>
|
||
<p class="task-item-status">\u8FD0\u884C\u4E2D...</p>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
return `${contentHtml}<div class="task-item-timestamp">${timeAgo}</div>`;
|
||
},
|
||
renderToastNarrative: (data, oldData, toastManager2) => {
|
||
const toastId = `task-${data.id}`;
|
||
const progress = data.total > 0 ? data.processed / data.total * 100 : 0;
|
||
toastManager2.showProgressToast(toastId, `\u6279\u91CF\u6DFB\u52A0Key`, "\u5904\u7406\u4E2D", progress);
|
||
},
|
||
// This now ONLY shows the FINAL summary toast, after everything else is done.
|
||
onSuccess: (data) => {
|
||
if (this.onImportSuccess) this.onImportSuccess();
|
||
const newlyLinked = data.result?.newly_linked_count || 0;
|
||
toastManager.show(`\u4EFB\u52A1\u5B8C\u6210\uFF01\u6210\u529F\u94FE\u63A5 ${newlyLinked} \u4E2AKey\u3002`, "success");
|
||
},
|
||
// This is the final error handler.
|
||
onError: (data) => {
|
||
toastManager.show(`\u4EFB\u52A1\u5931\u8D25: ${data.error || "\u672A\u77E5\u9519\u8BEF"}`, "error");
|
||
}
|
||
};
|
||
taskCenterManager.startTask(addKeysTask);
|
||
modalManager.hide(this.modalId);
|
||
this._reset();
|
||
}
|
||
_reset() {
|
||
this.elements.title.textContent = "\u6279\u91CF\u6DFB\u52A0 API Keys";
|
||
this.elements.inputView.classList.remove("hidden");
|
||
this.elements.textarea.value = "";
|
||
this.elements.textarea.disabled = false;
|
||
this.elements.importBtn.disabled = false;
|
||
this.elements.importBtn.innerHTML = "\u5BFC\u5165";
|
||
}
|
||
_parseAndCleanKeys(text) {
|
||
const keys = text.replace(/[,;]/g, " ").split(/[\s\n]+/);
|
||
const cleanedKeys = keys.map((key) => key.trim()).filter((key) => isValidApiKeyFormat(key));
|
||
return [...new Set(cleanedKeys)];
|
||
}
|
||
};
|
||
|
||
// frontend/js/pages/keys/deleteApiModal.js
|
||
var DeleteApiModal = class {
|
||
constructor({ onDeleteSuccess }) {
|
||
this.modalId = "delete-api-modal";
|
||
this.onDeleteSuccess = onDeleteSuccess;
|
||
this.activeGroupId = null;
|
||
this.elements = {
|
||
modal: document.getElementById(this.modalId),
|
||
textarea: document.getElementById("api-delete-textarea"),
|
||
deleteBtn: document.getElementById(this.modalId).querySelector(".modal-btn-danger")
|
||
};
|
||
if (!this.elements.modal) {
|
||
throw new Error(`Modal with id "${this.modalId}" not found.`);
|
||
}
|
||
this._initEventListeners();
|
||
}
|
||
open(activeGroupId) {
|
||
if (!activeGroupId) {
|
||
console.error("Cannot open DeleteApiModal: activeGroupId is required.");
|
||
return;
|
||
}
|
||
this.activeGroupId = activeGroupId;
|
||
this._reset();
|
||
modalManager.show(this.modalId);
|
||
}
|
||
_initEventListeners() {
|
||
this.elements.deleteBtn?.addEventListener("click", this._handleSubmit.bind(this));
|
||
const closeAction = () => {
|
||
this._reset();
|
||
modalManager.hide(this.modalId);
|
||
};
|
||
const closeTriggers = this.elements.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
|
||
closeTriggers.forEach((trigger) => trigger.addEventListener("click", closeAction));
|
||
this.elements.modal.addEventListener("click", (event) => {
|
||
if (event.target === this.elements.modal) closeAction();
|
||
});
|
||
}
|
||
async _handleSubmit(event) {
|
||
event.preventDefault();
|
||
const cleanedKeys = this._parseAndCleanKeys(this.elements.textarea.value);
|
||
if (cleanedKeys.length === 0) {
|
||
alert("\u6CA1\u6709\u68C0\u6D4B\u5230\u6709\u6548\u7684API Keys\u3002");
|
||
return;
|
||
}
|
||
this.elements.deleteBtn.disabled = true;
|
||
this.elements.deleteBtn.innerHTML = `<i class="fas fa-spinner fa-spin mr-2"></i>\u6B63\u5728\u542F\u52A8...`;
|
||
const deleteKeysTask = {
|
||
start: async () => {
|
||
const response = await apiKeyManager.unlinkKeysFromGroup(this.activeGroupId, cleanedKeys.join("\n"));
|
||
if (!response.success || !response.data) throw new Error(response.message || "\u542F\u52A8\u89E3\u7ED1\u4EFB\u52A1\u5931\u8D25\u3002");
|
||
return response.data;
|
||
},
|
||
poll: async (taskId) => {
|
||
return await apiKeyManager.getTaskStatus(taskId, { noCache: true });
|
||
},
|
||
renderTaskCenterItem: (data, timestamp, formatTimeAgo) => {
|
||
const timeAgo = formatTimeAgo(timestamp);
|
||
let contentHtml = "";
|
||
if (!data.is_running && !data.error) {
|
||
const result = data.result || {};
|
||
const unlinked = result.unlinked_count || 0;
|
||
const deleted = result.hard_deleted_count || 0;
|
||
const notFound = result.not_found_count || 0;
|
||
const totalInput = data.total;
|
||
const summaryTitle = `\u89E3\u7ED1 ${unlinked} Key\uFF0C\u6E05\u7406 ${deleted}`;
|
||
contentHtml = `
|
||
<div class="task-item-main">
|
||
<div class="task-item-icon-summary text-green-500"><i class="fas fa-check-circle"></i></div>
|
||
<div class="task-item-content flex-grow">
|
||
<div class="flex justify-between items-center cursor-pointer" data-task-toggle>
|
||
<p class="task-item-title">${summaryTitle}</p>
|
||
<i class="fas fa-chevron-down task-toggle-icon"></i>
|
||
</div>
|
||
<div class="task-details-content collapsed" data-task-content>
|
||
<div class="task-details-body space-y-1">
|
||
<p class="flex justify-between"><span>\u6709\u6548\u8F93\u5165:</span> <span class="font-semibold">${totalInput}</span></p>
|
||
<p class="flex justify-between"><span>\u672A\u5728\u5206\u7EC4\u4E2D\u627E\u5230:</span> <span class="font-semibold">${notFound}</span></p>
|
||
<p class="flex justify-between"><span>\u4ECE\u5206\u7EC4\u4E2D\u89E3\u7ED1:</span> <span class="font-semibold">${unlinked}</span></p>
|
||
<p class="flex justify-between font-bold"><span>\u5F7B\u5E95\u6E05\u7406\u5B64\u7ACBKey:</span> <span>${deleted}</span></p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else if (!data.is_running && data.error) {
|
||
contentHtml = `
|
||
<div class="task-item-main">
|
||
<div class="task-item-icon-summary text-red-500"><i class="fas fa-times-circle"></i></div>
|
||
<div class="task-item-content flex-grow">
|
||
<p class="task-item-title">\u6279\u91CF\u5220\u9664\u5931\u8D25</p>
|
||
<p class="task-item-status text-red-500 truncate" title="${data.error || "\u672A\u77E5\u9519\u8BEF"}">
|
||
${data.error || "\u672A\u77E5\u9519\u8BEF"}
|
||
</p>
|
||
</div>
|
||
</div>`;
|
||
} else {
|
||
contentHtml = `
|
||
<div class="task-item-main gap-3">
|
||
<div class="task-item-icon task-item-icon-running"><i class="fas fa-spinner animate-spin"></i></div>
|
||
<div class="task-item-content flex-grow">
|
||
<p class="task-item-title">\u6279\u91CF\u5220\u9664 ${data.total} \u4E2AAPI Key</p>
|
||
<p class="task-item-status">\u8FD0\u884C\u4E2D...</p>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
return `${contentHtml}<div class="task-item-timestamp">${timeAgo}</div>`;
|
||
},
|
||
// he Toast is now solely responsible for showing real-time progress.
|
||
renderToastNarrative: (data, oldData, toastManager2) => {
|
||
const toastId = `task-${data.id}`;
|
||
const progress = data.total > 0 ? data.processed / data.total * 100 : 0;
|
||
toastManager2.showProgressToast(toastId, `\u6279\u91CF\u5220\u9664Key`, "\u5904\u7406\u4E2D", progress);
|
||
},
|
||
// This now ONLY shows the FINAL summary toast, after everything else is done.
|
||
onSuccess: (data) => {
|
||
if (this.onDeleteSuccess) this.onDeleteSuccess();
|
||
const newlyLinked = data.result?.newly_linked_count || 0;
|
||
toastManager.show(`\u4EFB\u52A1\u5B8C\u6210\uFF01\u6210\u529F\u5220\u9664 ${newlyLinked} \u4E2AKey\u3002`, "success");
|
||
},
|
||
// This is the final error handler.
|
||
onError: (data) => {
|
||
toastManager.show(`\u4EFB\u52A1\u5931\u8D25: ${data.error || "\u672A\u77E5\u9519\u8BEF"}`, "error");
|
||
}
|
||
};
|
||
taskCenterManager.startTask(deleteKeysTask);
|
||
modalManager.hide(this.modalId);
|
||
this._reset();
|
||
}
|
||
_reset() {
|
||
this.elements.textarea.value = "";
|
||
this.elements.deleteBtn.disabled = false;
|
||
this.elements.deleteBtn.innerHTML = "\u5220\u9664";
|
||
}
|
||
_parseAndCleanKeys(text) {
|
||
const keys = text.replace(/[,;]/g, " ").split(/[\s\n]+/);
|
||
const cleanedKeys = keys.map((key) => key.trim()).filter((key) => isValidApiKeyFormat(key));
|
||
return [...new Set(cleanedKeys)];
|
||
}
|
||
};
|
||
|
||
// frontend/js/pages/keys/keyGroupModal.js
|
||
var MAX_GROUP_NAME_LENGTH = 32;
|
||
var KeyGroupModal = class {
|
||
constructor({ onSave, tagInputInstances }) {
|
||
this.modalId = "keygroup-modal";
|
||
this.onSave = onSave;
|
||
this.tagInputs = tagInputInstances;
|
||
this.editingGroupId = null;
|
||
const modal = document.getElementById(this.modalId);
|
||
if (!modal) {
|
||
throw new Error(`Modal with id "${this.modalId}" not found.`);
|
||
}
|
||
this.elements = {
|
||
modal,
|
||
title: document.getElementById("modal-title"),
|
||
saveBtn: document.getElementById("modal-save-btn"),
|
||
// 表单字段
|
||
nameInput: document.getElementById("group-name"),
|
||
nameHelper: document.getElementById("group-name-helper"),
|
||
displayNameInput: document.getElementById("group-display-name"),
|
||
descriptionInput: document.getElementById("group-description"),
|
||
strategySelect: document.getElementById("group-strategy"),
|
||
maxRetriesInput: document.getElementById("group-max-retries"),
|
||
failureThresholdInput: document.getElementById("group-key-blacklist-threshold"),
|
||
enableProxyToggle: document.getElementById("group-enable-proxy"),
|
||
enableSmartGatewayToggle: document.getElementById("group-enable-smart-gateway"),
|
||
// 自动验证设置
|
||
enableKeyCheckToggle: document.getElementById("group-enable-key-check"),
|
||
keyCheckSettingsPanel: document.getElementById("key-check-settings"),
|
||
keyCheckModelInput: document.getElementById("group-key-check-model"),
|
||
keyCheckIntervalInput: document.getElementById("group-key-check-interval-minutes"),
|
||
keyCheckConcurrencyInput: document.getElementById("group-key-check-concurrency"),
|
||
keyCooldownInput: document.getElementById("group-key-cooldown-minutes"),
|
||
keyCheckEndpointInput: document.getElementById("group-key-check-endpoint")
|
||
};
|
||
this._initEventListeners();
|
||
}
|
||
open(groupData = null) {
|
||
this._populateForm(groupData);
|
||
modalManager.show(this.modalId);
|
||
}
|
||
close() {
|
||
modalManager.hide(this.modalId);
|
||
}
|
||
_initEventListeners() {
|
||
if (this.elements.saveBtn) {
|
||
this.elements.saveBtn.addEventListener("click", this._handleSave.bind(this));
|
||
}
|
||
if (this.elements.nameInput) {
|
||
this.elements.nameInput.addEventListener("input", this._sanitizeGroupName.bind(this));
|
||
}
|
||
if (this.elements.enableKeyCheckToggle) {
|
||
this.elements.enableKeyCheckToggle.addEventListener("change", (e) => {
|
||
this.elements.keyCheckSettingsPanel.classList.toggle("hidden", !e.target.checked);
|
||
});
|
||
}
|
||
const closeAction = () => this.close();
|
||
const closeTriggers = this.elements.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
|
||
closeTriggers.forEach((trigger) => trigger.addEventListener("click", closeAction));
|
||
this.elements.modal.addEventListener("click", (event) => {
|
||
if (event.target === this.elements.modal) closeAction();
|
||
});
|
||
}
|
||
// 实时净化 group name 的哨兵函数
|
||
_sanitizeGroupName(event) {
|
||
const input = event.target;
|
||
let value = input.value;
|
||
value = value.toLowerCase();
|
||
value = value.replace(/[^a-z0-9-]/g, "");
|
||
if (value.length > MAX_GROUP_NAME_LENGTH) {
|
||
value = value.substring(0, MAX_GROUP_NAME_LENGTH);
|
||
}
|
||
if (input.value !== value) {
|
||
input.value = value;
|
||
}
|
||
}
|
||
async _handleSave() {
|
||
this._sanitizeGroupName({ target: this.elements.nameInput });
|
||
const data = this._collectFormData();
|
||
if (!data.name || !data.display_name) {
|
||
alert("\u5206\u7EC4\u540D\u79F0\u548C\u663E\u793A\u540D\u79F0\u662F\u5FC5\u586B\u9879\u3002");
|
||
return;
|
||
}
|
||
const groupNameRegex = /^[a-z0-9-]+$/;
|
||
if (!groupNameRegex.test(data.name) || data.name.length > MAX_GROUP_NAME_LENGTH) {
|
||
alert("\u5206\u7EC4\u540D\u79F0\u683C\u5F0F\u65E0\u6548\u3002\u4EC5\u9650\u4F7F\u7528\u5C0F\u5199\u5B57\u6BCD\u3001\u6570\u5B57\u548C\u8FDE\u5B57\u7B26(-)\uFF0C\u4E14\u957F\u5EA6\u4E0D\u8D85\u8FC732\u4E2A\u5B57\u7B26\u3002");
|
||
return;
|
||
}
|
||
if (this.onSave) {
|
||
this.elements.saveBtn.disabled = true;
|
||
this.elements.saveBtn.textContent = "\u4FDD\u5B58\u4E2D...";
|
||
try {
|
||
await this.onSave(data);
|
||
this.close();
|
||
} catch (error) {
|
||
console.error("Failed to save key group:", error);
|
||
} finally {
|
||
this.elements.saveBtn.disabled = false;
|
||
this.elements.saveBtn.textContent = "\u4FDD\u5B58";
|
||
}
|
||
}
|
||
}
|
||
_populateForm(data) {
|
||
if (data) {
|
||
this.editingGroupId = data.id;
|
||
this.elements.title.textContent = "\u7F16\u8F91 Key Group";
|
||
this.elements.nameInput.value = data.name || "";
|
||
this.elements.nameInput.disabled = false;
|
||
this.elements.displayNameInput.value = data.display_name || "";
|
||
this.elements.descriptionInput.value = data.description || "";
|
||
this.elements.strategySelect.value = data.polling_strategy || "random";
|
||
this.elements.enableProxyToggle.checked = data.enable_proxy || false;
|
||
const settings = data.settings && data.settings.SettingsJSON ? data.settings.SettingsJSON : {};
|
||
this.elements.maxRetriesInput.value = settings.max_retries ?? "";
|
||
this.elements.failureThresholdInput.value = settings.key_blacklist_threshold ?? "";
|
||
this.elements.enableSmartGatewayToggle.checked = settings.enable_smart_gateway || false;
|
||
const isKeyCheckEnabled = settings.enable_key_check || false;
|
||
this.elements.enableKeyCheckToggle.checked = isKeyCheckEnabled;
|
||
this.elements.keyCheckSettingsPanel.classList.toggle("hidden", !isKeyCheckEnabled);
|
||
this.elements.keyCheckModelInput.value = settings.key_check_model || "";
|
||
this.elements.keyCheckIntervalInput.value = settings.key_check_interval_minutes ?? "";
|
||
this.elements.keyCheckConcurrencyInput.value = settings.key_check_concurrency ?? "";
|
||
this.elements.keyCooldownInput.value = settings.key_cooldown_minutes ?? "";
|
||
this.elements.keyCheckEndpointInput.value = settings.key_check_endpoint || "";
|
||
this.tagInputs.models.setValues(data.allowed_models || []);
|
||
this.tagInputs.upstreams.setValues(data.allowed_upstreams || []);
|
||
this.tagInputs.tokens.setValues(data.allowed_tokens || []);
|
||
} else {
|
||
this.editingGroupId = null;
|
||
this.elements.title.textContent = "\u521B\u5EFA\u65B0\u7684 Key Group";
|
||
this._resetForm();
|
||
}
|
||
}
|
||
_collectFormData() {
|
||
const parseIntOrNull = (value) => {
|
||
const trimmed = value.trim();
|
||
return trimmed === "" ? null : parseInt(trimmed, 10);
|
||
};
|
||
const formData = {
|
||
name: this.elements.nameInput.value.trim(),
|
||
display_name: this.elements.displayNameInput.value.trim(),
|
||
description: this.elements.descriptionInput.value.trim(),
|
||
polling_strategy: this.elements.strategySelect.value,
|
||
max_retries: parseIntOrNull(this.elements.maxRetriesInput.value),
|
||
key_blacklist_threshold: parseIntOrNull(this.elements.failureThresholdInput.value),
|
||
enable_proxy: this.elements.enableProxyToggle.checked,
|
||
enable_smart_gateway: this.elements.enableSmartGatewayToggle.checked,
|
||
enable_key_check: this.elements.enableKeyCheckToggle.checked,
|
||
key_check_model: this.elements.keyCheckModelInput.value.trim() || null,
|
||
key_check_interval_minutes: parseIntOrNull(this.elements.keyCheckIntervalInput.value),
|
||
key_check_concurrency: parseIntOrNull(this.elements.keyCheckConcurrencyInput.value),
|
||
key_cooldown_minutes: parseIntOrNull(this.elements.keyCooldownInput.value),
|
||
key_check_endpoint: this.elements.keyCheckEndpointInput.value.trim() || null,
|
||
allowed_models: this.tagInputs.models.getValues(),
|
||
allowed_upstreams: this.tagInputs.upstreams.getValues(),
|
||
allowed_tokens: this.tagInputs.tokens.getValues()
|
||
};
|
||
if (this.editingGroupId) {
|
||
formData.id = this.editingGroupId;
|
||
}
|
||
return formData;
|
||
}
|
||
/**
|
||
* [核心修正] 完整且健壮的表单重置方法
|
||
*/
|
||
_resetForm() {
|
||
this.elements.nameInput.value = "";
|
||
this.elements.nameInput.disabled = false;
|
||
this.elements.displayNameInput.value = "";
|
||
this.elements.descriptionInput.value = "";
|
||
this.elements.strategySelect.value = "random";
|
||
this.elements.maxRetriesInput.value = "";
|
||
this.elements.failureThresholdInput.value = "";
|
||
this.elements.enableProxyToggle.checked = false;
|
||
this.elements.enableSmartGatewayToggle.checked = false;
|
||
this.elements.enableKeyCheckToggle.checked = false;
|
||
this.elements.keyCheckSettingsPanel.classList.add("hidden");
|
||
this.elements.keyCheckModelInput.value = "";
|
||
this.elements.keyCheckIntervalInput.value = "";
|
||
this.elements.keyCheckConcurrencyInput.value = "";
|
||
this.elements.keyCooldownInput.value = "";
|
||
this.elements.keyCheckEndpointInput.value = "";
|
||
this.tagInputs.models.setValues([]);
|
||
this.tagInputs.upstreams.setValues([]);
|
||
this.tagInputs.tokens.setValues([]);
|
||
}
|
||
};
|
||
|
||
// frontend/js/pages/keys/cloneGroupModal.js
|
||
var CloneGroupModal = class {
|
||
constructor({ onCloneSuccess }) {
|
||
this.modalId = "clone-group-modal";
|
||
this.onCloneSuccess = onCloneSuccess;
|
||
this.activeGroup = null;
|
||
this.elements = {
|
||
modal: document.getElementById(this.modalId),
|
||
title: document.getElementById("clone-group-modal-title"),
|
||
confirmBtn: document.getElementById("clone-group-confirm-btn")
|
||
};
|
||
if (!this.elements.modal) {
|
||
console.error(`Modal with id "${this.modalId}" not found. Ensure the HTML is in your document.`);
|
||
return;
|
||
}
|
||
this._initEventListeners();
|
||
}
|
||
open(group) {
|
||
if (!group || !group.id) {
|
||
console.error("Cannot open CloneGroupModal: a group object with an ID is required.");
|
||
return;
|
||
}
|
||
this.activeGroup = group;
|
||
this.elements.title.innerHTML = `\u786E\u8BA4\u514B\u9686\u5206\u7EC4 <code class="text-base font-semibold text-blue-500">${group.display_name}</code>`;
|
||
this._reset();
|
||
modalManager.show(this.modalId);
|
||
}
|
||
_initEventListeners() {
|
||
this.elements.confirmBtn?.addEventListener("click", this._handleSubmit.bind(this));
|
||
const closeAction = () => {
|
||
this._reset();
|
||
modalManager.hide(this.modalId);
|
||
};
|
||
const closeTriggers = this.elements.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
|
||
closeTriggers.forEach((trigger) => trigger.addEventListener("click", closeAction));
|
||
this.elements.modal.addEventListener("click", (event) => {
|
||
if (event.target === this.elements.modal) closeAction();
|
||
});
|
||
}
|
||
async _handleSubmit() {
|
||
if (!this.activeGroup) return;
|
||
this.elements.confirmBtn.disabled = true;
|
||
this.elements.confirmBtn.innerHTML = `<i class="fas fa-spinner fa-spin mr-2"></i>\u514B\u9686\u4E2D...`;
|
||
try {
|
||
const endpoint = `/admin/keygroups/${this.activeGroup.id}/clone`;
|
||
const response = await apiFetch(endpoint, {
|
||
method: "POST",
|
||
noCache: true
|
||
});
|
||
const result = await response.json();
|
||
if (result.success && result.data) {
|
||
toastManager.show(`\u5206\u7EC4 '${this.activeGroup.display_name}' \u5DF2\u6210\u529F\u514B\u9686\u3002`, "success");
|
||
if (this.onCloneSuccess) {
|
||
this.onCloneSuccess(result.data);
|
||
}
|
||
modalManager.hide(this.modalId);
|
||
} else {
|
||
throw new Error(result.error?.message || result.message || "\u514B\u9686\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002");
|
||
}
|
||
} catch (error) {
|
||
toastManager.show(`\u514B\u9686\u5931\u8D25: ${error.message}`, "error");
|
||
} finally {
|
||
this._reset();
|
||
}
|
||
}
|
||
_reset() {
|
||
if (this.elements.confirmBtn) {
|
||
this.elements.confirmBtn.disabled = false;
|
||
this.elements.confirmBtn.innerHTML = "\u786E\u8BA4\u514B\u9686";
|
||
}
|
||
}
|
||
};
|
||
|
||
// frontend/js/pages/keys/deleteGroupModal.js
|
||
var DeleteGroupModal = class {
|
||
constructor({ onDeleteSuccess }) {
|
||
this.modalId = "delete-group-modal";
|
||
this.onDeleteSuccess = onDeleteSuccess;
|
||
this.activeGroup = null;
|
||
this.elements = {
|
||
modal: document.getElementById(this.modalId),
|
||
title: document.getElementById("delete-group-modal-title"),
|
||
confirmInput: document.getElementById("delete-group-confirm-input"),
|
||
confirmBtn: document.getElementById("delete-group-confirm-btn")
|
||
};
|
||
if (!this.elements.modal) {
|
||
throw new Error(`Modal with id "${this.modalId}" not found.`);
|
||
}
|
||
this._initEventListeners();
|
||
}
|
||
open(group) {
|
||
if (!group || !group.id) {
|
||
console.error("Cannot open DeleteGroupModal: group object with id is required.");
|
||
return;
|
||
}
|
||
this.activeGroup = group;
|
||
this.elements.title.innerHTML = `\u786E\u8BA4\u5220\u9664\u5206\u7EC4 <code class="text-base font-semibold text-red-500">${group.display_name}</code>`;
|
||
this._reset();
|
||
modalManager.show(this.modalId);
|
||
}
|
||
_initEventListeners() {
|
||
this.elements.confirmBtn?.addEventListener("click", this._handleSubmit.bind(this));
|
||
this.elements.confirmInput?.addEventListener("input", () => {
|
||
const isConfirmed = this.elements.confirmInput.value.trim() === "\u5220\u9664";
|
||
this.elements.confirmBtn.disabled = !isConfirmed;
|
||
});
|
||
const closeAction = () => {
|
||
this._reset();
|
||
modalManager.hide(this.modalId);
|
||
};
|
||
const closeTriggers = this.elements.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
|
||
closeTriggers.forEach((trigger) => trigger.addEventListener("click", closeAction));
|
||
this.elements.modal.addEventListener("click", (event) => {
|
||
if (event.target === this.elements.modal) closeAction();
|
||
});
|
||
}
|
||
async _handleSubmit() {
|
||
if (!this.activeGroup) return;
|
||
this.elements.confirmBtn.disabled = true;
|
||
this.elements.confirmBtn.innerHTML = `<i class="fas fa-spinner fa-spin mr-2"></i>\u5220\u9664\u4E2D...`;
|
||
try {
|
||
const endpoint = `/admin/keygroups/${this.activeGroup.id}`;
|
||
const response = await apiFetch(endpoint, {
|
||
method: "DELETE",
|
||
noCache: true
|
||
// Ensure a fresh request
|
||
});
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
toastManager.show(`\u5206\u7EC4 '${this.activeGroup.display_name}' \u5DF2\u6210\u529F\u5220\u9664\u3002`, "success");
|
||
if (this.onDeleteSuccess) {
|
||
this.onDeleteSuccess(this.activeGroup.id);
|
||
}
|
||
modalManager.hide(this.modalId);
|
||
} else {
|
||
throw new Error(result.error?.message || result.message || "\u5220\u9664\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002");
|
||
}
|
||
} catch (error) {
|
||
toastManager.show(`\u5220\u9664\u5931\u8D25: ${error.message}`, "error");
|
||
} finally {
|
||
this._reset();
|
||
}
|
||
}
|
||
_reset() {
|
||
if (this.elements.confirmInput) this.elements.confirmInput.value = "";
|
||
if (this.elements.confirmBtn) {
|
||
this.elements.confirmBtn.disabled = true;
|
||
this.elements.confirmBtn.innerHTML = "\u786E\u8BA4\u5220\u9664";
|
||
}
|
||
}
|
||
};
|
||
|
||
// frontend/js/services/errorHandler.js
|
||
var ERROR_MESSAGES2 = {
|
||
"STATE_CONFLICT_MASTER_REVOKED": "\u64CD\u4F5C\u5931\u8D25\uFF1A\u65E0\u6CD5\u6FC0\u6D3B\u4E00\u4E2A\u5DF2\u88AB\u6C38\u4E45\u540A\u9500\uFF08Revoked\uFF09\u7684Key\u3002",
|
||
"NOT_FOUND": "\u64CD\u4F5C\u5931\u8D25\uFF1A\u76EE\u6807\u8D44\u6E90\u4E0D\u5B58\u5728\u6216\u5DF2\u4ECE\u672C\u7EC4\u79FB\u9664\u3002\u5217\u8868\u5C06\u81EA\u52A8\u5237\u65B0\u3002",
|
||
"NO_KEYS_MATCH_FILTER": "\u6CA1\u6709\u627E\u5230\u4EFB\u4F55\u7B26\u5408\u5F53\u524D\u8FC7\u6EE4\u6761\u4EF6\u7684Key\u53EF\u4F9B\u64CD\u4F5C\u3002",
|
||
// You can add many more specific codes here as your application grows.
|
||
"DEFAULT": "\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u6216\u8054\u7CFB\u7BA1\u7406\u5458\u3002"
|
||
};
|
||
function handleApiError(error, toastManager2, options = {}) {
|
||
const prefix = options.prefix || "";
|
||
const errorCode = error?.code || "DEFAULT";
|
||
const displayMessage = ERROR_MESSAGES2[errorCode] || error.rawMessageFromServer || error.message || ERROR_MESSAGES2["DEFAULT"];
|
||
toastManager2.show(`${prefix}${displayMessage}`, "error");
|
||
}
|
||
|
||
// frontend/js/pages/keys/apiKeyList.js
|
||
var ApiKeyList = class {
|
||
/**
|
||
* @param {HTMLElement} container - The DOM element that will contain the API key list.
|
||
*/
|
||
constructor(container) {
|
||
if (!container) {
|
||
throw new Error("ApiKeyListManager requires a valid container element.");
|
||
}
|
||
this.elements = {
|
||
container,
|
||
// This is the scrollable list container now, e.g., #api-list-container
|
||
gridContainer: null,
|
||
// Will be dynamically created inside container
|
||
paginationContainer: document.querySelector(".pagination-controls"),
|
||
// Find the pagination container
|
||
itemsPerPageSelect: document.querySelector(".items-per-page-select"),
|
||
// Find the dropdown
|
||
selectAllCheckbox: document.getElementById("select-all"),
|
||
batchActionButton: document.querySelector(".batch-action-btn"),
|
||
// The trigger button
|
||
batchActionPanel: document.querySelector(".batch-action-panel"),
|
||
// The dropdown panel
|
||
statusFilterSelects: document.querySelectorAll(".status-filter-select"),
|
||
desktopSearchInput: document.getElementById("desktop-search-input"),
|
||
mobileSearchBtn: document.getElementById("mobile-search-btn"),
|
||
desktopQuickActionsPanel: document.getElementById("desktop-quick-actions-panel"),
|
||
mobileQuickActionsPanel: document.getElementById("mobile-quick-actions-panel"),
|
||
desktopMultifunctionPanel: document.getElementById("desktop-multifunction-panel"),
|
||
mobileMultifunctionPanel: document.getElementById("mobile-multifunction-panel")
|
||
};
|
||
this.state = {
|
||
currentKeys: [],
|
||
// Now holds only the keys for the current page
|
||
selectedKeyIds: /* @__PURE__ */ new Set(),
|
||
isApiKeysLoading: false,
|
||
activeGroupId: null,
|
||
activeGroupName: "",
|
||
currentPage: 1,
|
||
itemsPerPage: 20,
|
||
// Default value, will be updated from select
|
||
totalItems: 0,
|
||
totalPages: 1,
|
||
filterStatus: "all",
|
||
searchText: ""
|
||
};
|
||
this.debouncedSearch = debounce(() => {
|
||
this.state.currentPage = 1;
|
||
this.loadApiKeys(this.state.activeGroupId, true);
|
||
}, 300);
|
||
this.boundListeners = {
|
||
handleContainerClick: this._handleContainerClick.bind(this),
|
||
handlePaginationClick: this.handlePaginationClick.bind(this),
|
||
handleItemsPerPageChange: this._handleItemsPerPageChange.bind(this),
|
||
handleSelectAllChange: this._handleSelectAllChange.bind(this),
|
||
handleStatusFilterChange: this._handleStatusFilterChange.bind(this),
|
||
handleBatchActionClick: this._handleBatchActionClick.bind(this),
|
||
handleDocumentClickForMenuClose: this._handleDocumentClickForMenuClose.bind(this),
|
||
handleSearchInput: this._handleSearchInput.bind(this),
|
||
handleSearchEnter: this._handleSearchEnter.bind(this),
|
||
showMobileSearchModal: this._showMobileSearchModal.bind(this),
|
||
handleGlobalClick: this._handleGlobalClick.bind(this)
|
||
};
|
||
}
|
||
init() {
|
||
if (!this.elements.container) return;
|
||
this.elements.container.addEventListener("click", this.boundListeners.handleContainerClick);
|
||
this.elements.paginationContainer?.addEventListener("click", this.boundListeners.handlePaginationClick);
|
||
this.elements.selectAllCheckbox?.addEventListener("change", this.boundListeners.handleSelectAllChange);
|
||
this.elements.batchActionPanel?.addEventListener("click", this.boundListeners.handleBatchActionClick);
|
||
this.elements.desktopSearchInput?.addEventListener("input", this.boundListeners.handleSearchInput);
|
||
this.elements.desktopSearchInput?.addEventListener("keydown", this.boundListeners.handleSearchEnter);
|
||
this.elements.mobileSearchBtn?.addEventListener("click", this.boundListeners.showMobileSearchModal);
|
||
document.addEventListener("click", this.boundListeners.handleDocumentClickForMenuClose);
|
||
document.body.addEventListener("click", this.boundListeners.handleGlobalClick);
|
||
const itemsPerPageSelect = this.elements.itemsPerPageSelect?.querySelector("select");
|
||
if (itemsPerPageSelect) {
|
||
itemsPerPageSelect.addEventListener("change", this.boundListeners.handleItemsPerPageChange);
|
||
this.state.itemsPerPage = parseInt(itemsPerPageSelect.value, 10);
|
||
}
|
||
this.elements.statusFilterSelects.forEach((selectContainer) => {
|
||
const actualSelect = selectContainer.querySelector("select");
|
||
actualSelect?.addEventListener("change", this.boundListeners.handleStatusFilterChange);
|
||
});
|
||
this._renderMultifunctionMenu();
|
||
this._renderQuickActionsMenu();
|
||
}
|
||
destroy() {
|
||
console.log("Destroying ApiKeyList instance and cleaning up listeners.");
|
||
this.elements.container.removeEventListener("click", this.boundListeners.handleContainerClick);
|
||
this.elements.paginationContainer?.removeEventListener("click", this.boundListeners.handlePaginationClick);
|
||
this.elements.selectAllCheckbox?.removeEventListener("change", this.boundListeners.handleSelectAllChange);
|
||
this.elements.batchActionPanel?.removeEventListener("click", this.boundListeners.handleBatchActionClick);
|
||
this.elements.desktopSearchInput?.removeEventListener("input", this.boundListeners.handleSearchInput);
|
||
this.elements.desktopSearchInput?.removeEventListener("keydown", this.boundListeners.handleSearchEnter);
|
||
this.elements.mobileSearchBtn?.removeEventListener("click", this.boundListeners.showMobileSearchModal);
|
||
document.removeEventListener("click", this.boundListeners.handleDocumentClickForMenuClose);
|
||
document.body.removeEventListener("click", this.boundListeners.handleGlobalClick);
|
||
const itemsPerPageSelect = this.elements.itemsPerPageSelect?.querySelector("select");
|
||
if (itemsPerPageSelect) {
|
||
itemsPerPageSelect.removeEventListener("change", this.boundListeners.handleItemsPerPageChange);
|
||
}
|
||
this.elements.statusFilterSelects.forEach((selectContainer) => {
|
||
const actualSelect = selectContainer.querySelector("select");
|
||
actualSelect?.removeEventListener("change", this.boundListeners.handleStatusFilterChange);
|
||
});
|
||
this.debouncedSearch.cancel?.();
|
||
this.elements.container.innerHTML = "";
|
||
}
|
||
_handleItemsPerPageChange(e) {
|
||
this.state.itemsPerPage = parseInt(e.target.value, 10);
|
||
this.state.currentPage = 1;
|
||
this.loadApiKeys(this.state.activeGroupId, true);
|
||
}
|
||
_handleStatusFilterChange(e) {
|
||
this.state.filterStatus = e.target.value;
|
||
this.state.currentPage = 1;
|
||
this.loadApiKeys(this.state.activeGroupId, true);
|
||
}
|
||
_handleDocumentClickForMenuClose(event) {
|
||
if (!event.target.closest(".api-card")) {
|
||
this._closeAllActionMenus();
|
||
}
|
||
}
|
||
_handleGlobalClick(event) {
|
||
this._handleQuickActionClick(event);
|
||
this._handleMultifunctionMenuClick(event);
|
||
this._handleDropdownToggle(event);
|
||
}
|
||
/**
|
||
* Updates the active group context for the manager.
|
||
* @param {number} groupId The new active group ID.
|
||
*/
|
||
setActiveGroup(groupId, groupName) {
|
||
this.state.activeGroupId = groupId;
|
||
this.state.activeGroupName = groupName || "";
|
||
this.state.currentPage = 1;
|
||
}
|
||
/**
|
||
* Fetches and renders API keys for the specified group.
|
||
* @param {number} groupId - The ID of the group to load keys for.
|
||
* @param {boolean} [force=false] - If true, bypasses the cache and fetches from the server.
|
||
*/
|
||
async loadApiKeys(groupId, force = false) {
|
||
this.state.selectedKeyIds.clear();
|
||
if (!groupId) {
|
||
this.state.currentKeys = [];
|
||
this.render();
|
||
return;
|
||
}
|
||
this.state.isApiKeysLoading = true;
|
||
this.render();
|
||
try {
|
||
const { currentPage, itemsPerPage, filterStatus, searchText } = this.state;
|
||
const params = {
|
||
page: currentPage,
|
||
limit: itemsPerPage
|
||
};
|
||
if (filterStatus !== "all") {
|
||
params.status = filterStatus;
|
||
}
|
||
if (searchText && searchText.trim() !== "") {
|
||
params.keyword = searchText.trim();
|
||
}
|
||
const pageData = await apiKeyManager.getKeysForGroup(groupId, params);
|
||
this.state.currentKeys = pageData.items;
|
||
this.state.totalItems = pageData.total;
|
||
this.state.totalPages = pageData.pages;
|
||
this.state.currentPage = pageData.page;
|
||
} catch (error) {
|
||
toastManager.show(`\u52A0\u8F7DAPI Keys\u5931\u8D25: ${error.message || "\u672A\u77E5\u9519\u8BEF"}`, "error");
|
||
this.state.currentKeys = [];
|
||
this.state.totalItems = 0;
|
||
this.state.totalPages = 1;
|
||
} finally {
|
||
this.state.isApiKeysLoading = false;
|
||
this.render();
|
||
}
|
||
}
|
||
/**
|
||
* Renders the list of API keys based on the current state.
|
||
*/
|
||
render() {
|
||
if (this.state.isApiKeysLoading && this.state.currentKeys.length === 0) {
|
||
this.elements.container.innerHTML = '<div class="flex items-center justify-center h-full text-zinc-500"><p>\u6B63\u5728\u52A0\u8F7D API Keys...</p></div>';
|
||
if (this.elements.paginationContainer) this.elements.paginationContainer.innerHTML = "";
|
||
return;
|
||
}
|
||
if (!this.state.activeGroupId) {
|
||
this.elements.container.innerHTML = '<div class="flex items-center justify-center h-full text-zinc-500"><p>\u8BF7\u5148\u5728\u5DE6\u4FA7\u9009\u62E9\u4E00\u4E2A\u5206\u7EC4</p></div>';
|
||
if (this.elements.paginationContainer) this.elements.paginationContainer.innerHTML = "";
|
||
return;
|
||
}
|
||
if (this.state.currentKeys.length === 0) {
|
||
this.elements.container.innerHTML = '<div class="flex items-center justify-center h-full text-zinc-500"><p>\u8BE5\u5206\u7EC4\u4E0B\u8FD8\u6CA1\u6709 API Key\u3002</p></div>';
|
||
if (this.elements.paginationContainer) this.elements.paginationContainer.innerHTML = "";
|
||
return;
|
||
}
|
||
const listHtml = this.state.currentKeys.map((apiKey) => this._createApiKeyCardHtml(apiKey)).join("");
|
||
this.elements.container.innerHTML = `<div class="grid grid-cols-1 md:grid-cols-2 gap-3">${listHtml}</div>`;
|
||
this.elements.gridContainer = this.elements.container.firstChild;
|
||
if (this.elements.paginationContainer) {
|
||
this.elements.paginationContainer.innerHTML = this._createPaginationHtml();
|
||
}
|
||
this._updateAllStatusIndicators();
|
||
this._syncCardCheckboxes();
|
||
this._syncSelectionUI();
|
||
}
|
||
// [NEW] Handles clicks on pagination buttons
|
||
handlePaginationClick(event) {
|
||
const button = event.target.closest("button[data-page]");
|
||
if (!button || button.disabled) return;
|
||
const newPage = parseInt(button.dataset.page, 10);
|
||
if (newPage !== this.state.currentPage) {
|
||
this.state.currentPage = newPage;
|
||
this.loadApiKeys(this.state.activeGroupId, true);
|
||
}
|
||
}
|
||
// [NEW] Generates the HTML for the pagination controls
|
||
_createPaginationHtml() {
|
||
const { currentPage, totalPages } = this.state;
|
||
if (totalPages < 1) return "";
|
||
const baseButtonClasses = "pagination-button px-3 py-1 rounded text-sm transition-colors duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed";
|
||
const activeClasses = "bg-zinc-500 text-white font-semibold";
|
||
const inactiveClasses = "hover:bg-zinc-200 dark:hover:bg-zinc-700";
|
||
let html = "";
|
||
const prevDisabled = currentPage <= 1 ? "disabled" : "";
|
||
const nextDisabled = currentPage >= totalPages ? "disabled" : "";
|
||
html += `<button class="${baseButtonClasses} ${inactiveClasses}" data-page="${currentPage - 1}" ${prevDisabled}>
|
||
<i class="fas fa-chevron-left"></i>
|
||
</button>`;
|
||
const pagesToShow = this._getPaginationPages(currentPage, totalPages);
|
||
pagesToShow.forEach((page) => {
|
||
if (page === "...") {
|
||
html += `<span class="px-3 py-1 text-zinc-400 dark:text-zinc-500 text-sm">...</span>`;
|
||
} else {
|
||
const pageClasses = page === currentPage ? activeClasses : inactiveClasses;
|
||
html += `<button class="${baseButtonClasses} ${pageClasses}" data-page="${page}">${page}</button>`;
|
||
}
|
||
});
|
||
html += `<button class="${baseButtonClasses} ${inactiveClasses}" data-page="${currentPage + 1}" ${nextDisabled}>
|
||
<i class="fas fa-chevron-right"></i>
|
||
</button>`;
|
||
return html;
|
||
}
|
||
// [NEW] Helper to determine which page numbers to show in pagination (e.g., 1 ... 5 6 7 ... 12)
|
||
_getPaginationPages(current, total, width = 2) {
|
||
if (total <= width * 2 + 3) {
|
||
return Array.from({ length: total }, (_, i) => i + 1);
|
||
}
|
||
const pages = [1];
|
||
if (current > width + 2) pages.push("...");
|
||
for (let i = Math.max(2, current - width); i <= Math.min(total - 1, current + width); i++) {
|
||
pages.push(i);
|
||
}
|
||
if (current < total - width - 1) pages.push("...");
|
||
pages.push(total);
|
||
return pages;
|
||
}
|
||
/**
|
||
* Handles all actions originating from within an API key card.
|
||
* @param {Event} event - The click event.
|
||
*/
|
||
async handleCardAction(event) {
|
||
const button = event.target.closest("button[data-action]");
|
||
if (!button) return;
|
||
const action = button.dataset.action;
|
||
const card = button.closest(".api-card");
|
||
if (!card) return;
|
||
if (action === "toggle-menu") {
|
||
const menu = card.querySelector('[data-menu="actions"]');
|
||
if (menu) {
|
||
const isMenuOpen = !menu.classList.contains("hidden");
|
||
this._closeAllActionMenus(card);
|
||
if (!isMenuOpen) {
|
||
menu.classList.remove("hidden");
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
this._closeAllActionMenus();
|
||
const keyId = parseInt(card.dataset.keyId, 10);
|
||
const groupId = this.state.activeGroupId;
|
||
const menuToClose = card.querySelector('[data-menu="actions"]');
|
||
if (menuToClose && !menuToClose.classList.contains("hidden")) {
|
||
this._closeAllActionMenus();
|
||
}
|
||
const apiKeyData = this.state.currentKeys.find((key) => key.id === keyId);
|
||
if (!apiKeyData) {
|
||
toastManager.show("\u9519\u8BEF: \u627E\u4E0D\u5230\u8BE5Key\u7684\u6570\u636E\u3002\u53EF\u80FD\u662F\u5217\u8868\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u5C1D\u8BD5\u5237\u65B0\u3002", "error");
|
||
return;
|
||
}
|
||
const fullApiKey = apiKeyData.api_key;
|
||
switch (action) {
|
||
case "toggle-visibility":
|
||
case "copy-key":
|
||
this._handleLocalCardActions(action, button, card, fullApiKey);
|
||
break;
|
||
case "set-status": {
|
||
const newStatus = button.dataset.newStatus;
|
||
if (!newStatus) return;
|
||
try {
|
||
await apiKeyManager.updateKeyStatusInGroup(groupId, keyId, newStatus);
|
||
toastManager.show(`Key \u72B6\u6001\u5DF2\u6210\u529F\u66F4\u65B0\u4E3A ${newStatus}`, "success");
|
||
} catch (error) {
|
||
handleApiError(error, toastManager);
|
||
} finally {
|
||
await this.loadApiKeys(groupId, true);
|
||
}
|
||
break;
|
||
}
|
||
case "revalidate": {
|
||
const revalidateTask = this._createRevalidateTaskDefinition(groupId, fullApiKey);
|
||
taskCenterManager.startTask(revalidateTask);
|
||
break;
|
||
}
|
||
case "delete-key": {
|
||
const result = await Swal.fire({
|
||
target: "#main-content-wrapper",
|
||
width: "20rem",
|
||
backdrop: `rgba(0,0,0,0.5)`,
|
||
heightAuto: false,
|
||
customClass: {
|
||
popup: `swal2-custom-style ${document.documentElement.classList.contains("dark") ? "swal2-dark" : ""}`
|
||
},
|
||
title: "\u786E\u8BA4\u5220\u9664",
|
||
html: `\u786E\u5B9A\u8981\u4ECE <b>\u5F53\u524D\u5206\u7EC4</b> \u4E2D\u79FB\u9664\u8FD9\u4E2AKey\u5417\uFF1F`,
|
||
//icon: 'warning',
|
||
showCancelButton: true,
|
||
confirmButtonText: "\u786E\u8BA4",
|
||
cancelButtonText: "\u53D6\u6D88",
|
||
reverseButtons: true,
|
||
confirmButtonColor: "#d33",
|
||
cancelButtonColor: "#6b7280",
|
||
focusConfirm: false,
|
||
focusCancel: false
|
||
});
|
||
if (!result.isConfirmed) {
|
||
return;
|
||
}
|
||
try {
|
||
const unlinkResult = await apiKeyManager.unlinkKeysFromGroup(groupId, [fullApiKey]);
|
||
if (!unlinkResult.success) {
|
||
throw new Error(unlinkResult.message || "\u540E\u7AEF\u672A\u80FD\u79FB\u9664Key\u3002");
|
||
}
|
||
toastManager.show(`\u6210\u529F\u79FB\u9664 1 \u4E2AKey\u3002`, "success");
|
||
await this.loadApiKeys(groupId, true);
|
||
} catch (error) {
|
||
const errorMessage = error && error.message ? error.message : ERROR_MESSAGES["DEFAULT"];
|
||
toastManager.show(`\u79FB\u9664Key\u5931\u8D25: ${errorMessage}`, "error");
|
||
await this.loadApiKeys(groupId, true);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
// --- Private Helper Methods (copied from original file) ---
|
||
_createRevalidateTaskDefinition(groupId, fullApiKey) {
|
||
return {
|
||
start: () => apiKeyManager.revalidateKeys(groupId, [fullApiKey]),
|
||
poll: (taskId) => apiKeyManager.getTaskStatus(taskId, { noCache: true }),
|
||
onSuccess: (data) => {
|
||
toastManager.show(`Key\u9A8C\u8BC1\u5B8C\u6210`, "success");
|
||
this.loadApiKeys(groupId, true);
|
||
},
|
||
onError: (data) => {
|
||
toastManager.show(`\u9A8C\u8BC1\u4EFB\u52A1\u5931\u8D25: ${data.error || "\u672A\u77E5\u9519\u8BEF"}`, "error");
|
||
},
|
||
renderToastNarrative: (data, oldData, toastManager2) => {
|
||
const toastId = `task-${data.id}`;
|
||
const progress = data.total > 0 ? data.processed / data.total * 100 : 0;
|
||
toastManager2.showProgressToast(toastId, `\u6B63\u5728\u9A8C\u8BC1Key`, "\u5904\u7406\u4E2D", progress);
|
||
},
|
||
renderTaskCenterItem: (data, timestamp, formatTimeAgo) => {
|
||
const timeAgo = formatTimeAgo(timestamp);
|
||
const maskedKey = escapeHTML(`${fullApiKey.substring(0, 4)}...${fullApiKey.substring(fullApiKey.length - 4)}`);
|
||
let contentHtml = "";
|
||
const isDone = !data.is_running;
|
||
if (isDone) {
|
||
if (data.error) {
|
||
const safeError = escapeHTML(data.error);
|
||
contentHtml = `
|
||
<div class="task-item-main">
|
||
<div class="task-item-icon-summary text-red-500"><i class="fas fa-exclamation-triangle"></i></div>
|
||
<div class="task-item-content flex-grow">
|
||
<p class="task-item-title">\u9A8C\u8BC1\u4EFB\u52A1\u51FA\u9519: ${maskedKey}</p>
|
||
<p class="task-item-status text-red-500 truncate" title="${safeError}">${safeError}</p>
|
||
</div>
|
||
</div>`;
|
||
} else {
|
||
const result = data.result?.results?.[0];
|
||
const isSuccess = result?.status === "valid";
|
||
const iconClass = isSuccess ? "text-green-500 fas fa-check-circle" : "text-red-500 fas fa-times-circle";
|
||
const title = isSuccess ? "\u9A8C\u8BC1\u6210\u529F" : "\u9A8C\u8BC1\u5931\u8D25";
|
||
const safeMessage = escapeHTML(result?.message || "\u6CA1\u6709\u8BE6\u7EC6\u4FE1\u606F\u3002");
|
||
contentHtml = `
|
||
<div class="task-item-main">
|
||
<div class="task-item-icon-summary"><i class="${iconClass}"></i></div>
|
||
<div class="task-item-content flex-grow">
|
||
<p class="task-item-title">${title}: ${maskedKey}</p>
|
||
<p class="task-item-status truncate" title="${safeMessage}">${safeMessage}</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
} else {
|
||
contentHtml = `
|
||
<div class="task-item-main gap-3">
|
||
<div class="task-item-icon task-item-icon-running"><i class="fas fa-spinner animate-spin"></i></div>
|
||
<div class="task-item-content flex-grow">
|
||
<p class="task-item-title">\u6B63\u5728\u9A8C\u8BC1: ${maskedKey}</p>
|
||
<p class="task-item-status">\u8FD0\u884C\u4E2D... (${data.processed}/${data.total})</p>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
return `${contentHtml}<div class="task-item-timestamp">${timeAgo}</div>`;
|
||
}
|
||
};
|
||
}
|
||
/**
|
||
* Creates the HTML for a single API key card, adapting to the flat APIKeyDetails structure.
|
||
* @param {object} item - The APIKeyDetails object from the API.
|
||
* @returns {string} The HTML string for the card.
|
||
*/
|
||
_createApiKeyCardHtml(item) {
|
||
if (!item || !item.api_key) return "";
|
||
const maskedKey = escapeHTML(`${item.api_key.substring(0, 4)}......${item.api_key.substring(item.api_key.length - 4)}`);
|
||
const status = escapeHTML(item.status);
|
||
const errorCount = escapeHTML(item.consecutive_error_count);
|
||
const keyId = escapeHTML(item.id);
|
||
const mappingId = escapeHTML(`${item.api_key_id}-${item.key_group_id}`);
|
||
const setActiveAction = `data-action="set-status" data-new-status="ACTIVE"`;
|
||
const revalidateAction = `data-action="revalidate"`;
|
||
const disableAction = `data-action="set-status" data-new-status="DISABLED"`;
|
||
const deleteAction = `data-action="delete-key"`;
|
||
return `
|
||
<div class="api-card group relative flex items-center gap-x-3 rounded-lg p-3 bg-white dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700/60"
|
||
data-status="${status}"
|
||
data-key-id="${keyId}"
|
||
data-mapping-id="${mappingId}">
|
||
<input type="checkbox" class="api-key-checkbox h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500 shrink-0">
|
||
<span data-status-indicator class="w-2 h-2 rounded-full shrink-0"></span>
|
||
<div class="flex-grow min-w-0">
|
||
<p class="font-mono text-xs font-semibold truncate">${maskedKey}</p>
|
||
<p class="text-xs text-zinc-400 mt-1">\u5931\u8D25: ${errorCount} \u6B21</p>
|
||
</div>
|
||
<!-- [DESKTOP ONLY] \u5FEB\u901F\u64CD\u4F5C\u6309\u94AE - \u5728\u79FB\u52A8\u7AEF(<lg)\u9690\u85CF -->
|
||
<div class="hidden lg:flex items-center gap-x-2 text-zinc-400 text-xs z-10">
|
||
<button class="hover:text-blue-500" data-action="toggle-visibility" title="\u67E5\u770B\u5B8C\u6574Key"><i class="fas fa-eye"></i></button>
|
||
<button class="hover:text-blue-500" data-action="copy-key" title="\u590D\u5236Key"><i class="fas fa-copy"></i></button>
|
||
</div>
|
||
<!-- [DESKTOP ONLY] Hover Menu - \u5728\u79FB\u52A8\u7AEF(<lg)\u9690\u85CF -->
|
||
<div class="hidden lg:flex absolute right-14 top-1/2 -translate-y-1/2 items-center bg-zinc-200 dark:bg-zinc-700 rounded-full shadow-md opacity-0 lg:group-hover:opacity-100 transition-opacity duration-200 z-20">
|
||
<button class="px-2 py-1 hover:text-green-500" ${setActiveAction} title="\u8BBE\u4E3A\u53EF\u7528"><i class="fas fa-check-circle"></i></button>
|
||
<button class="px-2 py-1 hover:text-blue-500" ${revalidateAction} title="\u91CD\u65B0\u9A8C\u8BC1"><i class="fas fa-sync-alt"></i></button>
|
||
<button class="px-2 py-1 hover:text-yellow-500" ${disableAction} title="\u7981\u7528"><i class="fas fa-ban"></i></button>
|
||
<button class="px-2 py-1 hover:text-red-500" ${deleteAction} title="\u4ECE\u5206\u7EC4\u4E2D\u79FB\u9664"><i class="fas fa-trash-alt"></i></button>
|
||
</div>
|
||
<!-- [MOBILE ONLY] Kebab Menu and Dropdown - \u5728\u684C\u9762\u7AEF(>=lg)\u9690\u85CF -->
|
||
<div class="relative lg:hidden">
|
||
<button data-action="toggle-menu" class="flex items-center justify-center h-8 w-8 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-700 text-zinc-500" title="\u66F4\u591A\u64CD\u4F5C">
|
||
<i class="fas fa-ellipsis-v"></i>
|
||
</button>
|
||
<div data-menu="actions" class="absolute right-0 mt-2 w-48 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-md shadow-lg z-30 hidden">
|
||
<!-- [NEW] "\u67E5\u770B"\u548C"\u590D\u5236"\u64CD\u4F5C\u5DF2\u79FB\u5165\u79FB\u52A8\u7AEF\u83DC\u5355 -->
|
||
<button class="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-x-3" data-action="toggle-visibility"><i class="fas fa-eye w-4"></i> \u67E5\u770B/\u9690\u85CF Key</button>
|
||
<button class="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-x-3" data-action="copy-key"><i class="fas fa-copy w-4"></i> \u590D\u5236 Key</button>
|
||
<div class="border-t border-zinc-200 dark:border-zinc-700 my-1"></div>
|
||
<button class="w-full text-left px-4 py-2 text-sm text-green-600 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-x-3" ${setActiveAction}><i class="fas fa-check-circle w-4"></i> \u8BBE\u4E3A\u53EF\u7528</button>
|
||
<button class="w-full text-left px-4 py-2 text-sm text-blue-600 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-x-3" ${revalidateAction}><i class="fas fa-sync-alt w-4"></i> \u91CD\u65B0\u9A8C\u8BC1</button>
|
||
<button class="w-full text-left px-4 py-2 text-sm text-yellow-600 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-x-3" ${disableAction}><i class="fas fa-ban w-4"></i> \u7981\u7528</button>
|
||
<div class="border-t border-zinc-200 dark:border-zinc-700 my-1"></div>
|
||
<button class="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-x-3" ${deleteAction}><i class="fas fa-trash-alt w-4"></i> \u4ECE\u5206\u7EC4\u4E2D\u79FB\u9664</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
_handleLocalCardActions(action, button, card, fullApiKey) {
|
||
switch (action) {
|
||
case "toggle-visibility": {
|
||
const safeApiKey = escapeHTML(fullApiKey);
|
||
Swal.fire({
|
||
target: "#main-content-wrapper",
|
||
width: "24rem",
|
||
// 适配移动端宽度
|
||
backdrop: `rgba(0,0,0,0.5)`,
|
||
heightAuto: false,
|
||
customClass: {
|
||
popup: `swal2-custom-style rounded-xl ${document.documentElement.classList.contains("dark") ? "swal2-dark" : ""}`,
|
||
htmlContainer: "m-0 text-left"
|
||
// 移除默认边距
|
||
},
|
||
showConfirmButton: false,
|
||
showCloseButton: false,
|
||
html: `
|
||
<div class="mt-2 flex items-center justify-between rounded-md bg-zinc-100 dark:bg-zinc-700 p-3 font-mono text-sm text-zinc-800 dark:text-zinc-200">
|
||
<code class="break-all">${safeApiKey}</code>
|
||
<button id="swal-copy-key-btn" class="ml-4 p-2 rounded-md hover:bg-zinc-200 dark:hover:bg-zinc-600 text-zinc-500 dark:text-zinc-300" title="\u590D\u5236">
|
||
<i class="far fa-copy"></i>
|
||
</button>
|
||
</div>
|
||
`,
|
||
didOpen: (modal) => {
|
||
const copyBtn = modal.querySelector("#swal-copy-key-btn");
|
||
if (copyBtn) {
|
||
copyBtn.addEventListener("click", () => {
|
||
navigator.clipboard.writeText(fullApiKey).then(() => {
|
||
toastManager.show("API Key \u5DF2\u590D\u5236\u5230\u526A\u8D34\u677F\u3002", "success");
|
||
copyBtn.innerHTML = '<i class="fas fa-check text-green-500"></i>';
|
||
setTimeout(() => {
|
||
copyBtn.innerHTML = '<i class="far fa-copy"></i>';
|
||
}, 1500);
|
||
}).catch((err) => {
|
||
toastManager.show(`\u590D\u5236\u5931\u8D25: ${err.message}`, "error");
|
||
});
|
||
});
|
||
}
|
||
}
|
||
});
|
||
break;
|
||
}
|
||
case "copy-key": {
|
||
if (!fullApiKey) {
|
||
toastManager.show("\u65E0\u6CD5\u627E\u5230\u5B8C\u6574\u7684Key\u7528\u4E8E\u590D\u5236\u3002", "error");
|
||
break;
|
||
}
|
||
navigator.clipboard.writeText(fullApiKey).then(() => {
|
||
toastManager.show("API Key \u5DF2\u590D\u5236\u5230\u526A\u8D34\u677F\u3002", "success");
|
||
}).catch((err) => {
|
||
toastManager.show(`\u590D\u5236\u5931\u8D25: ${err.message}`, "error");
|
||
});
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
_updateAllStatusIndicators() {
|
||
const allCards = this.elements.container.querySelectorAll(".api-card[data-status]");
|
||
allCards.forEach((card) => this._updateApiKeyStatusIndicator(card));
|
||
}
|
||
_updateApiKeyStatusIndicator(cardElement) {
|
||
const status = cardElement.dataset.status;
|
||
if (!status) return;
|
||
const indicator = cardElement.querySelector("[data-status-indicator]");
|
||
if (!indicator) return;
|
||
const statusColors = {
|
||
"ACTIVE": "bg-green-500",
|
||
"PENDING": "bg-gray-400",
|
||
"COOLDOWN": "bg-yellow-500",
|
||
"DISABLED": "bg-orange-500",
|
||
"BANNED": "bg-red-500"
|
||
};
|
||
Object.values(statusColors).forEach((colorClass) => indicator.classList.remove(colorClass));
|
||
if (statusColors[status]) {
|
||
indicator.classList.add(statusColors[status]);
|
||
}
|
||
}
|
||
/**
|
||
* [NEW HELPER] Closes all action menus, optionally ignoring one card.
|
||
* @param {HTMLElement} [ignoreCard=null] - The card whose menu should not be closed.
|
||
*/
|
||
_closeAllActionMenus(ignoreCard = null) {
|
||
this.elements.container.querySelectorAll(".api-card").forEach((card) => {
|
||
if (card === ignoreCard) return;
|
||
const menu = card.querySelector('[data-menu="actions"]');
|
||
if (menu) {
|
||
menu.classList.add("hidden");
|
||
}
|
||
});
|
||
}
|
||
/**
|
||
* [NEW] A central click handler for the entire list container.
|
||
* It delegates clicks on checkboxes or action buttons to specific handlers.
|
||
*/
|
||
_handleContainerClick(event) {
|
||
const target = event.target;
|
||
if (target.matches(".api-key-checkbox")) {
|
||
this._handleSelectionChange(target);
|
||
return;
|
||
}
|
||
const actionButton = target.closest("button[data-action]");
|
||
if (actionButton) {
|
||
this.handleCardAction(event);
|
||
return;
|
||
}
|
||
}
|
||
/**
|
||
* [NEW] Handles a click on an individual API key's checkbox.
|
||
* @param {HTMLInputElement} checkbox - The checkbox element that was clicked.
|
||
*/
|
||
_handleSelectionChange(checkbox) {
|
||
const card = checkbox.closest(".api-card");
|
||
if (!card) return;
|
||
const keyId = parseInt(card.dataset.keyId, 10);
|
||
if (isNaN(keyId)) return;
|
||
if (checkbox.checked) {
|
||
this.state.selectedKeyIds.add(keyId);
|
||
} else {
|
||
this.state.selectedKeyIds.delete(keyId);
|
||
}
|
||
this._syncSelectionUI();
|
||
}
|
||
/**
|
||
* [NEW] Synchronizes the UI elements based on the current selection state.
|
||
* This includes the "Select All" checkbox and batch action buttons.
|
||
*/
|
||
_syncSelectionUI() {
|
||
if (!this.elements.selectAllCheckbox || !this.elements.batchActionButton) return;
|
||
const selectedCount = this.state.selectedKeyIds.size;
|
||
const visibleKeysCount = this.state.currentKeys.length;
|
||
if (selectedCount === 0) {
|
||
this.elements.selectAllCheckbox.checked = false;
|
||
this.elements.selectAllCheckbox.indeterminate = false;
|
||
} else if (selectedCount < visibleKeysCount) {
|
||
this.elements.selectAllCheckbox.checked = false;
|
||
this.elements.selectAllCheckbox.indeterminate = true;
|
||
} else if (selectedCount === visibleKeysCount && visibleKeysCount > 0) {
|
||
this.elements.selectAllCheckbox.checked = true;
|
||
this.elements.selectAllCheckbox.indeterminate = false;
|
||
}
|
||
const isDisabled = selectedCount === 0;
|
||
if (isDisabled) {
|
||
this.elements.batchActionButton.classList.add("is-disabled");
|
||
} else {
|
||
this.elements.batchActionButton.classList.remove("is-disabled");
|
||
}
|
||
this.elements.batchActionButton.style.pointerEvents = isDisabled ? "none" : "auto";
|
||
this.elements.batchActionButton.style.opacity = isDisabled ? "0.5" : "1";
|
||
const counter = this.elements.batchActionButton.querySelector("span");
|
||
if (counter) {
|
||
counter.textContent = isDisabled ? "\u6279\u91CF\u64CD\u4F5C" : `\u5DF2\u9009 ${selectedCount} \u9879`;
|
||
}
|
||
}
|
||
/**
|
||
* [NEW] Handles the change event of the main "Select All" checkbox.
|
||
* @param {Event} event - The change event object.
|
||
*/
|
||
_handleSelectAllChange(event) {
|
||
const isChecked = event.target.checked;
|
||
this.state.currentKeys.forEach((key) => {
|
||
if (isChecked) {
|
||
this.state.selectedKeyIds.add(key.id);
|
||
} else {
|
||
this.state.selectedKeyIds.delete(key.id);
|
||
}
|
||
});
|
||
this._syncCardCheckboxes();
|
||
this._syncSelectionUI();
|
||
}
|
||
/**
|
||
* [NEW] Ensures that the checked status of each individual card's checkbox
|
||
* matches the selection state stored in `this.state.selectedKeyIds`.
|
||
*/
|
||
_syncCardCheckboxes() {
|
||
if (!this.elements.gridContainer) return;
|
||
const checkboxes = this.elements.gridContainer.querySelectorAll(".api-key-checkbox");
|
||
checkboxes.forEach((checkbox) => {
|
||
const card = checkbox.closest(".api-card");
|
||
if (card) {
|
||
const keyId = parseInt(card.dataset.keyId, 10);
|
||
if (!isNaN(keyId)) {
|
||
checkbox.checked = this.state.selectedKeyIds.has(keyId);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
/**
|
||
* [NEW] Handles clicks within the batch action dropdown panel.
|
||
* Uses event delegation to determine which action was triggered.
|
||
* This version is adapted for the final HTML structure.
|
||
* @param {Event} event - The click event.
|
||
*/
|
||
_handleBatchActionClick(event) {
|
||
const button = event.target.closest("button[data-batch-action]");
|
||
if (!button) return;
|
||
event.preventDefault();
|
||
const customSelectContainer = button.closest(".custom-select");
|
||
if (customSelectContainer) {
|
||
const panel = customSelectContainer.querySelector(".custom-select-panel");
|
||
if (panel) panel.classList.add("hidden");
|
||
}
|
||
const action = button.dataset.batchAction;
|
||
const selectedIds = Array.from(this.state.selectedKeyIds);
|
||
if (selectedIds.length === 0) {
|
||
toastManager.show("\u6CA1\u6709\u9009\u4E2D\u4EFB\u4F55Key\u3002", "warning");
|
||
return;
|
||
}
|
||
switch (action) {
|
||
case "copy-to-clipboard":
|
||
this._batchCopyToClipboard(selectedIds);
|
||
break;
|
||
case "set-status-active":
|
||
this._batchSetStatus("ACTIVE", selectedIds);
|
||
break;
|
||
case "set-status-disabled":
|
||
this._batchSetStatus("DISABLED", selectedIds);
|
||
break;
|
||
case "revalidate":
|
||
this._batchRevalidate(selectedIds);
|
||
break;
|
||
case "delete":
|
||
this._batchDelete(selectedIds);
|
||
break;
|
||
default:
|
||
console.warn(`Unknown batch action: ${action}`);
|
||
}
|
||
}
|
||
/**
|
||
* [NEW] Helper for batch updating the status of selected keys.
|
||
* @param {string} newStatus - The new status ('ACTIVE' or 'DISABLED').
|
||
* @param {number[]} keyIds - An array of selected key IDs.
|
||
*/
|
||
async _batchSetStatus(newStatus, keyIds) {
|
||
const groupId = this.state.activeGroupId;
|
||
const actionText = newStatus === "ACTIVE" ? "\u542F\u7528" : "\u7981\u7528";
|
||
toastManager.show(`\u6B63\u5728\u6279\u91CF${actionText} ${keyIds.length} \u4E2AKey...`, "info", 3e3);
|
||
try {
|
||
const promises = keyIds.map((id) => apiKeyManager.updateKeyStatusInGroup(groupId, id, newStatus));
|
||
const results = await Promise.allSettled(promises);
|
||
const fulfilledCount = results.filter((r) => r.status === "fulfilled").length;
|
||
const rejectedCount = results.length - fulfilledCount;
|
||
let toastMessage = `\u6279\u91CF${actionText}\u64CD\u4F5C\u5B8C\u6210\u3002`;
|
||
let toastType = "success";
|
||
if (rejectedCount > 0) {
|
||
toastMessage = `\u64CD\u4F5C\u5B8C\u6210: ${fulfilledCount} \u4E2A\u6210\u529F\uFF0C${rejectedCount} \u4E2A\u5931\u8D25\uFF08\u53EF\u80FD\u7531\u4E8EKey\u72B6\u6001\u9650\u5236\uFF09\u3002\u5217\u8868\u5DF2\u66F4\u65B0\u3002`;
|
||
toastType = fulfilledCount > 0 ? "warning" : "error";
|
||
}
|
||
toastManager.show(toastMessage, toastType);
|
||
} catch (error) {
|
||
toastManager.show(`\u6279\u91CF${actionText}\u65F6\u53D1\u751F\u7F51\u7EDC\u9519\u8BEF: ${error.message || "\u672A\u77E5\u9519\u8BEF"}`, "error");
|
||
} finally {
|
||
await this.loadApiKeys(groupId, true);
|
||
}
|
||
}
|
||
/**
|
||
* [NEW] Helper for batch revalidating selected keys.
|
||
* @param {number[]} keyIds - An array of selected key IDs.
|
||
*/
|
||
_batchRevalidate(keyIds) {
|
||
const groupId = this.state.activeGroupId;
|
||
const currentKeysMap = new Map(this.state.currentKeys.map((key) => [key.id, key.api_key]));
|
||
const keysToRevalidate = keyIds.map((id) => currentKeysMap.get(id)).filter(Boolean);
|
||
if (keysToRevalidate.length === 0) {
|
||
toastManager.show("\u627E\u4E0D\u5230\u5339\u914D\u7684Key\u8FDB\u884C\u9A8C\u8BC1\u3002\u8BF7\u5237\u65B0\u5217\u8868\u540E\u91CD\u8BD5\u3002", "error");
|
||
return;
|
||
}
|
||
const revalidateTask = {
|
||
start: () => apiKeyManager.revalidateKeys(groupId, keysToRevalidate),
|
||
poll: (taskId) => apiKeyManager.getTaskStatus(taskId, { noCache: true }),
|
||
onSuccess: (data) => {
|
||
toastManager.show(`\u6279\u91CF\u9A8C\u8BC1\u5B8C\u6210\u3002`, "success");
|
||
this.loadApiKeys(groupId, true);
|
||
},
|
||
onError: (data) => {
|
||
toastManager.show(`\u6279\u91CF\u9A8C\u8BC1\u4EFB\u52A1\u5931\u8D25: ${data.error || "\u672A\u77E5\u9519\u8BEF"}`, "error");
|
||
},
|
||
renderToastNarrative: (data, oldData, toastManager2) => {
|
||
const toastId = `task-${data.id}`;
|
||
const progress = data.total > 0 ? data.processed / data.total * 100 : 0;
|
||
toastManager2.showProgressToast(toastId, `\u6B63\u5728\u6279\u91CF\u9A8C\u8BC1Key`, `\u5904\u7406\u4E2D (${data.processed}/${data.total})`, progress);
|
||
},
|
||
// [MODIFIED] This is the core fix. A new, detailed renderer for the task center.
|
||
renderTaskCenterItem: (data, timestamp, formatTimeAgo) => {
|
||
const timeAgo = formatTimeAgo(timestamp);
|
||
let contentHtml = "";
|
||
if (data.is_running) {
|
||
contentHtml = `
|
||
<div class="task-item-main gap-3">
|
||
<div class="task-item-icon task-item-icon-running"><i class="fas fa-spinner animate-spin"></i></div>
|
||
<div class="task-item-content flex-grow">
|
||
<p class="task-item-title">\u6279\u91CF\u9A8C\u8BC1 ${data.total} \u4E2AKey</p>
|
||
<p class="task-item-status">\u8FD0\u884C\u4E2D... (${data.processed}/${data.total})</p>
|
||
</div>
|
||
</div>`;
|
||
} else {
|
||
if (data.error) {
|
||
contentHtml = `
|
||
<div class="task-item-main">
|
||
<div class="task-item-icon-summary text-red-500"><i class="fas fa-exclamation-triangle"></i></div>
|
||
<div class="task-item-content flex-grow">
|
||
<p class="task-item-title">\u6279\u91CF\u9A8C\u8BC1\u4EFB\u52A1\u51FA\u9519</p>
|
||
<p class="task-item-status text-red-500 truncate" title="${data.error}">${data.error}</p>
|
||
</div>
|
||
</div>`;
|
||
} else {
|
||
const results = data.result?.results || [];
|
||
const validCount = results.filter((r) => r.status === "valid").length;
|
||
const invalidCount = results.length - validCount;
|
||
const summaryTitle = `\u9A8C\u8BC1\u5B8C\u6210: ${validCount}\u4E2A\u6709\u6548, ${invalidCount}\u4E2A\u65E0\u6548`;
|
||
const overallIconClass = invalidCount > 0 ? "text-yellow-500 fas fa-exclamation-triangle" : "text-green-500 fas fa-check-circle";
|
||
const detailsHtml = results.map((result) => {
|
||
const maskedKey = escapeHTML(`${result.key.substring(0, 4)}...${result.key.substring(result.key.length - 4)}`);
|
||
const safeMessage = escapeHTML(result.message);
|
||
if (result.status === "valid") {
|
||
return `
|
||
<div class="flex items-start text-xs">
|
||
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2"></i>
|
||
<div class="flex-grow">
|
||
<p class="font-mono">${maskedKey}</p>
|
||
<p class="text-zinc-400">${safeMessage}</p>
|
||
</div>
|
||
</div>`;
|
||
} else {
|
||
return `
|
||
<div class="flex items-start text-xs">
|
||
<i class="fas fa-times-circle text-red-500 mt-0.5 mr-2"></i>
|
||
<div class="flex-grow">
|
||
<p class="font-mono">${maskedKey}</p>
|
||
<p class="text-zinc-400">${safeMessage}</p>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
}).join("");
|
||
contentHtml = `
|
||
<div class="task-item-main">
|
||
<div class="task-item-icon-summary"><i class="${overallIconClass}"></i></div>
|
||
<div class="task-item-content flex-grow">
|
||
<div class="flex justify-between items-center cursor-pointer" data-task-toggle>
|
||
<p class="task-item-title">${summaryTitle}</p>
|
||
<i class="fas fa-chevron-down task-toggle-icon"></i>
|
||
</div>
|
||
<div class="task-details-content collapsed" data-task-content>
|
||
<div class="task-details-body space-y-2 mt-2">
|
||
${detailsHtml}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
}
|
||
return `${contentHtml}<div class="task-item-timestamp">${timeAgo}</div>`;
|
||
}
|
||
};
|
||
taskCenterManager.startTask(revalidateTask);
|
||
}
|
||
/**
|
||
* [NEW] Helper for batch deleting (unlinking) selected keys from the group.
|
||
* @param {number[]} keyIds - An array of selected key IDs.
|
||
*/
|
||
async _batchDelete(keyIds) {
|
||
const groupId = this.state.activeGroupId;
|
||
const selectedCount = keyIds.length;
|
||
const result = await Swal.fire({
|
||
target: "#main-content-wrapper",
|
||
width: "20rem",
|
||
backdrop: `rgba(0,0,0,0.5)`,
|
||
heightAuto: false,
|
||
customClass: {
|
||
popup: `swal2-custom-style ${document.documentElement.classList.contains("dark") ? "swal2-dark" : ""}`
|
||
},
|
||
title: "\u786E\u8BA4\u6279\u91CF\u79FB\u9664",
|
||
html: `\u786E\u5B9A\u8981\u4ECE <b>\u5F53\u524D\u5206\u7EC4</b> \u4E2D\u79FB\u9664\u9009\u4E2D\u7684 <b>${selectedCount}</b> \u4E2AKey\u5417\uFF1F`,
|
||
showCancelButton: true,
|
||
confirmButtonText: "\u786E\u8BA4",
|
||
cancelButtonText: "\u53D6\u6D88",
|
||
reverseButtons: true,
|
||
confirmButtonColor: "#d33",
|
||
cancelButtonColor: "#6b7280"
|
||
});
|
||
if (!result.isConfirmed) return;
|
||
const keysToDelete = this.state.currentKeys.filter((key) => keyIds.includes(key.id)).map((key) => key.api_key);
|
||
if (keysToDelete.length === 0) {
|
||
toastManager.show("\u627E\u4E0D\u5230\u5339\u914D\u7684Key\u8FDB\u884C\u79FB\u9664\u3002\u8BF7\u5237\u65B0\u5217\u8868\u540E\u91CD\u8BD5\u3002", "error");
|
||
return;
|
||
}
|
||
try {
|
||
const unlinkResult = await apiKeyManager.unlinkKeysFromGroup(groupId, keysToDelete);
|
||
if (!unlinkResult.success) {
|
||
throw new Error(unlinkResult.message || "\u540E\u7AEF\u672A\u80FD\u79FB\u9664Keys\u3002");
|
||
}
|
||
toastManager.show(`\u6210\u529F\u79FB\u9664 ${keysToDelete.length} \u4E2AKey\u3002`, "success");
|
||
await this.loadApiKeys(groupId, true);
|
||
} catch (error) {
|
||
const errorMessage = error && error.message ? error.message : "\u672A\u77E5\u9519\u8BEF";
|
||
toastManager.show(`\u6279\u91CF\u79FB\u9664Key\u5931\u8D25: ${errorMessage}`, "error");
|
||
await this.loadApiKeys(groupId, true);
|
||
}
|
||
}
|
||
/**
|
||
* [NEW] Helper for copying all selected API keys to the clipboard.
|
||
* @param {number[]} keyIds - An array of selected key IDs.
|
||
*/
|
||
_batchCopyToClipboard(keyIds) {
|
||
const currentKeysMap = new Map(this.state.currentKeys.map((key) => [key.id, key.api_key]));
|
||
const keysToCopy = keyIds.map((id) => currentKeysMap.get(id)).filter(Boolean);
|
||
if (keysToCopy.length === 0) {
|
||
toastManager.show("\u6CA1\u6709\u627E\u5230\u53EF\u590D\u5236\u7684Key\u3002\u5217\u8868\u53EF\u80FD\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u5237\u65B0\u3002", "warning");
|
||
return;
|
||
}
|
||
const textToCopy = keysToCopy.join("\n");
|
||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||
toastManager.show(`\u6210\u529F\u590D\u5236 ${keysToCopy.length} \u4E2AKey\u5230\u526A\u8D34\u677F\u3002`, "success");
|
||
}).catch((err) => {
|
||
console.error("Failed to copy keys to clipboard:", err);
|
||
toastManager.show(`\u590D\u5236\u5931\u8D25: ${err.message}`, "error");
|
||
});
|
||
}
|
||
/** [NEW] Shows the mobile search modal using SweetAlert2. */
|
||
_showMobileSearchModal() {
|
||
Swal.fire({
|
||
target: "#main-content-wrapper",
|
||
width: "24rem",
|
||
backdrop: `rgba(0,0,0,0.5)`,
|
||
heightAuto: false,
|
||
customClass: {
|
||
popup: `swal2-custom-style rounded-xl ${document.documentElement.classList.contains("dark") ? "swal2-dark" : ""}`,
|
||
htmlContainer: "m-0"
|
||
},
|
||
showConfirmButton: false,
|
||
// We don't need a confirm button
|
||
showCloseButton: false,
|
||
// A close button is user-friendly
|
||
html: `
|
||
<div class="flex items-center justify-between rounded-md bg-zinc-100 dark:bg-zinc-700 p-3 mt-2 font-mono text-sm text-zinc-800 dark:text-zinc-200">
|
||
<input type="text" id="swal-search-input" placeholder="\u641C\u7D22 API Key..." class="w-full focus:outline-none focus:ring-0 rounded-lg dark:placeholder-zinc-400"
|
||
autocomplete="off">
|
||
</div>
|
||
`,
|
||
didOpen: (modal) => {
|
||
const input = modal.querySelector("#swal-search-input");
|
||
if (!input) return;
|
||
input.value = this.state.searchText;
|
||
input.focus();
|
||
input.addEventListener("input", this._handleSearchInput.bind(this));
|
||
input.addEventListener("keydown", (event) => {
|
||
this._handleSearchEnter.bind(this)(event);
|
||
if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
Swal.close();
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
/** [NEW] Central handler for any search input event. */
|
||
_handleSearchInput(event) {
|
||
this._updateSearchStateAndSyncInputs(event.target.value);
|
||
this.debouncedSearch();
|
||
}
|
||
/** Synchronizes search text across UI state and inputs. */
|
||
_updateSearchStateAndSyncInputs(value) {
|
||
this.state.searchText = value;
|
||
if (this.elements.desktopSearchInput && document.activeElement !== this.elements.desktopSearchInput) {
|
||
this.elements.desktopSearchInput.value = value;
|
||
}
|
||
}
|
||
/** [MODIFIED] Handles 'Enter' key press for immediate search. Bug fixed. */
|
||
_handleSearchEnter(event) {
|
||
if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
this.debouncedSearch.cancel?.();
|
||
this.state.currentPage = 1;
|
||
this.loadApiKeys(this.state.activeGroupId, true);
|
||
}
|
||
}
|
||
/** Hides the mobile search overlay */
|
||
_hideMobileSearch() {
|
||
this.elements.mobileSearchOverlay?.classList.add("hidden");
|
||
}
|
||
/**
|
||
* A private helper that returns the complete quick actions configuration.
|
||
* This is the single, authoritative source for all quick action menu data.
|
||
* @returns {Array<Object>} The configuration array for quick actions.
|
||
*/
|
||
_getQuickActionsConfiguration() {
|
||
return [
|
||
{ action: "revalidate-invalid", text: "\u9A8C\u8BC1\u6240\u6709\u65E0\u6548Key", icon: "fa-rocket text-blue-500", requiresConfirm: false },
|
||
{ action: "revalidate-all", text: "\u9A8C\u8BC1\u6240\u6709Key", icon: "fa-sync-alt text-blue-500", requiresConfirm: true, confirmText: "\u6B64\u64CD\u4F5C\u5C06\u5BF9\u5F53\u524D\u5206\u7EC4\u4E0B\u7684 **\u6240\u6709Key** \u53D1\u8D77\u4E00\u6B21API\u8BF7\u6C42\u4EE5\u9A8C\u8BC1\u5176\u6709\u6548\u6027\uFF0C\u53EF\u80FD\u4F1A\u6D88\u8017\u5927\u91CF\u989D\u5EA6\u3002\u786E\u5B9A\u8981\u7EE7\u7EED\u5417\uFF1F" },
|
||
{ action: "restore-cooldown", text: "\u6062\u590D\u6240\u6709\u51B7\u5374\u4E2DKey", icon: "fa-undo text-green-500", requiresConfirm: false },
|
||
{ type: "divider" },
|
||
{ action: "cleanup-banned", text: "\u5220\u9664\u6240\u6709\u5931\u6548Key", icon: "fa-trash-alt", danger: true, requiresConfirm: true, confirmText: "\u786E\u5B9A\u8981\u4ECE\u5F53\u524D\u5206\u7EC4\u4E2D\u79FB\u9664 **\u6240\u6709** \u72B6\u6001\u4E3A `BANNED` \u7684Key\u5417\uFF1F\u6B64\u64CD\u4F5C\u4E0D\u53EF\u6062\u590D\u3002" }
|
||
];
|
||
}
|
||
/**
|
||
* Renders the content of the quick actions dropdown menu into both desktop and mobile panels.
|
||
*/
|
||
_renderQuickActionsMenu() {
|
||
const actions = this._getQuickActionsConfiguration();
|
||
const menuHtml = actions.map((item) => {
|
||
if (item.type === "divider") {
|
||
return '<div class="menu-divider"></div>';
|
||
}
|
||
return `
|
||
<button data-quick-action="${item.action}" class="menu-item ${item.danger ? "menu-item-danger" : ""} whitespace-nowrap">
|
||
<i class="fas ${item.icon} menu-item-icon"></i>
|
||
<span>${item.text}</span>
|
||
</button>
|
||
`;
|
||
}).join("");
|
||
const menuWrapper = `<div class="py-1">${menuHtml}</div>`;
|
||
if (this.elements.desktopQuickActionsPanel) {
|
||
this.elements.desktopQuickActionsPanel.innerHTML = menuWrapper;
|
||
}
|
||
if (this.elements.mobileQuickActionsPanel) {
|
||
this.elements.mobileQuickActionsPanel.innerHTML = menuWrapper;
|
||
}
|
||
}
|
||
/**
|
||
* [NEW] Handles clicks on any quick action button.
|
||
* @param {Event} event The click event.
|
||
*/
|
||
async _handleQuickActionClick(event) {
|
||
const button = event.target.closest("button[data-quick-action]");
|
||
if (!button) return;
|
||
event.preventDefault();
|
||
const action = button.dataset.quickAction;
|
||
const groupId = this.state.activeGroupId;
|
||
if (!groupId) {
|
||
toastManager.show("\u8BF7\u5148\u9009\u62E9\u4E00\u4E2A\u5206\u7EC4\u3002", "warning");
|
||
return;
|
||
}
|
||
const actionConfig = this._getQuickActionConfig(action);
|
||
if (actionConfig && actionConfig.requiresConfirm) {
|
||
const result = await Swal.fire({
|
||
target: "#main-content-wrapper",
|
||
title: "\u8BF7\u786E\u8BA4\u64CD\u4F5C",
|
||
html: actionConfig.confirmText,
|
||
icon: "warning",
|
||
showCancelButton: true,
|
||
confirmButtonText: "\u786E\u8BA4\u6267\u884C",
|
||
cancelButtonText: "\u53D6\u6D88",
|
||
reverseButtons: true,
|
||
confirmButtonColor: "#d33",
|
||
customClass: { popup: `swal2-custom-style ${document.documentElement.classList.contains("dark") ? "swal2-dark" : ""}` }
|
||
});
|
||
if (!result.isConfirmed) return;
|
||
}
|
||
this._executeQuickAction(action, groupId);
|
||
}
|
||
/** [NEW] Helper to retrieve action configuration. */
|
||
_getQuickActionConfig(action) {
|
||
const actions = this._getQuickActionsConfiguration();
|
||
return actions.find((a) => a.action === action);
|
||
}
|
||
/**
|
||
* [MODIFIED] Executes the quick action by building the correct payload for the unified bulk-actions endpoint.
|
||
* @param {string} action The action to execute.
|
||
* @param {number} groupId The current group ID.
|
||
*/
|
||
async _executeQuickAction(action, groupId) {
|
||
let taskDefinition;
|
||
let payload;
|
||
let taskTitle;
|
||
switch (action) {
|
||
case "revalidate-invalid":
|
||
taskTitle = "\u9A8C\u8BC1\u5206\u7EC4\u65E0\u6548Key";
|
||
payload = {
|
||
action: "revalidate",
|
||
filter: {
|
||
// Backend should interpret 'invalid' as this set of statuses
|
||
status: ["disabled", "cooldown", "banned"]
|
||
}
|
||
};
|
||
break;
|
||
case "revalidate-all":
|
||
taskTitle = "\u9A8C\u8BC1\u5206\u7EC4\u6240\u6709Key";
|
||
payload = {
|
||
action: "revalidate",
|
||
filter: { status: ["all"] }
|
||
};
|
||
break;
|
||
case "restore-cooldown":
|
||
taskTitle = "\u6062\u590D\u51B7\u5374\u4E2DKey";
|
||
payload = {
|
||
action: "set_status",
|
||
new_status: "active",
|
||
// The target status
|
||
filter: { status: ["cooldown"] }
|
||
};
|
||
break;
|
||
case "cleanup-banned":
|
||
taskTitle = "\u6E05\u7406\u5931\u6548Key";
|
||
payload = {
|
||
action: "delete",
|
||
filter: { status: ["banned"] }
|
||
};
|
||
break;
|
||
default:
|
||
toastManager.show(`\u672A\u77E5\u7684\u5FEB\u901F\u5904\u7F6E\u64CD\u4F5C: ${action}`, "error");
|
||
return;
|
||
}
|
||
try {
|
||
const response = await apiKeyManager.startGroupBulkActionTask(groupId, payload);
|
||
if (response && response.id) {
|
||
const startPromise = Promise.resolve(response);
|
||
const taskDefinition2 = this._createGroupTaskDefinition(taskTitle, startPromise, groupId, action);
|
||
taskCenterManager.startTask(taskDefinition2);
|
||
} else {
|
||
if (response && response.result && response.result.message) {
|
||
toastManager.show(response.result.message, "info");
|
||
} else {
|
||
toastManager.show("\u64CD\u4F5C\u5DF2\u5B8C\u6210\uFF0C\u4F46\u65E0\u4EFB\u4F55\u9879\u76EE\u88AB\u66F4\u6539\u3002", "info");
|
||
}
|
||
}
|
||
} catch (error) {
|
||
handleApiError(error, toastManager, { prefix: `${taskTitle}\u65F6\u53D1\u751F\u610F\u5916\u9519\u8BEF: ` });
|
||
}
|
||
}
|
||
/**
|
||
* [NEW] Generic task definition factory for group-level operations.
|
||
*/
|
||
_createGroupTaskDefinition(title, startPromise, groupId, action) {
|
||
return {
|
||
start: () => startPromise,
|
||
poll: (taskId) => apiKeyManager.getTaskStatus(taskId, { noCache: true }),
|
||
onSuccess: (data) => {
|
||
toastManager.show(`${title}\u4EFB\u52A1\u5B8C\u6210\u3002`, "success");
|
||
this.loadApiKeys(groupId, true);
|
||
},
|
||
onError: (data) => {
|
||
const displayMessage = escapeHTML(data.error || "\u4EFB\u52A1\u6267\u884C\u65F6\u53D1\u751F\u672A\u77E5\u9519\u8BEF");
|
||
toastManager.show(`${title}\u4EFB\u52A1\u5931\u8D25: ${displayMessage}`, "error");
|
||
},
|
||
renderToastNarrative: (data, oldData, toastManager2) => {
|
||
const toastId = `task-${data.id}`;
|
||
const progress = data.total > 0 ? data.processed / data.total * 100 : 0;
|
||
toastManager2.showProgressToast(toastId, title, `\u5904\u7406\u4E2D (${data.processed}/${data.total})`, progress);
|
||
},
|
||
renderTaskCenterItem: (data, timestamp, formatTimeAgo) => {
|
||
const timeAgo = formatTimeAgo(timestamp);
|
||
let contentHtml = "";
|
||
if (data.is_running) {
|
||
contentHtml = `
|
||
<div class="task-item-main gap-3">
|
||
<div class="task-item-icon task-item-icon-running"><i class="fas fa-spinner animate-spin"></i></div>
|
||
<div class="task-item-content flex-grow">
|
||
<p class="task-item-title">${title}</p>
|
||
<p class="task-item-status">\u8FD0\u884C\u4E2D... (${data.processed}/${data.total})</p>
|
||
</div>
|
||
</div>`;
|
||
} else if (data.error) {
|
||
const safeError = escapeHTML(data.error);
|
||
contentHtml = `
|
||
<div class="task-item-main">
|
||
<div class="task-item-icon-summary text-red-500"><i class="fas fa-exclamation-triangle"></i></div>
|
||
<div class="task-item-content flex-grow">
|
||
<p class="task-item-title">${title}\u4EFB\u52A1\u51FA\u9519</p>
|
||
<p class="task-item-status text-red-500 truncate" title="${safeError}">${safeError}</p>
|
||
</div>
|
||
</div>`;
|
||
} else {
|
||
let summary = "\u4EFB\u52A1\u5DF2\u5B8C\u6210\u3002";
|
||
const result = data.result || {};
|
||
const iconClass = "fas fa-check-circle text-green-500";
|
||
switch (action) {
|
||
case "cleanup-banned":
|
||
summary = `\u6210\u529F\u6E05\u7406 ${result.unlinked_count || 0} \u4E2A\u5931\u6548Key\u3002`;
|
||
break;
|
||
case "restore-cooldown":
|
||
if (result.skipped_count > 0) {
|
||
summary = `\u5B8C\u6210: ${result.updated_count || 0} \u4E2A\u5DF2\u6062\u590D, ${result.skipped_count} \u4E2A\u88AB\u8DF3\u8FC7\u3002`;
|
||
} else {
|
||
summary = `\u6210\u529F\u6062\u590D ${result.updated_count || 0} \u4E2A\u51B7\u5374\u4E2DKey\u3002`;
|
||
}
|
||
break;
|
||
case "revalidate-invalid":
|
||
case "revalidate-all":
|
||
const results = result.results || [];
|
||
const validCount = results.filter((r) => r.status === "valid").length;
|
||
const invalidCount = results.length - validCount;
|
||
summary = `\u9A8C\u8BC1\u5B8C\u6210: ${validCount}\u4E2A\u6709\u6548, ${invalidCount}\u4E2A\u65E0\u6548\u3002`;
|
||
break;
|
||
default:
|
||
summary = `\u5904\u7406\u4E86 ${data.processed} \u4E2AKey\u3002`;
|
||
}
|
||
const safeSummary = escapeHTML(summary);
|
||
contentHtml = `
|
||
<div class="task-item-main">
|
||
<div class="task-item-icon-summary"><i class="${iconClass}"></i></div>
|
||
<div class="task-item-content flex-grow">
|
||
<p class="task-item-title">${title}</p>
|
||
<p class="task-item-status truncate" title="${safeSummary}">${safeSummary}</p>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
return `${contentHtml}<div class="task-item-timestamp">${timeAgo}</div>`;
|
||
}
|
||
};
|
||
}
|
||
/**
|
||
* [NEW] A generic handler to open/close any custom dropdown menu.
|
||
* It uses data attributes to link triggers to panels.
|
||
* @param {Event} event The click event.
|
||
*/
|
||
_handleDropdownToggle(event) {
|
||
const toggleButton = event.target.closest('[data-toggle="custom-select"]');
|
||
if (!toggleButton) {
|
||
this._closeAllDropdowns();
|
||
return;
|
||
}
|
||
const targetSelector = toggleButton.dataset.target;
|
||
const targetPanel = document.querySelector(targetSelector);
|
||
if (!targetPanel) return;
|
||
const isPanelOpen = !targetPanel.classList.contains("hidden");
|
||
this._closeAllDropdowns(targetPanel);
|
||
if (!isPanelOpen) {
|
||
targetPanel.classList.remove("hidden");
|
||
}
|
||
}
|
||
/**
|
||
* [NEW] A helper utility to close all custom dropdown panels.
|
||
* @param {HTMLElement} [excludePanel=null] An optional panel to exclude from closing.
|
||
*/
|
||
_closeAllDropdowns(excludePanel = null) {
|
||
const allPanels = document.querySelectorAll(".custom-select-panel");
|
||
allPanels.forEach((panel) => {
|
||
if (panel !== excludePanel) {
|
||
panel.classList.add("hidden");
|
||
}
|
||
});
|
||
}
|
||
/**
|
||
* [NEW] Renders the content of the multifunction (export) dropdown menu.
|
||
*/
|
||
_renderMultifunctionMenu() {
|
||
const actions = [
|
||
{ action: "export-all", text: "\u5BFC\u51FA\u6240\u6709Key", icon: "fa-file-export" },
|
||
{ action: "export-valid", text: "\u5BFC\u51FA\u6709\u6548Key", icon: "fa-file-alt" },
|
||
{ action: "export-invalid", text: "\u5BFC\u51FA\u65E0\u6548Key", icon: "fa-file-excel" }
|
||
];
|
||
const menuHtml = actions.map((item) => {
|
||
return `
|
||
<button data-multifunction-action="${item.action}" class="menu-item whitespace-nowrap">
|
||
<i class="fas ${item.icon} menu-item-icon"></i>
|
||
<span>${item.text}</span>
|
||
</button>
|
||
`;
|
||
}).join("");
|
||
const menuWrapper = `<div class="py-1">${menuHtml}</div>`;
|
||
if (this.elements.desktopMultifunctionPanel) {
|
||
this.elements.desktopMultifunctionPanel.innerHTML = menuWrapper;
|
||
}
|
||
if (this.elements.mobileMultifunctionPanel) {
|
||
this.elements.mobileMultifunctionPanel.innerHTML = menuWrapper;
|
||
}
|
||
}
|
||
/**
|
||
* [NEW] Handles clicks on any multifunction menu button.
|
||
* @param {Event} event The click event.
|
||
*/
|
||
async _handleMultifunctionMenuClick(event) {
|
||
const button = event.target.closest("button[data-multifunction-action]");
|
||
if (!button) return;
|
||
event.preventDefault();
|
||
this._closeAllDropdowns();
|
||
const action = button.dataset.multifunctionAction;
|
||
const groupId = this.state.activeGroupId;
|
||
if (!groupId) {
|
||
toastManager.show("\u8BF7\u5148\u9009\u62E9\u4E00\u4E2A\u5206\u7EC4\u3002", "warning");
|
||
return;
|
||
}
|
||
let statuses = [];
|
||
let filename = `${this.state.activeGroupName}_keys.txt`;
|
||
switch (action) {
|
||
case "export-all":
|
||
statuses = ["all"];
|
||
filename = `${this.state.activeGroupName}_all_keys.txt`;
|
||
break;
|
||
case "export-valid":
|
||
statuses = ["active", "cooldown"];
|
||
filename = `${this.state.activeGroupName}_valid_keys.txt`;
|
||
break;
|
||
case "export-invalid":
|
||
statuses = ["disabled", "banned"];
|
||
filename = `${this.state.activeGroupName}_invalid_keys.txt`;
|
||
break;
|
||
default:
|
||
return;
|
||
}
|
||
toastManager.show("\u6B63\u5728\u51C6\u5907\u5BFC\u51FA\u6570\u636E...", "info");
|
||
try {
|
||
const keys = await apiKeyManager.exportKeysForGroup(groupId, statuses);
|
||
if (keys.length === 0) {
|
||
toastManager.show("\u6CA1\u6709\u627E\u5230\u7B26\u5408\u6761\u4EF6\u7684Key\u53EF\u4F9B\u5BFC\u51FA\u3002", "warning");
|
||
return;
|
||
}
|
||
this._triggerTextFileDownload(keys.join("\n"), filename);
|
||
toastManager.show(`\u6210\u529F\u5BFC\u51FA ${keys.length} \u4E2AKey\u3002`, "success");
|
||
} catch (error) {
|
||
toastManager.show(`\u5BFC\u51FA\u5931\u8D25: ${error.message}`, "error");
|
||
}
|
||
}
|
||
/**
|
||
* [NEW] A utility function to trigger a text file download in the browser.
|
||
* @param {string} content The content of the file.
|
||
* @param {string} filename The desired name of the file.
|
||
*/
|
||
_triggerTextFileDownload(content, filename) {
|
||
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
};
|
||
var apiKeyList_default = ApiKeyList;
|
||
|
||
// frontend/js/pages/keys/index.js
|
||
var KeyGroupsPage = class {
|
||
constructor() {
|
||
this.state = {
|
||
groups: [],
|
||
groupDetailsCache: {},
|
||
activeGroupId: null,
|
||
isLoading: true
|
||
};
|
||
this.debouncedSaveOrder = debounce(this.saveGroupOrder.bind(this), 1500);
|
||
this.elements = {
|
||
dashboardTitle: document.querySelector("#group-dashboard h2"),
|
||
dashboardControls: document.querySelector("#group-dashboard .flex.items-center.gap-x-3"),
|
||
apiListContainer: document.getElementById("api-list-container"),
|
||
groupListCollapsible: document.getElementById("group-list-collapsible"),
|
||
desktopGroupContainer: document.querySelector("#desktop-group-cards-list .card-list-content"),
|
||
mobileGroupContainer: document.getElementById("mobile-group-cards-list"),
|
||
addGroupBtnContainer: document.getElementById("add-group-btn-container"),
|
||
groupMenuToggle: document.getElementById("group-menu-toggle"),
|
||
mobileActiveGroupDisplay: document.querySelector(".mobile-group-selector > div")
|
||
};
|
||
this.initialized = this.elements.desktopGroupContainer !== null && this.elements.apiListContainer !== null;
|
||
if (this.initialized) {
|
||
this.apiKeyList = new apiKeyList_default(this.elements.apiListContainer);
|
||
}
|
||
const allowedModelsInput = new TagInput(document.getElementById("allowed-models-container"), {
|
||
validator: /^[a-z0-9\.-]+$/,
|
||
validationMessage: "\u65E0\u6548\u7684\u6A21\u578B\u683C\u5F0F"
|
||
});
|
||
const allowedUpstreamsInput = new TagInput(document.getElementById("allowed-upstreams-container"), {
|
||
validator: /^(https?:\/\/)?[\w\.-]+\.[a-z]{2,}(\/[\w\.-]*)*\/?$/i,
|
||
validationMessage: "\u65E0\u6548\u7684 URL \u683C\u5F0F"
|
||
});
|
||
const allowedTokensInput = new TagInput(document.getElementById("allowed-tokens-container"), {
|
||
validator: /.+/,
|
||
validationMessage: "\u4EE4\u724C\u4E0D\u80FD\u4E3A\u7A7A"
|
||
});
|
||
this.keyGroupModal = new KeyGroupModal({
|
||
onSave: this.handleSaveGroup.bind(this),
|
||
tagInputInstances: {
|
||
models: allowedModelsInput,
|
||
upstreams: allowedUpstreamsInput,
|
||
tokens: allowedTokensInput
|
||
}
|
||
});
|
||
this.deleteGroupModal = new DeleteGroupModal({
|
||
onDeleteSuccess: (deletedGroupId) => {
|
||
if (this.state.activeGroupId === deletedGroupId) {
|
||
this.state.activeGroupId = null;
|
||
this.apiKeyList.loadApiKeys(null);
|
||
}
|
||
this.loadKeyGroups(true);
|
||
}
|
||
});
|
||
this.addApiModal = new AddApiModal({
|
||
onImportSuccess: () => this.apiKeyList.loadApiKeys(this.state.activeGroupId, true)
|
||
});
|
||
this.cloneGroupModal = new CloneGroupModal({
|
||
onCloneSuccess: (clonedGroup) => {
|
||
if (clonedGroup && clonedGroup.id) {
|
||
this.state.activeGroupId = clonedGroup.id;
|
||
}
|
||
this.loadKeyGroups(true);
|
||
}
|
||
});
|
||
this.deleteApiModal = new DeleteApiModal({
|
||
onDeleteSuccess: () => this.apiKeyList.loadApiKeys(this.state.activeGroupId, true)
|
||
});
|
||
this.requestSettingsModal = new RequestSettingsModal({
|
||
onSave: this.handleSaveRequestSettings.bind(this)
|
||
});
|
||
this.activeTooltip = null;
|
||
}
|
||
async init() {
|
||
if (!this.initialized) {
|
||
console.error("KeyGroupsPage: Could not initialize. Essential container elements like 'desktopGroupContainer' or 'apiListContainer' are missing from the DOM.");
|
||
return;
|
||
}
|
||
this.initEventListeners();
|
||
if (this.apiKeyList) {
|
||
this.apiKeyList.init();
|
||
}
|
||
await this.loadKeyGroups();
|
||
}
|
||
initEventListeners() {
|
||
document.body.addEventListener("click", (event) => {
|
||
const addGroupBtn = event.target.closest(".add-group-btn");
|
||
const addApiBtn = event.target.closest("#add-api-btn");
|
||
const deleteApiBtn = event.target.closest("#delete-api-btn");
|
||
if (addGroupBtn) this.keyGroupModal.open();
|
||
if (addApiBtn) this.addApiModal.open(this.state.activeGroupId);
|
||
if (deleteApiBtn) this.deleteApiModal.open(this.state.activeGroupId);
|
||
});
|
||
this.elements.dashboardControls?.addEventListener("click", (event) => {
|
||
const button = event.target.closest("button[data-action]");
|
||
if (!button) return;
|
||
const action = button.dataset.action;
|
||
const activeGroup = this.state.groups.find((g) => g.id === this.state.activeGroupId);
|
||
switch (action) {
|
||
case "edit-group":
|
||
if (activeGroup) {
|
||
this.openEditGroupModal(activeGroup.id);
|
||
} else {
|
||
alert("\u8BF7\u5148\u9009\u62E9\u4E00\u4E2A\u5206\u7EC4\u8FDB\u884C\u7F16\u8F91\u3002");
|
||
}
|
||
break;
|
||
case "open-settings":
|
||
this.openRequestSettingsModal();
|
||
break;
|
||
case "clone-group":
|
||
if (activeGroup) {
|
||
this.cloneGroupModal.open(activeGroup);
|
||
} else {
|
||
alert("\u8BF7\u5148\u9009\u62E9\u4E00\u4E2A\u5206\u7EC4\u8FDB\u884C\u514B\u9686\u3002");
|
||
}
|
||
break;
|
||
case "delete-group":
|
||
console.log("Delete action triggered for group:", this.state.activeGroupId);
|
||
this.deleteGroupModal.open(activeGroup);
|
||
break;
|
||
}
|
||
});
|
||
this.elements.groupListCollapsible?.addEventListener("click", (event) => {
|
||
this.handleGroupCardClick(event);
|
||
});
|
||
this.elements.groupMenuToggle?.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
const menu = this.elements.groupListCollapsible;
|
||
if (!menu) return;
|
||
menu.classList.toggle("hidden");
|
||
setTimeout(() => {
|
||
menu.classList.toggle("mobile-group-menu-active");
|
||
}, 0);
|
||
});
|
||
document.addEventListener("click", (event) => {
|
||
const menu = this.elements.groupListCollapsible;
|
||
const toggle = this.elements.groupMenuToggle;
|
||
if (menu && menu.classList.contains("mobile-group-menu-active") && !menu.contains(event.target) && !toggle.contains(event.target)) {
|
||
this._closeMobileMenu();
|
||
}
|
||
});
|
||
this.initCustomSelects();
|
||
this.initTooltips();
|
||
this.initDragAndDrop();
|
||
this._initBatchActions();
|
||
}
|
||
// 4. 数据获取与渲染逻辑
|
||
async loadKeyGroups(force = false) {
|
||
this.state.isLoading = true;
|
||
try {
|
||
const responseData = await apiFetchJson("/admin/keygroups", { noCache: force });
|
||
if (responseData && responseData.success && Array.isArray(responseData.data)) {
|
||
this.state.groups = responseData.data;
|
||
} else {
|
||
console.error("API response format is incorrect:", responseData);
|
||
this.state.groups = [];
|
||
}
|
||
if (this.state.groups.length > 0 && !this.state.activeGroupId) {
|
||
this.state.activeGroupId = this.state.groups[0].id;
|
||
}
|
||
this.renderGroupList();
|
||
if (this.state.activeGroupId) {
|
||
this.updateDashboard();
|
||
}
|
||
this.updateAllHealthIndicators();
|
||
} catch (error) {
|
||
console.error("Failed to load or parse key groups:", error);
|
||
this.state.groups = [];
|
||
this.renderGroupList();
|
||
this.updateDashboard();
|
||
} finally {
|
||
this.state.isLoading = false;
|
||
}
|
||
if (this.state.activeGroupId) {
|
||
this.updateDashboard();
|
||
} else {
|
||
this.apiKeyList.loadApiKeys(null);
|
||
}
|
||
}
|
||
/**
|
||
* Helper function to determine health indicator CSS classes based on success rate.
|
||
* @param {number} rate - The success rate (0-100).
|
||
* @returns {{ring: string, dot: string}} - The CSS classes for the ring and dot.
|
||
*/
|
||
_getHealthIndicatorClasses(rate) {
|
||
if (rate >= 50) return { ring: "bg-green-500/20", dot: "bg-green-500" };
|
||
if (rate >= 30) return { ring: "bg-yellow-500/20", dot: "bg-yellow-500" };
|
||
if (rate >= 10) return { ring: "bg-orange-500/20", dot: "bg-orange-500" };
|
||
return { ring: "bg-red-500/20", dot: "bg-red-500" };
|
||
}
|
||
/**
|
||
* Renders the list of group cards based on the current state.
|
||
*/
|
||
renderGroupList() {
|
||
if (!this.state.groups) return;
|
||
const desktopListHtml = this.state.groups.map((group) => {
|
||
const isActive = group.id === this.state.activeGroupId;
|
||
const cardClass = isActive ? "group-card-active" : "group-card-inactive";
|
||
const successRate = 100;
|
||
const healthClasses = this._getHealthIndicatorClasses(successRate);
|
||
const channelTag = this._getChannelTypeTag(group.channel_type || "Local");
|
||
const customTags = this._getCustomTags(group.custom_tags);
|
||
return `
|
||
<div class="${cardClass}" data-group-id="${group.id}" data-success-rate="${successRate}">
|
||
<div class="flex items-center gap-x-3">
|
||
<div data-health-indicator class="health-indicator-ring ${healthClasses.ring}">
|
||
<div data-health-dot class="health-indicator-dot ${healthClasses.dot}"></div>
|
||
</div>
|
||
<div class="flex-grow">
|
||
<!-- [\u6700\u7EC8\u5E03\u5C40] 1. \u540D\u79F0 -> 2. \u63CF\u8FF0 -> 3. \u6807\u7B7E -->
|
||
<h3 class="font-semibold text-sm">${group.display_name}</h3>
|
||
<p class="card-sub-text my-1.5">${group.description || "No description available"}</p>
|
||
<div class="flex items-center gap-x-1.5 flex-wrap">
|
||
${channelTag}
|
||
${customTags}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).join("");
|
||
if (this.elements.desktopGroupContainer) {
|
||
this.elements.desktopGroupContainer.innerHTML = desktopListHtml;
|
||
if (this.elements.addGroupBtnContainer) {
|
||
this.elements.desktopGroupContainer.parentElement.appendChild(this.elements.addGroupBtnContainer);
|
||
}
|
||
}
|
||
const mobileListHtml = this.state.groups.map((group) => {
|
||
const isActive = group.id === this.state.activeGroupId;
|
||
const cardClass = isActive ? "group-card-active" : "group-card-inactive";
|
||
return `
|
||
<div class="${cardClass}" data-group-id="${group.id}">
|
||
<h3 class="font-semibold text-sm">${group.display_name})</h3>
|
||
<p class="card-sub-text my-1.5">${group.description || "No description available"}</p>
|
||
</div>`;
|
||
}).join("");
|
||
if (this.elements.mobileGroupContainer) {
|
||
this.elements.mobileGroupContainer.innerHTML = mobileListHtml;
|
||
}
|
||
}
|
||
// 事件处理器和UI更新函数,现在完全由 state 驱动
|
||
handleGroupCardClick(event) {
|
||
const clickedCard = event.target.closest("[data-group-id]");
|
||
if (!clickedCard) return;
|
||
const groupId = parseInt(clickedCard.dataset.groupId, 10);
|
||
if (this.state.activeGroupId !== groupId) {
|
||
this.state.activeGroupId = groupId;
|
||
this.renderGroupList();
|
||
this.updateDashboard();
|
||
}
|
||
if (window.innerWidth < 1024) {
|
||
this._closeMobileMenu();
|
||
}
|
||
}
|
||
// [NEW HELPER METHOD] Centralizes the logic for closing the mobile menu.
|
||
_closeMobileMenu() {
|
||
const menu = this.elements.groupListCollapsible;
|
||
if (!menu) return;
|
||
menu.classList.remove("mobile-group-menu-active");
|
||
menu.classList.add("hidden");
|
||
}
|
||
updateDashboard() {
|
||
const activeGroup = this.state.groups.find((g) => g.id === this.state.activeGroupId);
|
||
if (activeGroup) {
|
||
if (this.elements.dashboardTitle) {
|
||
this.elements.dashboardTitle.textContent = `${activeGroup.display_name}`;
|
||
}
|
||
if (this.elements.mobileActiveGroupDisplay) {
|
||
this.elements.mobileActiveGroupDisplay.innerHTML = `
|
||
<h3 class="font-semibold text-sm">${activeGroup.display_name}</h3>
|
||
<p class="card-sub-text">\u5F53\u524D\u9009\u62E9</p>`;
|
||
}
|
||
this.apiKeyList.setActiveGroup(activeGroup.id, activeGroup.display_name);
|
||
this.apiKeyList.loadApiKeys(activeGroup.id);
|
||
} else {
|
||
if (this.elements.dashboardTitle) this.elements.dashboardTitle.textContent = "No Group Selected";
|
||
if (this.elements.mobileActiveGroupDisplay) this.elements.mobileActiveGroupDisplay.innerHTML = `<h3 class="font-semibold text-sm">\u8BF7\u9009\u62E9\u4E00\u4E2A\u5206\u7EC4</h3>`;
|
||
this.apiKeyList.loadApiKeys(null);
|
||
}
|
||
}
|
||
/**
|
||
* Handles the saving of a key group with modern toast notifications.
|
||
* @param {object} groupData The data collected from the KeyGroupModal.
|
||
*/
|
||
async handleSaveGroup(groupData) {
|
||
const isEditing = !!groupData.id;
|
||
const endpoint = isEditing ? `/admin/keygroups/${groupData.id}` : "/admin/keygroups";
|
||
const method = isEditing ? "PUT" : "POST";
|
||
console.log(`[CONTROLLER] ${isEditing ? "Updating" : "Creating"} group...`, { endpoint, method, data: groupData });
|
||
try {
|
||
const response = await apiFetch(endpoint, {
|
||
method,
|
||
body: JSON.stringify(groupData),
|
||
noCache: true
|
||
});
|
||
const result = await response.json();
|
||
if (!result.success) {
|
||
throw new Error(result.message || "An unknown error occurred on the server.");
|
||
}
|
||
if (isEditing) {
|
||
console.log(`[CACHE INVALIDATION] Deleting cached details for group ${groupData.id}.`);
|
||
delete this.state.groupDetailsCache[groupData.id];
|
||
}
|
||
if (!isEditing && result.data && result.data.id) {
|
||
this.state.activeGroupId = result.data.id;
|
||
}
|
||
toastManager.show(`\u5206\u7EC4 "${groupData.display_name}" \u5DF2\u6210\u529F\u4FDD\u5B58\u3002`, "success");
|
||
await this.loadKeyGroups(true);
|
||
} catch (error) {
|
||
console.error(`Failed to save group:`, error.message);
|
||
toastManager.show(`\u4FDD\u5B58\u5931\u8D25: ${error.message}`, "error");
|
||
throw error;
|
||
}
|
||
}
|
||
/**
|
||
* Opens the KeyGroupModal for editing, utilizing a cache-then-fetch strategy.
|
||
* @param {number} groupId The ID of the group to edit.
|
||
*/
|
||
async openEditGroupModal(groupId) {
|
||
if (this.state.groupDetailsCache[groupId]) {
|
||
console.log(`[CACHE HIT] Using cached details for group ${groupId}.`);
|
||
this.keyGroupModal.open(this.state.groupDetailsCache[groupId]);
|
||
return;
|
||
}
|
||
console.log(`[CACHE MISS] Fetching details for group ${groupId}.`);
|
||
try {
|
||
const endpoint = `/admin/keygroups/${groupId}`;
|
||
const responseData = await apiFetchJson(endpoint, { noCache: true });
|
||
if (responseData && responseData.success) {
|
||
const groupDetails = responseData.data;
|
||
this.state.groupDetailsCache[groupId] = groupDetails;
|
||
this.keyGroupModal.open(groupDetails);
|
||
} else {
|
||
throw new Error(responseData.message || "Failed to load group details.");
|
||
}
|
||
} catch (error) {
|
||
console.error(`Failed to fetch details for group ${groupId}:`, error);
|
||
alert(`\u65E0\u6CD5\u52A0\u8F7D\u5206\u7EC4\u8BE6\u60C5: ${error.message}`);
|
||
}
|
||
}
|
||
async openRequestSettingsModal() {
|
||
if (!this.state.activeGroupId) {
|
||
modalManager.showResult(false, "\u8BF7\u5148\u9009\u62E9\u4E00\u4E2A\u5206\u7EC4\u3002");
|
||
return;
|
||
}
|
||
console.log(`Opening request settings for group ID: ${this.state.activeGroupId}`);
|
||
const data = {};
|
||
this.requestSettingsModal.open(data);
|
||
}
|
||
/**
|
||
* @param {object} data The data collected from the RequestSettingsModal.
|
||
*/
|
||
async handleSaveRequestSettings(data) {
|
||
if (!this.state.activeGroupId) {
|
||
throw new Error("No active group selected.");
|
||
}
|
||
console.log(`[CONTROLLER] Saving request settings for group ${this.state.activeGroupId}:`, data);
|
||
return Promise.resolve();
|
||
}
|
||
initCustomSelects() {
|
||
const customSelects = document.querySelectorAll(".custom-select");
|
||
customSelects.forEach((select) => new CustomSelect(select));
|
||
}
|
||
_initBatchActions() {
|
||
}
|
||
/**
|
||
* Sends the new group UI order to the backend API.
|
||
* @param {Array<object>} orderData - An array of objects, e.g., [{id: 1, order: 0}, {id: 2, order: 1}]
|
||
*/
|
||
async saveGroupOrder(orderData) {
|
||
console.log("Debounced save triggered. Sending UI order to API:", orderData);
|
||
try {
|
||
const response = await apiFetch("/admin/keygroups/order", {
|
||
method: "PUT",
|
||
body: JSON.stringify(orderData),
|
||
noCache: true
|
||
});
|
||
const result = await response.json();
|
||
if (!result.success) {
|
||
throw new Error(result.message || "Failed to save UI order on the server.");
|
||
}
|
||
console.log("UI order saved successfully.");
|
||
} catch (error) {
|
||
console.error("Failed to save new group UI order:", error);
|
||
this.loadKeyGroups();
|
||
}
|
||
}
|
||
/**
|
||
* Initializes drag-and-drop functionality for the group list.
|
||
*/
|
||
initDragAndDrop() {
|
||
if (typeof Sortable === "undefined") {
|
||
console.error("SortableJS is not loaded.");
|
||
return;
|
||
}
|
||
const container = this.elements.desktopGroupContainer;
|
||
if (!container) return;
|
||
new Sortable(container, {
|
||
animation: 150,
|
||
ghostClass: "sortable-ghost",
|
||
dragClass: "sortable-drag",
|
||
filter: "#add-group-btn-container",
|
||
onEnd: (evt) => {
|
||
const groupCards = Array.from(container.querySelectorAll("[data-group-id]"));
|
||
const orderedState = groupCards.map((card) => {
|
||
const cardId = parseInt(card.dataset.groupId, 10);
|
||
return this.state.groups.find((group) => group.id === cardId);
|
||
}).filter(Boolean);
|
||
if (orderedState.length !== this.state.groups.length) {
|
||
console.error("Drag-and-drop failed: Could not map all DOM elements to state. Aborting.");
|
||
return;
|
||
}
|
||
this.state.groups = orderedState;
|
||
const payload = this.state.groups.map((group, index) => ({
|
||
id: group.id,
|
||
order: index
|
||
}));
|
||
this.debouncedSaveOrder(payload);
|
||
}
|
||
});
|
||
}
|
||
/**
|
||
* Helper function to generate a styled HTML tag for the channel type.
|
||
* @param {string} type - The channel type string (e.g., 'OpenAI', 'Azure').
|
||
* @returns {string} - The generated HTML span element.
|
||
*/
|
||
_getChannelTypeTag(type) {
|
||
if (!type) return "";
|
||
const styles = {
|
||
"OpenAI": "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
|
||
"Azure": "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300",
|
||
"Claude": "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300",
|
||
"Gemini": "bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-300",
|
||
"Local": "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
|
||
};
|
||
const baseClass = "inline-block text-xs font-medium px-2 py-0.5 rounded-md";
|
||
const tagClass = styles[type] || styles["Local"];
|
||
return `<span class="${baseClass} ${tagClass}">${type}</span>`;
|
||
}
|
||
/**
|
||
* Generates styled HTML for custom tags with deterministically assigned colors.
|
||
* @param {string[]} tags - An array of custom tag strings.
|
||
* @returns {string} - The generated HTML for all custom tags.
|
||
*/
|
||
_getCustomTags(tags) {
|
||
if (!tags || !Array.isArray(tags) || tags.length === 0) {
|
||
return "";
|
||
}
|
||
const colorPalette = [
|
||
"bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300",
|
||
"bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300",
|
||
"bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300",
|
||
"bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-300",
|
||
"bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300",
|
||
"bg-lime-100 text-lime-800 dark:bg-lime-900 dark:text-lime-300"
|
||
];
|
||
const baseClass = "inline-block text-xs font-medium px-2 py-0.5 rounded-md";
|
||
return tags.map((tag) => {
|
||
let hash = 0;
|
||
for (let i = 0; i < tag.length; i++) {
|
||
hash += tag.charCodeAt(i);
|
||
}
|
||
const colorClass = colorPalette[hash % colorPalette.length];
|
||
return `<span class="${baseClass} ${colorClass}">${tag}</span>`;
|
||
}).join("");
|
||
}
|
||
_updateHealthIndicator(cardElement) {
|
||
const rate = parseFloat(cardElement.dataset.successRate);
|
||
if (isNaN(rate)) return;
|
||
const indicator = cardElement.querySelector("[data-health-indicator]");
|
||
const dot = cardElement.querySelector("[data-health-dot]");
|
||
if (!indicator || !dot) return;
|
||
const colors = {
|
||
green: ["bg-green-500/20", "bg-green-500"],
|
||
yellow: ["bg-yellow-500/20", "bg-yellow-500"],
|
||
orange: ["bg-orange-500/20", "bg-orange-500"],
|
||
red: ["bg-red-500/20", "bg-red-500"]
|
||
};
|
||
Object.values(colors).forEach(([bgClass, dotClass]) => {
|
||
indicator.classList.remove(bgClass);
|
||
dot.classList.remove(dotClass);
|
||
});
|
||
let newColor;
|
||
if (rate >= 50) newColor = colors.green;
|
||
else if (rate >= 25) newColor = colors.yellow;
|
||
else if (rate >= 10) newColor = colors.orange;
|
||
else newColor = colors.red;
|
||
indicator.classList.add(newColor[0]);
|
||
dot.classList.add(newColor[1]);
|
||
}
|
||
updateAllHealthIndicators() {
|
||
if (!this.elements.groupListCollapsible) return;
|
||
const allCards = this.elements.groupListCollapsible.querySelectorAll("[data-success-rate]");
|
||
allCards.forEach((card) => this._updateHealthIndicator(card));
|
||
}
|
||
initTooltips() {
|
||
const tooltipIcons = document.querySelectorAll(".tooltip-icon");
|
||
tooltipIcons.forEach((icon) => {
|
||
icon.addEventListener("mouseenter", (e) => this.showTooltip(e));
|
||
icon.addEventListener("mouseleave", () => this.hideTooltip());
|
||
});
|
||
}
|
||
showTooltip(e) {
|
||
this.hideTooltip();
|
||
const target = e.currentTarget;
|
||
const text = target.dataset.tooltipText;
|
||
if (!text) return;
|
||
const tooltip = document.createElement("div");
|
||
tooltip.className = "global-tooltip";
|
||
tooltip.textContent = text;
|
||
document.body.appendChild(tooltip);
|
||
this.activeTooltip = tooltip;
|
||
const targetRect = target.getBoundingClientRect();
|
||
const tooltipRect = tooltip.getBoundingClientRect();
|
||
let top = targetRect.top - tooltipRect.height - 8;
|
||
let left = targetRect.left + targetRect.width / 2 - tooltipRect.width / 2;
|
||
if (top < 0) top = targetRect.bottom + 8;
|
||
if (left < 0) left = 8;
|
||
if (left + tooltipRect.width > window.innerWidth) {
|
||
left = window.innerWidth - tooltipRect.width - 8;
|
||
}
|
||
tooltip.style.top = `${top}px`;
|
||
tooltip.style.left = `${left}px`;
|
||
}
|
||
hideTooltip() {
|
||
if (this.activeTooltip) {
|
||
this.activeTooltip.remove();
|
||
this.activeTooltip = null;
|
||
}
|
||
}
|
||
};
|
||
function init2() {
|
||
console.log("[Modern Frontend] Keys page controller loaded.");
|
||
const page = new KeyGroupsPage();
|
||
page.init();
|
||
}
|
||
|
||
// frontend/js/main.js
|
||
var pageModules = {
|
||
"dashboard": init,
|
||
//'settings': initSettings,
|
||
//'error-logs': initErrorLogs,
|
||
"keys": init2
|
||
//keys是page_handler中对该页面pageID的定义简称,我们沿用即可
|
||
// 未来新增的页面,只需在这里添加一行映射
|
||
};
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
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]) {
|
||
pageModules[pageId]();
|
||
}
|
||
}
|
||
});
|
||
window.modalManager = modalManager;
|
||
window.taskCenterManager = taskCenterManager;
|
||
window.toastManager = toastManager;
|
||
window.uiPatterns = uiPatterns;
|
||
})();
|