Files
gemini-banlancer/frontend/js/pages/keys.js
2025-11-20 12:24:05 +08:00

1823 lines
84 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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}<button class="tag-delete">&times;</button>`;
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 = `
<input type="text" class="modal-input text-xs bg-zinc-100 dark:bg-zinc-700/50" placeholder="Header Name" value="${key}">
<input type="text" class="modal-input text-xs" placeholder="Header Value" value="${value}">
<button type="button" class="remove-btn text-zinc-400 hover:text-red-500 transition-colors"><i class="fas fa-trash-alt"></i></button>
`;
container.appendChild(item);
}
/**
* Adds a new item for Safety Settings.
* @param {string} [category=''] - The initial category.
* @param {string} [threshold=''] - The initial threshold.
*/
addSafetySettingItem(category = '', threshold = '') {
const container = this.elements.safetySettingsContainer;
const item = document.createElement('div');
item.className = 'safety-setting-item flex items-center gap-x-2';
const harmCategories = [
"HARM_CATEGORY_HARASSMENT", "HARM_CATEGORY_HATE_SPEECH",
"HARM_CATEGORY_SEXUALLY_EXPLICIT", "HARM_CATEGORY_DANGEROUS_CONTENT","HARM_CATEGORY_DANGEROUS_CONTENT",
"HARM_CATEGORY_CIVIC_INTEGRITY"
];
const harmThresholds = [
"BLOCK_OFF","BLOCK_NONE", "BLOCK_LOW_AND_ABOVE",
"BLOCK_MEDIUM_AND_ABOVE", "BLOCK_ONLY_HIGH"
];
const categorySelect = document.createElement('select');
categorySelect.className = 'modal-input flex-grow'; // .modal-input 在静态<select>上是有效的
harmCategories.forEach(cat => {
const option = new Option(cat.replace('HARM_CATEGORY_', ''), cat);
if (cat === category) option.selected = true;
categorySelect.add(option);
});
const thresholdSelect = document.createElement('select');
thresholdSelect.className = 'modal-input w-48';
harmThresholds.forEach(thr => {
const option = new Option(thr.replace('BLOCK_', '').replace('_AND_ABOVE', '+'), thr);
if (thr === threshold) option.selected = true;
thresholdSelect.add(option);
});
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'remove-btn text-zinc-400 hover:text-red-500 transition-colors';
removeButton.innerHTML = `<i class="fas fa-trash-alt"></i>`;
item.appendChild(categorySelect);
item.appendChild(thresholdSelect);
item.appendChild(removeButton);
container.appendChild(item);
}
/**
* 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 `
<div class="${cardClass}" data-group-id="${group.id}" data-success-rate="${successRate}">
<div class="flex items-center gap-x-3">
<div data-health-indicator class="health-indicator-ring ${healthClasses.ring}">
<div data-health-dot class="health-indicator-dot ${healthClasses.dot}"></div>
</div>
<div class="flex-grow">
<!-- [最终布局] 1. 名称 -> 2. 描述 -> 3. 标签 -->
<h3 class="font-semibold text-sm">${group.display_name}</h3>
<p class="card-sub-text my-1.5">${group.description || 'No description available'}</p>
<div class="flex items-center gap-x-1.5 flex-wrap">
${channelTag}
${customTags}
</div>
</div>
</div>
</div>`;
}).join('');
if (this.elements.desktopGroupContainer) {
this.elements.desktopGroupContainer.innerHTML = desktopListHtml;
if (this.elements.addGroupBtnContainer) {
this.elements.desktopGroupContainer.parentElement.appendChild(this.elements.addGroupBtnContainer);
}
}
// --- 移动端列表渲染 (保持不变) ---
const mobileListHtml = this.state.groups.map(group => {
const isActive = group.id === this.state.activeGroupId;
const cardClass = isActive ? 'group-card-active' : 'group-card-inactive';
return `
<div class="${cardClass}" data-group-id="${group.id}">
<h3 class="font-semibold text-sm">${group.display_name} (${group.name})</h3>
</div>`;
}).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 = `
<h3 class="font-semibold text-sm">${activeGroup.display_name}</h3>
<p class="card-sub-text">当前选择</p>`;
}
// [重大逻辑修改] 更新 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 = `<h3 class="font-semibold text-sm">请选择一个分组</h3>`;
// 如果没有选中的分组,清空 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 = '<div class="text-center p-8 text-gray-500">Loading API Keys...</div>';
return;
}
if (this.state.apiKeys.length === 0) {
container.innerHTML = '<div class="text-center p-8 text-gray-500">No API Keys in this group.</div>';
return;
}
const listHtml = this.state.apiKeys.map(mapping => this.createApiKeyCardHtml(mapping)).join('');
container.innerHTML = `
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
${listHtml}
</div>
`;
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 `
<div class="api-card group relative flex items-center gap-x-3 rounded-lg p-3 bg-white dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700/60"
data-status="${status}"
data-key-id="${keyId}"
data-mapping-id="${mappingId}">
<input type="checkbox" class="api-key-checkbox h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500 shrink-0">
<span data-status-indicator class="w-2 h-2 rounded-full shrink-0"></span>
<div class="flex-grow">
<p class="font-mono text-xs font-semibold">${maskedKey}</p>
<p class="text-xs text-zinc-400 mt-1">失败: ${errorCount} 次</p>
</div>
<div class="flex items-center gap-x-2 text-zinc-400 text-xs z-10">
<button class="hover:text-blue-500" data-action="toggle-visibility" title="查看完整Key"><i class="fas fa-eye"></i></button>
<button class="hover:text-blue-500" data-action="copy-key" title="复制Key"><i class="fas fa-copy"></i></button>
</div>
<!-- Hover Menu -->
<div class="absolute right-14 top-1/2 -translate-y-1/2 flex items-center bg-zinc-200 dark:bg-zinc-700 rounded-full shadow-md opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-20">
<button class="px-2 py-1 hover:text-green-500" ${setActiveAction} title="设为可用"><i class="fas fa-check-circle"></i></button>
<button class="px-2 py-1 hover:text-blue-500" ${revalidateAction} title="重新验证"><i class="fas fa-sync-alt"></i></button>
<button class="px-2 py-1 hover:text-yellow-500" ${disableAction} title="禁用"><i class="fas fa-ban"></i></button>
<button class="px-2 py-1 hover:text-red-500" ${deleteAction} title="从分组中移除"><i class="fas fa-trash-alt"></i></button>
</div>
</div>
`;
}
/**
* [升级] 辅助函数解析、清理并校验用户输入的Keys。
* @param {string} text - The raw text from the textarea.
* @returns {Array<string>} - 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 = `<i class="fas fa-spinner fa-spin mr-2"></i>正在启动...`;
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 = `<div class="p-4 bg-red-50 border border-red-200 rounded-md">
<p class="text-red-700 font-semibold">错误</p>
<p class="text-red-600 text-sm mt-1">${errorMessage}</p>
</div>`;
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 = `<i class="fas fa-spinner fa-spin mr-2"></i>正在启动...`;
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 = `<div class="p-4 bg-red-50 border border-red-200 rounded-md">
<p class="text-red-700 font-semibold">错误</p>
<p class="text-red-600 text-sm mt-1">${errorMessage}</p>
</div>`;
}
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 = `<div class="p-4 text-red-700 bg-red-100">无法解析任务数据请检查API响应。</div>`;
return;
}
const progressText = `正在处理: ${task.processed} / ${task.total}`;
if (task.is_running) {
resultContainer.innerHTML = `<div class="text-center py-4"><i class="fas fa-spinner fa-spin fa-2x text-gray-400 mb-4"></i><p class="text-sm text-gray-600">${progressText}</p></div>`;
} 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 = `<h3 class="font-semibold text-xl text-red-500 text-center">导入失败</h3><p class="mt-2 text-sm text-center text-gray-600">${task.error}</p>`;
} else {
const result = task.result || {};
resultHtml = `
<div class="text-center">
<h3 class="font-semibold text-xl text-green-600">导入完成</h3>
<ul class="mt-4 space-y-2 text-gray-700">
<li class="flex justify-between items-center text-sm"><span>新创建并链接:</span><span class="font-mono bg-gray-100 px-2 py-1 rounded">${result.newly_created_count || 0}</span></li>
<li class="flex justify-between items-center text-sm"><span>已存在并链接:</span><span class="font-mono bg-gray-100 px-2 py-1 rounded">${result.already_existed_count || 0}</span></li>
<li class="flex justify-between items-center text-sm font-bold"><span>总计链接到分组:</span><span class="font-mono bg-green-100 text-green-800 px-2 py-1 rounded">${result.linked_to_group_count || 0}</span></li>
</ul>
</div>`;
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 = `<div class="p-4 bg-red-50 border border-red-200 rounded-md">
<p class="text-red-700 font-semibold">轮询任务状态失败</p>
<p class="text-red-600 text-sm mt-1">${error.message}</p>
</div>`;
}
}
}, 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<object>} orderData - An array of objects, e.g., [{id: 1, order: 0}, {id: 2, order: 1}]
*/
async saveGroupOrder(orderData) {
console.log('Debounced save triggered. Sending UI order to API:', orderData);
try {
// 调用您已验证成功的API端点
const response = await apiFetch('/admin/keygroups/order', {
method: 'PUT',
body: JSON.stringify(orderData),
noCache: true
});
const result = await response.json();
if (!result.success) {
// 如果后端返回操作失败,抛出错误
throw new Error(result.message || 'Failed to save UI order on the server.');
}
console.log('UI order saved successfully.');
// (可选) 在这里可以显示一个短暂的 "保存成功" 的提示消息 (Toast/Snackbar)
} catch (error) {
console.error('Failed to save new group UI order:', error);
// [重要] 如果API调用失败应该重新加载一次分组列表
// 以便UI回滚到数据库中存储的、未经修改的正确顺序。
this.loadKeyGroups();
}
}
/**
* Initializes drag-and-drop functionality for the group list.
*/
initDragAndDrop() {
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 `<span class="${baseClass} ${tagClass}">${type}</span>`;
}
/**
* Generates styled HTML for custom tags with deterministically assigned colors.
* @param {string[]} tags - An array of custom tag strings.
* @returns {string} - The generated HTML for all custom tags.
*/
_getCustomTags(tags) {
if (!tags || !Array.isArray(tags) || tags.length === 0) {
return '';
}
// 预设的彩色背景调色板 (Tailwind classes)
const colorPalette = [
'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300',
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300',
'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-300',
'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300',
'bg-lime-100 text-lime-800 dark:bg-lime-900 dark:text-lime-300',
];
const baseClass = 'inline-block text-xs font-medium px-2 py-0.5 rounded-md';
return tags.map(tag => {
// 使用一个简单的确定性哈希算法,确保同一个标签名总能获得同一种颜色
let hash = 0;
for (let i = 0; i < tag.length; i++) {
hash += tag.charCodeAt(i);
}
const colorClass = colorPalette[hash % colorPalette.length];
return `<span class="${baseClass} ${colorClass}">${tag}</span>`;
}).join('');
}
_updateHealthIndicator(cardElement) {
const rate = parseFloat(cardElement.dataset.successRate);
if (isNaN(rate)) return;
const indicator = cardElement.querySelector('[data-health-indicator]');
const dot = cardElement.querySelector('[data-health-dot]');
if (!indicator || !dot) return;
const colors = {
green: ['bg-green-500/20', 'bg-green-500'],
yellow: ['bg-yellow-500/20', 'bg-yellow-500'],
orange: ['bg-orange-500/20', 'bg-orange-500'],
red: ['bg-red-500/20', 'bg-red-500'],
};
Object.values(colors).forEach(([bgClass, dotClass]) => {
indicator.classList.remove(bgClass);
dot.classList.remove(dotClass);
});
let newColor;
if (rate >= 50) newColor = colors.green;
else if (rate >= 25) newColor = colors.yellow;
else if (rate >= 10) newColor = colors.orange;
else newColor = colors.red;
indicator.classList.add(newColor[0]);
dot.classList.add(newColor[1]);
}
updateAllHealthIndicators() {
if (!this.elements.groupListCollapsible) return;
const allCards = this.elements.groupListCollapsible.querySelectorAll('[data-success-rate]');
allCards.forEach(card => this._updateHealthIndicator(card));
}
_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 = `
<input type="text" class="modal-input text-xs" name="custom_header_key" placeholder="Header Name" value="${key}">
<input type="text" class="modal-input text-xs" name="custom_header_value" placeholder="Header Value" value="${value}">
<button type="button" class="remove-btn text-zinc-400 hover:text-red-500 transition-colors"><i class="fas fa-trash-alt"></i></button>
`;
container.appendChild(item);
}
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 = `<i class="fas fa-trash-alt"></i>`;
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 方法
}