// frontend/js/pages/keys/index.js // --- 導入全局和頁面專屬模塊 --- import { modalManager } from '../../components/ui.js'; import TagInput from '../../components/tagInput.js'; import CustomSelect from '../../components/customSelect.js'; import RequestSettingsModal from './requestSettingsModal.js'; import AddApiModal from './addApiModal.js'; import DeleteApiModal from './deleteApiModal.js'; import KeyGroupModal from './keyGroupModal.js'; import CloneGroupModal from './cloneGroupModal.js'; import DeleteGroupModal from './deleteGroupModal.js'; import { debounce } from '../../utils/utils.js'; import { apiFetch, apiFetchJson } from '../../services/api.js'; import { apiKeyManager } from '../../components/apiKeyManager.js'; import { toastManager } from '../../components/taskCenter.js'; import ApiKeyList from './apiKeyList.js'; import Sortable from '../../vendor/sortable.esm.js'; class KeyGroupsPage { constructor() { this.state = { groups: [], groupDetailsCache: {}, activeGroupId: null, isLoading: true, }; this.debouncedSaveOrder = debounce(this.saveGroupOrder.bind(this), 1500); // elements對象現在只關心頁面級元素 this.elements = { dashboardTitle: document.querySelector('#group-dashboard h2'), dashboardControls: document.querySelector('#group-dashboard .flex.items-center.gap-x-3'), apiListContainer: document.getElementById('api-list-container'), groupListCollapsible: document.getElementById('group-list-collapsible'), desktopGroupContainer: document.querySelector('#desktop-group-cards-list .card-list-content'), mobileGroupContainer: document.getElementById('mobile-group-cards-list'), addGroupBtnContainer: document.getElementById('add-group-btn-container'), groupMenuToggle: document.getElementById('group-menu-toggle'), mobileActiveGroupDisplay: document.querySelector('.mobile-group-selector > div'), }; this.initialized = this.elements.desktopGroupContainer !== null && this.elements.apiListContainer !== null; if (this.initialized) { this.apiKeyList = new ApiKeyList(this.elements.apiListContainer); } // 實例化頁面專屬的子組件 const allowedModelsInput = new TagInput(document.getElementById('allowed-models-container'), { validator: /^[a-z0-9\.-]+$/, validationMessage: '无效的模型格式' }); // 验证上游地址:一个基础的 URL 格式验证 const allowedUpstreamsInput = new TagInput(document.getElementById('allowed-upstreams-container'), { validator: /^(https?:\/\/)?[\w\.-]+\.[a-z]{2,}(\/[\w\.-]*)*\/?$/i, validationMessage: '无效的 URL 格式' }); // 令牌验证:确保不为空即可 const allowedTokensInput = new TagInput(document.getElementById('allowed-tokens-container'), { validator: /.+/, validationMessage: '令牌不能为空' }); this.keyGroupModal = new KeyGroupModal({ onSave: this.handleSaveGroup.bind(this), tagInputInstances: { models: allowedModelsInput, upstreams: allowedUpstreamsInput, tokens: allowedTokensInput, } }); this.deleteGroupModal = new DeleteGroupModal({ onDeleteSuccess: (deletedGroupId) => { if (this.state.activeGroupId === deletedGroupId) { this.state.activeGroupId = null; this.apiKeyList.loadApiKeys(null); } this.loadKeyGroups(true); } }); this.addApiModal = new AddApiModal({ onImportSuccess: () => this.apiKeyList.loadApiKeys(this.state.activeGroupId, true), }); // CloneGroupModal this.cloneGroupModal = new CloneGroupModal({ onCloneSuccess: (clonedGroup) => { if (clonedGroup && clonedGroup.id) { this.state.activeGroupId = clonedGroup.id; } this.loadKeyGroups(true); } }); // DeleteApiModal this.deleteApiModal = new DeleteApiModal({ onDeleteSuccess: () => this.apiKeyList.loadApiKeys(this.state.activeGroupId, true), }); this.requestSettingsModal = new RequestSettingsModal({ onSave: this.handleSaveRequestSettings.bind(this) }); this.activeTooltip = null; } async init() { if (!this.initialized) { console.error("KeyGroupsPage: Could not initialize. Essential container elements like 'desktopGroupContainer' or 'apiListContainer' are missing from the DOM."); return; } this.initEventListeners(); if (this.apiKeyList) { this.apiKeyList.init(); } await this.loadKeyGroups(); } initEventListeners() { // --- 模态框全局触发器 --- document.body.addEventListener('click', (event) => { const addGroupBtn = event.target.closest('.add-group-btn'); const addApiBtn = event.target.closest('#add-api-btn'); const deleteApiBtn = event.target.closest('#delete-api-btn'); if (addGroupBtn) this.keyGroupModal.open(); if (addApiBtn) this.addApiModal.open(this.state.activeGroupId); if (deleteApiBtn) this.deleteApiModal.open(this.state.activeGroupId); }); // --- 使用事件委託來統一處理儀表板上的所有操作 --- this.elements.dashboardControls?.addEventListener('click', (event) => { const button = event.target.closest('button[data-action]'); if (!button) return; const action = button.dataset.action; const activeGroup = this.state.groups.find(g => g.id === this.state.activeGroupId); switch(action) { case 'edit-group': if (activeGroup) { this.openEditGroupModal(activeGroup.id); } else { alert("请先选择一个分组进行编辑。"); } break; case 'open-settings': this.openRequestSettingsModal(); break; case 'clone-group': if (activeGroup) { this.cloneGroupModal.open(activeGroup); } else { alert("请先选择一个分组进行克隆。"); } break; case 'delete-group': console.log('Delete action triggered for group:', this.state.activeGroupId); this.deleteGroupModal.open(activeGroup); break; } }); // --- 核心交互区域的事件委托 --- // 在共同父级上监听群组卡片点击 this.elements.groupListCollapsible?.addEventListener('click', (event) => { this.handleGroupCardClick(event); }); // 移动端菜单切换 this.elements.groupMenuToggle?.addEventListener('click', (event) => { event.stopPropagation(); const menu = this.elements.groupListCollapsible; if (!menu) return; menu.classList.toggle('hidden'); setTimeout(() => { menu.classList.toggle('mobile-group-menu-active'); }, 0); }); // Add a global listener to close the menu if clicking outside document.addEventListener('click', (event) => { const menu = this.elements.groupListCollapsible; const toggle = this.elements.groupMenuToggle; if (menu && menu.classList.contains('mobile-group-menu-active') && !menu.contains(event.target) && !toggle.contains(event.target)) { this._closeMobileMenu(); } }); // ... [其他頁面級事件監聽] ... this.initCustomSelects(); this.initTooltips(); this.initDragAndDrop(); this._initBatchActions(); } // 4. 数据获取与渲染逻辑 async loadKeyGroups(force = false) { this.state.isLoading = true; try { const responseData = await apiFetchJson("/admin/keygroups", { noCache: force }); if (responseData && responseData.success && Array.isArray(responseData.data)) { this.state.groups = responseData.data; } else { console.error("API response format is incorrect:", responseData); this.state.groups = []; } if (this.state.groups.length > 0 && !this.state.activeGroupId) { this.state.activeGroupId = this.state.groups[0].id; } this.renderGroupList(); if (this.state.activeGroupId) { this.updateDashboard(); } this.updateAllHealthIndicators(); } catch (error) { console.error("Failed to load or parse key groups:", error); this.state.groups = []; this.renderGroupList(); // 渲染空列表 this.updateDashboard(); // 更新仪表盘为空状态 } finally { this.state.isLoading = false; } if (this.state.activeGroupId) { this.updateDashboard(); } else { // If no groups exist, ensure the API key list is also cleared. this.apiKeyList.loadApiKeys(null); } } /** * Helper function to determine health indicator CSS classes based on success rate. * @param {number} rate - The success rate (0-100). * @returns {{ring: string, dot: string}} - The CSS classes for the ring and dot. */ _getHealthIndicatorClasses(rate) { if (rate >= 50) return { ring: 'bg-green-500/20', dot: 'bg-green-500' }; if (rate >= 30) return { ring: 'bg-yellow-500/20', dot: 'bg-yellow-500' }; if (rate >= 10) return { ring: 'bg-orange-500/20', dot: 'bg-orange-500' }; return { ring: 'bg-red-500/20', dot: 'bg-red-500' }; } /** * Renders the list of group cards based on the current state. */ renderGroupList() { if (!this.state.groups) return; // --- 桌面端列表渲染 (最终卡片布局) --- const desktopListHtml = this.state.groups.map(group => { const isActive = group.id === this.state.activeGroupId; const cardClass = isActive ? 'group-card-active' : 'group-card-inactive'; const successRate = 100; // Placeholder const healthClasses = this._getHealthIndicatorClasses(successRate); // [核心修正] 同时生成两种类型的标签 const channelTag = this._getChannelTypeTag(group.channel_type || 'Local'); const customTags = this._getCustomTags(group.custom_tags); // 假设 group.custom_tags 是一个数组 return `

${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.description || 'No description available'}

`; }).join(''); if (this.elements.mobileGroupContainer) { this.elements.mobileGroupContainer.innerHTML = mobileListHtml; } } // 事件处理器和UI更新函数,现在完全由 state 驱动 handleGroupCardClick(event) { const clickedCard = event.target.closest('[data-group-id]'); if (!clickedCard) return; const groupId = parseInt(clickedCard.dataset.groupId, 10); if (this.state.activeGroupId !== groupId) { this.state.activeGroupId = groupId; this.renderGroupList(); this.updateDashboard(); // updateDashboard 现在会处理 API key 的加载 } if (window.innerWidth < 1024) { this._closeMobileMenu(); } } // [NEW HELPER METHOD] Centralizes the logic for closing the mobile menu. _closeMobileMenu() { const menu = this.elements.groupListCollapsible; if (!menu) return; menu.classList.remove('mobile-group-menu-active'); menu.classList.add('hidden'); } updateDashboard() { const activeGroup = this.state.groups.find(g => g.id === this.state.activeGroupId); if (activeGroup) { if (this.elements.dashboardTitle) { this.elements.dashboardTitle.textContent = `${activeGroup.display_name}`; } if (this.elements.mobileActiveGroupDisplay) { this.elements.mobileActiveGroupDisplay.innerHTML = `

${activeGroup.display_name}

当前选择

`; } // 更新 Dashboard 时,加载对应的 API Keys this.apiKeyList.setActiveGroup(activeGroup.id, activeGroup.display_name); this.apiKeyList.loadApiKeys(activeGroup.id); } else { if (this.elements.dashboardTitle) this.elements.dashboardTitle.textContent = 'No Group Selected'; if (this.elements.mobileActiveGroupDisplay) this.elements.mobileActiveGroupDisplay.innerHTML = `

请选择一个分组

`; // 如果没有选中的分组,清空 API Key 列表 this.apiKeyList.loadApiKeys(null); } } /** * Handles the saving of a key group with modern toast notifications. * @param {object} groupData The data collected from the KeyGroupModal. */ async handleSaveGroup(groupData) { const isEditing = !!groupData.id; const endpoint = isEditing ? `/admin/keygroups/${groupData.id}` : '/admin/keygroups'; const method = isEditing ? 'PUT' : 'POST'; console.log(`[CONTROLLER] ${isEditing ? 'Updating' : 'Creating'} group...`, { endpoint, method, data: groupData }); try { const response = await apiFetch(endpoint, { method: method, body: JSON.stringify(groupData), noCache: true }); const result = await response.json(); if (!result.success) { throw new Error(result.message || 'An unknown error occurred on the server.'); } if (isEditing) { console.log(`[CACHE INVALIDATION] Deleting cached details for group ${groupData.id}.`); delete this.state.groupDetailsCache[groupData.id]; } if (!isEditing && result.data && result.data.id) { this.state.activeGroupId = result.data.id; } toastManager.show(`分组 "${groupData.display_name}" 已成功保存。`, 'success'); await this.loadKeyGroups(true); } catch (error) { console.error(`Failed to save group:`, error.message); toastManager.show(`保存失败: ${error.message}`, 'error'); throw error; } } /** * Opens the KeyGroupModal for editing, utilizing a cache-then-fetch strategy. * @param {number} groupId The ID of the group to edit. */ async openEditGroupModal(groupId) { // Step 1: Check the details cache first. if (this.state.groupDetailsCache[groupId]) { console.log(`[CACHE HIT] Using cached details for group ${groupId}.`); // If details exist, open the modal immediately with the cached data. this.keyGroupModal.open(this.state.groupDetailsCache[groupId]); return; } // Step 2: If not in cache, fetch from the API. console.log(`[CACHE MISS] Fetching details for group ${groupId}.`); try { // NOTE: No complex UI spinners on the button itself. The user just waits a moment. const endpoint = `/admin/keygroups/${groupId}`; const responseData = await apiFetchJson(endpoint, { noCache: true }); if (responseData && responseData.success) { const groupDetails = responseData.data; // Step 3: Store the newly fetched details in the cache. this.state.groupDetailsCache[groupId] = groupDetails; // Step 4: Open the modal with the fetched data. this.keyGroupModal.open(groupDetails); } else { throw new Error(responseData.message || 'Failed to load group details.'); } } catch (error) { console.error(`Failed to fetch details for group ${groupId}:`, error); alert(`无法加载分组详情: ${error.message}`); } } async openRequestSettingsModal() { if (!this.state.activeGroupId) { modalManager.showResult(false, "请先选择一个分组。"); return; } // [重構] 簡化後的邏輯:獲取數據,然後調用子模塊的 open 方法 console.log(`Opening request settings for group ID: ${this.state.activeGroupId}`); const data = {}; // 模擬從API獲取數據 this.requestSettingsModal.open(data); } /** * @param {object} data The data collected from the RequestSettingsModal. */ async handleSaveRequestSettings(data) { if (!this.state.activeGroupId) { throw new Error("No active group selected."); } console.log(`[CONTROLLER] Saving request settings for group ${this.state.activeGroupId}:`, data); // 此處執行API調用 // await apiFetch(...) // 成功後可以觸發一個全局通知或刷新列表 // this.loadKeyGroups(); return Promise.resolve(); // 模擬API調用成功 } initCustomSelects() { const customSelects = document.querySelectorAll('.custom-select'); customSelects.forEach(select => new CustomSelect(select)); } _initBatchActions() {} /** * Sends the new group UI order to the backend API. * @param {Array} orderData - An array of objects, e.g., [{id: 1, order: 0}, {id: 2, order: 1}] */ async saveGroupOrder(orderData) { console.log('Debounced save triggered. Sending UI order to API:', orderData); try { // 调用您已验证成功的API端点 const response = await apiFetch('/admin/keygroups/order', { method: 'PUT', body: JSON.stringify(orderData), noCache: true }); const result = await response.json(); if (!result.success) { // 如果后端返回操作失败,抛出错误 throw new Error(result.message || 'Failed to save UI order on the server.'); } console.log('UI order saved successfully.'); // (可选) 在这里可以显示一个短暂的 "保存成功" 的提示消息 (Toast/Snackbar) } catch (error) { console.error('Failed to save new group UI order:', error); // [重要] 如果API调用失败,应该重新加载一次分组列表, // 以便UI回滚到数据库中存储的、未经修改的正确顺序。 this.loadKeyGroups(); } } /** * Initializes drag-and-drop functionality for the group list. */ initDragAndDrop() { const container = this.elements.desktopGroupContainer; if (!container) return; new Sortable(container, { animation: 150, ghostClass: 'sortable-ghost', dragClass: 'sortable-drag', filter: '#add-group-btn-container', onEnd: (evt) => { const groupCards = Array.from(container.querySelectorAll('[data-group-id]')); const orderedState = groupCards.map(card => { const cardId = parseInt(card.dataset.groupId, 10); return this.state.groups.find(group => group.id === cardId); }).filter(Boolean); if (orderedState.length !== this.state.groups.length) { console.error("Drag-and-drop failed: Could not map all DOM elements to state. Aborting."); return; } // 更新正确的状态数组 this.state.groups = orderedState; const payload = this.state.groups.map((group, index) => ({ id: group.id, order: index })); this.debouncedSaveOrder(payload); }, }); } /** * Helper function to generate a styled HTML tag for the channel type. * @param {string} type - The channel type string (e.g., 'OpenAI', 'Azure'). * @returns {string} - The generated HTML span element. */ _getChannelTypeTag(type) { if (!type) return ''; // 如果没有类型,则返回空字符串 const styles = { 'OpenAI': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300', 'Azure': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300', 'Claude': 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300', 'Gemini': 'bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-300', 'Local': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' }; const baseClass = 'inline-block text-xs font-medium px-2 py-0.5 rounded-md'; const tagClass = styles[type] || styles['Local']; // 如果类型未知,则使用默认样式 return `${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)); } initTooltips() { const tooltipIcons = document.querySelectorAll('.tooltip-icon'); tooltipIcons.forEach(icon => { icon.addEventListener('mouseenter', (e) => this.showTooltip(e)); icon.addEventListener('mouseleave', () => this.hideTooltip()); }); } showTooltip(e) { this.hideTooltip(); const target = e.currentTarget; const text = target.dataset.tooltipText; if (!text) return; const tooltip = document.createElement('div'); tooltip.className = 'global-tooltip'; tooltip.textContent = text; document.body.appendChild(tooltip); this.activeTooltip = tooltip; const targetRect = target.getBoundingClientRect(); const tooltipRect = tooltip.getBoundingClientRect(); let top = targetRect.top - tooltipRect.height - 8; let left = targetRect.left + (targetRect.width / 2) - (tooltipRect.width / 2); if (top < 0) top = targetRect.bottom + 8; if (left < 0) left = 8; if (left + tooltipRect.width > window.innerWidth) { left = window.innerWidth - tooltipRect.width - 8; } tooltip.style.top = `${top}px`; tooltip.style.left = `${left}px`; } hideTooltip() { if (this.activeTooltip) { this.activeTooltip.remove(); this.activeTooltip = null; } } } export default function init() { console.log('[Modern Frontend] Keys page controller loaded.'); const page = new KeyGroupsPage(); page.init(); }