This commit is contained in:
XOF
2025-11-20 12:24:05 +08:00
commit f28bdc751f
164 changed files with 64248 additions and 0 deletions

View File

@@ -0,0 +1,657 @@
// frontend/js/pages/keys/index.js
// --- 導入全局和頁面專屬模塊 ---
import { modalManager } from '../../components/ui.js';
import TagInput from '../../components/tagInput.js';
import CustomSelect from '../../components/customSelect.js';
import RequestSettingsModal from './requestSettingsModal.js';
import AddApiModal from './addApiModal.js';
import DeleteApiModal from './deleteApiModal.js';
import KeyGroupModal from './keyGroupModal.js';
import CloneGroupModal from './cloneGroupModal.js';
import DeleteGroupModal from './deleteGroupModal.js';
import { debounce } from '../../utils/utils.js';
import { apiFetch, apiFetchJson } from '../../services/api.js';
import { apiKeyManager } from '../../components/apiKeyManager.js';
import { toastManager } from '../../components/taskCenter.js';
import ApiKeyList from './apiKeyList.js';
import Sortable from '../../vendor/sortable.esm.js';
class KeyGroupsPage {
constructor() {
this.state = {
groups: [],
groupDetailsCache: {},
activeGroupId: null,
isLoading: true,
};
this.debouncedSaveOrder = debounce(this.saveGroupOrder.bind(this), 1500);
// elements對象現在只關心頁面級元素
this.elements = {
dashboardTitle: document.querySelector('#group-dashboard h2'),
dashboardControls: document.querySelector('#group-dashboard .flex.items-center.gap-x-3'),
apiListContainer: document.getElementById('api-list-container'),
groupListCollapsible: document.getElementById('group-list-collapsible'),
desktopGroupContainer: document.querySelector('#desktop-group-cards-list .card-list-content'),
mobileGroupContainer: document.getElementById('mobile-group-cards-list'),
addGroupBtnContainer: document.getElementById('add-group-btn-container'),
groupMenuToggle: document.getElementById('group-menu-toggle'),
mobileActiveGroupDisplay: document.querySelector('.mobile-group-selector > div'),
};
this.initialized = this.elements.desktopGroupContainer !== null &&
this.elements.apiListContainer !== null;
if (this.initialized) {
this.apiKeyList = new ApiKeyList(this.elements.apiListContainer);
}
// 實例化頁面專屬的子組件
const allowedModelsInput = new TagInput(document.getElementById('allowed-models-container'), {
validator: /^[a-z0-9\.-]+$/,
validationMessage: '无效的模型格式'
});
// 验证上游地址:一个基础的 URL 格式验证
const allowedUpstreamsInput = new TagInput(document.getElementById('allowed-upstreams-container'), {
validator: /^(https?:\/\/)?[\w\.-]+\.[a-z]{2,}(\/[\w\.-]*)*\/?$/i,
validationMessage: '无效的 URL 格式'
});
// 令牌验证:确保不为空即可
const allowedTokensInput = new TagInput(document.getElementById('allowed-tokens-container'), {
validator: /.+/,
validationMessage: '令牌不能为空'
});
this.keyGroupModal = new KeyGroupModal({
onSave: this.handleSaveGroup.bind(this),
tagInputInstances: {
models: allowedModelsInput,
upstreams: allowedUpstreamsInput,
tokens: allowedTokensInput,
}
});
this.deleteGroupModal = new DeleteGroupModal({
onDeleteSuccess: (deletedGroupId) => {
if (this.state.activeGroupId === deletedGroupId) {
this.state.activeGroupId = null;
this.apiKeyList.loadApiKeys(null);
}
this.loadKeyGroups(true);
}
});
this.addApiModal = new AddApiModal({
onImportSuccess: () => this.apiKeyList.loadApiKeys(this.state.activeGroupId, true),
});
// CloneGroupModal
this.cloneGroupModal = new CloneGroupModal({
onCloneSuccess: (clonedGroup) => {
if (clonedGroup && clonedGroup.id) {
this.state.activeGroupId = clonedGroup.id;
}
this.loadKeyGroups(true);
}
});
// DeleteApiModal
this.deleteApiModal = new DeleteApiModal({
onDeleteSuccess: () => this.apiKeyList.loadApiKeys(this.state.activeGroupId, true),
});
this.requestSettingsModal = new RequestSettingsModal({
onSave: this.handleSaveRequestSettings.bind(this)
});
this.activeTooltip = null;
}
async init() {
if (!this.initialized) {
console.error("KeyGroupsPage: Could not initialize. Essential container elements like 'desktopGroupContainer' or 'apiListContainer' are missing from the DOM.");
return;
}
this.initEventListeners();
if (this.apiKeyList) {
this.apiKeyList.init();
}
await this.loadKeyGroups();
}
initEventListeners() {
// --- 模态框全局触发器 ---
document.body.addEventListener('click', (event) => {
const addGroupBtn = event.target.closest('.add-group-btn');
const addApiBtn = event.target.closest('#add-api-btn');
const deleteApiBtn = event.target.closest('#delete-api-btn');
if (addGroupBtn) this.keyGroupModal.open();
if (addApiBtn) this.addApiModal.open(this.state.activeGroupId);
if (deleteApiBtn) this.deleteApiModal.open(this.state.activeGroupId);
});
// --- 使用事件委託來統一處理儀表板上的所有操作 ---
this.elements.dashboardControls?.addEventListener('click', (event) => {
const button = event.target.closest('button[data-action]');
if (!button) return;
const action = button.dataset.action;
const activeGroup = this.state.groups.find(g => g.id === this.state.activeGroupId);
switch(action) {
case 'edit-group':
if (activeGroup) {
this.openEditGroupModal(activeGroup.id);
} else {
alert("请先选择一个分组进行编辑。");
}
break;
case 'open-settings':
this.openRequestSettingsModal();
break;
case 'clone-group':
if (activeGroup) {
this.cloneGroupModal.open(activeGroup);
} else {
alert("请先选择一个分组进行克隆。");
}
break;
case 'delete-group':
console.log('Delete action triggered for group:', this.state.activeGroupId);
this.deleteGroupModal.open(activeGroup);
break;
}
});
// --- 核心交互区域的事件委托 ---
// 在共同父级上监听群组卡片点击
this.elements.groupListCollapsible?.addEventListener('click', (event) => {
this.handleGroupCardClick(event);
});
// 移动端菜单切换
this.elements.groupMenuToggle?.addEventListener('click', (event) => {
event.stopPropagation();
const menu = this.elements.groupListCollapsible;
if (!menu) return;
menu.classList.toggle('hidden');
setTimeout(() => {
menu.classList.toggle('mobile-group-menu-active');
}, 0);
});
// Add a global listener to close the menu if clicking outside
document.addEventListener('click', (event) => {
const menu = this.elements.groupListCollapsible;
const toggle = this.elements.groupMenuToggle;
if (menu && menu.classList.contains('mobile-group-menu-active') && !menu.contains(event.target) && !toggle.contains(event.target)) {
this._closeMobileMenu();
}
});
// ... [其他頁面級事件監聽] ...
this.initCustomSelects();
this.initTooltips();
this.initDragAndDrop();
this._initBatchActions();
}
// 4. 数据获取与渲染逻辑
async loadKeyGroups(force = false) {
this.state.isLoading = true;
try {
const responseData = await apiFetchJson("/admin/keygroups", { noCache: force });
if (responseData && responseData.success && Array.isArray(responseData.data)) {
this.state.groups = responseData.data;
} else {
console.error("API response format is incorrect:", responseData);
this.state.groups = [];
}
if (this.state.groups.length > 0 && !this.state.activeGroupId) {
this.state.activeGroupId = this.state.groups[0].id;
}
this.renderGroupList();
if (this.state.activeGroupId) {
this.updateDashboard();
}
this.updateAllHealthIndicators();
} catch (error) {
console.error("Failed to load or parse key groups:", error);
this.state.groups = [];
this.renderGroupList(); // 渲染空列表
this.updateDashboard(); // 更新仪表盘为空状态
} finally {
this.state.isLoading = false;
}
if (this.state.activeGroupId) {
this.updateDashboard();
} else {
// If no groups exist, ensure the API key list is also cleared.
this.apiKeyList.loadApiKeys(null);
}
}
/**
* Helper function to determine health indicator CSS classes based on success rate.
* @param {number} rate - The success rate (0-100).
* @returns {{ring: string, dot: string}} - The CSS classes for the ring and dot.
*/
_getHealthIndicatorClasses(rate) {
if (rate >= 50) return { ring: 'bg-green-500/20', dot: 'bg-green-500' };
if (rate >= 30) return { ring: 'bg-yellow-500/20', dot: 'bg-yellow-500' };
if (rate >= 10) return { ring: 'bg-orange-500/20', dot: 'bg-orange-500' };
return { ring: 'bg-red-500/20', dot: 'bg-red-500' };
}
/**
* Renders the list of group cards based on the current state.
*/
renderGroupList() {
if (!this.state.groups) return;
// --- 桌面端列表渲染 (最终卡片布局) ---
const desktopListHtml = this.state.groups.map(group => {
const isActive = group.id === this.state.activeGroupId;
const cardClass = isActive ? 'group-card-active' : 'group-card-inactive';
const successRate = 100; // Placeholder
const healthClasses = this._getHealthIndicatorClasses(successRate);
// [核心修正] 同时生成两种类型的标签
const channelTag = this._getChannelTypeTag(group.channel_type || 'Local');
const customTags = this._getCustomTags(group.custom_tags); // 假设 group.custom_tags 是一个数组
return `
<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="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})</h3>
<p class="card-sub-text my-1.5">${group.description || 'No description available'}</p>
</div>`;
}).join('');
if (this.elements.mobileGroupContainer) {
this.elements.mobileGroupContainer.innerHTML = mobileListHtml;
}
}
// 事件处理器和UI更新函数现在完全由 state 驱动
handleGroupCardClick(event) {
const clickedCard = event.target.closest('[data-group-id]');
if (!clickedCard) return;
const groupId = parseInt(clickedCard.dataset.groupId, 10);
if (this.state.activeGroupId !== groupId) {
this.state.activeGroupId = groupId;
this.renderGroupList();
this.updateDashboard(); // updateDashboard 现在会处理 API key 的加载
}
if (window.innerWidth < 1024) {
this._closeMobileMenu();
}
}
// [NEW HELPER METHOD] Centralizes the logic for closing the mobile menu.
_closeMobileMenu() {
const menu = this.elements.groupListCollapsible;
if (!menu) return;
menu.classList.remove('mobile-group-menu-active');
menu.classList.add('hidden');
}
updateDashboard() {
const activeGroup = this.state.groups.find(g => g.id === this.state.activeGroupId);
if (activeGroup) {
if (this.elements.dashboardTitle) {
this.elements.dashboardTitle.textContent = `${activeGroup.display_name}`;
}
if (this.elements.mobileActiveGroupDisplay) {
this.elements.mobileActiveGroupDisplay.innerHTML = `
<h3 class="font-semibold text-sm">${activeGroup.display_name}</h3>
<p class="card-sub-text">当前选择</p>`;
}
// 更新 Dashboard 时,加载对应的 API Keys
this.apiKeyList.setActiveGroup(activeGroup.id, activeGroup.display_name);
this.apiKeyList.loadApiKeys(activeGroup.id);
} else {
if (this.elements.dashboardTitle) this.elements.dashboardTitle.textContent = 'No Group Selected';
if (this.elements.mobileActiveGroupDisplay) this.elements.mobileActiveGroupDisplay.innerHTML = `<h3 class="font-semibold text-sm">请选择一个分组</h3>`;
// 如果没有选中的分组,清空 API Key 列表
this.apiKeyList.loadApiKeys(null);
}
}
/**
* Handles the saving of a key group with modern toast notifications.
* @param {object} groupData The data collected from the KeyGroupModal.
*/
async handleSaveGroup(groupData) {
const isEditing = !!groupData.id;
const endpoint = isEditing ? `/admin/keygroups/${groupData.id}` : '/admin/keygroups';
const method = isEditing ? 'PUT' : 'POST';
console.log(`[CONTROLLER] ${isEditing ? 'Updating' : 'Creating'} group...`, { endpoint, method, data: groupData });
try {
const response = await apiFetch(endpoint, {
method: method,
body: JSON.stringify(groupData),
noCache: true
});
const result = await response.json();
if (!result.success) {
throw new Error(result.message || 'An unknown error occurred on the server.');
}
if (isEditing) {
console.log(`[CACHE INVALIDATION] Deleting cached details for group ${groupData.id}.`);
delete this.state.groupDetailsCache[groupData.id];
}
if (!isEditing && result.data && result.data.id) {
this.state.activeGroupId = result.data.id;
}
toastManager.show(`分组 "${groupData.display_name}" 已成功保存。`, 'success');
await this.loadKeyGroups(true);
} catch (error) {
console.error(`Failed to save group:`, error.message);
toastManager.show(`保存失败: ${error.message}`, 'error');
throw error;
}
}
/**
* Opens the KeyGroupModal for editing, utilizing a cache-then-fetch strategy.
* @param {number} groupId The ID of the group to edit.
*/
async openEditGroupModal(groupId) {
// Step 1: Check the details cache first.
if (this.state.groupDetailsCache[groupId]) {
console.log(`[CACHE HIT] Using cached details for group ${groupId}.`);
// If details exist, open the modal immediately with the cached data.
this.keyGroupModal.open(this.state.groupDetailsCache[groupId]);
return;
}
// Step 2: If not in cache, fetch from the API.
console.log(`[CACHE MISS] Fetching details for group ${groupId}.`);
try {
// NOTE: No complex UI spinners on the button itself. The user just waits a moment.
const endpoint = `/admin/keygroups/${groupId}`;
const responseData = await apiFetchJson(endpoint, { noCache: true });
if (responseData && responseData.success) {
const groupDetails = responseData.data;
// Step 3: Store the newly fetched details in the cache.
this.state.groupDetailsCache[groupId] = groupDetails;
// Step 4: Open the modal with the fetched data.
this.keyGroupModal.open(groupDetails);
} else {
throw new Error(responseData.message || 'Failed to load group details.');
}
} catch (error) {
console.error(`Failed to fetch details for group ${groupId}:`, error);
alert(`无法加载分组详情: ${error.message}`);
}
}
async openRequestSettingsModal() {
if (!this.state.activeGroupId) {
modalManager.showResult(false, "请先选择一个分组。");
return;
}
// [重構] 簡化後的邏輯:獲取數據,然後調用子模塊的 open 方法
console.log(`Opening request settings for group ID: ${this.state.activeGroupId}`);
const data = {}; // 模擬從API獲取數據
this.requestSettingsModal.open(data);
}
/**
* @param {object} data The data collected from the RequestSettingsModal.
*/
async handleSaveRequestSettings(data) {
if (!this.state.activeGroupId) {
throw new Error("No active group selected.");
}
console.log(`[CONTROLLER] Saving request settings for group ${this.state.activeGroupId}:`, data);
// 此處執行API調用
// await apiFetch(...)
// 成功後可以觸發一個全局通知或刷新列表
// this.loadKeyGroups();
return Promise.resolve(); // 模擬API調用成功
}
initCustomSelects() {
const customSelects = document.querySelectorAll('.custom-select');
customSelects.forEach(select => new CustomSelect(select));
}
_initBatchActions() {}
/**
* Sends the new group UI order to the backend API.
* @param {Array<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() {
const container = this.elements.desktopGroupContainer;
if (!container) return;
new Sortable(container, {
animation: 150,
ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
filter: '#add-group-btn-container',
onEnd: (evt) => {
const groupCards = Array.from(container.querySelectorAll('[data-group-id]'));
const orderedState = groupCards.map(card => {
const cardId = parseInt(card.dataset.groupId, 10);
return this.state.groups.find(group => group.id === cardId);
}).filter(Boolean);
if (orderedState.length !== this.state.groups.length) {
console.error("Drag-and-drop failed: Could not map all DOM elements to state. Aborting.");
return;
}
// 更新正确的状态数组
this.state.groups = orderedState;
const payload = this.state.groups.map((group, index) => ({
id: group.id,
order: index
}));
this.debouncedSaveOrder(payload);
},
});
}
/**
* Helper function to generate a styled HTML tag for the channel type.
* @param {string} type - The channel type string (e.g., 'OpenAI', 'Azure').
* @returns {string} - The generated HTML span element.
*/
_getChannelTypeTag(type) {
if (!type) return ''; // 如果没有类型,则返回空字符串
const styles = {
'OpenAI': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
'Azure': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
'Claude': 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300',
'Gemini': 'bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-300',
'Local': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
};
const baseClass = 'inline-block text-xs font-medium px-2 py-0.5 rounded-md';
const tagClass = styles[type] || styles['Local']; // 如果类型未知,则使用默认样式
return `<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));
}
initTooltips() {
const tooltipIcons = document.querySelectorAll('.tooltip-icon');
tooltipIcons.forEach(icon => {
icon.addEventListener('mouseenter', (e) => this.showTooltip(e));
icon.addEventListener('mouseleave', () => this.hideTooltip());
});
}
showTooltip(e) {
this.hideTooltip();
const target = e.currentTarget;
const text = target.dataset.tooltipText;
if (!text) return;
const tooltip = document.createElement('div');
tooltip.className = 'global-tooltip';
tooltip.textContent = text;
document.body.appendChild(tooltip);
this.activeTooltip = tooltip;
const targetRect = target.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
let top = targetRect.top - tooltipRect.height - 8;
let left = targetRect.left + (targetRect.width / 2) - (tooltipRect.width / 2);
if (top < 0) top = targetRect.bottom + 8;
if (left < 0) left = 8;
if (left + tooltipRect.width > window.innerWidth) {
left = window.innerWidth - tooltipRect.width - 8;
}
tooltip.style.top = `${top}px`;
tooltip.style.left = `${left}px`;
}
hideTooltip() {
if (this.activeTooltip) {
this.activeTooltip.remove();
this.activeTooltip = null;
}
}
}
export default function init() {
console.log('[Modern Frontend] Keys page controller loaded.');
const page = new KeyGroupsPage();
page.init();
}