658 lines
28 KiB
JavaScript
658 lines
28 KiB
JavaScript
// 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();
|
||
}
|