/** * @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 在静态上是有效的 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); } /** * Populates the modal form with data received from the backend. * @param {object} data - The request configuration data. */ populateForm(data) { if (!data) return; // --- Simple Toggles & Inputs --- const isStreamOptimizerEnabled = !!data.stream_optimizer_enabled; this._setToggle(this.elements.streamOptimizerEnabled, isStreamOptimizerEnabled); this._toggleStreamingPanel(isStreamOptimizerEnabled); this._setToggle(this.elements.streamOptimizerEnabled, data.stream_optimizer_enabled); 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._populateKVItems(this.elements.safetySettingsContainer, data.safety_settings, this.addSafetySettingItem.bind(this)); // TODO: Handle Tag Inputs for model lists // this.imageModelsInput.setValues(data.image_models || []); // this.searchModelsInput.setValues(data.search_models || []); // ... and so on for other tag inputs } /** * Collects all data from the form fields and returns it as an object. * @returns {object} The collected request configuration data. */ collectFormData() { const data = { // 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(), }; return data; } // --- 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; } } class KeyGroupsPage { // [重構] 构造函数现在接收 modalManager 实例 constructor(modalManagerInstance) { // 1. 引入UI和状态管理对象 this.modalManager = modalManagerInstance; this.state = { groups: [], apiKeys: [], // 新增:存储当前分组的API Keys activeGroupId: null, isLoading: true, isApiKeysLoading: false, // 新增:API Keys加载状态 }; this.taskPollInterval = null; this.debouncedSaveOrder = debounce(this.saveGroupOrder.bind(this), 1500); this.elements = { // Modals keyGroupModal: document.getElementById('keygroup-modal'), modalTitle: document.getElementById('modal-title'), addApiModal: document.getElementById('add-api-modal'), deleteApiModal: document.getElementById('delete-api-modal'), requestSettingsModal: document.getElementById('request-settings-modal'), // Page Elements dashboardTitle: document.querySelector('#group-dashboard h2'), dashboardControls: document.querySelector('#group-dashboard .flex.items-center.gap-x-3'), apiListContainer: document.getElementById('api-list-container'), // Group List specific elements 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'), // Mobile specific elements groupMenuToggle: document.getElementById('group-menu-toggle'), mobileActiveGroupDisplay: document.querySelector('.mobile-group-selector > div'), requestSettings: { // 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.initialized = this.elements.keyGroupModal !== null && this.elements.desktopGroupContainer !== null; if (this.initialized) { this.allowedModelsInput = new TagInput(document.getElementById('allowed-models-container')); this.allowedUpstreamsInput = new TagInput(document.getElementById('allowed-upstreams-container')); this.allowedTokensInput = new TagInput(document.getElementById('allowed-tokens-container')); } this.activeTooltip = null; this.requestSettingsModal = null; // Defer initialization } // 3. 统一的初始化流程 async init() { if (!this.initialized) { console.error("KeyGroupsPage: Could not initialize. Essential elements are missing from the DOM."); return; } // Initialize modal here to ensure DOM is ready this.requestSettingsModal = new RequestSettingsModal( 'request-settings-modal', this.handleSaveRequestSettings.bind(this) ); this.initEventListeners(); await this.loadKeyGroups(); // 页面初始化时加载数据 } /** * [重構] 全面重写事件监听器,使用 modalManager 并废除 initModal。 */ initEventListeners() { // --- 模态框触发器初始化 --- this._initModalTrigger('keygroup-modal', '.add-group-btn, [data-modal-open="keygroup-modal"]', (event, modalId) => { // 从事件目标中提取数据来判断是“创建”还是“编辑” const groupData = null; // 示例: const groupData = event.currentTarget.dataset.groupData; this.openGroupModal(groupData); }); this._initModalTrigger('add-api-modal', '#add-api-btn', this._resetAddApiModal.bind(this)); this._initModalTrigger('delete-api-modal', '#delete-api-btn'); this._initModalTrigger('request-settings-modal', '[data-modal-open="request-settings-modal"]', this.openRequestSettingsModal.bind(this)); // 为所有由 modalManager 控制的模态框统一设置关闭逻辑 this._initGlobalModalClosers(['keygroup-modal', 'add-api-modal', 'delete-api-modal', 'request-settings-modal']); const importButton = document.getElementById('add-api-import-btn'); if (importButton) { importButton.addEventListener('click', this.handleAddApiKeysSubmit.bind(this)); } if (this.elements.groupMenuToggle) { this.elements.groupMenuToggle.addEventListener('click', () => { this.elements.groupListCollapsible.classList.toggle('hidden'); }); } // 这个元素是桌面和移动列表的共同父级,非常适合事件委托。 if (this.elements.groupListCollapsible) { this.elements.groupListCollapsible.addEventListener('click', (event) => { this.handleGroupCardClick(event); }); } // Event delegation for dashboard controls (clone, edit, delete) if(this.elements.dashboardControls) { this.elements.dashboardControls.addEventListener('click', (event) => { this.handleDashboardAction(event); }); } // Event delegation for API card actions if(this.elements.apiListContainer) { this.elements.apiListContainer.addEventListener('click', (event) => { this.handleApiKeyCardAction(event); }); } // [重構] RequestSettingsModal 的事件监听器移至此处 if (this.elements.requestSettings.saveBtn) { this.elements.requestSettings.saveBtn.addEventListener('click', this.handleSaveRequestSettings.bind(this)); } if (this.elements.requestSettingsModal) { // Event delegation for remove buttons within the modal this.elements.requestSettingsModal.addEventListener('click', (e) => { const removeBtn = e.target.closest('.remove-btn'); if (removeBtn) { const itemToRemove = removeBtn.parentElement; itemToRemove.remove(); } }); // Specific button listeners if (this.elements.requestSettings.addCustomHeaderBtn) { this.elements.requestSettings.addCustomHeaderBtn.addEventListener('click', () => this.addCustomHeaderItem()); } if (this.elements.requestSettings.addSafetySettingBtn) { this.elements.requestSettings.addSafetySettingBtn.addEventListener('click', () => this.addSafetySettingItem()); } if (this.elements.requestSettings.streamOptimizerEnabled) { this.elements.requestSettings.streamOptimizerEnabled.addEventListener('change', (e) => { this._toggleStreamingPanel(e.target.checked); }); } } this.initCustomSelects(); this.initTooltips(); this.initDragAndDrop(); this._initBatchActions(); // [优化] 这两个函数在数据加载并渲染后调用会更好,但暂时保留在这里 //this.updateAllHealthIndicators(); //this.updateAllApiKeyStatusIndicators(); } // 4. 数据获取与渲染逻辑 async loadKeyGroups() { this.state.isLoading = true; // --- [测试数据注入点] --- /*const MOCK_DATA = [ { id: 1, display_name: '默认分组 (Default)', name: 'default', description: '当前选中的分组', successRate: 95 }, { id: 2, display_name: '高优先级 (High-Priority)', name: 'high-priority', description: '用于处理紧急任务', successRate: 80 }, { id: 3, display_name: '批量处理 (Batch-Jobs)', name: 'batch-jobs', description: '用于后台和批量作业', successRate: 45 }, { id: 4, display_name: '实验性功能 (Experimental)', name: 'experimental', description: '测试新模型和功能', successRate: 25 }, { id: 5, display_name: '旧系统密钥 (Legacy)', name: 'legacy', description: '即将弃用的旧密钥', successRate: 5 }, { id: 6, display_name: '内部工具 (Internal-Tools)', name: 'internal', description: '仅供内部使用的工具', successRate: 100 }, { id: 7, display_name: '合作伙伴 A (Partner-A)', name: 'partner-a', description: '为特定合作伙伴提供的API', successRate: 98 }, { id: 8, display_name: '数据分析 (Data-Analysis)', name: 'data-analysis', description: '用于数据分析和报告', successRate: 35 }, { id: 9, display_name: '性能测试 (Perf-Testing)', name: 'perf-testing', description: '用于进行压力和性能测试', successRate: 15 }, { id: 10, display_name: '通用API (General-API)', name: 'general', description: '提供给普通用户的通用接口', successRate: 72 }, { id: 11, display_name: '文档生成 (Docs-Gen)', name: 'docs-gen', description: '用于自动化生成文档', successRate: 99 }, { id: 12, display_name: '文档生成 (Docs-Gen)', name: 'docs-gen', description: '用于自动化生成文档', successRate: 99 }, { id: 13, display_name: '文档生成 (Docs-Gen)', name: 'docs-gen', description: '用于自动化生成文档', successRate: 99 }, { id: 14, display_name: '文档生成 (Docs-Gen)', name: 'docs-gen', description: '用于自动化生成文档', successRate: 99 }, { id: 15, display_name: '文档生成 (Docs-Gen)', name: 'docs-gen', description: '用于自动化生成文档', successRate: 99 }, { id: 16, display_name: '已禁用 (Disabled)', name: 'disabled', description: '此分组已被禁用', successRate: 2 } ]; const responseData = { success: true, data: MOCK_DATA };*/ try { const responseData = await apiFetchJson('/admin/keygroups'); //const responseData = await response.json(); // [修正] 根据您提供的API响应结构进行精准解析 if (responseData && responseData.success && Array.isArray(responseData.data)) { // 如果响应成功且 'data' 字段是一个数组,则赋值 this.state.groups = responseData.data; } else { // 如果API返回的结构不符合预期,记录错误并设置为空数组 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(); // [重大逻辑修改] updateDashboard 会触发 loadApiKeysForGroup 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; } } /** * 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 ` ${group.display_name} ${group.description || 'No description available'} ${channelTag} ${customTags} `; }).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 ` ${group.display_name} (${group.name}) `; }).join(''); if (this.elements.mobileGroupContainer) { this.elements.mobileGroupContainer.innerHTML = mobileListHtml; } } // [修正] 5. 事件处理器和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.elements.groupListCollapsible.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 = ` ${activeGroup.display_name} 当前选择`; } // [重大逻辑修改] 更新 Dashboard 时,加载对应的 API Keys this.loadApiKeysForGroup(this.state.activeGroupId); } else { if (this.elements.dashboardTitle) this.elements.dashboardTitle.textContent = 'No Group Selected'; if (this.elements.mobileActiveGroupDisplay) this.elements.mobileActiveGroupDisplay.innerHTML = `请选择一个分组`; // 如果没有选中的分组,清空 API Key 列表 this.state.apiKeys = []; this.renderApiKeyList(); } } // ========================================================================= // API Key Management (NEW LOGIC) // ========================================================================= /** * Fetches and renders API keys for the specified group. * @param {number} groupId - The ID of the group to load keys for. */ async loadApiKeysForGroup(groupId) { if (!groupId) return; this.state.isApiKeysLoading = true; this.renderApiKeyList(); // Render loading state try { const response = await apiFetch(`/admin/keygroups/${groupId}/keys`,{ noCache: true }); const responseData = await response.json(); if (responseData && responseData.success && Array.isArray(responseData.data)) { this.state.apiKeys = responseData.data; } else { console.error("API response for keys is incorrect:", responseData); this.state.apiKeys = []; } } catch (error) { console.error(`Failed to load API keys for group ${groupId}:`, error); this.state.apiKeys = []; } finally { this.state.isApiKeysLoading = false; this.renderApiKeyList(); // Render final state (data or empty message) } } /** * Renders the list of API keys based on the current state. */ renderApiKeyList() { const container = this.elements.apiListContainer; if (!container) return; if (this.state.isApiKeysLoading) { container.innerHTML = 'Loading API Keys...'; return; } if (this.state.apiKeys.length === 0) { container.innerHTML = 'No API Keys in this group.'; return; } const listHtml = this.state.apiKeys.map(mapping => this.createApiKeyCardHtml(mapping)).join(''); container.innerHTML = ` ${listHtml} `; this.updateAllApiKeyStatusIndicators(); // Re-apply status colors after render } /** * [最终修正版] * Creates the HTML for a single API key card, adapting to the flat APIKeyDetails structure. * @param {object} item - The APIKeyDetails object from the API. * @returns {string} The HTML string for the card. */ createApiKeyCardHtml(item) { // [核心数据结构变更] 不再有嵌套的 apiKey 对象,item 本身就是所有数据的集合 if (!item || !item.api_key) return ''; // 安全检查 // --- 数据准备 --- // 直接从 item 对象的最顶层访问所有字段 const maskedKey = `${item.api_key.substring(0, 4)}......${item.api_key.substring(item.api_key.length - 4)}`; const status = item.status; const errorCount = item.consecutive_error_count; const keyId = item.id; // 这是 api_keys.id // 使用从后端补全的字段 const mappingId = `${item.api_key_id}-${item.key_group_id}`; // --- 行为挂钩 (保持不变) --- const setActiveAction = `data-action="set-status" data-new-status="ACTIVE"`; const revalidateAction = `data-action="revalidate"`; const disableAction = `data-action="set-status" data-new-status="DISABLED"`; const deleteAction = `data-action="delete-key"`; // --- 模板渲染 (保持不变) --- return ` ${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 = ` 导入完成 新创建并链接:${result.newly_created_count || 0} 已存在并链接:${result.already_existed_count || 0} 总计链接到分组:${result.linked_to_group_count || 0} `; 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 方法 }
${group.description || 'No description available'}
当前选择
${maskedKey}
失败: ${errorCount} 次
错误
${errorMessage}
${progressText}
${task.error}
轮询任务状态失败
${error.message}