feat(webui): add Tailwind build system and refactor frontend architecture
- Replace Tailwind CDN with local build (PostCSS + autoprefixer + daisyui) - Add CSS build scripts with automatic prepare hook on npm install - Create account-actions.js service layer with unified response format - Extend ErrorHandler.withLoading() for automatic loading state management - Add skeleton screens for initial load, silent refresh for subsequent updates - Implement loading animations for async operations (buttons, modals) - Improve empty states and add ARIA labels for accessibility - Abstract component styles using @apply (buttons, badges, inputs) - Add JSDoc documentation for Dashboard modules - Update README and CLAUDE.md with development guidelines
This commit is contained in:
@@ -7,6 +7,10 @@ window.Components = window.Components || {};
|
||||
window.Components.accountManager = () => ({
|
||||
searchQuery: '',
|
||||
deleteTarget: '',
|
||||
refreshing: false,
|
||||
toggling: false,
|
||||
deleting: false,
|
||||
reloading: false,
|
||||
|
||||
get filteredAccounts() {
|
||||
const accounts = Alpine.store('data').accounts || [];
|
||||
@@ -36,11 +40,15 @@ window.Components.accountManager = () => ({
|
||||
},
|
||||
|
||||
async refreshAccount(email) {
|
||||
const store = Alpine.store('global');
|
||||
store.showToast(store.t('refreshingAccount', { email }), 'info');
|
||||
const password = store.webuiPassword;
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}/refresh`, { method: 'POST' }, password);
|
||||
return await window.ErrorHandler.withLoading(async () => {
|
||||
const store = Alpine.store('global');
|
||||
store.showToast(store.t('refreshingAccount', { email }), 'info');
|
||||
|
||||
const { response, newPassword } = await window.utils.request(
|
||||
`/api/accounts/${encodeURIComponent(email)}/refresh`,
|
||||
{ method: 'POST' },
|
||||
store.webuiPassword
|
||||
);
|
||||
if (newPassword) store.webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
@@ -48,11 +56,9 @@ window.Components.accountManager = () => ({
|
||||
store.showToast(store.t('refreshedAccount', { email }), 'success');
|
||||
Alpine.store('data').fetchData();
|
||||
} else {
|
||||
store.showToast(data.error || store.t('refreshFailed'), 'error');
|
||||
throw new Error(data.error || store.t('refreshFailed'));
|
||||
}
|
||||
} catch (e) {
|
||||
store.showToast(store.t('refreshFailed') + ': ' + e.message, 'error');
|
||||
}
|
||||
}, this, 'refreshing', { errorMessage: 'Failed to refresh account' });
|
||||
},
|
||||
|
||||
async toggleAccount(email, enabled) {
|
||||
@@ -125,11 +131,14 @@ window.Components.accountManager = () => ({
|
||||
|
||||
async executeDelete() {
|
||||
const email = this.deleteTarget;
|
||||
const store = Alpine.store('global');
|
||||
const password = store.webuiPassword;
|
||||
return await window.ErrorHandler.withLoading(async () => {
|
||||
const store = Alpine.store('global');
|
||||
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}`, { method: 'DELETE' }, password);
|
||||
const { response, newPassword } = await window.utils.request(
|
||||
`/api/accounts/${encodeURIComponent(email)}`,
|
||||
{ method: 'DELETE' },
|
||||
store.webuiPassword
|
||||
);
|
||||
if (newPassword) store.webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
@@ -139,18 +148,20 @@ window.Components.accountManager = () => ({
|
||||
document.getElementById('delete_account_modal').close();
|
||||
this.deleteTarget = '';
|
||||
} else {
|
||||
store.showToast(data.error || store.t('deleteFailed'), 'error');
|
||||
throw new Error(data.error || store.t('deleteFailed'));
|
||||
}
|
||||
} catch (e) {
|
||||
store.showToast(store.t('deleteFailed') + ': ' + e.message, 'error');
|
||||
}
|
||||
}, this, 'deleting', { errorMessage: 'Failed to delete account' });
|
||||
},
|
||||
|
||||
async reloadAccounts() {
|
||||
const store = Alpine.store('global');
|
||||
const password = store.webuiPassword;
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request('/api/accounts/reload', { method: 'POST' }, password);
|
||||
return await window.ErrorHandler.withLoading(async () => {
|
||||
const store = Alpine.store('global');
|
||||
|
||||
const { response, newPassword } = await window.utils.request(
|
||||
'/api/accounts/reload',
|
||||
{ method: 'POST' },
|
||||
store.webuiPassword
|
||||
);
|
||||
if (newPassword) store.webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
@@ -158,11 +169,9 @@ window.Components.accountManager = () => ({
|
||||
store.showToast(store.t('accountsReloaded'), 'success');
|
||||
Alpine.store('data').fetchData();
|
||||
} else {
|
||||
store.showToast(data.error || store.t('reloadFailed'), 'error');
|
||||
throw new Error(data.error || store.t('reloadFailed'));
|
||||
}
|
||||
} catch (e) {
|
||||
store.showToast(store.t('reloadFailed') + ': ' + e.message, 'error');
|
||||
}
|
||||
}, this, 'reloading', { errorMessage: 'Failed to reload accounts' });
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
/**
|
||||
* Dashboard Charts Module
|
||||
* Handles Chart.js visualizations (quota distribution & usage trend)
|
||||
* 职责:使用 Chart.js 渲染配额分布图和使用趋势图
|
||||
*
|
||||
* 调用时机:
|
||||
* - dashboard 组件 init() 时初始化图表
|
||||
* - 筛选器变化时更新图表数据
|
||||
* - $store.data 更新时刷新图表
|
||||
*
|
||||
* 图表类型:
|
||||
* 1. Quota Distribution(饼图):按模型家族或具体模型显示配额分布
|
||||
* 2. Usage Trend(折线图):显示历史使用趋势
|
||||
*
|
||||
* 特殊处理:
|
||||
* - 使用 _trendChartUpdateLock 防止并发更新导致的竞争条件
|
||||
* - 通过 debounce 优化频繁更新的性能
|
||||
* - 响应式处理:移动端自动调整图表大小和标签显示
|
||||
*
|
||||
* @module DashboardCharts
|
||||
*/
|
||||
window.DashboardCharts = window.DashboardCharts || {};
|
||||
|
||||
@@ -158,7 +174,7 @@ window.DashboardCharts.updateCharts = function (component) {
|
||||
if (!healthByFamily[family]) {
|
||||
healthByFamily[family] = { total: 0, weighted: 0 };
|
||||
}
|
||||
|
||||
|
||||
// Calculate average health from quotaInfo (each entry has { pct })
|
||||
// Health = average of all account quotas for this model
|
||||
const quotaInfo = row.quotaInfo || [];
|
||||
@@ -172,8 +188,8 @@ window.DashboardCharts.updateCharts = function (component) {
|
||||
});
|
||||
|
||||
// Update overall health for dashboard display
|
||||
component.stats.overallHealth = totalModelCount > 0
|
||||
? Math.round(totalHealthSum / totalModelCount)
|
||||
component.stats.overallHealth = totalModelCount > 0
|
||||
? Math.round(totalHealthSum / totalModelCount)
|
||||
: 0;
|
||||
|
||||
const familyColors = {
|
||||
@@ -355,13 +371,13 @@ window.DashboardCharts.updateTrendChart = function (component) {
|
||||
|
||||
// Determine if data spans multiple days (for smart label formatting)
|
||||
const timestamps = sortedEntries.map(([iso]) => new Date(iso));
|
||||
const isMultiDay = timestamps.length > 1 &&
|
||||
const isMultiDay = timestamps.length > 1 &&
|
||||
timestamps[0].toDateString() !== timestamps[timestamps.length - 1].toDateString();
|
||||
|
||||
// Helper to format X-axis labels based on time range and multi-day status
|
||||
const formatLabel = (date) => {
|
||||
const timeRange = component.timeRange || '24h';
|
||||
|
||||
|
||||
if (timeRange === '7d') {
|
||||
// Week view: show MM/DD
|
||||
return date.toLocaleDateString([], { month: '2-digit', day: '2-digit' });
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
/**
|
||||
* Dashboard Filters Module
|
||||
* Handles model/family filter selection and persistence
|
||||
* 职责:管理图表筛选器的状态和持久化
|
||||
*
|
||||
* 功能:
|
||||
* 1. 时间范围筛选(1h/6h/24h/7d/all)
|
||||
* 2. 显示模式切换(按家族/按模型)
|
||||
* 3. 模型/家族多选筛选
|
||||
* 4. 筛选器状态持久化到 localStorage
|
||||
*
|
||||
* 调用时机:
|
||||
* - 组件初始化时加载用户偏好
|
||||
* - 筛选器变化时保存并触发图表更新
|
||||
*
|
||||
* 持久化键:
|
||||
* - localStorage['dashboard_chart_prefs']
|
||||
*
|
||||
* @module DashboardFilters
|
||||
*/
|
||||
window.DashboardFilters = window.DashboardFilters || {};
|
||||
|
||||
|
||||
@@ -1,12 +1,37 @@
|
||||
/**
|
||||
* Dashboard Stats Module
|
||||
* Handles account statistics calculation
|
||||
* 职责:根据 Alpine.store('data') 计算账号统计数据
|
||||
*
|
||||
* 调用时机:
|
||||
* - dashboard 组件 init() 时
|
||||
* - $store.data 更新时(通过 $watch 监听)
|
||||
*
|
||||
* 统计维度:
|
||||
* - total: 启用账号总数(排除禁用账号)
|
||||
* - active: 有可用配额的账号数
|
||||
* - limited: 配额受限或失效的账号数
|
||||
* - subscription: 按订阅级别分类(ultra/pro/free)
|
||||
*
|
||||
* @module DashboardStats
|
||||
*/
|
||||
window.DashboardStats = window.DashboardStats || {};
|
||||
|
||||
/**
|
||||
* Update account statistics (active, limited, total)
|
||||
* @param {object} component - Dashboard component instance
|
||||
* 更新账号统计数据
|
||||
*
|
||||
* 统计逻辑:
|
||||
* 1. 仅统计启用的账号(enabled !== false)
|
||||
* 2. 优先统计核心模型(Sonnet/Opus/Pro/Flash)的配额
|
||||
* 3. 配额 > 5% 视为 active,否则为 limited
|
||||
* 4. 状态非 'ok' 的账号归为 limited
|
||||
*
|
||||
* @param {object} component - Dashboard 组件实例(Alpine.js 上下文)
|
||||
* @param {object} component.stats - 统计数据对象(会被修改)
|
||||
* @param {number} component.stats.total - 启用账号总数
|
||||
* @param {number} component.stats.active - 活跃账号数
|
||||
* @param {number} component.stats.limited - 受限账号数
|
||||
* @param {object} component.stats.subscription - 订阅级别分布
|
||||
* @returns {void}
|
||||
*/
|
||||
window.DashboardStats.updateStats = function(component) {
|
||||
const accounts = Alpine.store('data').accounts;
|
||||
|
||||
@@ -14,6 +14,7 @@ document.addEventListener('alpine:init', () => {
|
||||
quotaRows: [], // Filtered view
|
||||
usageHistory: {}, // Usage statistics history (from /account-limits?includeHistory=true)
|
||||
loading: false,
|
||||
initialLoad: true, // Track first load for skeleton screen
|
||||
connectionStatus: 'connecting',
|
||||
lastUpdated: '-',
|
||||
|
||||
@@ -36,7 +37,10 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
async fetchData() {
|
||||
this.loading = true;
|
||||
// Only show skeleton on initial load, not on refresh
|
||||
if (this.initialLoad) {
|
||||
this.loading = true;
|
||||
}
|
||||
try {
|
||||
// Get password from global store
|
||||
const password = Alpine.store('global').webuiPassword;
|
||||
@@ -72,6 +76,7 @@ document.addEventListener('alpine:init', () => {
|
||||
store.showToast(store.t('connectionLost'), 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.initialLoad = false; // Mark initial load as complete
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ document.addEventListener('alpine:init', () => {
|
||||
linkedAccounts: "Linked Accounts",
|
||||
noSignal: "NO SIGNAL DETECTED",
|
||||
establishingUplink: "ESTABLISHING UPLINK...",
|
||||
goToAccounts: "Go to Accounts",
|
||||
// Settings - Models
|
||||
modelsDesc: "Configure model visibility, pinning, and request routing.",
|
||||
modelsPageDesc: "Real-time quota and status for all available models.",
|
||||
@@ -326,6 +327,7 @@ document.addEventListener('alpine:init', () => {
|
||||
linkedAccounts: "已关联账号",
|
||||
noSignal: "无信号连接",
|
||||
establishingUplink: "正在建立上行链路...",
|
||||
goToAccounts: "前往账号管理",
|
||||
// Settings - Models
|
||||
modelsDesc: "配置模型的可见性、置顶和请求路由。",
|
||||
modelsPageDesc: "所有可用模型的实时配额和状态。",
|
||||
|
||||
199
public/js/utils/account-actions.js
Normal file
199
public/js/utils/account-actions.js
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Account Actions Service
|
||||
* 纯业务逻辑层 - 处理账号操作的 HTTP 请求、乐观更新和数据刷新
|
||||
* 不包含 UI 关注点(Toast、Loading、模态框由组件层处理)
|
||||
*/
|
||||
window.AccountActions = window.AccountActions || {};
|
||||
|
||||
/**
|
||||
* 刷新账号 token 和配额信息
|
||||
* @param {string} email - 账号邮箱
|
||||
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
|
||||
*/
|
||||
window.AccountActions.refreshAccount = async function(email) {
|
||||
const store = Alpine.store('global');
|
||||
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request(
|
||||
`/api/accounts/${encodeURIComponent(email)}/refresh`,
|
||||
{ method: 'POST' },
|
||||
store.webuiPassword
|
||||
);
|
||||
|
||||
if (newPassword) {
|
||||
store.webuiPassword = newPassword;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status !== 'ok') {
|
||||
return { success: false, error: data.error || 'Refresh failed' };
|
||||
}
|
||||
|
||||
// 触发数据刷新
|
||||
await Alpine.store('data').fetchData();
|
||||
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 切换账号启用/禁用状态(包含乐观更新和错误回滚)
|
||||
* @param {string} email - 账号邮箱
|
||||
* @param {boolean} enabled - 目标状态(true=启用, false=禁用)
|
||||
* @returns {Promise<{success: boolean, rolledBack?: boolean, data?: object, error?: string}>}
|
||||
*/
|
||||
window.AccountActions.toggleAccount = async function(email, enabled) {
|
||||
const store = Alpine.store('global');
|
||||
const dataStore = Alpine.store('data');
|
||||
|
||||
// 乐观更新:立即修改 UI
|
||||
const account = dataStore.accounts.find(a => a.email === email);
|
||||
const previousState = account ? account.enabled : !enabled;
|
||||
|
||||
if (account) {
|
||||
account.enabled = enabled;
|
||||
}
|
||||
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request(
|
||||
`/api/accounts/${encodeURIComponent(email)}/toggle`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled })
|
||||
},
|
||||
store.webuiPassword
|
||||
);
|
||||
|
||||
if (newPassword) {
|
||||
store.webuiPassword = newPassword;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status !== 'ok') {
|
||||
throw new Error(data.error || 'Toggle failed');
|
||||
}
|
||||
|
||||
// 确认服务器状态
|
||||
await dataStore.fetchData();
|
||||
return { success: true, data };
|
||||
|
||||
} catch (error) {
|
||||
// 错误回滚:恢复原状态
|
||||
if (account) {
|
||||
account.enabled = previousState;
|
||||
}
|
||||
await dataStore.fetchData();
|
||||
return { success: false, error: error.message, rolledBack: true };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除账号
|
||||
* @param {string} email - 账号邮箱
|
||||
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
|
||||
*/
|
||||
window.AccountActions.deleteAccount = async function(email) {
|
||||
const store = Alpine.store('global');
|
||||
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request(
|
||||
`/api/accounts/${encodeURIComponent(email)}`,
|
||||
{ method: 'DELETE' },
|
||||
store.webuiPassword
|
||||
);
|
||||
|
||||
if (newPassword) {
|
||||
store.webuiPassword = newPassword;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status !== 'ok') {
|
||||
return { success: false, error: data.error || 'Delete failed' };
|
||||
}
|
||||
|
||||
// 触发数据刷新
|
||||
await Alpine.store('data').fetchData();
|
||||
return { success: true, data };
|
||||
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取账号重新认证的 OAuth URL
|
||||
* 注意:此方法仅返回 URL,不打开窗口(由组件层决定如何处理)
|
||||
* @param {string} email - 账号邮箱
|
||||
* @returns {Promise<{success: boolean, url?: string, error?: string}>}
|
||||
*/
|
||||
window.AccountActions.getFixAccountUrl = async function(email) {
|
||||
const store = Alpine.store('global');
|
||||
|
||||
try {
|
||||
const urlPath = `/api/auth/url?email=${encodeURIComponent(email)}`;
|
||||
const { response, newPassword } = await window.utils.request(
|
||||
urlPath,
|
||||
{},
|
||||
store.webuiPassword
|
||||
);
|
||||
|
||||
if (newPassword) {
|
||||
store.webuiPassword = newPassword;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status !== 'ok') {
|
||||
return { success: false, error: data.error || 'Failed to get auth URL' };
|
||||
}
|
||||
|
||||
return { success: true, url: data.url };
|
||||
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从磁盘重新加载所有账号配置
|
||||
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
|
||||
*/
|
||||
window.AccountActions.reloadAccounts = async function() {
|
||||
const store = Alpine.store('global');
|
||||
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request(
|
||||
'/api/accounts/reload',
|
||||
{ method: 'POST' },
|
||||
store.webuiPassword
|
||||
);
|
||||
|
||||
if (newPassword) {
|
||||
store.webuiPassword = newPassword;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status !== 'ok') {
|
||||
return { success: false, error: data.error || 'Reload failed' };
|
||||
}
|
||||
|
||||
// 触发数据刷新
|
||||
await Alpine.store('data').fetchData();
|
||||
return { success: true, data };
|
||||
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查账号是否可以删除
|
||||
* 来自 Antigravity 数据库的账号(source='database')不可删除
|
||||
* @param {object} account - 账号对象
|
||||
* @returns {boolean} true 表示可删除
|
||||
*/
|
||||
window.AccountActions.canDelete = function(account) {
|
||||
return account && account.source !== 'database';
|
||||
};
|
||||
@@ -105,3 +105,41 @@ window.ErrorHandler.apiCall = async function(apiCall, successMessage = null, err
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute an async function with automatic loading state management
|
||||
* @param {Function} asyncFn - Async function to execute
|
||||
* @param {object} context - Component context (this) that contains the loading state
|
||||
* @param {string} loadingKey - Name of the loading state property (default: 'loading')
|
||||
* @param {object} options - Additional options (same as safeAsync)
|
||||
* @returns {Promise<any>} Result of the function or undefined on error
|
||||
*
|
||||
* @example
|
||||
* // In your Alpine component:
|
||||
* async refreshAccount(email) {
|
||||
* return await window.ErrorHandler.withLoading(async () => {
|
||||
* const response = await window.utils.request(`/api/accounts/${email}/refresh`, { method: 'POST' });
|
||||
* this.$store.global.showToast('Account refreshed', 'success');
|
||||
* return response;
|
||||
* }, this, 'refreshing');
|
||||
* }
|
||||
*
|
||||
* // In HTML:
|
||||
* // <button @click="refreshAccount(email)" :disabled="refreshing">
|
||||
* // <i class="fas fa-sync-alt" :class="{ 'fa-spin': refreshing }"></i>
|
||||
* // Refresh
|
||||
* // </button>
|
||||
*/
|
||||
window.ErrorHandler.withLoading = async function(asyncFn, context, loadingKey = 'loading', options = {}) {
|
||||
// Set loading state to true
|
||||
context[loadingKey] = true;
|
||||
|
||||
try {
|
||||
// Execute the async function with error handling
|
||||
const result = await window.ErrorHandler.safeAsync(asyncFn, options.errorMessage, options);
|
||||
return result;
|
||||
} finally {
|
||||
// Always reset loading state, even if there was an error
|
||||
context[loadingKey] = false;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user