/** * @fileoverview KeyGroups Page Initialization and Management. (Refactored to use ui.js) * * @description * This module handles all the client-side interactivity for the KeyGroups * management page. It leverages the central `modalManager` from ui.js for all modal * interactions, ensuring architectural consistency. */ import { modalManager,taskCenterManager } from '../components/ui.js'; import CustomSelect from '../components/customSelect.js'; import { debounce, isValidApiKeyFormat, initModal } from '../utils/utils.js'; import { apiFetch, apiFetchJson } from '../services/api.js'; import { apiKeyManager } from '../components/apiKeyManager.js'; class TagInput { constructor(container) { this.container = container; this.input = container.querySelector('.tag-input-new'); this.tags = []; this._initEventListeners(); } _initEventListeners() { this.container.addEventListener('click', (e) => { if (e.target.classList.contains('tag-delete')) { this._removeTag(e.target.parentElement); } }); this.input.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); const value = this.input.value.trim(); if (value) { this._addTag(value); this.input.value = ''; } } }); } _addTag(value) { if (this.tags.includes(value)) return; this.tags.push(value); const tagEl = document.createElement('span'); tagEl.className = 'tag-item'; tagEl.innerHTML = `${value}`; this.container.insertBefore(tagEl, this.input); } _removeTag(tagEl) { const value = tagEl.textContent.slice(0, -1); // Remove the '×' this.tags = this.tags.filter(t => t !== value); tagEl.remove(); } getValues() { return this.tags; } setValues(values) { // Clear existing tags this.container.querySelectorAll('.tag-item').forEach(el => el.remove()); this.tags = []; // Add new ones values.forEach(value => this._addTag(value)); } } class RequestSettingsModal { constructor(modalId, onSaveCallback) { this.modal = document.getElementById(modalId); if (!this.modal) { throw new Error(`Modal with id "${modalId}" not found.`); } this.onSave = onSaveCallback; // --- Form Element Mapping --- this.elements = { // Main buttons saveBtn: document.getElementById('request-settings-save-btn'), // Custom Headers customHeadersContainer: document.getElementById('CUSTOM_HEADERS_container'), addCustomHeaderBtn: document.getElementById('addCustomHeaderBtn'), // Streaming 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'), // Model Settings imageModelsContainer: document.getElementById('IMAGE_MODELS_container'), searchModelsContainer: document.getElementById('SEARCH_MODELS_container'), filteredModelsContainer: document.getElementById('FILTERED_MODELS_container'), toolsCodeExecutionEnabled: document.getElementById('TOOLS_CODE_EXECUTION_ENABLED'), urlContextEnabled: document.getElementById('URL_CONTEXT_ENABLED'), urlContextModelsContainer: document.getElementById('URL_CONTEXT_MODELS_container'), showSearchLink: document.getElementById('SHOW_SEARCH_LINK'), showThinkingProcess: document.getElementById('SHOW_THINKING_PROCESS'), thinkingModelsContainer: document.getElementById('THINKING_MODELS_container'), thinkingBudgetMapContainer: document.getElementById('THINKING_BUDGET_MAP_container'), safetySettingsContainer: document.getElementById('SAFETY_SETTINGS_container'), addSafetySettingBtn: document.getElementById('addSafetySettingBtn'), // Overrides configOverrides: document.getElementById('group-config-overrides'), }; this._initEventListeners(); } _initEventListeners() { // Event delegation for remove buttons within dynamic containers this.modal.addEventListener('click', (e) => { const removeBtn = e.target.closest('.remove-btn'); if (removeBtn) { const itemToRemove = removeBtn.parentElement; itemToRemove.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); }); } } // [新增] 控制流式面板显示/隐藏的辅助函数 _toggleStreamingPanel(is_enabled) { if (this.elements.streamingSettingsPanel) { if (is_enabled) { this.elements.streamingSettingsPanel.classList.remove('hidden'); } else { this.elements.streamingSettingsPanel.classList.add('hidden'); } } } 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); // TODO: Show error message to user in the modal } finally { if (this.elements.saveBtn) { this.elements.saveBtn.disabled = false; this.elements.saveBtn.textContent = 'Save Changes'; } } } } open() { this.modal.classList.remove('hidden'); } close() { this.modal.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 = ` `; 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 在静态

${maskedKey}

失败: ${errorCount} 次

`; } /** * [升级] 辅助函数:解析、清理并校验用户输入的Keys。 * @param {string} text - The raw text from the textarea. * @returns {Array} - An array of unique, valid-formatted API keys. */ _parseAndCleanKeys(text) { // 1. 替换常见分隔符为空格,然后按任意空白符分割 const keys = text.replace(/[,;]/g, ' ').split(/[\s\n]+/); const cleanedKeys = keys .map(key => key.trim()) // 2. 去除首尾空格 // [核心修正] 使用我们新的、更精准的格式校验函数进行过滤 .filter(key => isValidApiKeyFormat(key)); // 3. 使用 Set 去重 return [...new Set(cleanedKeys)]; } // [防禦性診斷版] handleAddApiKeysSubmit async handleAddApiKeysSubmit(event) { event.preventDefault(); console.log("handleAddApiKeysSubmit triggered."); // 診斷日誌1 if (!this.state.activeGroupId) { console.error("No active group ID. Aborting."); return; } if (this.taskPollInterval) { clearInterval(this.taskPollInterval); this.taskPollInterval = null; } // --- DOM 元素獲取與健壯性檢查 --- const textarea = document.getElementById('api-add-textarea'); const importButton = document.getElementById('add-api-import-btn'); const inputView = document.getElementById('add-api-input-view'); const resultView = document.getElementById('add-api-result-view'); const title = document.getElementById('add-api-modal-title'); const validateCheckbox = document.getElementById('validate-on-import-checkbox'); // 關鍵的防禦性檢查 if (!textarea || !importButton || !inputView || !resultView || !title || !validateCheckbox) { console.error("One or more modal elements are missing from the DOM!"); // 打印出哪個元素是null,幫助定位問題 console.table({ textarea, importButton, inputView, resultView, title, validateCheckbox }); alert("模態框內部組件不完整,無法執行導入。請檢查瀏覽器控制台。"); return; } // --- 檢查通過,繼續執行 --- console.log("All modal elements found. Proceeding with logic."); // 診斷日誌2 const shouldValidate = validateCheckbox.checked; const cleanedKeys = this._parseAndCleanKeys(textarea.value); if (cleanedKeys.length === 0) { alert('沒有檢測到有效的API Keys。'); return; } importButton.disabled = true; importButton.innerHTML = `正在启动...`; textarea.disabled = true; try { console.log("Attempting to call apiKeyManager.addKeysToGroup..."); // 診斷日誌3 const response = await apiKeyManager.addKeysToGroup(this.state.activeGroupId, cleanedKeys.join('\n'), shouldValidate); console.log("API call response received:", response); // 診斷日誌4 if (response && response.success && response.data && response.data.id) { const task = response.data; title.textContent = '正在批量添加...'; inputView.classList.add('hidden'); resultView.classList.remove('hidden'); this._pollTaskStatus(task.id, resultView); } else { // 如果API調用成功但業務失敗,也拋出錯誤 throw new Error(response.message || '啟動導入任務失敗,後端未返回任務ID。'); } } catch (error) { console.error("Error catched in handleAddApiKeysSubmit:", error); // 診斷日誌5 if (this.taskPollInterval) { clearInterval(this.taskPollInterval); this.taskPollInterval = null; } // 這裡的錯誤信息處理保持不變 const errorMessage = error.data && error.data.Message ? error.data.Message : (error.message || '启动任务失败,请检查网络或联系管理员。'); title.textContent = '操作失败'; inputView.classList.add('hidden'); resultView.classList.remove('hidden'); resultView.innerHTML = `

错误

${errorMessage}

`; importButton.style.display = 'none'; } } /* // [新增] 核心逻辑:处理“导入”按钮点击事件 async handleAddApiKeysSubmit(event) { event.preventDefault(); if (!this.state.activeGroupId) { return; } // [核心修正] 在所有操作開始之前,立即斬斷任何來自過去的定時器。 // 這是解決競態條件、防止“幽靈回調”覆蓋UI的關鍵。 if (this.taskPollInterval) { clearInterval(this.taskPollInterval); this.taskPollInterval = null; } const textarea = document.getElementById('api-add-textarea'); const importButton = document.getElementById('add-api-import-btn'); const inputView = document.getElementById('add-api-input-view'); const resultView = document.getElementById('add-api-result-view'); const title = document.getElementById('add-api-modal-title'); const validateCheckbox = document.getElementById('validate-on-import-checkbox'); const shouldValidate = validateCheckbox.checked; const cleanedKeys = this._parseAndCleanKeys(textarea.value); if (cleanedKeys.length === 0) { alert('沒有檢測到有效的API Keys。'); return; } importButton.disabled = true; importButton.innerHTML = `正在启动...`; textarea.disabled = true; try { const response = await apiKeyManager.addKeysToGroup(this.state.activeGroupId, cleanedKeys.join('\n'), shouldValidate); if (response && response.success && response.data && response.data.id) { const task = response.data; title.textContent = '正在批量添加...'; inputView.classList.add('hidden'); resultView.classList.remove('hidden'); this._pollTaskStatus(task.id, resultView); } else { throw new Error(response.message || '启动导入任务失败。'); } } catch (error) { // catch 塊中的 clearInterval 仍然保留,作為雙重保險。 if (this.taskPollInterval) { clearInterval(this.taskPollInterval); this.taskPollInterval = null; } const errorMessage = error.data && error.data.Message ? error.data.Message : '启动任务失败,请检查网络或联系管理员。'; if(title) title.textContent = '操作失败'; if (inputView) inputView.classList.add('hidden'); if (resultView) { resultView.classList.remove('hidden'); resultView.innerHTML = `

错误

${errorMessage}

`; } if (importButton) importButton.style.display = 'none'; } } */ // [新增] 辅助函数:重置“添加API”模态框到初始状态 // 在重置模態框時,強力清除任何可能存在的輪詢定時器 _resetAddApiModal() { // [核心修正] 這是解決“幽靈定時器”問題的關鍵 if (this.taskPollInterval) { clearInterval(this.taskPollInterval); this.taskPollInterval = null; // 徹底清除引用 } const title = document.getElementById('add-api-modal-title'); const inputView = document.getElementById('add-api-input-view'); const resultView = document.getElementById('add-api-result-view'); const textarea = document.getElementById('api-add-textarea'); const importBtn = document.getElementById('add-api-import-btn'); if (title) title.textContent = '批量添加 API Keys'; if (inputView) inputView.classList.remove('hidden'); if (resultView) { resultView.classList.add('hidden'); resultView.innerHTML = ''; } if (textarea) { textarea.value = ''; textarea.disabled = false; } // 恢復導入按鈕的狀態和可見性 if (importBtn) { importBtn.disabled = false; importBtn.textContent = '导入'; importBtn.style.display = ''; // 移除 'display: none' } } // [数据UI双修版] _pollTaskStatus(taskId, resultContainer) { if (this.taskPollInterval) { clearInterval(this.taskPollInterval); } this.taskPollInterval = setInterval(async () => { try { // apiKeyManager.getTaskStatus(taskId) 返回的是完整的API响应体 { success: true, data: {...} } const response = await apiKeyManager.getTaskStatus(taskId); // [核心修正] 我们需要的是 response.data 里的任务对象,而不是整个 response! const task = response.data; if (!task) { // 如果 task 不存在,说明 API 响应格式不對,立即停止以防错误 console.error("Task data is missing in the API response.", response); clearInterval(this.taskPollInterval); this.taskPollInterval = null; // 这里可以显示一个更通用的错误信息 resultContainer.innerHTML = `
无法解析任务数据,请检查API响应。
`; return; } const progressText = `正在处理: ${task.processed} / ${task.total}`; if (task.is_running) { resultContainer.innerHTML = `

${progressText}

`; } else { clearInterval(this.taskPollInterval); this.taskPollInterval = null; const importButton = document.getElementById('add-api-import-btn'); if (importButton) { importButton.style.display = 'none'; } let resultHtml = ``; if (task.error) { resultHtml = `

导入失败

${task.error}

`; } else { const result = task.result || {}; resultHtml = `

导入完成

`; this.loadApiKeysForGroup(this.state.activeGroupId); } resultContainer.innerHTML = resultHtml; } } catch (error) { clearInterval(this.taskPollInterval); this.taskPollInterval = null; // UI修正:在轮询失败时也应该隐藏“导入”按钮,只留关闭 const importButton = document.getElementById('add-api-import-btn'); if (importButton) { importButton.style.display = 'none'; } if(resultContainer) { resultContainer.innerHTML = `

轮询任务状态失败

${error.message}

`; } } }, 2000); } // --- [新增] 统一的模态框初始化和关闭辅助函数 --- /** * Helper to setup modal open triggers. * @param {string} modalId - The ID of the modal to open. * @param {string} selector - The CSS selector for the trigger elements. * @param {function} [onOpen=()=>{}] - Callback to execute before showing the modal. */ _initModalTrigger(modalId, selector, onOpen = () => {}) { document.querySelectorAll(selector).forEach(trigger => { trigger.addEventListener('click', (event) => { onOpen(event, modalId); // this.modalManager.show(modalId); }); }); } /** * Helper to setup close triggers for a list of modals. * @param {string[]} modalIds - An array of modal IDs. */ _initGlobalModalClosers(modalIds) { modalIds.forEach(modalId => { const modal = document.getElementById(modalId); if (!modal) return; // Close buttons with data-modal-close attribute modal.querySelectorAll(`[data-modal-close="${modalId}"]`).forEach(trigger => { trigger.addEventListener('click', () => { // 特殊处理 'add-api-modal' 的关闭,确保重置 if (modalId === 'add-api-modal') { this._resetAddApiModal(); } this.modalManager.hide(modalId); }); }); // Clicking on the overlay (the modal element itself) modal.addEventListener('click', (event) => { if (event.target === modal) { if (modalId === 'add-api-modal') { this._resetAddApiModal(); } this.modalManager.hide(modalId); } }); }); } /** * Initializes all custom select components on the page. */ initCustomSelects() { const customSelects = document.querySelectorAll('.custom-select'); customSelects.forEach(select => new CustomSelect(select)); } /** * Initializes all batch action dropdowns on the page. * This version uses class selectors to support multiple dropdown instances * for responsive layouts (e.g., one for mobile, one for desktop). */ _initBatchActions() { // 关键改动:使用 querySelectorAll 和 class 来获取所有批量操作的容器 const dropdownContainers = document.querySelectorAll('.batch-action-dropdown'); if (dropdownContainers.length === 0) return; // 为页面上找到的每一个批量操作组件都绑定事件 dropdownContainers.forEach(container => { const btn = container.querySelector('.batch-action-btn'); const panel = container.querySelector('.batch-action-panel'); if (!btn || !panel) return; btn.addEventListener('click', (event) => { event.stopPropagation(); // 阻止事件冒泡到 document // 优化:在打开当前面板前,关闭所有其他可能已打开的面板 document.querySelectorAll('.batch-action-panel').forEach(p => { if (p !== panel) { p.classList.add('hidden'); } }); // 切换当前点击按钮对应的面板 panel.classList.toggle('hidden'); }); }); // 全局点击监听器:现在它会关闭所有打开的面板,无论哪个被打开 document.addEventListener('click', () => { document.querySelectorAll('.batch-action-panel').forEach(panel => { panel.classList.add('hidden'); }); }); } // [新增] 负责与后端API通信的新方法 /** * Sends the new group UI order to the backend API. * @param {Array} 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() { if (typeof Sortable === 'undefined') { console.error('SortableJS is not loaded.'); return; } const container = this.elements.desktopGroupContainer; if (!container) return; new Sortable(container, { animation: 150, ghostClass: 'sortable-ghost', dragClass: 'sortable-drag', filter: '#add-group-btn-container', onEnd: (evt) => { // [核心修正] 使用属性选择器,这是最稳健的方式 const groupCards = Array.from(container.querySelectorAll('[data-group-id]')); const orderedState = groupCards.map(card => { // [核心修正] 读取正确的 dataset 属性 (data-group-id -> dataset.groupId) 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); }, }); } /** * Opens the modal for creating or editing a key group. * @param {object|null} groupData - If provided, fills the modal with data for editing. */ openGroupModal(groupData = null) { if (groupData && typeof groupData.name !== 'undefined') { // Check if it's actual data, not an event this.elements.modalTitle.textContent = '编辑 Key Group'; // TODO: Populate form fields with groupData } else { this.elements.modalTitle.textContent = '创建新的 Key Group'; // TODO: Clear form fields } } /** * Handles actions from the dashboard controls (clone, edit, delete). * @param {Event} event - The click event. */ handleDashboardAction(event) { const button = event.target.closest('button'); if (!button) return; if (button.innerHTML.includes('fa-clone')) { console.log('Clone action triggered for group:', this.elements.dashboardTitle.textContent); } else if (button.innerHTML.includes('fa-cog')) { console.log('Edit action triggered for group:', this.elements.dashboardTitle.textContent); this.openGroupModal({ name: this.elements.dashboardTitle.textContent, mock: true }); } else if (button.innerHTML.includes('fa-sliders-h')) { this.openRequestSettingsModal(); } else if (button.innerHTML.includes('fa-trash')) { console.log('Delete action triggered for group:', this.elements.dashboardTitle.textContent); } } /** * Handles all actions within an API key card using a data-action dispatch pattern. * @param {Event} event - The click event. */ async handleApiKeyCardAction(event) { const button = event.target.closest('button[data-action]'); if (!button) return; const card = button.closest('.api-card'); if (!card) return; const keyId = parseInt(card.dataset.keyId, 10); const groupId = this.state.activeGroupId; const action = button.dataset.action; // 从 state 中查找完整的 API Key 信息,用于复制和显示 const mapping = this.state.apiKeys.find(m => m.APIKey && m.APIKey.id === keyId); const fullApiKey = mapping ? mapping.APIKey.api_key : null; switch (action) { case 'toggle-visibility': { const keyElement = card.querySelector('.font-mono'); const icon = button.querySelector('i'); if (!keyElement || !fullApiKey) break; if (icon.classList.contains('fa-eye')) { // Currently showing masked keyElement.textContent = fullApiKey; icon.classList.replace('fa-eye', 'fa-eye-slash'); button.title = "隐藏完整Key"; } else { // Currently showing full key const maskedKey = `${fullApiKey.substring(0, 4)}...${fullApiKey.substring(fullApiKey.length - 4)}`; keyElement.textContent = maskedKey; icon.classList.replace('fa-eye-slash', 'fa-eye'); button.title = "查看完整Key"; } break; } case 'copy-key': { if (!fullApiKey) { console.warn("Could not find full API key to copy."); // TODO: Show user a notification break; } navigator.clipboard.writeText(fullApiKey).then(() => { // TODO: Show a success toast/tooltip, e.g., "Copied!" console.log(`API Key ${keyId} copied to clipboard.`); }).catch(err => { console.error('Failed to copy API key: ', err); }); break; } case 'set-status': { const newStatus = button.dataset.newStatus; if (!newStatus) break; console.log(`Setting status of key ${keyId} in group ${groupId} to ${newStatus}`); try { const response = await apiFetch(`/admin/groups/${groupId}/keys/${keyId}`, { method: 'PUT', body: JSON.stringify({ status: newStatus }) }); const result = await response.json(); if (result.success) { // 成功后,重新加载列表以显示最新状态 this.loadApiKeysForGroup(groupId); } else { throw new Error(result.message); } } catch (error) { console.error(`Failed to set status for key ${keyId}:`, error); // TODO: Show error notification } break; } case 'delete-key': { // 推荐在实际操作前增加一个确认对话框 if (!confirm(`确定要从这个分组中移除 API Key (ID: ${keyId}) 吗?`)) { return; } console.log(`Deleting key ${keyId} from group ${groupId}`); try { const response = await apiFetch(`/admin/keygroups/${groupId}/apikeys/bulk`, { method: 'DELETE', body: JSON.stringify({ api_key_ids: [keyId] }) }); const result = await response.json(); if (result.success) { this.loadApiKeysForGroup(groupId); } else { throw new Error(result.message); } } catch (error) { console.error(`Failed to delete key ${keyId}:`, error); // TODO: Show error notification } break; } case 'revalidate': { // [后端待办] 此功能需要后端提供一个专门的API端点 console.log(`[TODO] Revalidating key ${keyId} in group ${groupId}. Needs backend endpoint.`); alert("重新验证功能正在开发中。"); /* 理想的调用方式: try { const response = await apiFetch(`/admin/groups/${groupId}/keys/${keyId}/revalidate`, { method: 'POST' }); // ... 处理异步任务启动的逻辑 ... } catch (error) { console.error(`Failed to start revalidation for key ${keyId}:`, error); } */ break; } } } /** * 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 `${type}`; } /** * 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 `${tag}`; }).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)); } _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', }; // Remove any existing color classes Object.values(statusColors).forEach(colorClass => { indicator.classList.remove(colorClass); }); // Add the new color class if (statusColors[status]) { indicator.classList.add(statusColors[status]); } } updateAllApiKeyStatusIndicators() { const allCards = this.elements.apiListContainer.querySelectorAll('.api-card[data-status]'); allCards.forEach(card => this._updateApiKeyStatusIndicator(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; } } // [集成] 打开高级设置模态框并加载数据 // ========================================================================= // [完成] RequestSettingsModal 的方法被完整地迁移到这里 // ========================================================================= async openRequestSettingsModal() { if (!this.state.activeGroupId) { console.warn("No active group selected to open request settings."); return; } this._populateRequestSettingsForm({}); // Currently populating with empty data this.modalManager.show('request-settings-modal'); // 后续将取消此处的注释以从后端加载数据 /* try { const response = await apiFetch(`/admin/keygroups/${this.state.activeGroupId}/request-config`); const result = await response.json(); if (result.success) { this._populateRequestSettingsForm(result.data); } else { console.warn(`Failed to fetch request settings: ${result.message}. Creating new config.`); this._populateRequestSettingsForm({}); } this.modalManager.show('request-settings-modal'); } catch (error) { console.error("Error fetching request settings:", error); this._populateRequestSettingsForm({}); this.modalManager.show('request-settings-modal'); } */ } async handleSaveRequestSettings() { if (!this.state.activeGroupId) { console.error("No active group selected to save settings for."); return; } const data = this._collectRequestSettingsFormData(); const saveBtn = this.elements.requestSettings.saveBtn; try { if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = 'Saving...'; } console.log(`[SIMULATED] Saving request settings for group ${this.state.activeGroupId}:`, data); // 后续将取消此处的注释以向后端保存数据 /* const response = await apiFetch(`/admin/keygroups/${this.state.activeGroupId}/request-config`, { method: 'PUT', body: JSON.stringify(data) }); const result = await response.json(); if (!result.success) throw new Error(result.message); */ this.modalManager.hide('request-settings-modal'); } catch (error) { console.error("Failed to save request settings:", error); // 可以在模态框内显示错误信息给用户 alert(`保存失败: ${error.message}`); } finally { if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save Changes'; } } } // --- 所有来自旧 RequestSettingsModal 的辅助方法 --- _populateRequestSettingsForm(data = {}) { const rsElements = this.elements.requestSettings; // Simple Toggles & Inputs const isStreamOptimizerEnabled = !!data.stream_optimizer_enabled; this._setToggle(rsElements.streamOptimizerEnabled, isStreamOptimizerEnabled); this._toggleStreamingPanel(isStreamOptimizerEnabled); this._setValue(rsElements.streamMinDelay, data.stream_min_delay); this._setValue(rsElements.streamMaxDelay, data.stream_max_delay); this._setValue(rsElements.streamShortTextThresh, data.stream_short_text_threshold); this._setValue(rsElements.streamLongTextThresh, data.stream_long_text_threshold); this._setValue(rsElements.streamChunkSize, data.stream_chunk_size); this._setToggle(rsElements.fakeStreamEnabled, data.fake_stream_enabled); this._setValue(rsElements.fakeStreamInterval, data.fake_stream_empty_data_interval_seconds); this._setToggle(rsElements.toolsCodeExecutionEnabled, data.tools_code_execution_enabled); this._setToggle(rsElements.urlContextEnabled, data.url_context_enabled); this._setToggle(rsElements.showSearchLink, data.show_search_link); this._setToggle(rsElements.showThinkingProcess, data.show_thinking_process); this._setValue(rsElements.configOverrides, data.config_overrides); // Dynamic Fields this._populateKVItems(rsElements.customHeadersContainer, data.custom_headers, this.addCustomHeaderItem.bind(this)); // 由于 safety_settings 的 `add` 方法有硬编码列表,最好在调用 populate 之前确保容器是空的 this._clearContainer(rsElements.safetySettingsContainer); if (data.safety_settings && typeof data.safety_settings === 'object') { for (const [key, value] of Object.entries(data.safety_settings)) { this.addSafetySettingItem(key, value); } } } _collectRequestSettingsFormData() { const rsElements = this.elements.requestSettings; return { stream_optimizer_enabled: rsElements.streamOptimizerEnabled.checked, stream_min_delay: parseInt(rsElements.streamMinDelay.value, 10), stream_max_delay: parseInt(rsElements.streamMaxDelay.value, 10), stream_short_text_threshold: parseInt(rsElements.streamShortTextThresh.value, 10), stream_long_text_threshold: parseInt(rsElements.streamLongTextThresh.value, 10), stream_chunk_size: parseInt(rsElements.streamChunkSize.value, 10), fake_stream_enabled: rsElements.fakeStreamEnabled.checked, fake_stream_empty_data_interval_seconds: parseInt(rsElements.fakeStreamInterval.value, 10), tools_code_execution_enabled: rsElements.toolsCodeExecutionEnabled.checked, url_context_enabled: rsElements.urlContextEnabled.checked, show_search_link: rsElements.showSearchLink.checked, show_thinking_process: rsElements.showThinkingProcess.checked, config_overrides: rsElements.configOverrides.value, custom_headers: this._collectKVItems(rsElements.customHeadersContainer, 'text', 'text'), safety_settings: this._collectSafetySettings(rsElements.safetySettingsContainer), // TODO: Collect from Tag Inputs // image_models: this.imageModelsInput.getValues(), }; } _toggleStreamingPanel(is_enabled) { const panel = this.elements.requestSettings.streamingSettingsPanel; if (panel) { if (is_enabled) { panel.classList.remove('hidden'); } else { panel.classList.add('hidden'); } } } addCustomHeaderItem(key = '', value = '') { const container = this.elements.requestSettings.customHeadersContainer; if (!container) return; const item = document.createElement('div'); item.className = 'dynamic-kv-item'; item.innerHTML = ` `; container.appendChild(item); } addSafetySettingItem(category = '', threshold = '') { const container = this.elements.requestSettings.safetySettingsContainer; if (!container) return; 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_CIVIC_INTEGRITY"]; const harmThresholds = ["BLOCK_OFF", "BLOCK_NONE", "BLOCK_LOW_AND_ABOVE", "BLOCK_MEDIUM_AND_ABOVE", "BLOCK_ONLY_HIGH"]; const categorySelect = document.createElement('select'); categorySelect.className = 'modal-input flex-grow'; harmCategories.forEach(cat => { const option = new Option(cat.replace('HARM_CATEGORY_', ''), cat); if (cat === category) option.selected = true; categorySelect.add(option); }); const thresholdSelect = document.createElement('select'); thresholdSelect.className = 'modal-input w-48'; harmThresholds.forEach(thr => { const option = new Option(thr.replace('BLOCK_', '').replace('_AND_ABOVE', '+'), thr); if (thr === threshold) option.selected = true; thresholdSelect.add(option); }); const removeButton = document.createElement('button'); removeButton.type = 'button'; removeButton.className = 'remove-btn text-zinc-400 hover:text-red-500 transition-colors'; removeButton.innerHTML = ``; item.appendChild(categorySelect); item.appendChild(thresholdSelect); item.appendChild(removeButton); container.appendChild(item); } _setValue(element, value) { if (element && value !== null && value !== undefined) { element.value = value; } } _setToggle(element, value) { if (element) { element.checked = !!value; } } _clearContainer(container) { if (container) { while (container.firstChild) { container.removeChild(container.firstChild); } } } _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, keyName, valueName) { const items = {}; if (!container) return items; container.querySelectorAll('.dynamic-kv-item').forEach(item => { const keyEl = item.querySelector(`[name="${keyName}"]`); const valueEl = item.querySelector(`[name="${valueName}"]`); if (keyEl && valueEl && keyEl.value) { items[keyEl.value] = valueEl.value; } }); return items; } _collectSafetySettings(container) { const items = {}; if (!container) return items; container.querySelectorAll('.safety-setting-item').forEach(item => { const categorySelect = item.querySelector('select:first-child'); const thresholdSelect = item.querySelector('select:nth-child(2)'); if (categorySelect && thresholdSelect && categorySelect.value) { items[categorySelect.value] = thresholdSelect.value; } }); return items; } } /** * Entry point for the KeyGroups page script. */ export default function init() { console.log('[Modern Frontend] keygroups.js module loaded.'); const page = new KeyGroupsPage(modalManager); page.init(); //调用统一的 init 方法 }