Files
gemini-banlancer/web/static/js/keys-4GCIJ7HW.js
2025-11-21 19:33:05 +08:00

5362 lines
219 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
CustomSelect,
modalManager,
taskCenterManager,
toastManager
} from "./chunk-EZAP7GR4.js";
import {
debounce,
escapeHTML,
isValidApiKeyFormat
} from "./chunk-A4OOMLXK.js";
import {
apiFetch,
apiFetchJson
} from "./chunk-PLQL6WIO.js";
// 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">&times;</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 option2 = new Option(cat.replace("HARM_CATEGORY_", ""), cat);
if (cat === category) option2.selected = true;
categorySelect.add(option2);
});
const thresholdSelect = document.createElement("select");
thresholdSelect.className = "modal-input w-48";
harmThresholds.forEach((thr) => {
const option2 = new Option(thr.replace("BLOCK_", "").replace("_AND_ABOVE", "+"), thr);
if (thr === threshold) option2.selected = true;
thresholdSelect.add(option2);
});
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/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/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/vendor/sortable.esm.js
function ownKeys(object, enumerableOnly) {
var keys = Object.keys(object);
if (Object.getOwnPropertySymbols) {
var symbols = Object.getOwnPropertySymbols(object);
if (enumerableOnly) {
symbols = symbols.filter(function(sym) {
return Object.getOwnPropertyDescriptor(object, sym).enumerable;
});
}
keys.push.apply(keys, symbols);
}
return keys;
}
function _objectSpread2(target) {
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i] != null ? arguments[i] : {};
if (i % 2) {
ownKeys(Object(source), true).forEach(function(key) {
_defineProperty(target, key, source[key]);
});
} else if (Object.getOwnPropertyDescriptors) {
Object.defineProperties(target, Object.getOwnPropertyDescriptors(source));
} else {
ownKeys(Object(source)).forEach(function(key) {
Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key));
});
}
}
return target;
}
function _typeof(obj) {
"@babel/helpers - typeof";
if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") {
_typeof = function(obj2) {
return typeof obj2;
};
} else {
_typeof = function(obj2) {
return obj2 && typeof Symbol === "function" && obj2.constructor === Symbol && obj2 !== Symbol.prototype ? "symbol" : typeof obj2;
};
}
return _typeof(obj);
}
function _defineProperty(obj, key, value) {
if (key in obj) {
Object.defineProperty(obj, key, {
value,
enumerable: true,
configurable: true,
writable: true
});
} else {
obj[key] = value;
}
return obj;
}
function _extends() {
_extends = Object.assign || function(target) {
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i];
for (var key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
target[key] = source[key];
}
}
}
return target;
};
return _extends.apply(this, arguments);
}
function _objectWithoutPropertiesLoose(source, excluded) {
if (source == null) return {};
var target = {};
var sourceKeys = Object.keys(source);
var key, i;
for (i = 0; i < sourceKeys.length; i++) {
key = sourceKeys[i];
if (excluded.indexOf(key) >= 0) continue;
target[key] = source[key];
}
return target;
}
function _objectWithoutProperties(source, excluded) {
if (source == null) return {};
var target = _objectWithoutPropertiesLoose(source, excluded);
var key, i;
if (Object.getOwnPropertySymbols) {
var sourceSymbolKeys = Object.getOwnPropertySymbols(source);
for (i = 0; i < sourceSymbolKeys.length; i++) {
key = sourceSymbolKeys[i];
if (excluded.indexOf(key) >= 0) continue;
if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue;
target[key] = source[key];
}
}
return target;
}
var version = "1.15.6";
function userAgent(pattern) {
if (typeof window !== "undefined" && window.navigator) {
return !!/* @__PURE__ */ navigator.userAgent.match(pattern);
}
}
var IE11OrLess = userAgent(/(?:Trident.*rv[ :]?11\.|msie|iemobile|Windows Phone)/i);
var Edge = userAgent(/Edge/i);
var FireFox = userAgent(/firefox/i);
var Safari = userAgent(/safari/i) && !userAgent(/chrome/i) && !userAgent(/android/i);
var IOS = userAgent(/iP(ad|od|hone)/i);
var ChromeForAndroid = userAgent(/chrome/i) && userAgent(/android/i);
var captureMode = {
capture: false,
passive: false
};
function on(el, event, fn) {
el.addEventListener(event, fn, !IE11OrLess && captureMode);
}
function off(el, event, fn) {
el.removeEventListener(event, fn, !IE11OrLess && captureMode);
}
function matches(el, selector) {
if (!selector) return;
selector[0] === ">" && (selector = selector.substring(1));
if (el) {
try {
if (el.matches) {
return el.matches(selector);
} else if (el.msMatchesSelector) {
return el.msMatchesSelector(selector);
} else if (el.webkitMatchesSelector) {
return el.webkitMatchesSelector(selector);
}
} catch (_) {
return false;
}
}
return false;
}
function getParentOrHost(el) {
return el.host && el !== document && el.host.nodeType ? el.host : el.parentNode;
}
function closest(el, selector, ctx, includeCTX) {
if (el) {
ctx = ctx || document;
do {
if (selector != null && (selector[0] === ">" ? el.parentNode === ctx && matches(el, selector) : matches(el, selector)) || includeCTX && el === ctx) {
return el;
}
if (el === ctx) break;
} while (el = getParentOrHost(el));
}
return null;
}
var R_SPACE = /\s+/g;
function toggleClass(el, name, state) {
if (el && name) {
if (el.classList) {
el.classList[state ? "add" : "remove"](name);
} else {
var className = (" " + el.className + " ").replace(R_SPACE, " ").replace(" " + name + " ", " ");
el.className = (className + (state ? " " + name : "")).replace(R_SPACE, " ");
}
}
}
function css(el, prop, val) {
var style = el && el.style;
if (style) {
if (val === void 0) {
if (document.defaultView && document.defaultView.getComputedStyle) {
val = document.defaultView.getComputedStyle(el, "");
} else if (el.currentStyle) {
val = el.currentStyle;
}
return prop === void 0 ? val : val[prop];
} else {
if (!(prop in style) && prop.indexOf("webkit") === -1) {
prop = "-webkit-" + prop;
}
style[prop] = val + (typeof val === "string" ? "" : "px");
}
}
}
function matrix(el, selfOnly) {
var appliedTransforms = "";
if (typeof el === "string") {
appliedTransforms = el;
} else {
do {
var transform = css(el, "transform");
if (transform && transform !== "none") {
appliedTransforms = transform + " " + appliedTransforms;
}
} while (!selfOnly && (el = el.parentNode));
}
var matrixFn = window.DOMMatrix || window.WebKitCSSMatrix || window.CSSMatrix || window.MSCSSMatrix;
return matrixFn && new matrixFn(appliedTransforms);
}
function find(ctx, tagName, iterator) {
if (ctx) {
var list = ctx.getElementsByTagName(tagName), i = 0, n = list.length;
if (iterator) {
for (; i < n; i++) {
iterator(list[i], i);
}
}
return list;
}
return [];
}
function getWindowScrollingElement() {
var scrollingElement = document.scrollingElement;
if (scrollingElement) {
return scrollingElement;
} else {
return document.documentElement;
}
}
function getRect(el, relativeToContainingBlock, relativeToNonStaticParent, undoScale, container) {
if (!el.getBoundingClientRect && el !== window) return;
var elRect, top, left, bottom, right, height, width;
if (el !== window && el.parentNode && el !== getWindowScrollingElement()) {
elRect = el.getBoundingClientRect();
top = elRect.top;
left = elRect.left;
bottom = elRect.bottom;
right = elRect.right;
height = elRect.height;
width = elRect.width;
} else {
top = 0;
left = 0;
bottom = window.innerHeight;
right = window.innerWidth;
height = window.innerHeight;
width = window.innerWidth;
}
if ((relativeToContainingBlock || relativeToNonStaticParent) && el !== window) {
container = container || el.parentNode;
if (!IE11OrLess) {
do {
if (container && container.getBoundingClientRect && (css(container, "transform") !== "none" || relativeToNonStaticParent && css(container, "position") !== "static")) {
var containerRect = container.getBoundingClientRect();
top -= containerRect.top + parseInt(css(container, "border-top-width"));
left -= containerRect.left + parseInt(css(container, "border-left-width"));
bottom = top + elRect.height;
right = left + elRect.width;
break;
}
} while (container = container.parentNode);
}
}
if (undoScale && el !== window) {
var elMatrix = matrix(container || el), scaleX = elMatrix && elMatrix.a, scaleY = elMatrix && elMatrix.d;
if (elMatrix) {
top /= scaleY;
left /= scaleX;
width /= scaleX;
height /= scaleY;
bottom = top + height;
right = left + width;
}
}
return {
top,
left,
bottom,
right,
width,
height
};
}
function isScrolledPast(el, elSide, parentSide) {
var parent = getParentAutoScrollElement(el, true), elSideVal = getRect(el)[elSide];
while (parent) {
var parentSideVal = getRect(parent)[parentSide], visible = void 0;
if (parentSide === "top" || parentSide === "left") {
visible = elSideVal >= parentSideVal;
} else {
visible = elSideVal <= parentSideVal;
}
if (!visible) return parent;
if (parent === getWindowScrollingElement()) break;
parent = getParentAutoScrollElement(parent, false);
}
return false;
}
function getChild(el, childNum, options, includeDragEl) {
var currentChild = 0, i = 0, children = el.children;
while (i < children.length) {
if (children[i].style.display !== "none" && children[i] !== Sortable.ghost && (includeDragEl || children[i] !== Sortable.dragged) && closest(children[i], options.draggable, el, false)) {
if (currentChild === childNum) {
return children[i];
}
currentChild++;
}
i++;
}
return null;
}
function lastChild(el, selector) {
var last = el.lastElementChild;
while (last && (last === Sortable.ghost || css(last, "display") === "none" || selector && !matches(last, selector))) {
last = last.previousElementSibling;
}
return last || null;
}
function index(el, selector) {
var index2 = 0;
if (!el || !el.parentNode) {
return -1;
}
while (el = el.previousElementSibling) {
if (el.nodeName.toUpperCase() !== "TEMPLATE" && el !== Sortable.clone && (!selector || matches(el, selector))) {
index2++;
}
}
return index2;
}
function getRelativeScrollOffset(el) {
var offsetLeft = 0, offsetTop = 0, winScroller = getWindowScrollingElement();
if (el) {
do {
var elMatrix = matrix(el), scaleX = elMatrix.a, scaleY = elMatrix.d;
offsetLeft += el.scrollLeft * scaleX;
offsetTop += el.scrollTop * scaleY;
} while (el !== winScroller && (el = el.parentNode));
}
return [offsetLeft, offsetTop];
}
function indexOfObject(arr, obj) {
for (var i in arr) {
if (!arr.hasOwnProperty(i)) continue;
for (var key in obj) {
if (obj.hasOwnProperty(key) && obj[key] === arr[i][key]) return Number(i);
}
}
return -1;
}
function getParentAutoScrollElement(el, includeSelf) {
if (!el || !el.getBoundingClientRect) return getWindowScrollingElement();
var elem = el;
var gotSelf = false;
do {
if (elem.clientWidth < elem.scrollWidth || elem.clientHeight < elem.scrollHeight) {
var elemCSS = css(elem);
if (elem.clientWidth < elem.scrollWidth && (elemCSS.overflowX == "auto" || elemCSS.overflowX == "scroll") || elem.clientHeight < elem.scrollHeight && (elemCSS.overflowY == "auto" || elemCSS.overflowY == "scroll")) {
if (!elem.getBoundingClientRect || elem === document.body) return getWindowScrollingElement();
if (gotSelf || includeSelf) return elem;
gotSelf = true;
}
}
} while (elem = elem.parentNode);
return getWindowScrollingElement();
}
function extend(dst, src) {
if (dst && src) {
for (var key in src) {
if (src.hasOwnProperty(key)) {
dst[key] = src[key];
}
}
}
return dst;
}
function isRectEqual(rect1, rect2) {
return Math.round(rect1.top) === Math.round(rect2.top) && Math.round(rect1.left) === Math.round(rect2.left) && Math.round(rect1.height) === Math.round(rect2.height) && Math.round(rect1.width) === Math.round(rect2.width);
}
var _throttleTimeout;
function throttle(callback, ms) {
return function() {
if (!_throttleTimeout) {
var args = arguments, _this = this;
if (args.length === 1) {
callback.call(_this, args[0]);
} else {
callback.apply(_this, args);
}
_throttleTimeout = setTimeout(function() {
_throttleTimeout = void 0;
}, ms);
}
};
}
function cancelThrottle() {
clearTimeout(_throttleTimeout);
_throttleTimeout = void 0;
}
function scrollBy(el, x, y) {
el.scrollLeft += x;
el.scrollTop += y;
}
function clone(el) {
var Polymer = window.Polymer;
var $ = window.jQuery || window.Zepto;
if (Polymer && Polymer.dom) {
return Polymer.dom(el).cloneNode(true);
} else if ($) {
return $(el).clone(true)[0];
} else {
return el.cloneNode(true);
}
}
function getChildContainingRectFromElement(container, options, ghostEl2) {
var rect = {};
Array.from(container.children).forEach(function(child) {
var _rect$left, _rect$top, _rect$right, _rect$bottom;
if (!closest(child, options.draggable, container, false) || child.animated || child === ghostEl2) return;
var childRect = getRect(child);
rect.left = Math.min((_rect$left = rect.left) !== null && _rect$left !== void 0 ? _rect$left : Infinity, childRect.left);
rect.top = Math.min((_rect$top = rect.top) !== null && _rect$top !== void 0 ? _rect$top : Infinity, childRect.top);
rect.right = Math.max((_rect$right = rect.right) !== null && _rect$right !== void 0 ? _rect$right : -Infinity, childRect.right);
rect.bottom = Math.max((_rect$bottom = rect.bottom) !== null && _rect$bottom !== void 0 ? _rect$bottom : -Infinity, childRect.bottom);
});
rect.width = rect.right - rect.left;
rect.height = rect.bottom - rect.top;
rect.x = rect.left;
rect.y = rect.top;
return rect;
}
var expando = "Sortable" + (/* @__PURE__ */ new Date()).getTime();
function AnimationStateManager() {
var animationStates = [], animationCallbackId;
return {
captureAnimationState: function captureAnimationState() {
animationStates = [];
if (!this.options.animation) return;
var children = [].slice.call(this.el.children);
children.forEach(function(child) {
if (css(child, "display") === "none" || child === Sortable.ghost) return;
animationStates.push({
target: child,
rect: getRect(child)
});
var fromRect = _objectSpread2({}, animationStates[animationStates.length - 1].rect);
if (child.thisAnimationDuration) {
var childMatrix = matrix(child, true);
if (childMatrix) {
fromRect.top -= childMatrix.f;
fromRect.left -= childMatrix.e;
}
}
child.fromRect = fromRect;
});
},
addAnimationState: function addAnimationState(state) {
animationStates.push(state);
},
removeAnimationState: function removeAnimationState(target) {
animationStates.splice(indexOfObject(animationStates, {
target
}), 1);
},
animateAll: function animateAll(callback) {
var _this = this;
if (!this.options.animation) {
clearTimeout(animationCallbackId);
if (typeof callback === "function") callback();
return;
}
var animating = false, animationTime = 0;
animationStates.forEach(function(state) {
var time = 0, target = state.target, fromRect = target.fromRect, toRect = getRect(target), prevFromRect = target.prevFromRect, prevToRect = target.prevToRect, animatingRect = state.rect, targetMatrix = matrix(target, true);
if (targetMatrix) {
toRect.top -= targetMatrix.f;
toRect.left -= targetMatrix.e;
}
target.toRect = toRect;
if (target.thisAnimationDuration) {
if (isRectEqual(prevFromRect, toRect) && !isRectEqual(fromRect, toRect) && // Make sure animatingRect is on line between toRect & fromRect
(animatingRect.top - toRect.top) / (animatingRect.left - toRect.left) === (fromRect.top - toRect.top) / (fromRect.left - toRect.left)) {
time = calculateRealTime(animatingRect, prevFromRect, prevToRect, _this.options);
}
}
if (!isRectEqual(toRect, fromRect)) {
target.prevFromRect = fromRect;
target.prevToRect = toRect;
if (!time) {
time = _this.options.animation;
}
_this.animate(target, animatingRect, toRect, time);
}
if (time) {
animating = true;
animationTime = Math.max(animationTime, time);
clearTimeout(target.animationResetTimer);
target.animationResetTimer = setTimeout(function() {
target.animationTime = 0;
target.prevFromRect = null;
target.fromRect = null;
target.prevToRect = null;
target.thisAnimationDuration = null;
}, time);
target.thisAnimationDuration = time;
}
});
clearTimeout(animationCallbackId);
if (!animating) {
if (typeof callback === "function") callback();
} else {
animationCallbackId = setTimeout(function() {
if (typeof callback === "function") callback();
}, animationTime);
}
animationStates = [];
},
animate: function animate(target, currentRect, toRect, duration) {
if (duration) {
css(target, "transition", "");
css(target, "transform", "");
var elMatrix = matrix(this.el), scaleX = elMatrix && elMatrix.a, scaleY = elMatrix && elMatrix.d, translateX = (currentRect.left - toRect.left) / (scaleX || 1), translateY = (currentRect.top - toRect.top) / (scaleY || 1);
target.animatingX = !!translateX;
target.animatingY = !!translateY;
css(target, "transform", "translate3d(" + translateX + "px," + translateY + "px,0)");
this.forRepaintDummy = repaint(target);
css(target, "transition", "transform " + duration + "ms" + (this.options.easing ? " " + this.options.easing : ""));
css(target, "transform", "translate3d(0,0,0)");
typeof target.animated === "number" && clearTimeout(target.animated);
target.animated = setTimeout(function() {
css(target, "transition", "");
css(target, "transform", "");
target.animated = false;
target.animatingX = false;
target.animatingY = false;
}, duration);
}
}
};
}
function repaint(target) {
return target.offsetWidth;
}
function calculateRealTime(animatingRect, fromRect, toRect, options) {
return Math.sqrt(Math.pow(fromRect.top - animatingRect.top, 2) + Math.pow(fromRect.left - animatingRect.left, 2)) / Math.sqrt(Math.pow(fromRect.top - toRect.top, 2) + Math.pow(fromRect.left - toRect.left, 2)) * options.animation;
}
var plugins = [];
var defaults = {
initializeByDefault: true
};
var PluginManager = {
mount: function mount(plugin) {
for (var option2 in defaults) {
if (defaults.hasOwnProperty(option2) && !(option2 in plugin)) {
plugin[option2] = defaults[option2];
}
}
plugins.forEach(function(p) {
if (p.pluginName === plugin.pluginName) {
throw "Sortable: Cannot mount plugin ".concat(plugin.pluginName, " more than once");
}
});
plugins.push(plugin);
},
pluginEvent: function pluginEvent(eventName, sortable, evt) {
var _this = this;
this.eventCanceled = false;
evt.cancel = function() {
_this.eventCanceled = true;
};
var eventNameGlobal = eventName + "Global";
plugins.forEach(function(plugin) {
if (!sortable[plugin.pluginName]) return;
if (sortable[plugin.pluginName][eventNameGlobal]) {
sortable[plugin.pluginName][eventNameGlobal](_objectSpread2({
sortable
}, evt));
}
if (sortable.options[plugin.pluginName] && sortable[plugin.pluginName][eventName]) {
sortable[plugin.pluginName][eventName](_objectSpread2({
sortable
}, evt));
}
});
},
initializePlugins: function initializePlugins(sortable, el, defaults2, options) {
plugins.forEach(function(plugin) {
var pluginName = plugin.pluginName;
if (!sortable.options[pluginName] && !plugin.initializeByDefault) return;
var initialized = new plugin(sortable, el, sortable.options);
initialized.sortable = sortable;
initialized.options = sortable.options;
sortable[pluginName] = initialized;
_extends(defaults2, initialized.defaults);
});
for (var option2 in sortable.options) {
if (!sortable.options.hasOwnProperty(option2)) continue;
var modified = this.modifyOption(sortable, option2, sortable.options[option2]);
if (typeof modified !== "undefined") {
sortable.options[option2] = modified;
}
}
},
getEventProperties: function getEventProperties(name, sortable) {
var eventProperties = {};
plugins.forEach(function(plugin) {
if (typeof plugin.eventProperties !== "function") return;
_extends(eventProperties, plugin.eventProperties.call(sortable[plugin.pluginName], name));
});
return eventProperties;
},
modifyOption: function modifyOption(sortable, name, value) {
var modifiedValue;
plugins.forEach(function(plugin) {
if (!sortable[plugin.pluginName]) return;
if (plugin.optionListeners && typeof plugin.optionListeners[name] === "function") {
modifiedValue = plugin.optionListeners[name].call(sortable[plugin.pluginName], value);
}
});
return modifiedValue;
}
};
function dispatchEvent(_ref) {
var sortable = _ref.sortable, rootEl2 = _ref.rootEl, name = _ref.name, targetEl = _ref.targetEl, cloneEl2 = _ref.cloneEl, toEl = _ref.toEl, fromEl = _ref.fromEl, oldIndex2 = _ref.oldIndex, newIndex2 = _ref.newIndex, oldDraggableIndex2 = _ref.oldDraggableIndex, newDraggableIndex2 = _ref.newDraggableIndex, originalEvent = _ref.originalEvent, putSortable2 = _ref.putSortable, extraEventProperties = _ref.extraEventProperties;
sortable = sortable || rootEl2 && rootEl2[expando];
if (!sortable) return;
var evt, options = sortable.options, onName = "on" + name.charAt(0).toUpperCase() + name.substr(1);
if (window.CustomEvent && !IE11OrLess && !Edge) {
evt = new CustomEvent(name, {
bubbles: true,
cancelable: true
});
} else {
evt = document.createEvent("Event");
evt.initEvent(name, true, true);
}
evt.to = toEl || rootEl2;
evt.from = fromEl || rootEl2;
evt.item = targetEl || rootEl2;
evt.clone = cloneEl2;
evt.oldIndex = oldIndex2;
evt.newIndex = newIndex2;
evt.oldDraggableIndex = oldDraggableIndex2;
evt.newDraggableIndex = newDraggableIndex2;
evt.originalEvent = originalEvent;
evt.pullMode = putSortable2 ? putSortable2.lastPutMode : void 0;
var allEventProperties = _objectSpread2(_objectSpread2({}, extraEventProperties), PluginManager.getEventProperties(name, sortable));
for (var option2 in allEventProperties) {
evt[option2] = allEventProperties[option2];
}
if (rootEl2) {
rootEl2.dispatchEvent(evt);
}
if (options[onName]) {
options[onName].call(sortable, evt);
}
}
var _excluded = ["evt"];
var pluginEvent2 = function pluginEvent3(eventName, sortable) {
var _ref = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : {}, originalEvent = _ref.evt, data = _objectWithoutProperties(_ref, _excluded);
PluginManager.pluginEvent.bind(Sortable)(eventName, sortable, _objectSpread2({
dragEl,
parentEl,
ghostEl,
rootEl,
nextEl,
lastDownEl,
cloneEl,
cloneHidden,
dragStarted: moved,
putSortable,
activeSortable: Sortable.active,
originalEvent,
oldIndex,
oldDraggableIndex,
newIndex,
newDraggableIndex,
hideGhostForTarget: _hideGhostForTarget,
unhideGhostForTarget: _unhideGhostForTarget,
cloneNowHidden: function cloneNowHidden() {
cloneHidden = true;
},
cloneNowShown: function cloneNowShown() {
cloneHidden = false;
},
dispatchSortableEvent: function dispatchSortableEvent(name) {
_dispatchEvent({
sortable,
name,
originalEvent
});
}
}, data));
};
function _dispatchEvent(info) {
dispatchEvent(_objectSpread2({
putSortable,
cloneEl,
targetEl: dragEl,
rootEl,
oldIndex,
oldDraggableIndex,
newIndex,
newDraggableIndex
}, info));
}
var dragEl;
var parentEl;
var ghostEl;
var rootEl;
var nextEl;
var lastDownEl;
var cloneEl;
var cloneHidden;
var oldIndex;
var newIndex;
var oldDraggableIndex;
var newDraggableIndex;
var activeGroup;
var putSortable;
var awaitingDragStarted = false;
var ignoreNextClick = false;
var sortables = [];
var tapEvt;
var touchEvt;
var lastDx;
var lastDy;
var tapDistanceLeft;
var tapDistanceTop;
var moved;
var lastTarget;
var lastDirection;
var pastFirstInvertThresh = false;
var isCircumstantialInvert = false;
var targetMoveDistance;
var ghostRelativeParent;
var ghostRelativeParentInitialScroll = [];
var _silent = false;
var savedInputChecked = [];
var documentExists = typeof document !== "undefined";
var PositionGhostAbsolutely = IOS;
var CSSFloatProperty = Edge || IE11OrLess ? "cssFloat" : "float";
var supportDraggable = documentExists && !ChromeForAndroid && !IOS && "draggable" in document.createElement("div");
var supportCssPointerEvents = (function() {
if (!documentExists) return;
if (IE11OrLess) {
return false;
}
var el = document.createElement("x");
el.style.cssText = "pointer-events:auto";
return el.style.pointerEvents === "auto";
})();
var _detectDirection = function _detectDirection2(el, options) {
var elCSS = css(el), elWidth = parseInt(elCSS.width) - parseInt(elCSS.paddingLeft) - parseInt(elCSS.paddingRight) - parseInt(elCSS.borderLeftWidth) - parseInt(elCSS.borderRightWidth), child1 = getChild(el, 0, options), child2 = getChild(el, 1, options), firstChildCSS = child1 && css(child1), secondChildCSS = child2 && css(child2), firstChildWidth = firstChildCSS && parseInt(firstChildCSS.marginLeft) + parseInt(firstChildCSS.marginRight) + getRect(child1).width, secondChildWidth = secondChildCSS && parseInt(secondChildCSS.marginLeft) + parseInt(secondChildCSS.marginRight) + getRect(child2).width;
if (elCSS.display === "flex") {
return elCSS.flexDirection === "column" || elCSS.flexDirection === "column-reverse" ? "vertical" : "horizontal";
}
if (elCSS.display === "grid") {
return elCSS.gridTemplateColumns.split(" ").length <= 1 ? "vertical" : "horizontal";
}
if (child1 && firstChildCSS["float"] && firstChildCSS["float"] !== "none") {
var touchingSideChild2 = firstChildCSS["float"] === "left" ? "left" : "right";
return child2 && (secondChildCSS.clear === "both" || secondChildCSS.clear === touchingSideChild2) ? "vertical" : "horizontal";
}
return child1 && (firstChildCSS.display === "block" || firstChildCSS.display === "flex" || firstChildCSS.display === "table" || firstChildCSS.display === "grid" || firstChildWidth >= elWidth && elCSS[CSSFloatProperty] === "none" || child2 && elCSS[CSSFloatProperty] === "none" && firstChildWidth + secondChildWidth > elWidth) ? "vertical" : "horizontal";
};
var _dragElInRowColumn = function _dragElInRowColumn2(dragRect, targetRect, vertical) {
var dragElS1Opp = vertical ? dragRect.left : dragRect.top, dragElS2Opp = vertical ? dragRect.right : dragRect.bottom, dragElOppLength = vertical ? dragRect.width : dragRect.height, targetS1Opp = vertical ? targetRect.left : targetRect.top, targetS2Opp = vertical ? targetRect.right : targetRect.bottom, targetOppLength = vertical ? targetRect.width : targetRect.height;
return dragElS1Opp === targetS1Opp || dragElS2Opp === targetS2Opp || dragElS1Opp + dragElOppLength / 2 === targetS1Opp + targetOppLength / 2;
};
var _detectNearestEmptySortable = function _detectNearestEmptySortable2(x, y) {
var ret;
sortables.some(function(sortable) {
var threshold = sortable[expando].options.emptyInsertThreshold;
if (!threshold || lastChild(sortable)) return;
var rect = getRect(sortable), insideHorizontally = x >= rect.left - threshold && x <= rect.right + threshold, insideVertically = y >= rect.top - threshold && y <= rect.bottom + threshold;
if (insideHorizontally && insideVertically) {
return ret = sortable;
}
});
return ret;
};
var _prepareGroup = function _prepareGroup2(options) {
function toFn(value, pull) {
return function(to, from, dragEl2, evt) {
var sameGroup = to.options.group.name && from.options.group.name && to.options.group.name === from.options.group.name;
if (value == null && (pull || sameGroup)) {
return true;
} else if (value == null || value === false) {
return false;
} else if (pull && value === "clone") {
return value;
} else if (typeof value === "function") {
return toFn(value(to, from, dragEl2, evt), pull)(to, from, dragEl2, evt);
} else {
var otherGroup = (pull ? to : from).options.group.name;
return value === true || typeof value === "string" && value === otherGroup || value.join && value.indexOf(otherGroup) > -1;
}
};
}
var group = {};
var originalGroup = options.group;
if (!originalGroup || _typeof(originalGroup) != "object") {
originalGroup = {
name: originalGroup
};
}
group.name = originalGroup.name;
group.checkPull = toFn(originalGroup.pull, true);
group.checkPut = toFn(originalGroup.put);
group.revertClone = originalGroup.revertClone;
options.group = group;
};
var _hideGhostForTarget = function _hideGhostForTarget2() {
if (!supportCssPointerEvents && ghostEl) {
css(ghostEl, "display", "none");
}
};
var _unhideGhostForTarget = function _unhideGhostForTarget2() {
if (!supportCssPointerEvents && ghostEl) {
css(ghostEl, "display", "");
}
};
if (documentExists && !ChromeForAndroid) {
document.addEventListener("click", function(evt) {
if (ignoreNextClick) {
evt.preventDefault();
evt.stopPropagation && evt.stopPropagation();
evt.stopImmediatePropagation && evt.stopImmediatePropagation();
ignoreNextClick = false;
return false;
}
}, true);
}
var nearestEmptyInsertDetectEvent = function nearestEmptyInsertDetectEvent2(evt) {
if (dragEl) {
evt = evt.touches ? evt.touches[0] : evt;
var nearest = _detectNearestEmptySortable(evt.clientX, evt.clientY);
if (nearest) {
var event = {};
for (var i in evt) {
if (evt.hasOwnProperty(i)) {
event[i] = evt[i];
}
}
event.target = event.rootEl = nearest;
event.preventDefault = void 0;
event.stopPropagation = void 0;
nearest[expando]._onDragOver(event);
}
}
};
var _checkOutsideTargetEl = function _checkOutsideTargetEl2(evt) {
if (dragEl) {
dragEl.parentNode[expando]._isOutsideThisEl(evt.target);
}
};
function Sortable(el, options) {
if (!(el && el.nodeType && el.nodeType === 1)) {
throw "Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(el));
}
this.el = el;
this.options = options = _extends({}, options);
el[expando] = this;
var defaults2 = {
group: null,
sort: true,
disabled: false,
store: null,
handle: null,
draggable: /^[uo]l$/i.test(el.nodeName) ? ">li" : ">*",
swapThreshold: 1,
// percentage; 0 <= x <= 1
invertSwap: false,
// invert always
invertedSwapThreshold: null,
// will be set to same as swapThreshold if default
removeCloneOnHide: true,
direction: function direction() {
return _detectDirection(el, this.options);
},
ghostClass: "sortable-ghost",
chosenClass: "sortable-chosen",
dragClass: "sortable-drag",
ignore: "a, img",
filter: null,
preventOnFilter: true,
animation: 0,
easing: null,
setData: function setData(dataTransfer, dragEl2) {
dataTransfer.setData("Text", dragEl2.textContent);
},
dropBubble: false,
dragoverBubble: false,
dataIdAttr: "data-id",
delay: 0,
delayOnTouchOnly: false,
touchStartThreshold: (Number.parseInt ? Number : window).parseInt(window.devicePixelRatio, 10) || 1,
forceFallback: false,
fallbackClass: "sortable-fallback",
fallbackOnBody: false,
fallbackTolerance: 0,
fallbackOffset: {
x: 0,
y: 0
},
// Disabled on Safari: #1571; Enabled on Safari IOS: #2244
supportPointer: Sortable.supportPointer !== false && "PointerEvent" in window && (!Safari || IOS),
emptyInsertThreshold: 5
};
PluginManager.initializePlugins(this, el, defaults2);
for (var name in defaults2) {
!(name in options) && (options[name] = defaults2[name]);
}
_prepareGroup(options);
for (var fn in this) {
if (fn.charAt(0) === "_" && typeof this[fn] === "function") {
this[fn] = this[fn].bind(this);
}
}
this.nativeDraggable = options.forceFallback ? false : supportDraggable;
if (this.nativeDraggable) {
this.options.touchStartThreshold = 1;
}
if (options.supportPointer) {
on(el, "pointerdown", this._onTapStart);
} else {
on(el, "mousedown", this._onTapStart);
on(el, "touchstart", this._onTapStart);
}
if (this.nativeDraggable) {
on(el, "dragover", this);
on(el, "dragenter", this);
}
sortables.push(this.el);
options.store && options.store.get && this.sort(options.store.get(this) || []);
_extends(this, AnimationStateManager());
}
Sortable.prototype = /** @lends Sortable.prototype */
{
constructor: Sortable,
_isOutsideThisEl: function _isOutsideThisEl(target) {
if (!this.el.contains(target) && target !== this.el) {
lastTarget = null;
}
},
_getDirection: function _getDirection(evt, target) {
return typeof this.options.direction === "function" ? this.options.direction.call(this, evt, target, dragEl) : this.options.direction;
},
_onTapStart: function _onTapStart(evt) {
if (!evt.cancelable) return;
var _this = this, el = this.el, options = this.options, preventOnFilter = options.preventOnFilter, type = evt.type, touch = evt.touches && evt.touches[0] || evt.pointerType && evt.pointerType === "touch" && evt, target = (touch || evt).target, originalTarget = evt.target.shadowRoot && (evt.path && evt.path[0] || evt.composedPath && evt.composedPath()[0]) || target, filter = options.filter;
_saveInputCheckedState(el);
if (dragEl) {
return;
}
if (/mousedown|pointerdown/.test(type) && evt.button !== 0 || options.disabled) {
return;
}
if (originalTarget.isContentEditable) {
return;
}
if (!this.nativeDraggable && Safari && target && target.tagName.toUpperCase() === "SELECT") {
return;
}
target = closest(target, options.draggable, el, false);
if (target && target.animated) {
return;
}
if (lastDownEl === target) {
return;
}
oldIndex = index(target);
oldDraggableIndex = index(target, options.draggable);
if (typeof filter === "function") {
if (filter.call(this, evt, target, this)) {
_dispatchEvent({
sortable: _this,
rootEl: originalTarget,
name: "filter",
targetEl: target,
toEl: el,
fromEl: el
});
pluginEvent2("filter", _this, {
evt
});
preventOnFilter && evt.preventDefault();
return;
}
} else if (filter) {
filter = filter.split(",").some(function(criteria) {
criteria = closest(originalTarget, criteria.trim(), el, false);
if (criteria) {
_dispatchEvent({
sortable: _this,
rootEl: criteria,
name: "filter",
targetEl: target,
fromEl: el,
toEl: el
});
pluginEvent2("filter", _this, {
evt
});
return true;
}
});
if (filter) {
preventOnFilter && evt.preventDefault();
return;
}
}
if (options.handle && !closest(originalTarget, options.handle, el, false)) {
return;
}
this._prepareDragStart(evt, touch, target);
},
_prepareDragStart: function _prepareDragStart(evt, touch, target) {
var _this = this, el = _this.el, options = _this.options, ownerDocument = el.ownerDocument, dragStartFn;
if (target && !dragEl && target.parentNode === el) {
var dragRect = getRect(target);
rootEl = el;
dragEl = target;
parentEl = dragEl.parentNode;
nextEl = dragEl.nextSibling;
lastDownEl = target;
activeGroup = options.group;
Sortable.dragged = dragEl;
tapEvt = {
target: dragEl,
clientX: (touch || evt).clientX,
clientY: (touch || evt).clientY
};
tapDistanceLeft = tapEvt.clientX - dragRect.left;
tapDistanceTop = tapEvt.clientY - dragRect.top;
this._lastX = (touch || evt).clientX;
this._lastY = (touch || evt).clientY;
dragEl.style["will-change"] = "all";
dragStartFn = function dragStartFn2() {
pluginEvent2("delayEnded", _this, {
evt
});
if (Sortable.eventCanceled) {
_this._onDrop();
return;
}
_this._disableDelayedDragEvents();
if (!FireFox && _this.nativeDraggable) {
dragEl.draggable = true;
}
_this._triggerDragStart(evt, touch);
_dispatchEvent({
sortable: _this,
name: "choose",
originalEvent: evt
});
toggleClass(dragEl, options.chosenClass, true);
};
options.ignore.split(",").forEach(function(criteria) {
find(dragEl, criteria.trim(), _disableDraggable);
});
on(ownerDocument, "dragover", nearestEmptyInsertDetectEvent);
on(ownerDocument, "mousemove", nearestEmptyInsertDetectEvent);
on(ownerDocument, "touchmove", nearestEmptyInsertDetectEvent);
if (options.supportPointer) {
on(ownerDocument, "pointerup", _this._onDrop);
!this.nativeDraggable && on(ownerDocument, "pointercancel", _this._onDrop);
} else {
on(ownerDocument, "mouseup", _this._onDrop);
on(ownerDocument, "touchend", _this._onDrop);
on(ownerDocument, "touchcancel", _this._onDrop);
}
if (FireFox && this.nativeDraggable) {
this.options.touchStartThreshold = 4;
dragEl.draggable = true;
}
pluginEvent2("delayStart", this, {
evt
});
if (options.delay && (!options.delayOnTouchOnly || touch) && (!this.nativeDraggable || !(Edge || IE11OrLess))) {
if (Sortable.eventCanceled) {
this._onDrop();
return;
}
if (options.supportPointer) {
on(ownerDocument, "pointerup", _this._disableDelayedDrag);
on(ownerDocument, "pointercancel", _this._disableDelayedDrag);
} else {
on(ownerDocument, "mouseup", _this._disableDelayedDrag);
on(ownerDocument, "touchend", _this._disableDelayedDrag);
on(ownerDocument, "touchcancel", _this._disableDelayedDrag);
}
on(ownerDocument, "mousemove", _this._delayedDragTouchMoveHandler);
on(ownerDocument, "touchmove", _this._delayedDragTouchMoveHandler);
options.supportPointer && on(ownerDocument, "pointermove", _this._delayedDragTouchMoveHandler);
_this._dragStartTimer = setTimeout(dragStartFn, options.delay);
} else {
dragStartFn();
}
}
},
_delayedDragTouchMoveHandler: function _delayedDragTouchMoveHandler(e) {
var touch = e.touches ? e.touches[0] : e;
if (Math.max(Math.abs(touch.clientX - this._lastX), Math.abs(touch.clientY - this._lastY)) >= Math.floor(this.options.touchStartThreshold / (this.nativeDraggable && window.devicePixelRatio || 1))) {
this._disableDelayedDrag();
}
},
_disableDelayedDrag: function _disableDelayedDrag() {
dragEl && _disableDraggable(dragEl);
clearTimeout(this._dragStartTimer);
this._disableDelayedDragEvents();
},
_disableDelayedDragEvents: function _disableDelayedDragEvents() {
var ownerDocument = this.el.ownerDocument;
off(ownerDocument, "mouseup", this._disableDelayedDrag);
off(ownerDocument, "touchend", this._disableDelayedDrag);
off(ownerDocument, "touchcancel", this._disableDelayedDrag);
off(ownerDocument, "pointerup", this._disableDelayedDrag);
off(ownerDocument, "pointercancel", this._disableDelayedDrag);
off(ownerDocument, "mousemove", this._delayedDragTouchMoveHandler);
off(ownerDocument, "touchmove", this._delayedDragTouchMoveHandler);
off(ownerDocument, "pointermove", this._delayedDragTouchMoveHandler);
},
_triggerDragStart: function _triggerDragStart(evt, touch) {
touch = touch || evt.pointerType == "touch" && evt;
if (!this.nativeDraggable || touch) {
if (this.options.supportPointer) {
on(document, "pointermove", this._onTouchMove);
} else if (touch) {
on(document, "touchmove", this._onTouchMove);
} else {
on(document, "mousemove", this._onTouchMove);
}
} else {
on(dragEl, "dragend", this);
on(rootEl, "dragstart", this._onDragStart);
}
try {
if (document.selection) {
_nextTick(function() {
document.selection.empty();
});
} else {
window.getSelection().removeAllRanges();
}
} catch (err) {
}
},
_dragStarted: function _dragStarted(fallback, evt) {
awaitingDragStarted = false;
if (rootEl && dragEl) {
pluginEvent2("dragStarted", this, {
evt
});
if (this.nativeDraggable) {
on(document, "dragover", _checkOutsideTargetEl);
}
var options = this.options;
!fallback && toggleClass(dragEl, options.dragClass, false);
toggleClass(dragEl, options.ghostClass, true);
Sortable.active = this;
fallback && this._appendGhost();
_dispatchEvent({
sortable: this,
name: "start",
originalEvent: evt
});
} else {
this._nulling();
}
},
_emulateDragOver: function _emulateDragOver() {
if (touchEvt) {
this._lastX = touchEvt.clientX;
this._lastY = touchEvt.clientY;
_hideGhostForTarget();
var target = document.elementFromPoint(touchEvt.clientX, touchEvt.clientY);
var parent = target;
while (target && target.shadowRoot) {
target = target.shadowRoot.elementFromPoint(touchEvt.clientX, touchEvt.clientY);
if (target === parent) break;
parent = target;
}
dragEl.parentNode[expando]._isOutsideThisEl(target);
if (parent) {
do {
if (parent[expando]) {
var inserted = void 0;
inserted = parent[expando]._onDragOver({
clientX: touchEvt.clientX,
clientY: touchEvt.clientY,
target,
rootEl: parent
});
if (inserted && !this.options.dragoverBubble) {
break;
}
}
target = parent;
} while (parent = getParentOrHost(parent));
}
_unhideGhostForTarget();
}
},
_onTouchMove: function _onTouchMove(evt) {
if (tapEvt) {
var options = this.options, fallbackTolerance = options.fallbackTolerance, fallbackOffset = options.fallbackOffset, touch = evt.touches ? evt.touches[0] : evt, ghostMatrix = ghostEl && matrix(ghostEl, true), scaleX = ghostEl && ghostMatrix && ghostMatrix.a, scaleY = ghostEl && ghostMatrix && ghostMatrix.d, relativeScrollOffset = PositionGhostAbsolutely && ghostRelativeParent && getRelativeScrollOffset(ghostRelativeParent), dx = (touch.clientX - tapEvt.clientX + fallbackOffset.x) / (scaleX || 1) + (relativeScrollOffset ? relativeScrollOffset[0] - ghostRelativeParentInitialScroll[0] : 0) / (scaleX || 1), dy = (touch.clientY - tapEvt.clientY + fallbackOffset.y) / (scaleY || 1) + (relativeScrollOffset ? relativeScrollOffset[1] - ghostRelativeParentInitialScroll[1] : 0) / (scaleY || 1);
if (!Sortable.active && !awaitingDragStarted) {
if (fallbackTolerance && Math.max(Math.abs(touch.clientX - this._lastX), Math.abs(touch.clientY - this._lastY)) < fallbackTolerance) {
return;
}
this._onDragStart(evt, true);
}
if (ghostEl) {
if (ghostMatrix) {
ghostMatrix.e += dx - (lastDx || 0);
ghostMatrix.f += dy - (lastDy || 0);
} else {
ghostMatrix = {
a: 1,
b: 0,
c: 0,
d: 1,
e: dx,
f: dy
};
}
var cssMatrix = "matrix(".concat(ghostMatrix.a, ",").concat(ghostMatrix.b, ",").concat(ghostMatrix.c, ",").concat(ghostMatrix.d, ",").concat(ghostMatrix.e, ",").concat(ghostMatrix.f, ")");
css(ghostEl, "webkitTransform", cssMatrix);
css(ghostEl, "mozTransform", cssMatrix);
css(ghostEl, "msTransform", cssMatrix);
css(ghostEl, "transform", cssMatrix);
lastDx = dx;
lastDy = dy;
touchEvt = touch;
}
evt.cancelable && evt.preventDefault();
}
},
_appendGhost: function _appendGhost() {
if (!ghostEl) {
var container = this.options.fallbackOnBody ? document.body : rootEl, rect = getRect(dragEl, true, PositionGhostAbsolutely, true, container), options = this.options;
if (PositionGhostAbsolutely) {
ghostRelativeParent = container;
while (css(ghostRelativeParent, "position") === "static" && css(ghostRelativeParent, "transform") === "none" && ghostRelativeParent !== document) {
ghostRelativeParent = ghostRelativeParent.parentNode;
}
if (ghostRelativeParent !== document.body && ghostRelativeParent !== document.documentElement) {
if (ghostRelativeParent === document) ghostRelativeParent = getWindowScrollingElement();
rect.top += ghostRelativeParent.scrollTop;
rect.left += ghostRelativeParent.scrollLeft;
} else {
ghostRelativeParent = getWindowScrollingElement();
}
ghostRelativeParentInitialScroll = getRelativeScrollOffset(ghostRelativeParent);
}
ghostEl = dragEl.cloneNode(true);
toggleClass(ghostEl, options.ghostClass, false);
toggleClass(ghostEl, options.fallbackClass, true);
toggleClass(ghostEl, options.dragClass, true);
css(ghostEl, "transition", "");
css(ghostEl, "transform", "");
css(ghostEl, "box-sizing", "border-box");
css(ghostEl, "margin", 0);
css(ghostEl, "top", rect.top);
css(ghostEl, "left", rect.left);
css(ghostEl, "width", rect.width);
css(ghostEl, "height", rect.height);
css(ghostEl, "opacity", "0.8");
css(ghostEl, "position", PositionGhostAbsolutely ? "absolute" : "fixed");
css(ghostEl, "zIndex", "100000");
css(ghostEl, "pointerEvents", "none");
Sortable.ghost = ghostEl;
container.appendChild(ghostEl);
css(ghostEl, "transform-origin", tapDistanceLeft / parseInt(ghostEl.style.width) * 100 + "% " + tapDistanceTop / parseInt(ghostEl.style.height) * 100 + "%");
}
},
_onDragStart: function _onDragStart(evt, fallback) {
var _this = this;
var dataTransfer = evt.dataTransfer;
var options = _this.options;
pluginEvent2("dragStart", this, {
evt
});
if (Sortable.eventCanceled) {
this._onDrop();
return;
}
pluginEvent2("setupClone", this);
if (!Sortable.eventCanceled) {
cloneEl = clone(dragEl);
cloneEl.removeAttribute("id");
cloneEl.draggable = false;
cloneEl.style["will-change"] = "";
this._hideClone();
toggleClass(cloneEl, this.options.chosenClass, false);
Sortable.clone = cloneEl;
}
_this.cloneId = _nextTick(function() {
pluginEvent2("clone", _this);
if (Sortable.eventCanceled) return;
if (!_this.options.removeCloneOnHide) {
rootEl.insertBefore(cloneEl, dragEl);
}
_this._hideClone();
_dispatchEvent({
sortable: _this,
name: "clone"
});
});
!fallback && toggleClass(dragEl, options.dragClass, true);
if (fallback) {
ignoreNextClick = true;
_this._loopId = setInterval(_this._emulateDragOver, 50);
} else {
off(document, "mouseup", _this._onDrop);
off(document, "touchend", _this._onDrop);
off(document, "touchcancel", _this._onDrop);
if (dataTransfer) {
dataTransfer.effectAllowed = "move";
options.setData && options.setData.call(_this, dataTransfer, dragEl);
}
on(document, "drop", _this);
css(dragEl, "transform", "translateZ(0)");
}
awaitingDragStarted = true;
_this._dragStartId = _nextTick(_this._dragStarted.bind(_this, fallback, evt));
on(document, "selectstart", _this);
moved = true;
window.getSelection().removeAllRanges();
if (Safari) {
css(document.body, "user-select", "none");
}
},
// Returns true - if no further action is needed (either inserted or another condition)
_onDragOver: function _onDragOver(evt) {
var el = this.el, target = evt.target, dragRect, targetRect, revert, options = this.options, group = options.group, activeSortable = Sortable.active, isOwner = activeGroup === group, canSort = options.sort, fromSortable = putSortable || activeSortable, vertical, _this = this, completedFired = false;
if (_silent) return;
function dragOverEvent(name, extra) {
pluginEvent2(name, _this, _objectSpread2({
evt,
isOwner,
axis: vertical ? "vertical" : "horizontal",
revert,
dragRect,
targetRect,
canSort,
fromSortable,
target,
completed,
onMove: function onMove(target2, after2) {
return _onMove(rootEl, el, dragEl, dragRect, target2, getRect(target2), evt, after2);
},
changed
}, extra));
}
function capture() {
dragOverEvent("dragOverAnimationCapture");
_this.captureAnimationState();
if (_this !== fromSortable) {
fromSortable.captureAnimationState();
}
}
function completed(insertion) {
dragOverEvent("dragOverCompleted", {
insertion
});
if (insertion) {
if (isOwner) {
activeSortable._hideClone();
} else {
activeSortable._showClone(_this);
}
if (_this !== fromSortable) {
toggleClass(dragEl, putSortable ? putSortable.options.ghostClass : activeSortable.options.ghostClass, false);
toggleClass(dragEl, options.ghostClass, true);
}
if (putSortable !== _this && _this !== Sortable.active) {
putSortable = _this;
} else if (_this === Sortable.active && putSortable) {
putSortable = null;
}
if (fromSortable === _this) {
_this._ignoreWhileAnimating = target;
}
_this.animateAll(function() {
dragOverEvent("dragOverAnimationComplete");
_this._ignoreWhileAnimating = null;
});
if (_this !== fromSortable) {
fromSortable.animateAll();
fromSortable._ignoreWhileAnimating = null;
}
}
if (target === dragEl && !dragEl.animated || target === el && !target.animated) {
lastTarget = null;
}
if (!options.dragoverBubble && !evt.rootEl && target !== document) {
dragEl.parentNode[expando]._isOutsideThisEl(evt.target);
!insertion && nearestEmptyInsertDetectEvent(evt);
}
!options.dragoverBubble && evt.stopPropagation && evt.stopPropagation();
return completedFired = true;
}
function changed() {
newIndex = index(dragEl);
newDraggableIndex = index(dragEl, options.draggable);
_dispatchEvent({
sortable: _this,
name: "change",
toEl: el,
newIndex,
newDraggableIndex,
originalEvent: evt
});
}
if (evt.preventDefault !== void 0) {
evt.cancelable && evt.preventDefault();
}
target = closest(target, options.draggable, el, true);
dragOverEvent("dragOver");
if (Sortable.eventCanceled) return completedFired;
if (dragEl.contains(evt.target) || target.animated && target.animatingX && target.animatingY || _this._ignoreWhileAnimating === target) {
return completed(false);
}
ignoreNextClick = false;
if (activeSortable && !options.disabled && (isOwner ? canSort || (revert = parentEl !== rootEl) : putSortable === this || (this.lastPutMode = activeGroup.checkPull(this, activeSortable, dragEl, evt)) && group.checkPut(this, activeSortable, dragEl, evt))) {
vertical = this._getDirection(evt, target) === "vertical";
dragRect = getRect(dragEl);
dragOverEvent("dragOverValid");
if (Sortable.eventCanceled) return completedFired;
if (revert) {
parentEl = rootEl;
capture();
this._hideClone();
dragOverEvent("revert");
if (!Sortable.eventCanceled) {
if (nextEl) {
rootEl.insertBefore(dragEl, nextEl);
} else {
rootEl.appendChild(dragEl);
}
}
return completed(true);
}
var elLastChild = lastChild(el, options.draggable);
if (!elLastChild || _ghostIsLast(evt, vertical, this) && !elLastChild.animated) {
if (elLastChild === dragEl) {
return completed(false);
}
if (elLastChild && el === evt.target) {
target = elLastChild;
}
if (target) {
targetRect = getRect(target);
}
if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, !!target) !== false) {
capture();
if (elLastChild && elLastChild.nextSibling) {
el.insertBefore(dragEl, elLastChild.nextSibling);
} else {
el.appendChild(dragEl);
}
parentEl = el;
changed();
return completed(true);
}
} else if (elLastChild && _ghostIsFirst(evt, vertical, this)) {
var firstChild = getChild(el, 0, options, true);
if (firstChild === dragEl) {
return completed(false);
}
target = firstChild;
targetRect = getRect(target);
if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, false) !== false) {
capture();
el.insertBefore(dragEl, firstChild);
parentEl = el;
changed();
return completed(true);
}
} else if (target.parentNode === el) {
targetRect = getRect(target);
var direction = 0, targetBeforeFirstSwap, differentLevel = dragEl.parentNode !== el, differentRowCol = !_dragElInRowColumn(dragEl.animated && dragEl.toRect || dragRect, target.animated && target.toRect || targetRect, vertical), side1 = vertical ? "top" : "left", scrolledPastTop = isScrolledPast(target, "top", "top") || isScrolledPast(dragEl, "top", "top"), scrollBefore = scrolledPastTop ? scrolledPastTop.scrollTop : void 0;
if (lastTarget !== target) {
targetBeforeFirstSwap = targetRect[side1];
pastFirstInvertThresh = false;
isCircumstantialInvert = !differentRowCol && options.invertSwap || differentLevel;
}
direction = _getSwapDirection(evt, target, targetRect, vertical, differentRowCol ? 1 : options.swapThreshold, options.invertedSwapThreshold == null ? options.swapThreshold : options.invertedSwapThreshold, isCircumstantialInvert, lastTarget === target);
var sibling;
if (direction !== 0) {
var dragIndex = index(dragEl);
do {
dragIndex -= direction;
sibling = parentEl.children[dragIndex];
} while (sibling && (css(sibling, "display") === "none" || sibling === ghostEl));
}
if (direction === 0 || sibling === target) {
return completed(false);
}
lastTarget = target;
lastDirection = direction;
var nextSibling = target.nextElementSibling, after = false;
after = direction === 1;
var moveVector = _onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, after);
if (moveVector !== false) {
if (moveVector === 1 || moveVector === -1) {
after = moveVector === 1;
}
_silent = true;
setTimeout(_unsilent, 30);
capture();
if (after && !nextSibling) {
el.appendChild(dragEl);
} else {
target.parentNode.insertBefore(dragEl, after ? nextSibling : target);
}
if (scrolledPastTop) {
scrollBy(scrolledPastTop, 0, scrollBefore - scrolledPastTop.scrollTop);
}
parentEl = dragEl.parentNode;
if (targetBeforeFirstSwap !== void 0 && !isCircumstantialInvert) {
targetMoveDistance = Math.abs(targetBeforeFirstSwap - getRect(target)[side1]);
}
changed();
return completed(true);
}
}
if (el.contains(dragEl)) {
return completed(false);
}
}
return false;
},
_ignoreWhileAnimating: null,
_offMoveEvents: function _offMoveEvents() {
off(document, "mousemove", this._onTouchMove);
off(document, "touchmove", this._onTouchMove);
off(document, "pointermove", this._onTouchMove);
off(document, "dragover", nearestEmptyInsertDetectEvent);
off(document, "mousemove", nearestEmptyInsertDetectEvent);
off(document, "touchmove", nearestEmptyInsertDetectEvent);
},
_offUpEvents: function _offUpEvents() {
var ownerDocument = this.el.ownerDocument;
off(ownerDocument, "mouseup", this._onDrop);
off(ownerDocument, "touchend", this._onDrop);
off(ownerDocument, "pointerup", this._onDrop);
off(ownerDocument, "pointercancel", this._onDrop);
off(ownerDocument, "touchcancel", this._onDrop);
off(document, "selectstart", this);
},
_onDrop: function _onDrop(evt) {
var el = this.el, options = this.options;
newIndex = index(dragEl);
newDraggableIndex = index(dragEl, options.draggable);
pluginEvent2("drop", this, {
evt
});
parentEl = dragEl && dragEl.parentNode;
newIndex = index(dragEl);
newDraggableIndex = index(dragEl, options.draggable);
if (Sortable.eventCanceled) {
this._nulling();
return;
}
awaitingDragStarted = false;
isCircumstantialInvert = false;
pastFirstInvertThresh = false;
clearInterval(this._loopId);
clearTimeout(this._dragStartTimer);
_cancelNextTick(this.cloneId);
_cancelNextTick(this._dragStartId);
if (this.nativeDraggable) {
off(document, "drop", this);
off(el, "dragstart", this._onDragStart);
}
this._offMoveEvents();
this._offUpEvents();
if (Safari) {
css(document.body, "user-select", "");
}
css(dragEl, "transform", "");
if (evt) {
if (moved) {
evt.cancelable && evt.preventDefault();
!options.dropBubble && evt.stopPropagation();
}
ghostEl && ghostEl.parentNode && ghostEl.parentNode.removeChild(ghostEl);
if (rootEl === parentEl || putSortable && putSortable.lastPutMode !== "clone") {
cloneEl && cloneEl.parentNode && cloneEl.parentNode.removeChild(cloneEl);
}
if (dragEl) {
if (this.nativeDraggable) {
off(dragEl, "dragend", this);
}
_disableDraggable(dragEl);
dragEl.style["will-change"] = "";
if (moved && !awaitingDragStarted) {
toggleClass(dragEl, putSortable ? putSortable.options.ghostClass : this.options.ghostClass, false);
}
toggleClass(dragEl, this.options.chosenClass, false);
_dispatchEvent({
sortable: this,
name: "unchoose",
toEl: parentEl,
newIndex: null,
newDraggableIndex: null,
originalEvent: evt
});
if (rootEl !== parentEl) {
if (newIndex >= 0) {
_dispatchEvent({
rootEl: parentEl,
name: "add",
toEl: parentEl,
fromEl: rootEl,
originalEvent: evt
});
_dispatchEvent({
sortable: this,
name: "remove",
toEl: parentEl,
originalEvent: evt
});
_dispatchEvent({
rootEl: parentEl,
name: "sort",
toEl: parentEl,
fromEl: rootEl,
originalEvent: evt
});
_dispatchEvent({
sortable: this,
name: "sort",
toEl: parentEl,
originalEvent: evt
});
}
putSortable && putSortable.save();
} else {
if (newIndex !== oldIndex) {
if (newIndex >= 0) {
_dispatchEvent({
sortable: this,
name: "update",
toEl: parentEl,
originalEvent: evt
});
_dispatchEvent({
sortable: this,
name: "sort",
toEl: parentEl,
originalEvent: evt
});
}
}
}
if (Sortable.active) {
if (newIndex == null || newIndex === -1) {
newIndex = oldIndex;
newDraggableIndex = oldDraggableIndex;
}
_dispatchEvent({
sortable: this,
name: "end",
toEl: parentEl,
originalEvent: evt
});
this.save();
}
}
}
this._nulling();
},
_nulling: function _nulling() {
pluginEvent2("nulling", this);
rootEl = dragEl = parentEl = ghostEl = nextEl = cloneEl = lastDownEl = cloneHidden = tapEvt = touchEvt = moved = newIndex = newDraggableIndex = oldIndex = oldDraggableIndex = lastTarget = lastDirection = putSortable = activeGroup = Sortable.dragged = Sortable.ghost = Sortable.clone = Sortable.active = null;
savedInputChecked.forEach(function(el) {
el.checked = true;
});
savedInputChecked.length = lastDx = lastDy = 0;
},
handleEvent: function handleEvent(evt) {
switch (evt.type) {
case "drop":
case "dragend":
this._onDrop(evt);
break;
case "dragenter":
case "dragover":
if (dragEl) {
this._onDragOver(evt);
_globalDragOver(evt);
}
break;
case "selectstart":
evt.preventDefault();
break;
}
},
/**
* Serializes the item into an array of string.
* @returns {String[]}
*/
toArray: function toArray() {
var order = [], el, children = this.el.children, i = 0, n = children.length, options = this.options;
for (; i < n; i++) {
el = children[i];
if (closest(el, options.draggable, this.el, false)) {
order.push(el.getAttribute(options.dataIdAttr) || _generateId(el));
}
}
return order;
},
/**
* Sorts the elements according to the array.
* @param {String[]} order order of the items
*/
sort: function sort(order, useAnimation) {
var items = {}, rootEl2 = this.el;
this.toArray().forEach(function(id, i) {
var el = rootEl2.children[i];
if (closest(el, this.options.draggable, rootEl2, false)) {
items[id] = el;
}
}, this);
useAnimation && this.captureAnimationState();
order.forEach(function(id) {
if (items[id]) {
rootEl2.removeChild(items[id]);
rootEl2.appendChild(items[id]);
}
});
useAnimation && this.animateAll();
},
/**
* Save the current sorting
*/
save: function save() {
var store = this.options.store;
store && store.set && store.set(this);
},
/**
* For each element in the set, get the first element that matches the selector by testing the element itself and traversing up through its ancestors in the DOM tree.
* @param {HTMLElement} el
* @param {String} [selector] default: `options.draggable`
* @returns {HTMLElement|null}
*/
closest: function closest$1(el, selector) {
return closest(el, selector || this.options.draggable, this.el, false);
},
/**
* Set/get option
* @param {string} name
* @param {*} [value]
* @returns {*}
*/
option: function option(name, value) {
var options = this.options;
if (value === void 0) {
return options[name];
} else {
var modifiedValue = PluginManager.modifyOption(this, name, value);
if (typeof modifiedValue !== "undefined") {
options[name] = modifiedValue;
} else {
options[name] = value;
}
if (name === "group") {
_prepareGroup(options);
}
}
},
/**
* Destroy
*/
destroy: function destroy() {
pluginEvent2("destroy", this);
var el = this.el;
el[expando] = null;
off(el, "mousedown", this._onTapStart);
off(el, "touchstart", this._onTapStart);
off(el, "pointerdown", this._onTapStart);
if (this.nativeDraggable) {
off(el, "dragover", this);
off(el, "dragenter", this);
}
Array.prototype.forEach.call(el.querySelectorAll("[draggable]"), function(el2) {
el2.removeAttribute("draggable");
});
this._onDrop();
this._disableDelayedDragEvents();
sortables.splice(sortables.indexOf(this.el), 1);
this.el = el = null;
},
_hideClone: function _hideClone() {
if (!cloneHidden) {
pluginEvent2("hideClone", this);
if (Sortable.eventCanceled) return;
css(cloneEl, "display", "none");
if (this.options.removeCloneOnHide && cloneEl.parentNode) {
cloneEl.parentNode.removeChild(cloneEl);
}
cloneHidden = true;
}
},
_showClone: function _showClone(putSortable2) {
if (putSortable2.lastPutMode !== "clone") {
this._hideClone();
return;
}
if (cloneHidden) {
pluginEvent2("showClone", this);
if (Sortable.eventCanceled) return;
if (dragEl.parentNode == rootEl && !this.options.group.revertClone) {
rootEl.insertBefore(cloneEl, dragEl);
} else if (nextEl) {
rootEl.insertBefore(cloneEl, nextEl);
} else {
rootEl.appendChild(cloneEl);
}
if (this.options.group.revertClone) {
this.animate(dragEl, cloneEl);
}
css(cloneEl, "display", "");
cloneHidden = false;
}
}
};
function _globalDragOver(evt) {
if (evt.dataTransfer) {
evt.dataTransfer.dropEffect = "move";
}
evt.cancelable && evt.preventDefault();
}
function _onMove(fromEl, toEl, dragEl2, dragRect, targetEl, targetRect, originalEvent, willInsertAfter) {
var evt, sortable = fromEl[expando], onMoveFn = sortable.options.onMove, retVal;
if (window.CustomEvent && !IE11OrLess && !Edge) {
evt = new CustomEvent("move", {
bubbles: true,
cancelable: true
});
} else {
evt = document.createEvent("Event");
evt.initEvent("move", true, true);
}
evt.to = toEl;
evt.from = fromEl;
evt.dragged = dragEl2;
evt.draggedRect = dragRect;
evt.related = targetEl || toEl;
evt.relatedRect = targetRect || getRect(toEl);
evt.willInsertAfter = willInsertAfter;
evt.originalEvent = originalEvent;
fromEl.dispatchEvent(evt);
if (onMoveFn) {
retVal = onMoveFn.call(sortable, evt, originalEvent);
}
return retVal;
}
function _disableDraggable(el) {
el.draggable = false;
}
function _unsilent() {
_silent = false;
}
function _ghostIsFirst(evt, vertical, sortable) {
var firstElRect = getRect(getChild(sortable.el, 0, sortable.options, true));
var childContainingRect = getChildContainingRectFromElement(sortable.el, sortable.options, ghostEl);
var spacer = 10;
return vertical ? evt.clientX < childContainingRect.left - spacer || evt.clientY < firstElRect.top && evt.clientX < firstElRect.right : evt.clientY < childContainingRect.top - spacer || evt.clientY < firstElRect.bottom && evt.clientX < firstElRect.left;
}
function _ghostIsLast(evt, vertical, sortable) {
var lastElRect = getRect(lastChild(sortable.el, sortable.options.draggable));
var childContainingRect = getChildContainingRectFromElement(sortable.el, sortable.options, ghostEl);
var spacer = 10;
return vertical ? evt.clientX > childContainingRect.right + spacer || evt.clientY > lastElRect.bottom && evt.clientX > lastElRect.left : evt.clientY > childContainingRect.bottom + spacer || evt.clientX > lastElRect.right && evt.clientY > lastElRect.top;
}
function _getSwapDirection(evt, target, targetRect, vertical, swapThreshold, invertedSwapThreshold, invertSwap, isLastTarget) {
var mouseOnAxis = vertical ? evt.clientY : evt.clientX, targetLength = vertical ? targetRect.height : targetRect.width, targetS1 = vertical ? targetRect.top : targetRect.left, targetS2 = vertical ? targetRect.bottom : targetRect.right, invert = false;
if (!invertSwap) {
if (isLastTarget && targetMoveDistance < targetLength * swapThreshold) {
if (!pastFirstInvertThresh && (lastDirection === 1 ? mouseOnAxis > targetS1 + targetLength * invertedSwapThreshold / 2 : mouseOnAxis < targetS2 - targetLength * invertedSwapThreshold / 2)) {
pastFirstInvertThresh = true;
}
if (!pastFirstInvertThresh) {
if (lastDirection === 1 ? mouseOnAxis < targetS1 + targetMoveDistance : mouseOnAxis > targetS2 - targetMoveDistance) {
return -lastDirection;
}
} else {
invert = true;
}
} else {
if (mouseOnAxis > targetS1 + targetLength * (1 - swapThreshold) / 2 && mouseOnAxis < targetS2 - targetLength * (1 - swapThreshold) / 2) {
return _getInsertDirection(target);
}
}
}
invert = invert || invertSwap;
if (invert) {
if (mouseOnAxis < targetS1 + targetLength * invertedSwapThreshold / 2 || mouseOnAxis > targetS2 - targetLength * invertedSwapThreshold / 2) {
return mouseOnAxis > targetS1 + targetLength / 2 ? 1 : -1;
}
}
return 0;
}
function _getInsertDirection(target) {
if (index(dragEl) < index(target)) {
return 1;
} else {
return -1;
}
}
function _generateId(el) {
var str = el.tagName + el.className + el.src + el.href + el.textContent, i = str.length, sum = 0;
while (i--) {
sum += str.charCodeAt(i);
}
return sum.toString(36);
}
function _saveInputCheckedState(root) {
savedInputChecked.length = 0;
var inputs = root.getElementsByTagName("input");
var idx = inputs.length;
while (idx--) {
var el = inputs[idx];
el.checked && savedInputChecked.push(el);
}
}
function _nextTick(fn) {
return setTimeout(fn, 0);
}
function _cancelNextTick(id) {
return clearTimeout(id);
}
if (documentExists) {
on(document, "touchmove", function(evt) {
if ((Sortable.active || awaitingDragStarted) && evt.cancelable) {
evt.preventDefault();
}
});
}
Sortable.utils = {
on,
off,
css,
find,
is: function is(el, selector) {
return !!closest(el, selector, el, false);
},
extend,
throttle,
closest,
toggleClass,
clone,
index,
nextTick: _nextTick,
cancelNextTick: _cancelNextTick,
detectDirection: _detectDirection,
getChild,
expando
};
Sortable.get = function(element) {
return element[expando];
};
Sortable.mount = function() {
for (var _len = arguments.length, plugins2 = new Array(_len), _key = 0; _key < _len; _key++) {
plugins2[_key] = arguments[_key];
}
if (plugins2[0].constructor === Array) plugins2 = plugins2[0];
plugins2.forEach(function(plugin) {
if (!plugin.prototype || !plugin.prototype.constructor) {
throw "Sortable: Mounted plugin must be a constructor function, not ".concat({}.toString.call(plugin));
}
if (plugin.utils) Sortable.utils = _objectSpread2(_objectSpread2({}, Sortable.utils), plugin.utils);
PluginManager.mount(plugin);
});
};
Sortable.create = function(el, options) {
return new Sortable(el, options);
};
Sortable.version = version;
var autoScrolls = [];
var scrollEl;
var scrollRootEl;
var scrolling = false;
var lastAutoScrollX;
var lastAutoScrollY;
var touchEvt$1;
var pointerElemChangedInterval;
function AutoScrollPlugin() {
function AutoScroll() {
this.defaults = {
scroll: true,
forceAutoScrollFallback: false,
scrollSensitivity: 30,
scrollSpeed: 10,
bubbleScroll: true
};
for (var fn in this) {
if (fn.charAt(0) === "_" && typeof this[fn] === "function") {
this[fn] = this[fn].bind(this);
}
}
}
AutoScroll.prototype = {
dragStarted: function dragStarted(_ref) {
var originalEvent = _ref.originalEvent;
if (this.sortable.nativeDraggable) {
on(document, "dragover", this._handleAutoScroll);
} else {
if (this.options.supportPointer) {
on(document, "pointermove", this._handleFallbackAutoScroll);
} else if (originalEvent.touches) {
on(document, "touchmove", this._handleFallbackAutoScroll);
} else {
on(document, "mousemove", this._handleFallbackAutoScroll);
}
}
},
dragOverCompleted: function dragOverCompleted(_ref2) {
var originalEvent = _ref2.originalEvent;
if (!this.options.dragOverBubble && !originalEvent.rootEl) {
this._handleAutoScroll(originalEvent);
}
},
drop: function drop3() {
if (this.sortable.nativeDraggable) {
off(document, "dragover", this._handleAutoScroll);
} else {
off(document, "pointermove", this._handleFallbackAutoScroll);
off(document, "touchmove", this._handleFallbackAutoScroll);
off(document, "mousemove", this._handleFallbackAutoScroll);
}
clearPointerElemChangedInterval();
clearAutoScrolls();
cancelThrottle();
},
nulling: function nulling() {
touchEvt$1 = scrollRootEl = scrollEl = scrolling = pointerElemChangedInterval = lastAutoScrollX = lastAutoScrollY = null;
autoScrolls.length = 0;
},
_handleFallbackAutoScroll: function _handleFallbackAutoScroll(evt) {
this._handleAutoScroll(evt, true);
},
_handleAutoScroll: function _handleAutoScroll(evt, fallback) {
var _this = this;
var x = (evt.touches ? evt.touches[0] : evt).clientX, y = (evt.touches ? evt.touches[0] : evt).clientY, elem = document.elementFromPoint(x, y);
touchEvt$1 = evt;
if (fallback || this.options.forceAutoScrollFallback || Edge || IE11OrLess || Safari) {
autoScroll(evt, this.options, elem, fallback);
var ogElemScroller = getParentAutoScrollElement(elem, true);
if (scrolling && (!pointerElemChangedInterval || x !== lastAutoScrollX || y !== lastAutoScrollY)) {
pointerElemChangedInterval && clearPointerElemChangedInterval();
pointerElemChangedInterval = setInterval(function() {
var newElem = getParentAutoScrollElement(document.elementFromPoint(x, y), true);
if (newElem !== ogElemScroller) {
ogElemScroller = newElem;
clearAutoScrolls();
}
autoScroll(evt, _this.options, newElem, fallback);
}, 10);
lastAutoScrollX = x;
lastAutoScrollY = y;
}
} else {
if (!this.options.bubbleScroll || getParentAutoScrollElement(elem, true) === getWindowScrollingElement()) {
clearAutoScrolls();
return;
}
autoScroll(evt, this.options, getParentAutoScrollElement(elem, false), false);
}
}
};
return _extends(AutoScroll, {
pluginName: "scroll",
initializeByDefault: true
});
}
function clearAutoScrolls() {
autoScrolls.forEach(function(autoScroll2) {
clearInterval(autoScroll2.pid);
});
autoScrolls = [];
}
function clearPointerElemChangedInterval() {
clearInterval(pointerElemChangedInterval);
}
var autoScroll = throttle(function(evt, options, rootEl2, isFallback) {
if (!options.scroll) return;
var x = (evt.touches ? evt.touches[0] : evt).clientX, y = (evt.touches ? evt.touches[0] : evt).clientY, sens = options.scrollSensitivity, speed = options.scrollSpeed, winScroller = getWindowScrollingElement();
var scrollThisInstance = false, scrollCustomFn;
if (scrollRootEl !== rootEl2) {
scrollRootEl = rootEl2;
clearAutoScrolls();
scrollEl = options.scroll;
scrollCustomFn = options.scrollFn;
if (scrollEl === true) {
scrollEl = getParentAutoScrollElement(rootEl2, true);
}
}
var layersOut = 0;
var currentParent = scrollEl;
do {
var el = currentParent, rect = getRect(el), top = rect.top, bottom = rect.bottom, left = rect.left, right = rect.right, width = rect.width, height = rect.height, canScrollX = void 0, canScrollY = void 0, scrollWidth = el.scrollWidth, scrollHeight = el.scrollHeight, elCSS = css(el), scrollPosX = el.scrollLeft, scrollPosY = el.scrollTop;
if (el === winScroller) {
canScrollX = width < scrollWidth && (elCSS.overflowX === "auto" || elCSS.overflowX === "scroll" || elCSS.overflowX === "visible");
canScrollY = height < scrollHeight && (elCSS.overflowY === "auto" || elCSS.overflowY === "scroll" || elCSS.overflowY === "visible");
} else {
canScrollX = width < scrollWidth && (elCSS.overflowX === "auto" || elCSS.overflowX === "scroll");
canScrollY = height < scrollHeight && (elCSS.overflowY === "auto" || elCSS.overflowY === "scroll");
}
var vx = canScrollX && (Math.abs(right - x) <= sens && scrollPosX + width < scrollWidth) - (Math.abs(left - x) <= sens && !!scrollPosX);
var vy = canScrollY && (Math.abs(bottom - y) <= sens && scrollPosY + height < scrollHeight) - (Math.abs(top - y) <= sens && !!scrollPosY);
if (!autoScrolls[layersOut]) {
for (var i = 0; i <= layersOut; i++) {
if (!autoScrolls[i]) {
autoScrolls[i] = {};
}
}
}
if (autoScrolls[layersOut].vx != vx || autoScrolls[layersOut].vy != vy || autoScrolls[layersOut].el !== el) {
autoScrolls[layersOut].el = el;
autoScrolls[layersOut].vx = vx;
autoScrolls[layersOut].vy = vy;
clearInterval(autoScrolls[layersOut].pid);
if (vx != 0 || vy != 0) {
scrollThisInstance = true;
autoScrolls[layersOut].pid = setInterval(function() {
if (isFallback && this.layer === 0) {
Sortable.active._onTouchMove(touchEvt$1);
}
var scrollOffsetY = autoScrolls[this.layer].vy ? autoScrolls[this.layer].vy * speed : 0;
var scrollOffsetX = autoScrolls[this.layer].vx ? autoScrolls[this.layer].vx * speed : 0;
if (typeof scrollCustomFn === "function") {
if (scrollCustomFn.call(Sortable.dragged.parentNode[expando], scrollOffsetX, scrollOffsetY, evt, touchEvt$1, autoScrolls[this.layer].el) !== "continue") {
return;
}
}
scrollBy(autoScrolls[this.layer].el, scrollOffsetX, scrollOffsetY);
}.bind({
layer: layersOut
}), 24);
}
}
layersOut++;
} while (options.bubbleScroll && currentParent !== winScroller && (currentParent = getParentAutoScrollElement(currentParent, false)));
scrolling = scrollThisInstance;
}, 30);
var drop = function drop2(_ref) {
var originalEvent = _ref.originalEvent, putSortable2 = _ref.putSortable, dragEl2 = _ref.dragEl, activeSortable = _ref.activeSortable, dispatchSortableEvent = _ref.dispatchSortableEvent, hideGhostForTarget = _ref.hideGhostForTarget, unhideGhostForTarget = _ref.unhideGhostForTarget;
if (!originalEvent) return;
var toSortable = putSortable2 || activeSortable;
hideGhostForTarget();
var touch = originalEvent.changedTouches && originalEvent.changedTouches.length ? originalEvent.changedTouches[0] : originalEvent;
var target = document.elementFromPoint(touch.clientX, touch.clientY);
unhideGhostForTarget();
if (toSortable && !toSortable.el.contains(target)) {
dispatchSortableEvent("spill");
this.onSpill({
dragEl: dragEl2,
putSortable: putSortable2
});
}
};
function Revert() {
}
Revert.prototype = {
startIndex: null,
dragStart: function dragStart(_ref2) {
var oldDraggableIndex2 = _ref2.oldDraggableIndex;
this.startIndex = oldDraggableIndex2;
},
onSpill: function onSpill(_ref3) {
var dragEl2 = _ref3.dragEl, putSortable2 = _ref3.putSortable;
this.sortable.captureAnimationState();
if (putSortable2) {
putSortable2.captureAnimationState();
}
var nextSibling = getChild(this.sortable.el, this.startIndex, this.options);
if (nextSibling) {
this.sortable.el.insertBefore(dragEl2, nextSibling);
} else {
this.sortable.el.appendChild(dragEl2);
}
this.sortable.animateAll();
if (putSortable2) {
putSortable2.animateAll();
}
},
drop
};
_extends(Revert, {
pluginName: "revertOnSpill"
});
function Remove() {
}
Remove.prototype = {
onSpill: function onSpill2(_ref4) {
var dragEl2 = _ref4.dragEl, putSortable2 = _ref4.putSortable;
var parentSortable = putSortable2 || this.sortable;
parentSortable.captureAnimationState();
dragEl2.parentNode && dragEl2.parentNode.removeChild(dragEl2);
parentSortable.animateAll();
},
drop
};
_extends(Remove, {
pluginName: "removeOnSpill"
});
Sortable.mount(new AutoScrollPlugin());
Sortable.mount(Remove, Revert);
var sortable_esm_default = Sortable;
// 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 activeGroup2 = this.state.groups.find((g) => g.id === this.state.activeGroupId);
switch (action) {
case "edit-group":
if (activeGroup2) {
this.openEditGroupModal(activeGroup2.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 (activeGroup2) {
this.cloneGroupModal.open(activeGroup2);
} 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(activeGroup2);
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="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 activeGroup2 = this.state.groups.find((g) => g.id === this.state.activeGroupId);
if (activeGroup2) {
if (this.elements.dashboardTitle) {
this.elements.dashboardTitle.textContent = `${activeGroup2.display_name}`;
}
if (this.elements.mobileActiveGroupDisplay) {
this.elements.mobileActiveGroupDisplay.innerHTML = `
<h3 class="font-semibold text-sm">${activeGroup2.display_name}</h3>
<p class="card-sub-text">\u5F53\u524D\u9009\u62E9</p>`;
}
this.apiKeyList.setActiveGroup(activeGroup2.id, activeGroup2.display_name);
this.apiKeyList.loadApiKeys(activeGroup2.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() {
const container = this.elements.desktopGroupContainer;
if (!container) return;
new sortable_esm_default(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, index2) => ({
id: group.id,
order: index2
}));
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 init() {
console.log("[Modern Frontend] Keys page controller loaded.");
const page = new KeyGroupsPage();
page.init();
}
export {
init as default
};
/**!
* Sortable 1.15.6
* @author RubaXa <trash@rubaxa.org>
* @author owenm <owen23355@gmail.com>
* @license MIT
*/