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

@@ -19,15 +19,20 @@
<input type="text"
x-model="searchQuery"
:placeholder="$store.global.t('searchAccounts')"
class="input input-sm input-bordered bg-space-800 border-space-border text-white w-48 pl-9 text-xs h-8"
:aria-label="$store.global.t('searchAccounts')"
class="input-search-sm w-48 pl-9 h-8"
@keydown.escape="searchQuery = ''">
<svg class="w-4 h-4 absolute left-3 top-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<button class="btn btn-xs btn-outline border-space-border text-gray-400 hover:text-white transition-all gap-2 h-8"
@click="reloadAccounts()">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@click="reloadAccounts()"
:disabled="reloading">
<svg xmlns="http://www.w3.org/2000/svg"
class="w-3.5 h-3.5 transition-transform"
:class="{ 'animate-spin': reloading }"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span x-text="$store.global.t('reload')">Reload</span>
@@ -109,17 +114,15 @@
</div>
</td>
<td class="py-4">
<span class="px-2 py-1 text-[10px] font-mono font-bold uppercase rounded"
:class="acc.source === 'oauth' ? 'bg-neon-purple/10 text-neon-purple border border-neon-purple/30' : 'bg-gray-500/10 text-gray-400 border border-gray-500/30'"
<span :class="acc.source === 'oauth' ? 'status-pill-purple' : 'status-pill-free'"
x-text="acc.source || 'oauth'">
</span>
</td>
<td class="py-4">
<span class="px-2 py-1 text-[10px] font-mono font-bold uppercase rounded"
:class="{
'bg-yellow-500/10 text-yellow-400 border border-yellow-500/30': acc.subscription?.tier === 'ultra',
'bg-blue-500/10 text-blue-400 border border-blue-500/30': acc.subscription?.tier === 'pro',
'bg-gray-500/10 text-gray-400 border border-gray-500/30': !acc.subscription || acc.subscription.tier === 'free' || acc.subscription.tier === 'unknown'
<span :class="{
'status-pill-ultra': acc.subscription?.tier === 'ultra',
'status-pill-pro': acc.subscription?.tier === 'pro',
'status-pill-free': !acc.subscription || acc.subscription.tier === 'free' || acc.subscription.tier === 'unknown'
}"
x-text="(acc.subscription?.tier || 'free').toUpperCase()">
</span>
@@ -169,8 +172,12 @@
FIX
</button>
<button class="p-2 rounded hover:bg-white/10 text-gray-500 hover:text-white transition-colors"
@click="refreshAccount(acc.email)" :title="$store.global.t('refreshData')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@click="refreshAccount(acc.email)"
:disabled="refreshing"
:title="$store.global.t('refreshData')">
<svg class="w-4 h-4 transition-transform"
:class="{ 'animate-spin': refreshing }"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
@@ -200,7 +207,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<p class="text-sm text-gray-600" x-text="$store.global.t('noSearchResults')">No accounts match your search</p>
<button class="btn btn-xs btn-ghost text-gray-500" @click="searchQuery = ''" x-text="$store.global.t('clearSearch')">Clear Search</button>
<button class="btn-action-ghost !text-gray-500" @click="searchQuery = ''" x-text="$store.global.t('clearSearch')">Clear Search</button>
</div>
</td>
</tr>
@@ -245,8 +252,10 @@
Cancel
</button>
<button class="btn bg-red-500 hover:bg-red-600 border-none text-white"
@click="executeDelete()">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@click="executeDelete()"
:disabled="deleting"
:class="{ 'loading': deleting }">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" x-show="!deleting">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span x-text="$store.global.t('confirmDelete')">Confirm Delete</span>

View File

@@ -26,8 +26,27 @@
</div>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- Skeleton Loading (仅在首次加载时显示) -->
<div x-show="$store.data.initialLoad" class="space-y-6">
<!-- Skeleton Stats Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<div class="skeleton-stat-card"></div>
<div class="skeleton-stat-card"></div>
<div class="skeleton-stat-card"></div>
<div class="skeleton-stat-card"></div>
</div>
<!-- Skeleton Charts -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="skeleton-chart"></div>
<div class="skeleton-chart"></div>
</div>
</div>
<!-- Actual Content (首次加载完成后显示) -->
<div x-show="!$store.data.initialLoad" class="space-y-6">
<!-- Stats Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<div
class="stat bg-space-900/40 border border-space-border/30 rounded-xl p-6 hover:border-cyan-500/30 hover:bg-cyan-500/5 transition-all duration-300 group relative cursor-pointer"
@click="$store.global.activeTab = 'accounts'"
@@ -434,4 +453,5 @@
</div>
</div>
</div>
</div> <!-- End of x-show="!$store.data.loading" -->
</div>

View File

@@ -1,5 +1,5 @@
<div x-data="logsViewer" class="view-container h-full flex flex-col">
<div class="glass-panel rounded-xl overflow-hidden border-space-border flex flex-col flex-1 min-h-0">
<div class="view-card !p-0 flex flex-col flex-1 min-h-0">
<!-- Toolbar -->
<div class="bg-space-900 flex flex-wrap gap-y-2 justify-between items-center p-2 px-4 border-b border-space-border select-none min-h-[48px] shrink-0">
@@ -53,7 +53,7 @@
x-text="$store.global.t('autoScroll')">Auto-Scroll</span>
<input type="checkbox" class="toggle toggle-xs toggle-success" x-model="isAutoScroll">
</label>
<button class="btn btn-xs btn-ghost btn-square text-gray-400 hover:text-white" @click="clearLogs" :title="$store.global.t('clearLogs')">
<button class="btn-action-ghost-square" @click="clearLogs" :title="$store.global.t('clearLogs')">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>

View File

@@ -20,9 +20,12 @@
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input type="text" :placeholder="$store.global.t('searchPlaceholder')"
class="w-full h-full bg-space-800 border border-space-border text-gray-300 rounded-lg pl-10 pr-10 focus:outline-none focus:border-neon-purple focus:ring-1 focus:ring-neon-purple transition-all text-xs placeholder-gray-600"
x-model.debounce="$store.data.filters.search" @input="$store.data.computeQuotaRows()">
<input type="text"
:placeholder="$store.global.t('searchPlaceholder')"
:aria-label="$store.global.t('searchPlaceholder')"
class="input-search-sm pr-10"
x-model.debounce="$store.data.filters.search"
@input="$store.data.computeQuotaRows()">
<button x-show="$store.data.filters.search"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-75"
@@ -237,11 +240,28 @@
</div>
</td>
</tr>
<!-- Empty -->
<tr x-show="!$store.data.loading && $store.data.quotaRows.length === 0">
<td colspan="6" class="h-64 text-center text-gray-600 font-mono text-xs"
x-text="$store.global.t('noSignal')">
NO SIGNAL DETECTED
<!-- Empty State -->
<tr x-show="!$store.data.initialLoad && $store.data.quotaRows.length === 0">
<td colspan="6" class="py-16 text-center">
<div class="flex flex-col items-center gap-4 max-w-lg mx-auto">
<svg class="w-20 h-20 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
<h3 class="text-xl font-semibold text-gray-400" x-text="$store.global.t('noSignal')">
NO SIGNAL DETECTED
</h3>
<p class="text-sm text-gray-600 max-w-md leading-relaxed">
No model data available. Add accounts to see available models and quota information.
</p>
<button class="btn bg-neon-purple hover:bg-purple-600 border-none text-white btn-sm gap-2 shadow-lg shadow-neon-purple/20"
@click="$store.global.activeTab = 'accounts'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span x-text="$store.global.t('goToAccounts')">Go to Accounts</span>
</button>
</div>
</td>
</tr>
</tbody>

View File

@@ -2,7 +2,7 @@
activeTab: 'ui'
}" class="view-container">
<!-- Header & Tabs -->
<div class="glass-panel rounded-xl border border-space-border flex flex-col overflow-hidden">
<div class="view-card !p-0 flex flex-col overflow-hidden">
<div class="bg-space-900/50 border-b border-space-border px-8 pt-8 pb-0 shrink-0">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-bold text-white flex items-center gap-2">
@@ -558,7 +558,7 @@
</div>
<!-- Models List -->
<div class="glass-panel rounded-lg overflow-hidden">
<div class="view-card !p-0">
<table class="standard-table">
<thead>
<tr>
@@ -609,7 +609,7 @@
placeholder="e.g. claude-sonnet-4-5 or gemini-3-flash"
@keydown.enter="await updateModelConfig(modelId, { mapping: newMapping }); stopEditing()"
@keydown.escape="newMapping = config.mapping || ''; stopEditing()">
<button class="btn btn-xs btn-ghost btn-square text-green-500 hover:bg-green-500/20"
<button class="btn-action-success"
@click="await updateModelConfig(modelId, { mapping: newMapping }); stopEditing()"
title="Save">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none"
@@ -618,7 +618,7 @@
stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</button>
<button class="btn btn-xs btn-ghost btn-square text-gray-500 hover:bg-gray-500/20"
<button class="btn-action-neutral"
@click="newMapping = config.mapping || ''; stopEditing()"
title="Cancel">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none"
@@ -628,7 +628,7 @@
</svg>
</button>
<button x-show="config.mapping"
class="btn btn-xs btn-ghost btn-square text-red-400 hover:bg-red-500/20"
class="btn-action-danger"
@click="await updateModelConfig(modelId, { mapping: '' }); stopEditing()"
title="Clear mapping">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none"
@@ -700,7 +700,7 @@
<div x-show="activeTab === 'server'" x-data="window.Components.serverConfig()"
class="space-y-6 max-w-2xl animate-fade-in pb-10">
<!-- 🔐 Security Section -->
<div class="glass-panel p-6 border border-neon-yellow/20 bg-neon-yellow/5">
<div class="view-card border-neon-yellow/20 bg-neon-yellow/5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div
@@ -741,7 +741,7 @@
<!-- Debug Mode -->
<div
class="form-control glass-panel p-4 border border-space-border/50 hover:border-neon-purple/50 transition-all">
class="form-control view-card border-space-border/50 hover:border-neon-purple/50">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-gray-200"
@@ -763,7 +763,7 @@
<!-- Token Cache -->
<div
class="form-control glass-panel p-4 border border-space-border/50 hover:border-neon-green/50 transition-all">
class="form-control view-card border-space-border/50 hover:border-neon-green/50">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-gray-200"
@@ -787,7 +787,7 @@
</div>
<!-- ▼ Advanced Tuning (Fixed Logic) -->
<div class="glass-panel border border-space-border/50 overflow-hidden">
<div class="view-card !p-0 border-space-border/50">
<div class="flex items-center justify-between p-4 cursor-pointer hover:bg-white/5 transition-colors"
@click="advancedExpanded = !advancedExpanded">
<div class="flex items-center gap-3">