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:
Wha1eChai
2026-01-11 02:11:35 +08:00
parent ee6d222e4d
commit a56bc06cc1
22 changed files with 2730 additions and 499 deletions

View File

@@ -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' });
},
/**

View File

@@ -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' });

View File

@@ -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 || {};

View File

@@ -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;