1823 lines
84 KiB
JavaScript
1823 lines
84 KiB
JavaScript
/**
|
||
* @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">×</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 方法
|
||
} |