Initial commit

This commit is contained in:
XOF
2025-11-20 12:12:26 +08:00
commit 179a58b55a
169 changed files with 64463 additions and 0 deletions

View File

@@ -0,0 +1,312 @@
// frontend/js/components/apiKeyManager.js
//import { apiFetch } from "../main.js"; // Assuming apiFetch is exported from main.js
import { apiFetch, apiFetchJson } from '../services/api.js';
import { modalManager } from "./ui.js";
/**
* Manages all API operations related to keys.
* This class provides a centralized interface for actions such as
* fetching, verifying, resetting, and deleting keys.
*/
class ApiKeyManager {
constructor() {
// The constructor can be used to initialize any properties,
// though for this static-like service class, it might be empty.
}
// [新增] 开始一个向指定分组添加Keys的异步任务
/**
* Starts a task to add multiple API keys to a specific group.
* @param {number} groupId - The ID of the group.
* @param {string} keysText - A string of keys, separated by newlines.
* @returns {Promise<object>} A promise that resolves to the initial task status object.
*/
async addKeysToGroup(groupId, keysText, validate) {
// 后端期望的 Body 结构
const payload = {
key_group_id: groupId,
keys: keysText,
validate_on_import: validate
};
// POST 请求不应被缓存,使用原始的 apiFetch 并设置 noCache
const response = await apiFetch(`/admin/keygroups/${groupId}/apikeys/bulk`, {
method: 'POST',
body: JSON.stringify(payload),
noCache: true
});
return response.json();
}
// [新增] 查询一个指定任务的当前状态
/**
* Gets the current status of a background task.
* @param {string} taskId - The ID of the task.
* @returns {Promise<object>} A promise that resolves to the task status object.
*/
getTaskStatus(taskId, options = {}) {
return apiFetchJson(`/admin/tasks/${taskId}`, options);
}
/**
* Fetches a paginated and filtered list of keys.
* @param {string} type - The type of keys to fetch ('valid' or 'invalid').
* @param {number} [page=1] - The page number to retrieve.
* @param {number} [limit=10] - The number of keys per page.
* @param {string} [searchTerm=''] - A search term to filter keys.
* @param {number|null} [failCountThreshold=null] - A threshold for filtering by failure count.
* @returns {Promise<object>} A promise that resolves to the API response data.
*/
async fetchKeys(type, page = 1, limit = 10, searchTerm = '', failCountThreshold = null) {
const params = new URLSearchParams({
page: page,
limit: limit,
status: type,
});
if (searchTerm) params.append('search', searchTerm);
if (failCountThreshold !== null) params.append('fail_count_threshold', failCountThreshold);
return await apiFetch(`/api/keys?${params.toString()}`);
}
/**
* Starts a task to unlink multiple API keys from a specific group.
* @param {number} groupId - The ID of the group.
* @param {string} keysText - A string of keys, separated by newlines.
* @returns {Promise<object>} A promise that resolves to the initial task status object.
*/
async unlinkKeysFromGroup(groupId, keysInput) {
let keysAsText;
if (Array.isArray(keysInput)) {
keysAsText = keysInput.join('\n');
} else {
keysAsText = keysInput;
}
const payload = {
key_group_id: groupId,
keys: keysAsText
};
const response = await apiFetch(`/admin/keygroups/${groupId}/apikeys/bulk`, {
method: 'DELETE',
body: JSON.stringify(payload),
noCache: true
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(errorData.message || `Request failed with status ${response.status}`);
}
return response.json();
}
/**
* 更新一个Key在特定分组中的状态 (e.g., 'ACTIVE', 'DISABLED').
* @param {number} groupId - The ID of the group.
* @param {number} keyId - The ID of the API key (api_keys.id).
* @param {string} newStatus - The new operational status ('ACTIVE', 'DISABLED', etc.).
* @returns {Promise<object>} A promise that resolves to the updated mapping object.
*/
async updateKeyStatusInGroup(groupId, keyId, newStatus) {
const endpoint = `/admin/keygroups/${groupId}/apikeys/${keyId}`;
const payload = { status: newStatus };
return await apiFetchJson(endpoint, {
method: 'PUT',
body: JSON.stringify(payload),
noCache: true
});
}
/**
* [MODIFIED] Fetches a paginated and filtered list of API key details for a specific group.
* @param {number} groupId - The ID of the group.
* @param {object} [params={}] - An object containing pagination and filter parameters.
* @param {number} [params.page=1] - The page number to fetch.
* @param {number} [params.limit=20] - The number of items per page.
* @param {string} [params.status] - An optional status to filter the keys by.
* @returns {Promise<object>} A promise that resolves to a pagination object.
*/
async getKeysForGroup(groupId, params = {}) {
// Step 1: Create a URLSearchParams object. This is the modern, safe way to build query strings.
const query = new URLSearchParams({
page: params.page || 1, // Default to page 1 if not provided
limit: params.limit || 20, // Default to 20 per page if not provided
});
// Step 2: Conditionally add the 'status' parameter IF it exists in the params object.
if (params.status) {
query.append('status', params.status);
}
if (params.keyword && params.keyword.trim() !== '') {
query.append('keyword', params.keyword.trim());
}
// Step 3: Construct the final URL by converting the query object to a string.
const url = `/admin/keygroups/${groupId}/apikeys?${query.toString()}`;
// The rest of the logic remains the same.
const responseData = await apiFetchJson(url, { noCache: true });
if (!responseData.success || typeof responseData.data !== 'object' || !Array.isArray(responseData.data.items)) {
throw new Error(responseData.message || 'Failed to fetch paginated keys for the group.');
}
return responseData.data;
}
/**
* 启动一个重新验证一个或多个Key的异步任务。
* @param {number} groupId - The ID of the group context for validation.
* @param {string[]} keyValues - An array of API key strings to revalidate.
* @returns {Promise<object>} A promise that resolves to the initial task status object.
*/
async revalidateKeys(groupId, keyValues) {
const payload = {
keys: keyValues.join('\n')
};
const url = `/admin/keygroups/${groupId}/apikeys/test`;
const responseData = await apiFetchJson(url, {
method: 'POST',
body: JSON.stringify(payload),
noCache: true,
});
if (!responseData.success || !responseData.data) {
throw new Error(responseData.message || "Failed to start revalidation task.");
}
return responseData.data;
}
/**
* Starts a generic bulk action task for an entire group based on filters.
* This single function replaces the need for separate cleanup, revalidate, and restore functions.
* @param {number} groupId The group ID.
* @param {object} payload The body of the request, defining the action and filters.
* @returns {Promise<object>} The initial task response with a task_id.
*/
async startGroupBulkActionTask(groupId, payload) {
// This assumes a new, unified endpoint on the backend.
const url = `/admin/keygroups/${groupId}/bulk-actions`;
const responseData = await apiFetchJson(url, {
method: 'POST',
body: JSON.stringify(payload)
});
if (!responseData.success || !responseData.data) {
throw new Error(responseData.message || "未能启动分组批量任务。");
}
return responseData.data;
}
/**
* [NEW] Fetches all keys for a group, filtered by status, for export purposes using the dedicated export API.
* @param {number} groupId The ID of the group.
* @param {string[]} statuses An array of statuses to filter by (e.g., ['active', 'cooldown']). Use ['all'] for everything.
* @returns {Promise<string[]>} A promise that resolves to an array of API key strings.
*/
async exportKeysForGroup(groupId, statuses = ['all']) {
const params = new URLSearchParams();
statuses.forEach(status => params.append('status', status));
// This now points to our new, clean, non-paginated API endpoint
const url = `/admin/keygroups/${groupId}/apikeys/export?${params.toString()}`;
const responseData = await apiFetchJson(url, { noCache: true });
if (!responseData.success || !Array.isArray(responseData.data)) {
throw new Error(responseData.message || '未能获取用于导出的Key列表。');
}
return responseData.data;
}
/** 以下为GB预置函数未做对齐
* Verifies a single API key.
* @param {string} key - The API key to verify.
* @returns {Promise<object>} A promise that resolves to the API response data.
*/
async verifyKey(key) {
return await apiFetch(`/gemini/v1beta/verify-key/${key}`, { method: "POST" });
}
/**
* Verifies a batch of selected API keys.
* @param {string[]} keys - An array of API keys to verify.
* @returns {Promise<object>} A promise that resolves to the API response data.
*/
async verifySelectedKeys(keys) {
return await apiFetch(`/gemini/v1beta/verify-selected-keys`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ keys }),
});
}
/**
* Resets the failure count for a single API key.
* @param {string} key - The API key whose failure count is to be reset.
* @returns {Promise<object>} A promise that resolves to the API response data.
*/
async resetFailCount(key) {
return await apiFetch(`/gemini/v1beta/reset-fail-count/${key}`, { method: "POST" });
}
/**
* Resets the failure count for a batch of selected API keys.
* @param {string[]} keys - An array of API keys to reset.
* @returns {Promise<object>} A promise that resolves to the API response data.
*/
async resetSelectedFailCounts(keys) {
return await apiFetch(`/gemini/v1beta/reset-selected-fail-counts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ keys }),
});
}
/**
* Deletes a single API key.
* @param {string} key - The API key to delete.
* @returns {Promise<object>} A promise that resolves to the API response data.
*/
async deleteKey(key) {
return await apiFetch(`/api/config/keys/${key}`, { method: "DELETE" });
}
/**
* Deletes a batch of selected API keys.
* @param {string[]} keys - An array of API keys to delete.
* @returns {Promise<object>} A promise that resolves to the API response data.
*/
async deleteSelectedKeys(keys) {
return await apiFetch("/api/config/keys/delete-selected", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ keys }),
});
}
/**
* Fetches all keys, both valid and invalid.
* @returns {Promise<object>} A promise that resolves to an object containing 'valid_keys' and 'invalid_keys' arrays.
*/
async fetchAllKeys() {
return await apiFetch('/api/keys/all');
}
/**
* Fetches usage details for a specific key over the last 24 hours.
* @param {string} key - The API key to get details for.
* @returns {Promise<object>} A promise that resolves to the API response data.
*/
async getKeyUsageDetails(key) {
return await apiFetch(`/api/key-usage-details/${key}`);
}
/**
* Fetches API call statistics for a given period.
* @param {string} period - The time period for the stats (e.g., '1m', '1h', '24h').
* @returns {Promise<object>} A promise that resolves to the API response data.
*/
async getStatsDetails(period) {
return await apiFetch(`/api/stats/details?period=${period}`);
}
}
export const apiKeyManager = new ApiKeyManager();

View File

@@ -0,0 +1,126 @@
// Filename: frontend/js/components/customSelect.js
export default class CustomSelect {
constructor(container) {
this.container = container;
this.trigger = this.container.querySelector('.custom-select-trigger');
this.panel = this.container.querySelector('.custom-select-panel');
if (!this.trigger || !this.panel) {
console.warn('CustomSelect cannot initialize: missing .custom-select-trigger or .custom-select-panel.', this.container);
return;
}
this.nativeSelect = this.container.querySelector('select');
this.triggerText = this.trigger.querySelector('span');
this.template = this.panel.querySelector('.custom-select-option-template');
if (typeof CustomSelect.openInstance === 'undefined') {
CustomSelect.openInstance = null;
CustomSelect.initGlobalListener();
}
if (this.nativeSelect) {
this.generateOptions();
this.updateTriggerText();
}
this.bindEvents();
}
static initGlobalListener() {
document.addEventListener('click', (event) => {
if (CustomSelect.openInstance && !CustomSelect.openInstance.container.contains(event.target)) {
CustomSelect.openInstance.close();
}
});
}
generateOptions() {
this.panel.querySelectorAll(':scope > *:not(.custom-select-option-template)').forEach(child => child.remove());
Array.from(this.nativeSelect.options).forEach(option => {
let item;
if (this.template) {
item = this.template.cloneNode(true);
item.classList.remove('custom-select-option-template');
item.removeAttribute('hidden');
} else {
item = document.createElement('a');
item.href = '#';
item.className = 'block px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-600';
}
item.classList.add('custom-select-option');
item.textContent = option.textContent;
item.dataset.value = option.value;
if (option.selected) { item.classList.add('is-selected'); }
this.panel.appendChild(item);
});
}
bindEvents() {
this.trigger.addEventListener('click', (event) => {
// [NEW] Guard clause: If the trigger is functionally disabled, do nothing.
if (this.trigger.classList.contains('is-disabled')) {
return;
}
event.stopPropagation();
if (CustomSelect.openInstance && CustomSelect.openInstance !== this) {
CustomSelect.openInstance.close();
}
this.toggle();
});
if (this.nativeSelect) {
this.panel.addEventListener('click', (event) => {
event.preventDefault();
const option = event.target.closest('.custom-select-option');
if (option) { this.selectOption(option); }
});
}
}
selectOption(optionEl) {
const selectedValue = optionEl.dataset.value;
if (this.nativeSelect.value !== selectedValue) {
this.nativeSelect.value = selectedValue;
this.nativeSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
this.updateTriggerText();
this.panel.querySelectorAll('.custom-select-option').forEach(el => el.classList.remove('is-selected'));
optionEl.classList.add('is-selected');
this.close();
}
updateTriggerText() {
// [IMPROVEMENT] Guard against missing elements.
if (!this.nativeSelect || !this.triggerText) return;
const selectedOption = this.nativeSelect.options[this.nativeSelect.selectedIndex];
if (selectedOption) {
this.triggerText.textContent = selectedOption.textContent;
}
}
toggle() {
this.panel.classList.toggle('hidden');
if (this.panel.classList.contains('hidden')) {
if (CustomSelect.openInstance === this) {
CustomSelect.openInstance = null;
}
} else {
CustomSelect.openInstance = this;
}
}
open() {
this.panel.classList.remove('hidden');
CustomSelect.openInstance = this;
}
close() {
this.panel.classList.add('hidden');
if (CustomSelect.openInstance === this) {
CustomSelect.openInstance = null;
}
}
}

View File

@@ -0,0 +1,80 @@
export default class SlidingTabs {
/**
* @param {HTMLElement} containerElement - The main container element with the `data-sliding-tabs-container` attribute.
*/
constructor(containerElement) {
this.container = containerElement;
this.indicator = this.container.querySelector('[data-tab-indicator]');
this.tabs = this.container.querySelectorAll('[data-tab-item]');
// Find the initially active tab and store it as the component's state
this.activeTab = this.container.querySelector('.tab-active');
if (!this.indicator || this.tabs.length === 0) {
console.error('SlidingTabs component is missing required elements (indicator or items).', this.container);
return;
}
this.init();
}
init() {
// Set initial indicator position
if (this.activeTab) {
// Use a small delay to ensure layout is fully calculated
setTimeout(() => this.updateIndicator(this.activeTab), 50);
}
// Bind all necessary event listeners
this.bindEvents();
}
updateIndicator(targetTab) {
if (!targetTab) return;
const containerRect = this.container.getBoundingClientRect();
const targetRect = targetTab.getBoundingClientRect();
const left = targetRect.left - containerRect.left;
const width = targetRect.width;
this.indicator.style.left = `${left}px`;
this.indicator.style.width = `${width}px`;
}
bindEvents() {
this.tabs.forEach(tab => {
// On click, update the active state
tab.addEventListener('click', (e) => {
// e.preventDefault(); // Uncomment if using <a> tags for SPA routing
if (this.activeTab) {
this.activeTab.classList.remove('tab-active');
}
tab.classList.add('tab-active');
this.activeTab = tab; // Update the component's state
this.updateIndicator(this.activeTab);
});
// On hover, preview the indicator position
tab.addEventListener('mouseenter', () => {
this.updateIndicator(tab);
});
});
// When the mouse leaves the entire container, reset indicator to the active tab
this.container.addEventListener('mouseleave', () => {
this.updateIndicator(this.activeTab);
});
}
}
// ---- Auto-Initialization Logic ----
// This is the "bootstrapper". It finds all components on the page and brings them to life.
document.addEventListener('DOMContentLoaded', () => {
const allTabContainers = document.querySelectorAll('[data-sliding-tabs-container]');
allTabContainers.forEach(container => {
new SlidingTabs(container);
});
});

View File

@@ -0,0 +1,132 @@
// frontend/js/components/tagInput.js
export default class TagInput {
constructor(container, options = {}) {
if (!container) {
console.error("TagInput container not found.");
return;
}
this.container = container;
this.input = container.querySelector('.tag-input-new');
this.tags = [];
this.options = {
validator: /.+/,
validationMessage: '输入格式无效',
...options
};
this.copyBtn = document.createElement('button');
this.copyBtn.className = 'tag-copy-btn';
this.copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
this.copyBtn.title = '复制所有';
this.container.appendChild(this.copyBtn);
this._initEventListeners();
}
_initEventListeners() {
this.container.addEventListener('click', (e) => {
// 使用 .closest() 来处理点击事件,即使点击到图标也能正确触发
if (e.target.closest('.tag-delete')) {
this._removeTag(e.target.closest('.tag-item'));
}
});
if (this.input) {
this.input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ',' || e.key === ' ') {
e.preventDefault();
const value = this.input.value.trim();
if (value) {
this._addTag(value);
this.input.value = '';
}
}
});
this.input.addEventListener('blur', () => {
const value = this.input.value.trim();
if (value) {
this._addTag(value);
this.input.value = '';
}
});
}
// 为“复制”按钮绑定点击事件
this.copyBtn.addEventListener('click', this._handleCopyAll.bind(this));
}
_addTag(raw_value) {
// 在所有操作之前,自动转换为小写
const value = raw_value.toLowerCase();
if (!this.options.validator.test(value)) {
console.warn(`Tag validation failed for value: "${value}". Rule: ${this.options.validator}`);
this.input.placeholder = this.options.validationMessage;
this.input.classList.add('input-error');
setTimeout(() => {
this.input.classList.remove('input-error');
this.input.placeholder = '添加...';
}, 2000);
return;
}
if (this.tags.includes(value)) return;
this.tags.push(value);
const tagEl = document.createElement('span');
tagEl.className = 'tag-item';
tagEl.innerHTML = `<span class="tag-text">${value}</span><button class="tag-delete">&times;</button>`;
this.container.insertBefore(tagEl, this.input);
}
// 处理复制逻辑的专用方法
_handleCopyAll() {
const tagsString = this.tags.join(',');
if (!tagsString) {
// 如果没有标签,可以给个提示
this.copyBtn.innerHTML = '<span>无内容!</span>';
this.copyBtn.classList.add('none');
setTimeout(() => {
this.copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
this.copyBtn.classList.remove('copied');
}, 1500);
return;
}
navigator.clipboard.writeText(tagsString).then(() => {
// 复制成功,提供视觉反馈
this.copyBtn.innerHTML = '<span>已复制!</span>';
this.copyBtn.classList.add('copied');
setTimeout(() => {
this.copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
this.copyBtn.classList.remove('copied');
}, 2000);
}).catch(err => {
// 复制失败
console.error('Could not copy text: ', err);
this.copyBtn.innerHTML = '<span>失败!</span>';
setTimeout(() => {
this.copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
}, 2000);
});
}
_removeTag(tagEl) {
const value = tagEl.querySelector('.tag-text').textContent;
this.tags = this.tags.filter(t => t !== value);
tagEl.remove();
}
getValues() {
return this.tags;
}
setValues(values) {
this.container.querySelectorAll('.tag-item').forEach(el => el.remove());
this.tags = [];
if (Array.isArray(values)) {
values.filter(value => value).forEach(value => this._addTag(value));
}
}
}

View File

@@ -0,0 +1,502 @@
/**
* @file taskCenter.js
* @description Centralizes Task component classes for global.
* This module exports singleton instances of `TaskCenterManager` and `ToastManager`
* to ensure consistent task service across the application.
*/
// ===================================================================
// 任务中心UI管理器
// ===================================================================
/**
* Manages the UI and state for the global asynchronous task center.
* It handles task rendering, state updates, and user interactions like
* opening/closing the panel and clearing completed tasks.
*/
class TaskCenterManager {
constructor() {
// --- 核心状态 ---
this.tasks = []; // A history of all tasks started in this session.
this.activePolls = new Map();
this.heartbeatInterval = null;
this.MINIMUM_TASK_DISPLAY_TIME_MS = 800;
this.hasUnreadCompletedTasks = false;
this.isAnimating = false;
this.countdownTimer = null;
// --- 核心DOM元素引用 ---
this.trigger = document.getElementById('task-hub-trigger');
this.panel = document.getElementById('task-hub-panel');
this.countdownBar = document.getElementById('task-hub-countdown-bar');
this.countdownRing = document.getElementById('task-hub-countdown-ring');
this.indicator = document.getElementById('task-hub-indicator');
this.clearBtn = document.getElementById('task-hub-clear-btn');
this.taskListContainer = document.getElementById('task-list-container');
this.emptyState = document.getElementById('task-list-empty');
}
// [THE FINAL, DEFINITIVE VERSION]
init() {
if (!this.trigger || !this.panel) {
console.warn('Task Center UI core elements not found. Initialization skipped.');
return;
}
// --- UI Event Listeners (Corrected and final) ---
this.trigger.addEventListener('click', (event) => {
event.stopPropagation();
if (this.isAnimating) return;
if (this.panel.classList.contains('hidden')) {
this._handleUserInteraction();
this.openPanel();
} else {
this.closePanel();
}
});
document.addEventListener('click', (event) => {
if (!this.panel.classList.contains('hidden') && !this.isAnimating && !this.panel.contains(event.target) && !this.trigger.contains(event.target)) {
this.closePanel();
}
});
this.trigger.addEventListener('mouseenter', this._stopCountdown.bind(this));
this.panel.addEventListener('mouseenter', this._handleUserInteraction.bind(this));
const handleMouseLeave = () => {
if (!this.panel.classList.contains('hidden')) {
this._startCountdown();
}
};
this.panel.addEventListener('mouseleave', handleMouseLeave);
this.trigger.addEventListener('mouseleave', handleMouseLeave);
this.clearBtn?.addEventListener('click', this.clearCompletedTasks.bind(this));
this.taskListContainer.addEventListener('click', (event) => {
const toggleHeader = event.target.closest('[data-task-toggle]');
if (!toggleHeader) return;
this._handleUserInteraction();
const taskItem = toggleHeader.closest('.task-list-item');
const content = taskItem.querySelector('[data-task-content]');
if (!content) return;
const isCollapsed = content.classList.contains('collapsed');
toggleHeader.classList.toggle('expanded', isCollapsed);
if (isCollapsed) {
content.classList.remove('collapsed');
content.style.maxHeight = `${content.scrollHeight}px`;
content.style.opacity = '1';
content.addEventListener('transitionend', () => {
if (!content.classList.contains('collapsed')) content.style.maxHeight = 'none';
}, { once: true });
} else {
content.style.maxHeight = `${content.scrollHeight}px`;
requestAnimationFrame(() => {
content.style.maxHeight = '0px';
content.style.opacity = '0';
content.classList.add('collapsed');
});
}
});
this._render();
// [CRITICAL FIX] IGNITION! START THE ENGINE!
this._startHeartbeat();
console.log('Task Center UI Initialized [Multi-Task Heartbeat Polling Architecture - IGNITED].');
}
async startTask(taskDefinition) {
try {
const initialTaskData = await taskDefinition.start();
if (!initialTaskData || !initialTaskData.id) throw new Error("Task definition did not return a valid initial task object.");
const newTask = {
id: initialTaskData.id,
definition: taskDefinition,
data: initialTaskData,
timestamp: new Date(),
startTime: Date.now()
};
if (!initialTaskData.is_running) {
console.log(`[TaskCenter] Task ${newTask.id} completed synchronously. Skipping poll.`);
// We still show a brief toast for UX feedback.
taskDefinition.renderToastNarrative(newTask.data, {}, toastManager);
this.tasks.unshift(newTask);
this._render();
this._handleTaskCompletion(newTask);
return; // IMPORTANT: Exit here to avoid adding it to the polling queue.
}
this.tasks.unshift(newTask);
this.activePolls.set(newTask.id, newTask);
this._render();
this.openPanel();
taskDefinition.renderToastNarrative(newTask.data, {}, toastManager);
this._updateIndicatorState(); // [SAFETY] Update indicator immediately on new task.
} catch (error) {
console.error("Failed to start task:", error);
toastManager.show(`任务启动失败: ${error.message}`, 'error');
}
}
_startHeartbeat() {
if (this.heartbeatInterval) return;
this.heartbeatInterval = setInterval(this._tick.bind(this), 1500);
}
_stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
async _tick() {
if (this.activePolls.size === 0) {
return;
}
// Iterate over a copy of keys to safely remove items during iteration.
for (const taskId of [...this.activePolls.keys()]) {
const task = this.activePolls.get(taskId);
if (!task) continue; // Safety check
try {
const response = await task.definition.poll(taskId);
if (!response.success || !response.data) throw new Error(response.message || "Polling failed");
const oldData = { ...task.data };
task.data = response.data;
this._updateTaskItemInHistory(task.id, task.data); // [SAFETY] Keep history in sync.
task.definition.renderToastNarrative(task.data, oldData, toastManager);
if (!task.data.is_running) {
this._handleTaskCompletion(task);
}
} catch (error) {
console.error(`Polling for task ${taskId} failed:`, error);
task.data.error = error.message;
this._updateTaskItemInHistory(task.id, task.data);
this._handleTaskCompletion(task);
}
}
}
_handleTaskCompletion(task) {
this.activePolls.delete(task.id);
this._updateIndicatorState(); // [SAFETY] Update indicator as soon as a task is no longer active.
const toastId = `task-${task.id}`;
const finalize = async () => {
await toastManager.dismiss(toastId, !task.data.error);
this._updateTaskItemInDom(task);
this.hasUnreadCompletedTasks = true;
this._updateIndicatorState();
if (task.data.error) {
if (task.definition.onError) task.definition.onError(task.data);
} else {
if (task.definition.onSuccess) task.definition.onSuccess(task.data);
}
};
const elapsedTime = Date.now() - task.startTime;
const remainingTime = this.MINIMUM_TASK_DISPLAY_TIME_MS - elapsedTime;
if (remainingTime > 0) {
setTimeout(finalize, remainingTime);
} else {
finalize();
}
}
// [REFACTORED for robustness]
_updateIndicatorState() {
const hasRunningTasks = this.activePolls.size > 0;
const shouldBeVisible = hasRunningTasks || this.hasUnreadCompletedTasks;
this.indicator.classList.toggle('hidden', !shouldBeVisible);
}
// [REFACTORED for robustness]
clearCompletedTasks() {
// Only keep tasks that are still in the active polling map.
this.tasks = this.tasks.filter(task => this.activePolls.has(task.id));
this.hasUnreadCompletedTasks = false;
this._render();
}
// [NEW SAFETY METHOD]
_updateTaskItemInHistory(taskId, newData) {
const taskInHistory = this.tasks.find(t => t.id === taskId);
if (taskInHistory) {
taskInHistory.data = newData;
}
}
// --- 渲染与DOM操作 ---
_render() {
this.taskListContainer.innerHTML = this.tasks.map(task => this._createTaskItemHtml(task)).join('');
const hasTasks = this.tasks.length > 0;
this.taskListContainer.classList.toggle('hidden', !hasTasks);
this.emptyState.classList.toggle('hidden', hasTasks);
this._updateIndicatorState();
}
_createTaskItemHtml(task) {
// [MODIFIED] 将 this._formatTimeAgo 作为一个服务传递给渲染器
const innerHtml = task.definition.renderTaskCenterItem(task.data, task.timestamp, this._formatTimeAgo);
return `<div class="task-list-item" data-task-id="${task.id}">${innerHtml}</div>`;
}
_updateTaskItemInDom(task) {
const item = this.taskListContainer.querySelector(`[data-task-id="${task.id}"]`);
if (item) {
// [MODIFIED] 将 this._formatTimeAgo 作为一个服务传递给渲染器
item.innerHTML = task.definition.renderTaskCenterItem(task.data, task.timestamp, this._formatTimeAgo);
}
}
// --- 核心面板开关逻辑 ---
openPanel() {
if (this.isAnimating || !this.panel.classList.contains('hidden')) return;
this.isAnimating = true;
this.panel.classList.remove('hidden');
this.panel.classList.add('animate-panel-in');
// 动画结束后,启动倒计时
setTimeout(() => {
this.panel.classList.remove('animate-panel-in');
this.isAnimating = false;
this._startCountdown(); // 启动倒计时
}, 150);
}
closePanel() {
if (this.isAnimating || this.panel.classList.contains('hidden')) return;
this._stopCountdown(); // [修改] 关闭前立即停止倒计时
this.isAnimating = true;
this.panel.classList.add('animate-panel-out');
setTimeout(() => {
this.panel.classList.remove('animate-panel-out');
this.panel.classList.add('hidden');
this.isAnimating = false;
}, 50);
}
// --- [新增] 倒计时管理方法 ---
/**
* 启动或重启倒计时和进度条动画
* @private
*/
_startCountdown() {
this._stopCountdown(); // 先重置
// 启动进度条动画
this.countdownBar.classList.add('w-full', 'duration-[4950ms]');
// 启动圆环动画 (通过可靠的JS强制重绘)
this.countdownRing.style.transition = 'none'; // 1. 禁用动画
this.countdownRing.style.strokeDashoffset = '72.26'; // 2. 立即重置
void this.countdownRing.offsetHeight; // 3. 强制浏览器重排
this.countdownRing.style.transition = 'stroke-dashoffset 4.95s linear'; // 4. 重新启用动画
this.countdownRing.style.strokeDashoffset = '0'; // 5. 设置目标值,开始动画
// 启动关闭计时器
this.countdownTimer = setTimeout(() => {
this.closePanel();
}, 4950);
}
/**
* 停止倒计时并重置进度条
* @private
*/
_stopCountdown() {
if (this.countdownTimer) {
clearTimeout(this.countdownTimer);
this.countdownTimer = null;
}
// 重置进度条的视觉状态
this.countdownBar.classList.remove('w-full');
this.countdownRing.style.transition = 'none';
this.countdownRing.style.strokeDashoffset = '72.26';
}
// [NEW] A central handler for any action that confirms the user has seen the panel.
_handleUserInteraction() {
// 1. Stop the auto-close countdown because the user is now interacting.
this._stopCountdown();
// 2. If there were unread tasks, mark them as read *now*.
if (this.hasUnreadCompletedTasks) {
this.hasUnreadCompletedTasks = false;
this._updateIndicatorState(); // The indicator light turns off at this moment.
}
}
_formatTimeAgo(date) {
if (!date) return '';
const seconds = Math.floor((new Date() - new Date(date)) / 1000);
if (seconds < 2) return "刚刚";
if (seconds < 60) return `${seconds}秒前`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}小时前`;
const days = Math.floor(hours / 24);
return `${days}天前`;
}
}
// ===================================================================
// [NEW] Toast 通知管理器
// ===================================================================
class ToastManager {
constructor() {
this.container = document.getElementById('toast-container');
if (!this.container) {
this.container = document.createElement('div');
this.container.id = 'toast-container';
this.container.className = 'fixed bottom-4 right-4 z-[100] w-full max-w-sm space-y-3'; // 宽度可稍大
document.body.appendChild(this.container);
}
this.activeToasts = new Map(); // [NEW] 用于跟踪可更新的进度Toast
}
/**
* 显示一个 Toast 通知
* @param {string} message - The message to display.
* @param {string} [type='info'] - 'info', 'success', or 'error'.
* @param {number} [duration=4000] - Duration in milliseconds.
*/
show(message, type = 'info', duration = 4000) {
const toastElement = this._createToastHtml(message, type);
this.container.appendChild(toastElement);
// 强制重绘以触发入场动画
requestAnimationFrame(() => {
toastElement.classList.remove('opacity-0', 'translate-y-2');
toastElement.classList.add('opacity-100', 'translate-y-0');
});
// 设置定时器以移除 Toast
setTimeout(() => {
toastElement.classList.remove('opacity-100', 'translate-y-0');
toastElement.classList.add('opacity-0', 'translate-y-2');
// 在动画结束后从 DOM 中移除
toastElement.addEventListener('transitionend', () => toastElement.remove(), { once: true });
}, duration);
}
// [NEW] 创建或更新一个带进度条的Toast
showProgressToast(toastId, title, message, progress) {
if (this.activeToasts.has(toastId)) {
// --- 更新现有Toast ---
const toastElement = this.activeToasts.get(toastId);
const messageEl = toastElement.querySelector('.toast-message');
const progressBar = toastElement.querySelector('.toast-progress-bar');
messageEl.textContent = `${message} - ${Math.round(progress)}%`;
anime({
targets: progressBar,
width: `${progress}%`,
duration: 400,
easing: 'easeOutQuad'
});
} else {
// --- 创建新的Toast ---
const toastElement = this._createProgressToastHtml(toastId, title, message, progress);
this.container.appendChild(toastElement);
this.activeToasts.set(toastId, toastElement);
requestAnimationFrame(() => {
toastElement.classList.remove('opacity-0', 'translate-x-full');
toastElement.classList.add('opacity-100', 'translate-x-0');
});
}
}
// [NEW] 移除一个进度Toast
dismiss(toastId, success = null) {
return new Promise((resolve) => {
if (!this.activeToasts.has(toastId)) {
resolve();
return;
}
const toastElement = this.activeToasts.get(toastId);
const performFadeOut = () => {
toastElement.classList.remove('opacity-100', 'translate-x-0');
toastElement.classList.add('opacity-0', 'translate-x-full');
toastElement.addEventListener('transitionend', () => {
toastElement.remove();
this.activeToasts.delete(toastId);
resolve(); // Resolve the promise ONLY when the element is fully gone.
}, { once: true });
};
if (success === null) { // Immediate dismissal
performFadeOut();
} else { // Graceful, animated dismissal
const iconContainer = toastElement.querySelector('.toast-icon');
const messageEl = toastElement.querySelector('.toast-message');
if (success) {
const progressBar = toastElement.querySelector('.toast-progress-bar');
messageEl.textContent = '已完成';
anime({
targets: progressBar,
width: '100%',
duration: 300,
easing: 'easeOutQuad',
complete: () => {
iconContainer.innerHTML = `<i class="fas fa-check-circle text-white"></i>`;
iconContainer.className = `toast-icon bg-green-500`;
setTimeout(performFadeOut, 900);
}
});
} else { // Failure
iconContainer.innerHTML = `<i class="fas fa-times-circle text-white"></i>`;
iconContainer.className = `toast-icon bg-red-500`;
messageEl.textContent = '失败';
setTimeout(performFadeOut, 1200);
}
}
});
}
_createToastHtml(message, type) {
const icons = {
info: { class: 'bg-blue-500', icon: 'fa-info-circle' },
success: { class: 'bg-green-500', icon: 'fa-check-circle' },
error: { class: 'bg-red-500', icon: 'fa-exclamation-triangle' }
};
const typeInfo = icons[type] || icons.info;
const toast = document.createElement('div');
toast.className = 'toast-item opacity-0 translate-y-2 transition-all duration-300 ease-out'; // 初始状态为动画准备
toast.innerHTML = `
<div class="toast-icon ${typeInfo.class}">
<i class="fas ${typeInfo.icon}"></i>
</div>
<div class="toast-content">
<p class="toast-title">${this._capitalizeFirstLetter(type)}</p>
<p class="toast-message">${message}</p>
</div>
`;
return toast;
}
// [NEW] 创建带进度条Toast的HTML结构
_createProgressToastHtml(toastId, title, message, progress) {
const toast = document.createElement('div');
toast.className = 'toast-item opacity-0 translate-x-full transition-all duration-300 ease-out';
toast.dataset.toastId = toastId;
toast.innerHTML = `
<div class="toast-icon bg-blue-500">
<i class="fas fa-spinner animate-spin"></i>
</div>
<div class="toast-content">
<p class="toast-title">${title}</p>
<p class="toast-message">${message} - ${Math.round(progress)}%</p>
<div class="w-full bg-slate-200 dark:bg-zinc-700 rounded-full h-1 mt-1.5 overflow-hidden">
<div class="toast-progress-bar bg-blue-500 h-1 rounded-full" style="width: ${progress}%;"></div>
</div>
</div>
`;
return toast;
}
_capitalizeFirstLetter(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
}
export const taskCenterManager = new TaskCenterManager();
export const toastManager = new ToastManager();
// [OPTIONAL] 为了向后兼容或简化调用,可以导出一个独立的 showToast 函数
export const showToast = (message, type, duration) => toastManager.show(message, type, duration);

View File

@@ -0,0 +1,105 @@
// Filename: frontend/js/components/themeManager.js
/**
* 负责管理应用程序的三态主题(系统、亮色、暗色)。
* 封装了所有与主题切换相关的 DOM 操作、事件监听和 localStorage 交互。
*/
export const themeManager = {
// 用于存储图标的 SVG HTML
icons: {},
init: function() {
this.html = document.documentElement;
this.buttons = document.querySelectorAll('.theme-btn');
this.cyclerBtn = document.getElementById('theme-cycler-btn');
this.cyclerIconContainer = document.getElementById('theme-cycler-icon');
if (!this.html || this.buttons.length === 0 || !this.cyclerBtn || !this.cyclerIconContainer) {
console.warn("ThemeManager init failed: one or more required elements not found.");
return;
}
this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
// 初始化时,从三按钮组中提取 SVG 并存储起来
this.storeIcons();
// 绑定宽屏按钮组的点击事件
this.buttons.forEach(btn => {
btn.addEventListener('click', () => this.setTheme(btn.dataset.theme));
});
// 绑定移动端循环按钮的点击事件
this.cyclerBtn.addEventListener('click', () => this.cycleTheme());
this.mediaQuery.addEventListener('change', () => this.applyTheme());
this.applyTheme();
},
// 从现有按钮中提取并存储 SVG 图标
storeIcons: function() {
this.buttons.forEach(btn => {
const theme = btn.dataset.theme;
const svg = btn.querySelector('svg');
if (theme && svg) {
this.icons[theme] = svg.outerHTML;
}
});
},
// 循环切换主题的核心逻辑
cycleTheme: function() {
const themes = ['system', 'light', 'dark'];
const currentTheme = this.getTheme();
const currentIndex = themes.indexOf(currentTheme);
const nextIndex = (currentIndex + 1) % themes.length; // brilliantly simple cycling logic
this.setTheme(themes[nextIndex]);
},
applyTheme: function() {
let theme = this.getTheme();
if (theme === 'system') {
theme = this.mediaQuery.matches ? 'dark' : 'light';
}
if (theme === 'dark') {
this.html.classList.add('dark');
} else {
this.html.classList.remove('dark');
}
this.updateButtons();
this.updateCyclerIcon();
},
setTheme: function(theme) {
localStorage.setItem('theme', theme);
this.applyTheme();
},
getTheme: function() {
return localStorage.getItem('theme') || 'system';
},
updateButtons: function() {
const currentTheme = this.getTheme();
this.buttons.forEach(btn => {
if (btn.dataset.theme === currentTheme) {
btn.classList.add('bg-white', 'dark:bg-zinc-700');
} else {
btn.classList.remove('bg-white', 'dark:bg-zinc-700');
}
});
},
// 更新移动端循环按钮的图标
updateCyclerIcon: function() {
if (this.cyclerIconContainer) {
const currentTheme = this.getTheme();
// 从我们存储的 icons 对象中找到对应的 SVG 并注入
if (this.icons[currentTheme]) {
this.cyclerIconContainer.innerHTML = this.icons[currentTheme];
}
}
}
};

View File

@@ -0,0 +1,338 @@
/**
* @file ui.js
* @description Centralizes UI component classes for modals and common UI patterns.
* This module exports singleton instances of `ModalManager` and `UIPatterns`
* to ensure consistent UI behavior across the application.
*/
/**
* Manages the display and interaction of various modals across the application.
* This class centralizes modal logic to ensure consistency and ease of use.
* It assumes specific HTML structures for modals (e.g., resultModal, progressModal).
*/
class ModalManager {
/**
* Shows a generic modal by its ID.
* @param {string} modalId The ID of the modal element to show.
*/
show(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.remove("hidden");
} else {
console.error(`Modal with ID "${modalId}" not found.`);
}
}
/**
* Hides a generic modal by its ID.
* @param {string} modalId The ID of the modal element to hide.
*/
hide(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.add("hidden");
} else {
console.error(`Modal with ID "${modalId}" not found.`);
}
}
/**
* Shows a confirmation dialog. This is a versatile method for 'Are you sure?' style prompts.
* It dynamically sets the title, message, and confirm action for a generic confirmation modal.
* @param {object} options - The options for the confirmation modal.
* @param {string} options.modalId - The ID of the confirmation modal element (e.g., 'resetModal', 'deleteConfirmModal').
* @param {string} options.title - The title to display in the modal header.
* @param {string} options.message - The message to display in the modal body. Can contain HTML.
* @param {function} options.onConfirm - The callback function to execute when the confirm button is clicked.
* @param {boolean} [options.disableConfirm=false] - Whether the confirm button should be initially disabled.
*/
showConfirm({ modalId, title, message, onConfirm, disableConfirm = false }) {
const modalElement = document.getElementById(modalId);
if (!modalElement) {
console.error(`Confirmation modal with ID "${modalId}" not found.`);
return;
}
const titleElement = modalElement.querySelector('[id$="ModalTitle"]');
const messageElement = modalElement.querySelector('[id$="ModalMessage"]');
const confirmButton = modalElement.querySelector('[id^="confirm"]');
if (!titleElement || !messageElement || !confirmButton) {
console.error(`Modal "${modalId}" is missing required child elements (title, message, or confirm button).`);
return;
}
titleElement.textContent = title;
messageElement.innerHTML = message;
confirmButton.disabled = disableConfirm;
// Re-clone the button to remove old event listeners and attach the new one.
const newConfirmButton = confirmButton.cloneNode(true);
confirmButton.parentNode.replaceChild(newConfirmButton, confirmButton);
newConfirmButton.onclick = () => onConfirm();
this.show(modalId);
}
/**
* Shows a result modal to indicate the outcome of an operation (success or failure).
* @param {boolean} success - If true, displays a success icon and title; otherwise, shows failure indicators.
* @param {string|Node} message - The message to display. Can be a simple string or a complex DOM Node for rich content.
* @param {boolean} [autoReload=false] - If true, the page will automatically reload when the modal is closed.
*/
showResult(success, message, autoReload = false) {
const modalElement = document.getElementById("resultModal");
if (!modalElement) {
console.error("Result modal with ID 'resultModal' not found.");
return;
}
const titleElement = document.getElementById("resultModalTitle");
const messageElement = document.getElementById("resultModalMessage");
const iconElement = document.getElementById("resultIcon");
const confirmButton = document.getElementById("resultModalConfirmBtn");
if (!titleElement || !messageElement || !iconElement || !confirmButton) {
console.error("Result modal is missing required child elements.");
return;
}
titleElement.textContent = success ? "操作成功" : "操作失败";
if (success) {
iconElement.innerHTML = '<i class="fas fa-check-circle text-success-500"></i>';
iconElement.className = "text-6xl mb-3 text-success-500";
} else {
iconElement.innerHTML = '<i class="fas fa-times-circle text-danger-500"></i>';
iconElement.className = "text-6xl mb-3 text-danger-500";
}
messageElement.innerHTML = "";
if (typeof message === "string") {
const messageDiv = document.createElement("div");
messageDiv.innerText = message; // Use innerText for security with plain strings
messageElement.appendChild(messageDiv);
} else if (message instanceof Node) {
messageElement.appendChild(message); // Append if it's already a DOM node
} else {
const messageDiv = document.createElement("div");
messageDiv.innerText = String(message);
messageElement.appendChild(messageDiv);
}
confirmButton.onclick = () => this.closeResult(autoReload);
this.show("resultModal");
}
/**
* Closes the result modal.
* @param {boolean} [reload=false] - If true, reloads the page after closing the modal.
*/
closeResult(reload = false) {
this.hide("resultModal");
if (reload) {
location.reload();
}
}
/**
* Shows and initializes the progress modal for long-running operations.
* @param {string} title - The title to display for the progress modal.
*/
showProgress(title) {
const modal = document.getElementById("progressModal");
if (!modal) {
console.error("Progress modal with ID 'progressModal' not found.");
return;
}
const titleElement = document.getElementById("progressModalTitle");
const statusText = document.getElementById("progressStatusText");
const progressBar = document.getElementById("progressBar");
const progressPercentage = document.getElementById("progressPercentage");
const progressLog = document.getElementById("progressLog");
const closeButton = document.getElementById("progressModalCloseBtn");
const closeIcon = document.getElementById("closeProgressModalBtn");
if (!titleElement || !statusText || !progressBar || !progressPercentage || !progressLog || !closeButton || !closeIcon) {
console.error("Progress modal is missing required child elements.");
return;
}
titleElement.textContent = title;
statusText.textContent = "准备开始...";
progressBar.style.width = "0%";
progressPercentage.textContent = "0%";
progressLog.innerHTML = "";
closeButton.disabled = true;
closeIcon.disabled = true;
this.show("progressModal");
}
/**
* Updates the progress bar and status text within the progress modal.
* @param {number} processed - The number of items that have been processed.
* @param {number} total - The total number of items to process.
* @param {string} status - The current status message to display.
*/
updateProgress(processed, total, status) {
const modal = document.getElementById("progressModal");
if (!modal || modal.classList.contains('hidden')) return;
const progressBar = document.getElementById("progressBar");
const progressPercentage = document.getElementById("progressPercentage");
const statusText = document.getElementById("progressStatusText");
const closeButton = document.getElementById("progressModalCloseBtn");
const closeIcon = document.getElementById("closeProgressModalBtn");
const percentage = total > 0 ? Math.round((processed / total) * 100) : 0;
progressBar.style.width = `${percentage}%`;
progressPercentage.textContent = `${percentage}%`;
statusText.textContent = status;
if (processed === total) {
closeButton.disabled = false;
closeIcon.disabled = false;
}
}
/**
* Adds a log entry to the progress modal's log area.
* @param {string} message - The log message to append.
* @param {boolean} [isError=false] - If true, styles the log entry as an error.
*/
addProgressLog(message, isError = false) {
const progressLog = document.getElementById("progressLog");
if (!progressLog) return;
const logEntry = document.createElement("div");
logEntry.textContent = message;
logEntry.className = isError ? "text-danger-600" : "text-gray-700";
progressLog.appendChild(logEntry);
progressLog.scrollTop = progressLog.scrollHeight; // Auto-scroll to the latest log
}
/**
* Closes the progress modal.
* @param {boolean} [reload=false] - If true, reloads the page after closing.
*/
closeProgress(reload = false) {
this.hide("progressModal");
if (reload) {
location.reload();
}
}
}
/**
* Provides a collection of common UI patterns and animations.
* This class includes helpers for creating engaging and consistent user experiences,
* such as animated counters and collapsible sections.
*/
class UIPatterns {
/**
* Animates numerical values in elements from 0 to their target number.
* The target number is read from the element's text content.
* @param {string} selector - The CSS selector for the elements to animate (e.g., '.stat-value').
* @param {number} [duration=1500] - The duration of the animation in milliseconds.
*/
animateCounters(selector = ".stat-value", duration = 1500) {
const statValues = document.querySelectorAll(selector);
statValues.forEach((valueElement) => {
const finalValue = parseInt(valueElement.textContent, 10);
if (isNaN(finalValue)) return;
if (!valueElement.dataset.originalValue) {
valueElement.dataset.originalValue = valueElement.textContent;
}
let startValue = 0;
const startTime = performance.now();
const updateCounter = (currentTime) => {
const elapsedTime = currentTime - startTime;
if (elapsedTime < duration) {
const progress = elapsedTime / duration;
const easeOutValue = 1 - Math.pow(1 - progress, 3); // Ease-out cubic
const currentValue = Math.floor(easeOutValue * finalValue);
valueElement.textContent = currentValue;
requestAnimationFrame(updateCounter);
} else {
valueElement.textContent = valueElement.dataset.originalValue; // Ensure final value is accurate
}
};
requestAnimationFrame(updateCounter);
});
}
/**
* Toggles the visibility of a content section with a smooth height animation.
* It expects a specific HTML structure where the header and content are within a common parent (e.g., a card).
* The content element should have a `collapsed` class when hidden.
* @param {HTMLElement} header - The header element that was clicked to trigger the toggle.
*/
toggleSection(header) {
const card = header.closest(".stats-card");
if (!card) return;
const content = card.querySelector(".key-content");
const toggleIcon = header.querySelector(".toggle-icon");
if (!content || !toggleIcon) {
console.error("Toggle section failed: Content or icon element not found.", { header });
return;
}
const isCollapsed = content.classList.contains("collapsed");
toggleIcon.classList.toggle("collapsed", !isCollapsed);
if (isCollapsed) {
// Expand
content.classList.remove("collapsed");
content.style.maxHeight = null;
content.style.opacity = null;
content.style.paddingTop = null;
content.style.paddingBottom = null;
content.style.overflow = "hidden";
requestAnimationFrame(() => {
const targetHeight = content.scrollHeight;
content.style.maxHeight = `${targetHeight}px`;
content.style.opacity = "1";
content.style.paddingTop = "1rem"; // Assumes p-4, adjust if needed
content.style.paddingBottom = "1rem";
content.addEventListener("transitionend", function onExpansionEnd() {
content.removeEventListener("transitionend", onExpansionEnd);
if (!content.classList.contains("collapsed")) {
content.style.maxHeight = "";
content.style.overflow = "visible";
}
}, { once: true });
});
} else {
// Collapse
const currentHeight = content.scrollHeight;
content.style.maxHeight = `${currentHeight}px`;
content.style.overflow = "hidden";
requestAnimationFrame(() => {
content.style.maxHeight = "0px";
content.style.opacity = "0";
content.style.paddingTop = "0";
content.style.paddingBottom = "0";
content.classList.add("collapsed");
});
}
}
}
/**
* Exports singleton instances of the UI component classes for easy import and use elsewhere.
* This allows any part of the application to access the same instance of ModalManager and UIPatterns,
* ensuring a single source of truth for UI component management.
*/
export const modalManager = new ModalManager();
export const uiPatterns = new UIPatterns();