Initial commit
This commit is contained in:
34
frontend/js/pages/dashboard.js
Normal file
34
frontend/js/pages/dashboard.js
Normal file
@@ -0,0 +1,34 @@
|
||||
// frontend/js/pages/dashboard.js
|
||||
/**
|
||||
* @fileoverview Dashboard Page Initialization Module (Placeholder)
|
||||
*
|
||||
* @description
|
||||
* This file is the designated entry point for all modern, modular JavaScript
|
||||
* specific to the dashboard page.
|
||||
*
|
||||
* CURRENT STATUS:
|
||||
* As of [25.08.23], the dashboard's primary logic is still handled by legacy
|
||||
* scripts loaded via <script> tags in `dashboard.html` (e.g., `static/js/dashboard.js`).
|
||||
*
|
||||
* MIGRATION STRATEGY:
|
||||
* 1. Identify a piece of functionality in the legacy scripts (e.g., auto-refresh timer).
|
||||
* 2. Re-implement that functionality within the `init()` function below, following
|
||||
* modern ES module standards.
|
||||
* 3. Remove the corresponding code from the legacy script file.
|
||||
* 4. Repeat until the legacy scripts are empty and can be removed entirely.
|
||||
*
|
||||
* @version 0.1.0
|
||||
* @author [xof/团队名]
|
||||
*/
|
||||
export default function init() {
|
||||
// This console log serves as a confirmation that the modern module is being
|
||||
// correctly dispatched by main.js. It's safe to leave here during migration.
|
||||
console.log('[Modern Frontend] Dashboard module loaded. Future logic will execute here.');
|
||||
// === MIGRATION AREA ===
|
||||
// When you migrate a feature, add its initialization code here.
|
||||
// For example:
|
||||
//
|
||||
// import { initializeAutoRefresh } from '../features/autoRefresh.js';
|
||||
// initializeAutoRefresh();
|
||||
//
|
||||
}
|
||||
1182
frontend/js/pages/error_logs.js
Normal file
1182
frontend/js/pages/error_logs.js
Normal file
File diff suppressed because it is too large
Load Diff
1823
frontend/js/pages/keys.js
Normal file
1823
frontend/js/pages/keys.js
Normal file
File diff suppressed because it is too large
Load Diff
167
frontend/js/pages/keys/addApiModal.js
Normal file
167
frontend/js/pages/keys/addApiModal.js
Normal file
@@ -0,0 +1,167 @@
|
||||
// frontend/js/pages/keys/addApiModal.js
|
||||
|
||||
// [REFACTORED] 引入全局的 taskCenterManager 和 modalManager
|
||||
import { modalManager } from '../../components/ui.js';
|
||||
import { taskCenterManager, toastManager } from '../../components/taskCenter.js';
|
||||
import { apiKeyManager } from '../../components/apiKeyManager.js';
|
||||
import { isValidApiKeyFormat } from '../../utils/utils.js';
|
||||
|
||||
export default class AddApiModal {
|
||||
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('没有检测到有效的API Keys。');
|
||||
return;
|
||||
}
|
||||
|
||||
this.elements.importBtn.disabled = true;
|
||||
this.elements.importBtn.innerHTML = `<i class="fas fa-spinner fa-spin mr-2"></i>正在启动...`;
|
||||
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 || '启动导入任务失败。');
|
||||
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) { // --- SUCCESS state ---
|
||||
const result = data.result || {};
|
||||
const newlyLinked = result.newly_linked_count || 0;
|
||||
const alreadyLinked = result.already_linked_count || 0;
|
||||
const summaryTitle = `批量链接 ${newlyLinked} Key,已跳过 ${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>有效输入:</span> <span class="font-semibold">${data.total}</span></p>
|
||||
<p class="flex justify-between"><span>分组中已存在 (跳过):</span> <span class="font-semibold">${alreadyLinked}</span></p>
|
||||
<p class="flex justify-between font-bold"><span>新增链接:</span> <span>${newlyLinked}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (!data.is_running && data.error) { // --- ERROR state ---
|
||||
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">批量添加失败</p>
|
||||
<p class="task-item-status text-red-500 truncate" title="${data.error || '未知错误'}">
|
||||
${data.error || '未知错误'}
|
||||
</p>
|
||||
</div>
|
||||
</div>`;
|
||||
} else { // --- RUNNING state ---
|
||||
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">批量添加 ${data.total} 个API Key</p>
|
||||
<p class="task-item-status">运行中...</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
return `${contentHtml}<div class="task-item-timestamp">${timeAgo}</div>`;
|
||||
},
|
||||
renderToastNarrative: (data, oldData, toastManager) => {
|
||||
const toastId = `task-${data.id}`;
|
||||
const progress = data.total > 0 ? (data.processed / data.total) * 100 : 0;
|
||||
// It just reports the current progress, that's its only job.
|
||||
toastManager.showProgressToast(toastId, `批量添加Key`, '处理中', progress); // (Change title for delete modal)
|
||||
},
|
||||
|
||||
// This now ONLY shows the FINAL summary toast, after everything else is done.
|
||||
onSuccess: (data) => {
|
||||
if (this.onImportSuccess) this.onImportSuccess(); // (Or onDeleteSuccess)
|
||||
const newlyLinked = data.result?.newly_linked_count || 0; // (Or unlinked_count)
|
||||
toastManager.show(`任务完成!成功链接 ${newlyLinked} 个Key。`, 'success'); // (Adjust text for delete)
|
||||
},
|
||||
|
||||
// This is the final error handler.
|
||||
onError: (data) => {
|
||||
toastManager.show(`任务失败: ${data.error || '未知错误'}`, 'error');
|
||||
}
|
||||
};
|
||||
// Pass the entire definition to the dispatcher
|
||||
taskCenterManager.startTask(addKeysTask);
|
||||
|
||||
modalManager.hide(this.modalId);
|
||||
this._reset();
|
||||
}
|
||||
|
||||
_reset() {
|
||||
// [REMOVED] 不再需要管理 resultView
|
||||
this.elements.title.textContent = '批量添加 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 = '导入'; // 使用 innerHTML 避免潜在的 XSS
|
||||
}
|
||||
|
||||
_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)];
|
||||
}
|
||||
}
|
||||
1434
frontend/js/pages/keys/apiKeyList.js
Normal file
1434
frontend/js/pages/keys/apiKeyList.js
Normal file
File diff suppressed because it is too large
Load Diff
90
frontend/js/pages/keys/cloneGroupModal.js
Normal file
90
frontend/js/pages/keys/cloneGroupModal.js
Normal file
@@ -0,0 +1,90 @@
|
||||
// Filename: frontend/js/pages/keys/cloneGroupModal.js
|
||||
|
||||
import { modalManager } from '../../components/ui.js';
|
||||
import { apiFetch } from '../../services/api.js';
|
||||
import { toastManager } from '../../components/taskCenter.js';
|
||||
|
||||
export default class CloneGroupModal {
|
||||
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 = `确认克隆分组 <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>克隆中...`;
|
||||
|
||||
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(`分组 '${this.activeGroup.display_name}' 已成功克隆。`, 'success');
|
||||
if (this.onCloneSuccess) {
|
||||
// Pass the entire new group object back to the main controller.
|
||||
this.onCloneSuccess(result.data);
|
||||
}
|
||||
modalManager.hide(this.modalId);
|
||||
} else {
|
||||
throw new Error(result.error?.message || result.message || '克隆失败,请稍后再试。');
|
||||
}
|
||||
} catch (error) {
|
||||
toastManager.show(`克隆失败: ${error.message}`, 'error');
|
||||
} finally {
|
||||
this._reset();
|
||||
}
|
||||
}
|
||||
|
||||
_reset() {
|
||||
if (this.elements.confirmBtn) {
|
||||
this.elements.confirmBtn.disabled = false;
|
||||
this.elements.confirmBtn.innerHTML = '确认克隆';
|
||||
}
|
||||
}
|
||||
}
|
||||
165
frontend/js/pages/keys/deleteApiModal.js
Normal file
165
frontend/js/pages/keys/deleteApiModal.js
Normal file
@@ -0,0 +1,165 @@
|
||||
// frontend/js/pages/keys/deleteApiModal.js
|
||||
|
||||
import { modalManager } from '../../components/ui.js';
|
||||
import { taskCenterManager, toastManager } from '../../components/taskCenter.js';
|
||||
import { apiKeyManager } from '../../components/apiKeyManager.js';
|
||||
import { isValidApiKeyFormat } from '../../utils/utils.js';
|
||||
|
||||
export default class DeleteApiModal {
|
||||
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('没有检测到有效的API Keys。');
|
||||
return;
|
||||
}
|
||||
this.elements.deleteBtn.disabled = true;
|
||||
this.elements.deleteBtn.innerHTML = `<i class="fas fa-spinner fa-spin mr-2"></i>正在启动...`;
|
||||
const deleteKeysTask = {
|
||||
start: async () => {
|
||||
const response = await apiKeyManager.unlinkKeysFromGroup(this.activeGroupId, cleanedKeys.join('\n'));
|
||||
if (!response.success || !response.data) throw new Error(response.message || '启动解绑任务失败。');
|
||||
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) { // --- SUCCESS state ---
|
||||
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 = `解绑 ${unlinked} Key,清理 ${deleted}`;
|
||||
|
||||
// [MODIFIED] Applied Flexbox layout for proper spacing.
|
||||
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>有效输入:</span> <span class="font-semibold">${totalInput}</span></p>
|
||||
<p class="flex justify-between"><span>未在分组中找到:</span> <span class="font-semibold">${notFound}</span></p>
|
||||
<p class="flex justify-between"><span>从分组中解绑:</span> <span class="font-semibold">${unlinked}</span></p>
|
||||
<p class="flex justify-between font-bold"><span>彻底清理孤立Key:</span> <span>${deleted}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (!data.is_running && data.error) { // --- ERROR state ---
|
||||
// [MODIFIED] Applied Flexbox layout for proper spacing.
|
||||
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">批量删除失败</p>
|
||||
<p class="task-item-status text-red-500 truncate" title="${data.error || '未知错误'}">
|
||||
${data.error || '未知错误'}
|
||||
</p>
|
||||
</div>
|
||||
</div>`;
|
||||
} else { // --- RUNNING state ---
|
||||
// [MODIFIED] Applied Flexbox layout with gap for spacing.
|
||||
// [FIX] Replaced 'fa-spin' with Tailwind's 'animate-spin' for reliable animation.
|
||||
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">批量删除 ${data.total} 个API Key</p>
|
||||
<p class="task-item-status">运行中...</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, toastManager) => {
|
||||
const toastId = `task-${data.id}`;
|
||||
const progress = data.total > 0 ? (data.processed / data.total) * 100 : 0;
|
||||
// It just reports the current progress, that's its only job.
|
||||
toastManager.showProgressToast(toastId, `批量删除Key`, '处理中', progress); // (Change title for delete modal)
|
||||
},
|
||||
|
||||
// This now ONLY shows the FINAL summary toast, after everything else is done.
|
||||
onSuccess: (data) => {
|
||||
if (this.onDeleteSuccess) this.onDeleteSuccess(); // (Or onDeleteSuccess)
|
||||
const newlyLinked = data.result?.newly_linked_count || 0; // (Or unlinked_count)
|
||||
toastManager.show(`任务完成!成功删除 ${newlyLinked} 个Key。`, 'success'); // (Adjust text for delete)
|
||||
},
|
||||
|
||||
// This is the final error handler.
|
||||
onError: (data) => {
|
||||
toastManager.show(`任务失败: ${data.error || '未知错误'}`, 'error');
|
||||
}
|
||||
};
|
||||
taskCenterManager.startTask(deleteKeysTask);
|
||||
|
||||
modalManager.hide(this.modalId);
|
||||
this._reset();
|
||||
}
|
||||
|
||||
_reset() {
|
||||
this.elements.textarea.value = '';
|
||||
this.elements.deleteBtn.disabled = false;
|
||||
this.elements.deleteBtn.innerHTML = '删除';
|
||||
}
|
||||
|
||||
_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)];
|
||||
}
|
||||
}
|
||||
|
||||
88
frontend/js/pages/keys/deleteGroupModal.js
Normal file
88
frontend/js/pages/keys/deleteGroupModal.js
Normal file
@@ -0,0 +1,88 @@
|
||||
// Filename: frontend/js/pages/keys/deleteGroupModal.js
|
||||
|
||||
import { modalManager } from '../../components/ui.js';
|
||||
import { apiFetch } from '../../services/api.js';
|
||||
import { toastManager } from '../../components/taskCenter.js';
|
||||
|
||||
export default class DeleteGroupModal {
|
||||
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 = `确认删除分组 <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() === '删除';
|
||||
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>删除中...`;
|
||||
try {
|
||||
// [FIX] Use apiFetch directly to call the backend endpoint.
|
||||
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(); // Parse the JSON response
|
||||
if (result.success) {
|
||||
toastManager.show(`分组 '${this.activeGroup.display_name}' 已成功删除。`, 'success');
|
||||
if (this.onDeleteSuccess) {
|
||||
this.onDeleteSuccess(this.activeGroup.id);
|
||||
}
|
||||
modalManager.hide(this.modalId);
|
||||
} else {
|
||||
// Use the error message from the backend response
|
||||
throw new Error(result.error?.message || result.message || '删除失败,请稍后再试。');
|
||||
}
|
||||
} catch (error) {
|
||||
toastManager.show(`删除失败: ${error.message}`, 'error');
|
||||
} finally {
|
||||
// We do a full reset in finally to ensure the button state is always correct.
|
||||
this._reset();
|
||||
}
|
||||
}
|
||||
|
||||
_reset() {
|
||||
if (this.elements.confirmInput) this.elements.confirmInput.value = '';
|
||||
if (this.elements.confirmBtn) {
|
||||
this.elements.confirmBtn.disabled = true;
|
||||
this.elements.confirmBtn.innerHTML = '确认删除';
|
||||
}
|
||||
}
|
||||
}
|
||||
657
frontend/js/pages/keys/index.js
Normal file
657
frontend/js/pages/keys/index.js
Normal file
@@ -0,0 +1,657 @@
|
||||
// frontend/js/pages/keys/index.js
|
||||
|
||||
// --- 導入全局和頁面專屬模塊 ---
|
||||
import { modalManager } from '../../components/ui.js';
|
||||
import TagInput from '../../components/tagInput.js';
|
||||
import CustomSelect from '../../components/customSelect.js';
|
||||
import RequestSettingsModal from './requestSettingsModal.js';
|
||||
import AddApiModal from './addApiModal.js';
|
||||
import DeleteApiModal from './deleteApiModal.js';
|
||||
import KeyGroupModal from './keyGroupModal.js';
|
||||
import CloneGroupModal from './cloneGroupModal.js';
|
||||
import DeleteGroupModal from './deleteGroupModal.js';
|
||||
import { debounce } from '../../utils/utils.js';
|
||||
import { apiFetch, apiFetchJson } from '../../services/api.js';
|
||||
import { apiKeyManager } from '../../components/apiKeyManager.js';
|
||||
import { toastManager } from '../../components/taskCenter.js';
|
||||
import ApiKeyList from './apiKeyList.js';
|
||||
import Sortable from '../../vendor/sortable.esm.js';
|
||||
|
||||
class KeyGroupsPage {
|
||||
constructor() {
|
||||
this.state = {
|
||||
groups: [],
|
||||
groupDetailsCache: {},
|
||||
activeGroupId: null,
|
||||
isLoading: true,
|
||||
};
|
||||
|
||||
this.debouncedSaveOrder = debounce(this.saveGroupOrder.bind(this), 1500);
|
||||
|
||||
// elements對象現在只關心頁面級元素
|
||||
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(this.elements.apiListContainer);
|
||||
}
|
||||
// 實例化頁面專屬的子組件
|
||||
const allowedModelsInput = new TagInput(document.getElementById('allowed-models-container'), {
|
||||
validator: /^[a-z0-9\.-]+$/,
|
||||
validationMessage: '无效的模型格式'
|
||||
});
|
||||
// 验证上游地址:一个基础的 URL 格式验证
|
||||
const allowedUpstreamsInput = new TagInput(document.getElementById('allowed-upstreams-container'), {
|
||||
validator: /^(https?:\/\/)?[\w\.-]+\.[a-z]{2,}(\/[\w\.-]*)*\/?$/i,
|
||||
validationMessage: '无效的 URL 格式'
|
||||
});
|
||||
// 令牌验证:确保不为空即可
|
||||
const allowedTokensInput = new TagInput(document.getElementById('allowed-tokens-container'), {
|
||||
validator: /.+/,
|
||||
validationMessage: '令牌不能为空'
|
||||
});
|
||||
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),
|
||||
});
|
||||
// CloneGroupModal
|
||||
this.cloneGroupModal = new CloneGroupModal({
|
||||
onCloneSuccess: (clonedGroup) => {
|
||||
if (clonedGroup && clonedGroup.id) {
|
||||
this.state.activeGroupId = clonedGroup.id;
|
||||
}
|
||||
this.loadKeyGroups(true);
|
||||
}
|
||||
});
|
||||
// DeleteApiModal
|
||||
this.deleteApiModal = new DeleteApiModal({
|
||||
onDeleteSuccess: () => this.apiKeyList.loadApiKeys(this.state.activeGroupId, true),
|
||||
});
|
||||
|
||||
this.requestSettingsModal = new RequestSettingsModal({
|
||||
onSave: this.handleSaveRequestSettings.bind(this)
|
||||
});
|
||||
this.activeTooltip = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.initialized) {
|
||||
console.error("KeyGroupsPage: Could not initialize. Essential container elements like 'desktopGroupContainer' or 'apiListContainer' are missing from the DOM.");
|
||||
return;
|
||||
}
|
||||
this.initEventListeners();
|
||||
if (this.apiKeyList) {
|
||||
this.apiKeyList.init();
|
||||
}
|
||||
await this.loadKeyGroups();
|
||||
}
|
||||
|
||||
initEventListeners() {
|
||||
// --- 模态框全局触发器 ---
|
||||
document.body.addEventListener('click', (event) => {
|
||||
const addGroupBtn = event.target.closest('.add-group-btn');
|
||||
const addApiBtn = event.target.closest('#add-api-btn');
|
||||
const deleteApiBtn = event.target.closest('#delete-api-btn');
|
||||
if (addGroupBtn) this.keyGroupModal.open();
|
||||
if (addApiBtn) this.addApiModal.open(this.state.activeGroupId);
|
||||
if (deleteApiBtn) this.deleteApiModal.open(this.state.activeGroupId);
|
||||
});
|
||||
|
||||
// --- 使用事件委託來統一處理儀表板上的所有操作 ---
|
||||
this.elements.dashboardControls?.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('button[data-action]');
|
||||
if (!button) return;
|
||||
const action = button.dataset.action;
|
||||
const activeGroup = this.state.groups.find(g => g.id === this.state.activeGroupId);
|
||||
switch(action) {
|
||||
case 'edit-group':
|
||||
if (activeGroup) {
|
||||
this.openEditGroupModal(activeGroup.id);
|
||||
} else {
|
||||
alert("请先选择一个分组进行编辑。");
|
||||
}
|
||||
break;
|
||||
case 'open-settings':
|
||||
this.openRequestSettingsModal();
|
||||
break;
|
||||
case 'clone-group':
|
||||
if (activeGroup) {
|
||||
this.cloneGroupModal.open(activeGroup);
|
||||
} else {
|
||||
alert("请先选择一个分组进行克隆。");
|
||||
}
|
||||
break;
|
||||
case 'delete-group':
|
||||
console.log('Delete action triggered for group:', this.state.activeGroupId);
|
||||
this.deleteGroupModal.open(activeGroup);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// --- 核心交互区域的事件委托 ---
|
||||
// 在共同父级上监听群组卡片点击
|
||||
this.elements.groupListCollapsible?.addEventListener('click', (event) => {
|
||||
this.handleGroupCardClick(event);
|
||||
});
|
||||
|
||||
// 移动端菜单切换
|
||||
this.elements.groupMenuToggle?.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const menu = this.elements.groupListCollapsible;
|
||||
if (!menu) return;
|
||||
menu.classList.toggle('hidden');
|
||||
setTimeout(() => {
|
||||
menu.classList.toggle('mobile-group-menu-active');
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Add a global listener to close the menu if clicking outside
|
||||
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 {
|
||||
// If no groups exist, ensure the API key list is also cleared.
|
||||
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; // Placeholder
|
||||
const healthClasses = this._getHealthIndicatorClasses(successRate);
|
||||
|
||||
// [核心修正] 同时生成两种类型的标签
|
||||
const channelTag = this._getChannelTypeTag(group.channel_type || 'Local');
|
||||
const customTags = this._getCustomTags(group.custom_tags); // 假设 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">
|
||||
<!-- [最终布局] 1. 名称 -> 2. 描述 -> 3. 标签 -->
|
||||
<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(); // updateDashboard 现在会处理 API key 的加载
|
||||
}
|
||||
|
||||
|
||||
if (window.innerWidth < 1024) {
|
||||
this._closeMobileMenu();
|
||||
}
|
||||
}
|
||||
|
||||
// [NEW HELPER METHOD] Centralizes the logic for closing the mobile menu.
|
||||
_closeMobileMenu() {
|
||||
const menu = this.elements.groupListCollapsible;
|
||||
if (!menu) return;
|
||||
|
||||
menu.classList.remove('mobile-group-menu-active');
|
||||
menu.classList.add('hidden');
|
||||
}
|
||||
|
||||
updateDashboard() {
|
||||
const activeGroup = this.state.groups.find(g => g.id === this.state.activeGroupId);
|
||||
|
||||
if (activeGroup) {
|
||||
if (this.elements.dashboardTitle) {
|
||||
this.elements.dashboardTitle.textContent = `${activeGroup.display_name}`;
|
||||
}
|
||||
if (this.elements.mobileActiveGroupDisplay) {
|
||||
this.elements.mobileActiveGroupDisplay.innerHTML = `
|
||||
<h3 class="font-semibold text-sm">${activeGroup.display_name}</h3>
|
||||
<p class="card-sub-text">当前选择</p>`;
|
||||
}
|
||||
// 更新 Dashboard 时,加载对应的 API Keys
|
||||
this.apiKeyList.setActiveGroup(activeGroup.id, activeGroup.display_name);
|
||||
this.apiKeyList.loadApiKeys(activeGroup.id);
|
||||
} else {
|
||||
if (this.elements.dashboardTitle) this.elements.dashboardTitle.textContent = 'No Group Selected';
|
||||
if (this.elements.mobileActiveGroupDisplay) this.elements.mobileActiveGroupDisplay.innerHTML = `<h3 class="font-semibold text-sm">请选择一个分组</h3>`;
|
||||
// 如果没有选中的分组,清空 API Key 列表
|
||||
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: 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(`分组 "${groupData.display_name}" 已成功保存。`, 'success');
|
||||
|
||||
await this.loadKeyGroups(true);
|
||||
} catch (error) {
|
||||
console.error(`Failed to save group:`, error.message);
|
||||
toastManager.show(`保存失败: ${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) {
|
||||
// Step 1: Check the details cache first.
|
||||
if (this.state.groupDetailsCache[groupId]) {
|
||||
console.log(`[CACHE HIT] Using cached details for group ${groupId}.`);
|
||||
// If details exist, open the modal immediately with the cached data.
|
||||
this.keyGroupModal.open(this.state.groupDetailsCache[groupId]);
|
||||
return;
|
||||
}
|
||||
// Step 2: If not in cache, fetch from the API.
|
||||
console.log(`[CACHE MISS] Fetching details for group ${groupId}.`);
|
||||
try {
|
||||
// NOTE: No complex UI spinners on the button itself. The user just waits a moment.
|
||||
const endpoint = `/admin/keygroups/${groupId}`;
|
||||
const responseData = await apiFetchJson(endpoint, { noCache: true });
|
||||
if (responseData && responseData.success) {
|
||||
const groupDetails = responseData.data;
|
||||
// Step 3: Store the newly fetched details in the cache.
|
||||
this.state.groupDetailsCache[groupId] = groupDetails;
|
||||
|
||||
// Step 4: Open the modal with the fetched data.
|
||||
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(`无法加载分组详情: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async openRequestSettingsModal() {
|
||||
if (!this.state.activeGroupId) {
|
||||
modalManager.showResult(false, "请先选择一个分组。");
|
||||
return;
|
||||
}
|
||||
// [重構] 簡化後的邏輯:獲取數據,然後調用子模塊的 open 方法
|
||||
console.log(`Opening request settings for group ID: ${this.state.activeGroupId}`);
|
||||
const data = {}; // 模擬從API獲取數據
|
||||
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);
|
||||
// 此處執行API調用
|
||||
// await apiFetch(...)
|
||||
// 成功後可以觸發一個全局通知或刷新列表
|
||||
// this.loadKeyGroups();
|
||||
return Promise.resolve(); // 模擬API調用成功
|
||||
}
|
||||
|
||||
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 {
|
||||
// 调用您已验证成功的API端点
|
||||
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.');
|
||||
// (可选) 在这里可以显示一个短暂的 "保存成功" 的提示消息 (Toast/Snackbar)
|
||||
} catch (error) {
|
||||
console.error('Failed to save new group UI order:', error);
|
||||
// [重要] 如果API调用失败,应该重新加载一次分组列表,
|
||||
// 以便UI回滚到数据库中存储的、未经修改的正确顺序。
|
||||
this.loadKeyGroups();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes drag-and-drop functionality for the group list.
|
||||
*/
|
||||
initDragAndDrop() {
|
||||
const container = this.elements.desktopGroupContainer;
|
||||
if (!container) return;
|
||||
|
||||
new Sortable(container, {
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
dragClass: 'sortable-drag',
|
||||
filter: '#add-group-btn-container',
|
||||
onEnd: (evt) => {
|
||||
const groupCards = Array.from(container.querySelectorAll('[data-group-id]'));
|
||||
const orderedState = groupCards.map(card => {
|
||||
const cardId = parseInt(card.dataset.groupId, 10);
|
||||
return this.state.groups.find(group => group.id === cardId);
|
||||
}).filter(Boolean);
|
||||
|
||||
if (orderedState.length !== this.state.groups.length) {
|
||||
console.error("Drag-and-drop failed: Could not map all DOM elements to state. Aborting.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新正确的状态数组
|
||||
this.state.groups = orderedState;
|
||||
|
||||
const payload = this.state.groups.map((group, index) => ({
|
||||
id: group.id,
|
||||
order: index
|
||||
}));
|
||||
|
||||
this.debouncedSaveOrder(payload);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to generate a styled HTML tag for the channel type.
|
||||
* @param {string} type - The channel type string (e.g., 'OpenAI', 'Azure').
|
||||
* @returns {string} - The generated HTML span element.
|
||||
*/
|
||||
_getChannelTypeTag(type) {
|
||||
if (!type) return ''; // 如果没有类型,则返回空字符串
|
||||
const styles = {
|
||||
'OpenAI': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
|
||||
'Azure': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
|
||||
'Claude': 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300',
|
||||
'Gemini': 'bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-300',
|
||||
'Local': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||
};
|
||||
const baseClass = 'inline-block text-xs font-medium px-2 py-0.5 rounded-md';
|
||||
const tagClass = styles[type] || styles['Local']; // 如果类型未知,则使用默认样式
|
||||
return `<span class="${baseClass} ${tagClass}">${type}</span>`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generates styled HTML for custom tags with deterministically assigned colors.
|
||||
* @param {string[]} tags - An array of custom tag strings.
|
||||
* @returns {string} - The generated HTML for all custom tags.
|
||||
*/
|
||||
_getCustomTags(tags) {
|
||||
if (!tags || !Array.isArray(tags) || tags.length === 0) {
|
||||
return '';
|
||||
}
|
||||
// 预设的彩色背景调色板 (Tailwind classes)
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function init() {
|
||||
console.log('[Modern Frontend] Keys page controller loaded.');
|
||||
const page = new KeyGroupsPage();
|
||||
page.init();
|
||||
}
|
||||
221
frontend/js/pages/keys/keyGroupModal.js
Normal file
221
frontend/js/pages/keys/keyGroupModal.js
Normal file
@@ -0,0 +1,221 @@
|
||||
// frontend/js/pages/keys/keyGroupModal.js
|
||||
|
||||
import { modalManager } from '../../components/ui.js';
|
||||
|
||||
const MAX_GROUP_NAME_LENGTH = 32;
|
||||
|
||||
export default class KeyGroupModal {
|
||||
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: 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;
|
||||
// 1. Convert to lowercase.
|
||||
value = value.toLowerCase();
|
||||
// 2. Remove all illegal characters.
|
||||
value = value.replace(/[^a-z0-9-]/g, '');
|
||||
// 3. Enforce the length limit by truncating.
|
||||
if (value.length > MAX_GROUP_NAME_LENGTH) {
|
||||
value = value.substring(0, MAX_GROUP_NAME_LENGTH);
|
||||
}
|
||||
if (input.value !== value) {
|
||||
input.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
async _handleSave() {
|
||||
// [MODIFICATION] The save button's disabled state is now reset in a finally block for robustness.
|
||||
this._sanitizeGroupName({ target: this.elements.nameInput });
|
||||
const data = this._collectFormData();
|
||||
if (!data.name || !data.display_name) {
|
||||
alert('分组名称和显示名称是必填项。');
|
||||
return;
|
||||
}
|
||||
// 最终提交前的正则验证
|
||||
const groupNameRegex = /^[a-z0-9-]+$/;
|
||||
if (!groupNameRegex.test(data.name) || data.name.length > MAX_GROUP_NAME_LENGTH) {
|
||||
alert('分组名称格式无效。仅限使用小写字母、数字和连字符(-),且长度不超过32个字符。');
|
||||
return;
|
||||
}
|
||||
if (this.onSave) {
|
||||
this.elements.saveBtn.disabled = true;
|
||||
this.elements.saveBtn.textContent = '保存中...';
|
||||
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 = '保存';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
_populateForm(data) {
|
||||
if (data) { // 编辑模式
|
||||
this.editingGroupId = data.id;
|
||||
this.elements.title.textContent = '编辑 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 = '创建新的 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([]);
|
||||
}
|
||||
}
|
||||
306
frontend/js/pages/keys/requestSettingsModal.js
Normal file
306
frontend/js/pages/keys/requestSettingsModal.js
Normal file
@@ -0,0 +1,306 @@
|
||||
// frontend/js/pages/keys/requestSettingsModal.js
|
||||
import { modalManager } from '../../components/ui.js';
|
||||
|
||||
export default class RequestSettingsModal {
|
||||
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.`);
|
||||
}
|
||||
// 映射所有內部DOM元素
|
||||
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 = () => {
|
||||
// 此處無需 _reset(),因為每次 open 都會重新 populate
|
||||
modalManager.hide(this.modalId);
|
||||
};
|
||||
// 綁定所有帶有 data-modal-close 屬性的按鈕
|
||||
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(`保存失敗: ${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);
|
||||
// --- Dynamic & Complex Fields ---
|
||||
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'; // .modal-input 在静态<select>上是有效的
|
||||
harmCategories.forEach(cat => {
|
||||
const option = new Option(cat.replace('HARM_CATEGORY_', ''), cat);
|
||||
if (cat === category) option.selected = true;
|
||||
categorySelect.add(option);
|
||||
});
|
||||
const thresholdSelect = document.createElement('select');
|
||||
thresholdSelect.className = 'modal-input w-48';
|
||||
harmThresholds.forEach(thr => {
|
||||
const option = new Option(thr.replace('BLOCK_', '').replace('_AND_ABOVE', '+'), thr);
|
||||
if (thr === threshold) option.selected = true;
|
||||
thresholdSelect.add(option);
|
||||
});
|
||||
|
||||
const removeButton = document.createElement('button');
|
||||
removeButton.type = 'button';
|
||||
removeButton.className = 'remove-btn text-zinc-400 hover:text-red-500 transition-colors';
|
||||
removeButton.innerHTML = `<i class="fas fa-trash-alt"></i>`;
|
||||
item.appendChild(categorySelect);
|
||||
item.appendChild(thresholdSelect);
|
||||
item.appendChild(removeButton);
|
||||
container.appendChild(item);
|
||||
}
|
||||
|
||||
// --- Private Helper Methods for Form Handling ---
|
||||
|
||||
_setValue(element, value) {
|
||||
if (element && value !== null && value !== undefined) {
|
||||
element.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
_setToggle(element, value) {
|
||||
if (element) {
|
||||
element.checked = !!value;
|
||||
}
|
||||
}
|
||||
|
||||
_clearContainer(container) {
|
||||
if (container) {
|
||||
// Keep the first child if it's a template or header
|
||||
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;
|
||||
}
|
||||
}
|
||||
2190
frontend/js/pages/keys_status.js
Normal file
2190
frontend/js/pages/keys_status.js
Normal file
File diff suppressed because it is too large
Load Diff
77
frontend/js/pages/logs/index.js
Normal file
77
frontend/js/pages/logs/index.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// Filename: frontend/js/pages/logs/index.js
|
||||
|
||||
import { apiFetchJson } from '../../services/api.js';
|
||||
import LogList from './logList.js';
|
||||
|
||||
class LogsPage {
|
||||
constructor() {
|
||||
this.state = {
|
||||
logs: [],
|
||||
// [修正] 暂时将分页状态设为默认值,直到后端添加分页支持
|
||||
pagination: { page: 1, pages: 1, total: 0 },
|
||||
isLoading: true,
|
||||
filters: { page: 1, page_size: 20 }
|
||||
};
|
||||
|
||||
this.elements = {
|
||||
tableBody: document.getElementById('logs-table-body'),
|
||||
};
|
||||
|
||||
this.initialized = !!this.elements.tableBody;
|
||||
|
||||
if (this.initialized) {
|
||||
this.logList = new LogList(this.elements.tableBody);
|
||||
}
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.initialized) {
|
||||
console.error("LogsPage: Could not initialize. Essential container element 'logs-table-body' is missing.");
|
||||
return;
|
||||
}
|
||||
this.initEventListeners();
|
||||
await this.loadAndRenderLogs();
|
||||
}
|
||||
|
||||
initEventListeners() {
|
||||
// 分页和筛选的事件监听器将在后续任务中添加
|
||||
}
|
||||
|
||||
async loadAndRenderLogs() {
|
||||
this.state.isLoading = true;
|
||||
this.logList.renderLoading();
|
||||
|
||||
try {
|
||||
const url = `/admin/logs?page=${this.state.filters.page}&page_size=${this.state.filters.page_size}`;
|
||||
const responseData = await apiFetchJson(url);
|
||||
|
||||
// [核心修正] 调整条件以匹配当前 API 返回的 { success: true, data: [...] } 结构
|
||||
if (responseData && responseData.success && Array.isArray(responseData.data)) {
|
||||
|
||||
// [核心修正] 直接从 responseData.data 获取日志数组
|
||||
this.state.logs = responseData.data;
|
||||
|
||||
// [临时] 由于当前响应不包含分页信息,我们暂时不更新 this.state.pagination
|
||||
// 等待后端完善分页后,再恢复这里的逻辑
|
||||
|
||||
this.logList.render(this.state.logs);
|
||||
|
||||
// this.renderPaginationControls();
|
||||
} else {
|
||||
console.error("API response for logs is incorrect:", responseData);
|
||||
this.logList.render([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load logs:", error);
|
||||
// this.logList.renderError(error);
|
||||
} finally {
|
||||
this.state.isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出符合 main.js 规范的 default 函数
|
||||
export default function() {
|
||||
const page = new LogsPage();
|
||||
page.init();
|
||||
}
|
||||
60
frontend/js/pages/logs/logList.js
Normal file
60
frontend/js/pages/logs/logList.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// Filename: frontend/js/pages/logs/logList.js
|
||||
|
||||
class LogList {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
if (!this.container) {
|
||||
console.error("LogList: container element (tbody) not found.");
|
||||
}
|
||||
}
|
||||
|
||||
renderLoading() {
|
||||
if (!this.container) return;
|
||||
this.container.innerHTML = `<tr><td colspan="9" class="p-8 text-center text-muted-foreground"><i class="fas fa-spinner fa-spin mr-2"></i> 加载日志中...</td></tr>`;
|
||||
}
|
||||
|
||||
render(logs) {
|
||||
if (!this.container) return;
|
||||
|
||||
if (!logs || logs.length === 0) {
|
||||
this.container.innerHTML = `<tr><td colspan="9" class="p-8 text-center text-muted-foreground">没有找到相关的日志记录。</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const logsHtml = logs.map(log => this.createLogRowHtml(log)).join('');
|
||||
this.container.innerHTML = logsHtml;
|
||||
}
|
||||
|
||||
createLogRowHtml(log) {
|
||||
// [后端协作点] 假设后端未来会提供 GroupDisplayName 和 APIKeyName
|
||||
const groupName = log.GroupDisplayName || (log.GroupID ? `Group #${log.GroupID}` : 'N/A');
|
||||
const apiKeyName = log.APIKeyName || (log.KeyID ? `Key #${log.KeyID}` : 'N/A');
|
||||
|
||||
const errorTag = log.IsSuccess
|
||||
? `<span class="inline-flex items-center rounded-md bg-green-500/10 px-2 py-1 text-xs font-medium text-green-600">成功</span>`
|
||||
: `<span class="inline-flex items-center rounded-md bg-destructive/10 px-2 py-1 text-xs font-medium text-destructive">${log.ErrorCode || '失败'}</span>`;
|
||||
|
||||
// 使用 toLocaleString 格式化时间,更符合用户本地习惯
|
||||
const requestTime = new Date(log.RequestTime).toLocaleString();
|
||||
|
||||
return `
|
||||
<tr class="border-b border-b-border transition-colors hover:bg-muted/80" data-log-id="${log.ID}">
|
||||
<td class="p-4 align-middle"><input type="checkbox" class="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500"></td>
|
||||
<td class="p-4 align-middle font-mono text-muted-foreground">#${log.ID}</td>
|
||||
<td class="p-4 align-middle font-medium font-mono">${apiKeyName}</td>
|
||||
<td class="p-4 align-middle">${groupName}</td>
|
||||
<td class="p-4 align-middle text-foreground">${log.ErrorMessage || (log.IsSuccess ? '' : '未知错误')}</td>
|
||||
<td class="p-4 align-middle">${errorTag}</td>
|
||||
<td class="p-4 align-middle font-mono">${log.ModelName}</td>
|
||||
<td class="p-4 align-middle text-muted-foreground text-xs">${requestTime}</td>
|
||||
<td class="p-4 align-middle">
|
||||
<button class="btn btn-ghost btn-icon btn-sm" aria-label="查看详情">
|
||||
<i class="fas fa-ellipsis-h h-4 w-4"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export default LogList;
|
||||
Reference in New Issue
Block a user