Initial commit
This commit is contained in:
312
frontend/js/components/apiKeyManager.js
Normal file
312
frontend/js/components/apiKeyManager.js
Normal 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();
|
||||
126
frontend/js/components/customSelect.js
Normal file
126
frontend/js/components/customSelect.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
80
frontend/js/components/slidingTabs.js
Normal file
80
frontend/js/components/slidingTabs.js
Normal 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);
|
||||
});
|
||||
});
|
||||
132
frontend/js/components/tagInput.js
Normal file
132
frontend/js/components/tagInput.js
Normal 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">×</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));
|
||||
}
|
||||
}
|
||||
}
|
||||
502
frontend/js/components/taskCenter.js
Normal file
502
frontend/js/components/taskCenter.js
Normal 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);
|
||||
105
frontend/js/components/themeManager.js
Normal file
105
frontend/js/components/themeManager.js
Normal 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
338
frontend/js/components/ui.js
Normal file
338
frontend/js/components/ui.js
Normal 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();
|
||||
|
||||
Reference in New Issue
Block a user