feat(dashboard): comprehensive filter enhancement and UI layout fixes

- Add time range selector (1H/6H/24H/7D/All) with preference persistence
- Implement smart X-axis label formatting for multi-day usage data
- Standardize global component spacing and fix CSS @apply limitations
- Add elegant empty state UI for charts when filtered data is absent
- Update i18n translations for all new dashboard features
This commit is contained in:
Wha1eChai
2026-01-09 22:33:11 +08:00
parent 40d3d3f3b6
commit 48ad476b5f
10 changed files with 450 additions and 198 deletions

View File

@@ -1,162 +1,183 @@
:root { :root {
/* === Background Layers === */ /* === Background Layers === */
--color-space-950: #09090b; --color-space-950: #09090b;
--color-space-900: #0f0f11; --color-space-900: #0f0f11;
--color-space-850: #121214; --color-space-850: #121214;
--color-space-800: #18181b; --color-space-800: #18181b;
--color-space-border: #27272a; --color-space-border: #27272a;
/* === Neon Accents (Full Saturation) === */ /* === Neon Accents (Full Saturation) === */
--color-neon-purple: #a855f7; --color-neon-purple: #a855f7;
--color-neon-green: #22c55e; --color-neon-green: #22c55e;
--color-neon-cyan: #06b6d4; --color-neon-cyan: #06b6d4;
--color-neon-yellow: #eab308; --color-neon-yellow: #eab308;
--color-neon-red: #ef4444; --color-neon-red: #ef4444;
/* === Soft Neon (Reduced Saturation for Fills) === */ /* === Soft Neon (Reduced Saturation for Fills) === */
--color-neon-purple-soft: #9333ea; --color-neon-purple-soft: #9333ea;
--color-neon-green-soft: #16a34a; --color-neon-green-soft: #16a34a;
--color-neon-cyan-soft: #0891b2; --color-neon-cyan-soft: #0891b2;
/* === Text Hierarchy (WCAG AA Compliant) === */ /* === Text Hierarchy (WCAG AA Compliant) === */
--color-text-primary: #ffffff; /* Emphasis: Titles, Key Numbers */ --color-text-primary: #ffffff; /* Emphasis: Titles, Key Numbers */
--color-text-secondary: #d4d4d8; /* Content: Body Text (zinc-300) */ --color-text-secondary: #d4d4d8; /* Content: Body Text (zinc-300) */
--color-text-tertiary: #a1a1aa; /* Metadata: Timestamps, Labels (zinc-400) */ --color-text-tertiary: #a1a1aa; /* Metadata: Timestamps, Labels (zinc-400) */
--color-text-quaternary: #71717a; /* Subtle: Decorative (zinc-500) */ --color-text-quaternary: #71717a; /* Subtle: Decorative (zinc-500) */
/* === Legacy Aliases (Backward Compatibility) === */ /* === Legacy Aliases (Backward Compatibility) === */
--color-text-main: var(--color-text-secondary); --color-text-main: var(--color-text-secondary);
--color-text-dim: var(--color-text-tertiary); --color-text-dim: var(--color-text-tertiary);
--color-text-muted: var(--color-text-tertiary); --color-text-muted: var(--color-text-tertiary);
--color-text-bright: var(--color-text-primary); --color-text-bright: var(--color-text-primary);
/* Gradient Accents */ /* Gradient Accents */
--color-green-400: #4ade80; --color-green-400: #4ade80;
--color-yellow-400: #facc15; --color-yellow-400: #facc15;
--color-red-400: #f87171; --color-red-400: #f87171;
/* Chart Colors */ /* Chart Colors */
--color-chart-1: #a855f7; --color-chart-1: #a855f7;
--color-chart-2: #c084fc; --color-chart-2: #c084fc;
--color-chart-3: #e879f9; --color-chart-3: #e879f9;
--color-chart-4: #d946ef; --color-chart-4: #d946ef;
--color-chart-5: #22c55e; --color-chart-5: #22c55e;
--color-chart-6: #4ade80; --color-chart-6: #4ade80;
--color-chart-7: #86efac; --color-chart-7: #86efac;
--color-chart-8: #10b981; --color-chart-8: #10b981;
--color-chart-9: #06b6d4; --color-chart-9: #06b6d4;
--color-chart-10: #f59e0b; --color-chart-10: #f59e0b;
--color-chart-11: #ef4444; --color-chart-11: #ef4444;
--color-chart-12: #ec4899; --color-chart-12: #ec4899;
--color-chart-13: #8b5cf6; --color-chart-13: #8b5cf6;
--color-chart-14: #14b8a6; --color-chart-14: #14b8a6;
--color-chart-15: #f97316; --color-chart-15: #f97316;
--color-chart-16: #6366f1; --color-chart-16: #6366f1;
} }
[x-cloak] { [x-cloak] {
display: none !important; display: none !important;
} }
/* Custom Scrollbar */ /* Custom Scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;
height: 8px; height: 8px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: rgba(9, 9, 11, 0.3); background: rgba(9, 9, 11, 0.3);
border-radius: 4px; border-radius: 4px;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #27272a 0%, #18181b 100%); background: linear-gradient(180deg, #27272a 0%, #18181b 100%);
border-radius: 4px; border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.05);
transition: background 0.2s ease; transition: background 0.2s ease;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #3f3f46 0%, #27272a 100%); background: linear-gradient(180deg, #3f3f46 0%, #27272a 100%);
border-color: rgba(168, 85, 247, 0.3); border-color: rgba(168, 85, 247, 0.3);
} }
/* Animations */ /* Animations */
.fade-enter-active, .fade-enter-active,
.fade-leave-active { .fade-leave-active {
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
} }
.fade-enter-from, .fade-enter-from,
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
} }
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(5px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
.animate-fade-in { .animate-fade-in {
animation: fadeIn 0.4s ease-out forwards; animation: fadeIn 0.4s ease-out forwards;
} }
/* Utility */ /* Utility */
.glass-panel { .glass-panel {
background: linear-gradient(135deg, background: linear-gradient(
rgba(15, 15, 17, 0.75) 0%, 135deg,
rgba(18, 18, 20, 0.70) 100% rgba(15, 15, 17, 0.75) 0%,
); rgba(18, 18, 20, 0.7) 100%
backdrop-filter: blur(12px); );
border: 1px solid rgba(255, 255, 255, 0.08); backdrop-filter: blur(12px);
box-shadow: border: 1px solid rgba(255, 255, 255, 0.08);
0 0 0 1px rgba(255, 255, 255, 0.02) inset, box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.02) inset,
0 4px 24px rgba(0, 0, 0, 0.4); 0 4px 24px rgba(0, 0, 0, 0.4);
transition: border-color 0.3s ease, box-shadow 0.3s ease; transition: border-color 0.3s ease, box-shadow 0.3s ease;
} }
.glass-panel:hover { .glass-panel:hover {
border-color: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.12);
box-shadow: box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04) inset,
0 0 0 1px rgba(255, 255, 255, 0.04) inset, 0 8px 32px rgba(0, 0, 0, 0.5);
0 8px 32px rgba(0, 0, 0, 0.5);
} }
.nav-item.active { .nav-item.active {
background: linear-gradient(90deg, theme('colors.neon.purple / 15%') 0%, transparent 100%); background: linear-gradient(
@apply border-l-4 border-neon-purple text-white; 90deg,
theme("colors.neon.purple / 15%") 0%,
transparent 100%
);
@apply border-l-4 border-neon-purple text-white;
} }
.nav-item { .nav-item {
@apply border-l-4 border-transparent transition-all duration-200; @apply border-l-4 border-transparent transition-all duration-200;
} }
.progress-gradient-success::-webkit-progress-value { .progress-gradient-success::-webkit-progress-value {
background-image: linear-gradient(to right, var(--color-neon-green), var(--color-green-400)); background-image: linear-gradient(
to right,
var(--color-neon-green),
var(--color-green-400)
);
} }
.progress-gradient-warning::-webkit-progress-value { .progress-gradient-warning::-webkit-progress-value {
background-image: linear-gradient(to right, var(--color-neon-yellow), var(--color-yellow-400)); background-image: linear-gradient(
to right,
var(--color-neon-yellow),
var(--color-yellow-400)
);
} }
.progress-gradient-error::-webkit-progress-value { .progress-gradient-error::-webkit-progress-value {
background-image: linear-gradient(to right, var(--color-neon-red), var(--color-red-400)); background-image: linear-gradient(
to right,
var(--color-neon-red),
var(--color-red-400)
);
} }
/* Dashboard Grid */ /* Dashboard Grid */
.stats-grid { .stats-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.5rem; gap: 1.5rem;
} }
/* Tooltip Customization */ /* Tooltip Customization */
.tooltip:before { .tooltip:before {
@apply bg-space-800 border border-space-border text-gray-200 font-mono text-xs; @apply bg-space-800 border border-space-border text-gray-200 font-mono text-xs;
} }
.tooltip-left:before { .tooltip-left:before {
margin-right: 0.5rem; margin-right: 0.5rem;
} }
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@@ -165,21 +186,25 @@
/* Standard Layout Constants */ /* Standard Layout Constants */
:root { :root {
--view-padding: 2rem; /* 32px - Standard Padding */ --view-padding: 2rem; /* 32px - Standard Padding */
--view-gap: 1.5rem; /* 24px - Standard component gap */ --view-gap: 2rem; /* 32px - Standard component gap */
--card-radius: 0.75rem; /* 12px */ --card-radius: 0.75rem; /* 12px */
} }
@media (max-width: 768px) { @media (max-width: 768px) {
:root { :root {
--view-padding: 1rem; --view-padding: 1rem;
--view-gap: 1rem; --view-gap: 1.25rem;
} }
} }
/* Base View Container */ /* Base View Container */
.view-container { .view-container {
@apply mx-auto w-full animate-fade-in flex flex-col; display: flex;
flex-direction: column;
margin-left: auto;
margin-right: auto;
width: 100%;
padding: var(--view-padding); padding: var(--view-padding);
gap: var(--view-gap); gap: var(--view-gap);
min-height: calc(100vh - 56px); /* Align with navbar height */ min-height: calc(100vh - 56px); /* Align with navbar height */
@@ -189,128 +214,159 @@
/* Specialized container for data-heavy pages (Logs) */ /* Specialized container for data-heavy pages (Logs) */
.view-container-full { .view-container-full {
@apply w-full animate-fade-in flex flex-col; @apply w-full animate-fade-in flex flex-col;
padding: var(--view-padding); padding: var(--view-padding);
gap: var(--view-gap); gap: var(--view-gap);
min-height: calc(100vh - 56px); min-height: calc(100vh - 56px);
max-width: 100%; max-width: 100%;
} }
/* Centered container for form-heavy pages (Settings/Accounts) */ /* Centered container for form-heavy pages (Settings/Accounts) */
.view-container-centered { .view-container-centered {
@apply mx-auto w-full animate-fade-in flex flex-col; @apply mx-auto w-full animate-fade-in flex flex-col;
padding: var(--view-padding); padding: var(--view-padding);
gap: var(--view-gap); gap: var(--view-gap);
min-height: calc(100vh - 56px); min-height: calc(100vh - 56px);
max-width: 900px; /* Comfortable reading width for forms */ max-width: 900px; /* Comfortable reading width for forms */
} }
/* Standard Section Header */ /* Standard Section Header */
.view-header { .view-header {
@apply flex flex-col md:flex-row md:items-end justify-between mb-2; display: flex;
flex-direction: column;
justify-content: space-between;
margin-bottom: 0.5rem;
gap: 1rem; gap: 1rem;
} }
@media (min-width: 768px) {
.view-header {
flex-direction: row;
align-items: flex-end;
}
}
.view-header-title { .view-header-title {
@apply flex flex-col; @apply flex flex-col;
} }
.view-header-title h2 { .view-header-title h2 {
@apply text-2xl font-bold text-white tracking-tight; @apply text-2xl font-bold text-white tracking-tight;
} }
.view-header-title p { .view-header-title p {
@apply text-sm text-gray-500 mt-1; @apply text-sm text-gray-500 mt-1;
} }
.view-header-actions { .view-header-actions {
@apply flex items-center gap-3; @apply flex items-center gap-3;
} }
/* Standard Card Panel */ /* Standard Card Panel */
.view-card { .view-card {
@apply glass-panel rounded-xl p-6 border border-space-border/50 relative overflow-hidden; position: relative;
overflow: hidden;
border-radius: var(--card-radius);
padding: 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(135deg,
rgba(15, 15, 17, 0.75) 0%,
rgba(18, 18, 20, 0.70) 100%
);
backdrop-filter: blur(12px);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.02) inset,
0 4px 24px rgba(0, 0, 0, 0.4);
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.view-card:hover {
border-color: rgba(255, 255, 255, 0.12);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.04) inset,
0 8px 32px rgba(0, 0, 0, 0.5);
} }
.view-card-header { .view-card-header {
@apply flex items-center justify-between mb-4 pb-4 border-b border-space-border/30; @apply flex items-center justify-between mb-4 pb-4 border-b border-space-border/30;
} }
/* Component Unification */ /* Component Unification */
.standard-table { .standard-table {
@apply table w-full border-separate border-spacing-0; @apply table w-full border-separate border-spacing-0;
} }
.standard-table thead { .standard-table thead {
@apply bg-space-900/50 text-gray-500 font-mono text-xs uppercase border-b border-space-border; @apply bg-space-900/50 text-gray-500 font-mono text-xs uppercase border-b border-space-border;
} }
.standard-table tbody tr { .standard-table tbody tr {
@apply transition-all duration-200 border-b border-space-border/30 last:border-0; @apply transition-all duration-200 border-b border-space-border/30 last:border-0;
} }
.standard-table tbody tr:hover { .standard-table tbody tr:hover {
background: linear-gradient(90deg, background: linear-gradient(
rgba(255, 255, 255, 0.03) 0%, 90deg,
rgba(255, 255, 255, 0.05) 50%, rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.03) 100% rgba(255, 255, 255, 0.05) 50%,
); rgba(255, 255, 255, 0.03) 100%
border-color: rgba(255, 255, 255, 0.08); );
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); border-color: rgba(255, 255, 255, 0.08);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
} }
/* Custom Range Slider - Simplified */ /* Custom Range Slider - Simplified */
.custom-range { .custom-range {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 100%; width: 100%;
height: 4px; height: 4px;
background: var(--color-space-800); background: var(--color-space-800);
border-radius: 999px; border-radius: 999px;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
} }
.custom-range::-webkit-slider-thumb { .custom-range::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 14px; width: 14px;
height: 14px; height: 14px;
border-radius: 50%; border-radius: 50%;
background: var(--range-color, var(--color-neon-purple)); background: var(--range-color, var(--color-neon-purple));
cursor: pointer; cursor: pointer;
transition: transform 0.1s ease; transition: transform 0.1s ease;
} }
.custom-range::-webkit-slider-thumb:hover { .custom-range::-webkit-slider-thumb:hover {
transform: scale(1.15); transform: scale(1.15);
} }
.custom-range::-moz-range-thumb { .custom-range::-moz-range-thumb {
width: 14px; width: 14px;
height: 14px; height: 14px;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
background: var(--range-color, var(--color-neon-purple)); background: var(--range-color, var(--color-neon-purple));
cursor: pointer; cursor: pointer;
transition: transform 0.1s ease; transition: transform 0.1s ease;
} }
.custom-range::-moz-range-thumb:hover { .custom-range::-moz-range-thumb:hover {
transform: scale(1.15); transform: scale(1.15);
} }
/* Color Variants */ /* Color Variants */
.custom-range-purple { .custom-range-purple {
--range-color: var(--color-neon-purple); --range-color: var(--color-neon-purple);
} }
.custom-range-green { .custom-range-green {
--range-color: var(--color-neon-green); --range-color: var(--color-neon-green);
} }
.custom-range-cyan { .custom-range-cyan {
--range-color: var(--color-neon-cyan); --range-color: var(--color-neon-cyan);
} }
.custom-range-yellow { .custom-range-yellow {
--range-color: var(--color-neon-yellow); --range-color: var(--color-neon-yellow);
} }
.custom-range-accent { .custom-range-accent {
--range-color: var(--color-neon-cyan); --range-color: var(--color-neon-cyan);
} }

View File

@@ -8,6 +8,7 @@ window.Components = window.Components || {};
window.Components.dashboard = () => ({ window.Components.dashboard = () => ({
// Core state // Core state
stats: { total: 0, active: 0, limited: 0, overallHealth: 0, hasTrendData: false }, stats: { total: 0, active: 0, limited: 0, overallHealth: 0, hasTrendData: false },
hasFilteredTrendData: true,
charts: { quotaDistribution: null, usageTrend: null }, charts: { quotaDistribution: null, usageTrend: null },
usageStats: { total: 0, today: 0, thisHour: 0 }, usageStats: { total: 0, today: 0, thisHour: 0 },
historyData: {}, historyData: {},
@@ -165,6 +166,14 @@ window.Components.dashboard = () => ({
window.DashboardFilters.setDisplayMode(this, mode); window.DashboardFilters.setDisplayMode(this, mode);
}, },
setTimeRange(range) {
window.DashboardFilters.setTimeRange(this, range);
},
getTimeRangeLabel() {
return window.DashboardFilters.getTimeRangeLabel(this);
},
toggleFamily(family) { toggleFamily(family) {
window.DashboardFilters.toggleFamily(this, family); window.DashboardFilters.toggleFamily(this, family);
}, },

View File

@@ -337,13 +337,44 @@ window.DashboardCharts.updateTrendChart = function (component) {
"[updateTrendChart] Canvas is ready, proceeding with chart creation" "[updateTrendChart] Canvas is ready, proceeding with chart creation"
); );
const history = component.historyData; // Use filtered history data based on time range
const history = window.DashboardFilters.getFilteredHistoryData(component);
if (!history || Object.keys(history).length === 0) { if (!history || Object.keys(history).length === 0) {
console.warn("No history data available for trend chart"); console.warn("No history data available for trend chart (after filtering)");
component.hasFilteredTrendData = false;
_trendChartUpdateLock = false; _trendChartUpdateLock = false;
return; return;
} }
component.hasFilteredTrendData = true;
// Sort entries by timestamp for correct order
const sortedEntries = Object.entries(history).sort(
([a], [b]) => new Date(a).getTime() - new Date(b).getTime()
);
// Determine if data spans multiple days (for smart label formatting)
const timestamps = sortedEntries.map(([iso]) => new Date(iso));
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' });
} else if (isMultiDay || timeRange === 'all') {
// Multi-day data: show MM/DD HH:MM
return date.toLocaleDateString([], { month: '2-digit', day: '2-digit' }) + ' ' +
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else {
// Same day: show HH:MM only
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
};
const labels = []; const labels = [];
const datasets = []; const datasets = [];
@@ -354,11 +385,9 @@ window.DashboardCharts.updateTrendChart = function (component) {
dataByFamily[family] = []; dataByFamily[family] = [];
}); });
Object.entries(history).forEach(([iso, hourData]) => { sortedEntries.forEach(([iso, hourData]) => {
const date = new Date(iso); const date = new Date(iso);
labels.push( labels.push(formatLabel(date));
date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
);
component.selectedFamilies.forEach((family) => { component.selectedFamilies.forEach((family) => {
const familyData = hourData[family]; const familyData = hourData[family];
@@ -394,11 +423,9 @@ window.DashboardCharts.updateTrendChart = function (component) {
}); });
}); });
Object.entries(history).forEach(([iso, hourData]) => { sortedEntries.forEach(([iso, hourData]) => {
const date = new Date(iso); const date = new Date(iso);
labels.push( labels.push(formatLabel(date));
date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
);
component.families.forEach((family) => { component.families.forEach((family) => {
const familyData = hourData[family] || {}; const familyData = hourData[family] || {};

View File

@@ -10,10 +10,13 @@ window.DashboardFilters = window.DashboardFilters || {};
*/ */
window.DashboardFilters.getInitialState = function() { window.DashboardFilters.getInitialState = function() {
return { return {
timeRange: '24h', // '1h', '6h', '24h', '7d', 'all'
displayMode: 'model', displayMode: 'model',
selectedFamilies: [], selectedFamilies: [],
selectedModels: {}, selectedModels: {},
showModelFilter: false showModelFilter: false,
showTimeRangeDropdown: false,
showDisplayModeDropdown: false
}; };
}; };
@@ -26,6 +29,7 @@ window.DashboardFilters.loadPreferences = function(component) {
const saved = localStorage.getItem('dashboard_chart_prefs'); const saved = localStorage.getItem('dashboard_chart_prefs');
if (saved) { if (saved) {
const prefs = JSON.parse(saved); const prefs = JSON.parse(saved);
component.timeRange = prefs.timeRange || '24h';
component.displayMode = prefs.displayMode || 'model'; component.displayMode = prefs.displayMode || 'model';
component.selectedFamilies = prefs.selectedFamilies || []; component.selectedFamilies = prefs.selectedFamilies || [];
component.selectedModels = prefs.selectedModels || {}; component.selectedModels = prefs.selectedModels || {};
@@ -42,6 +46,7 @@ window.DashboardFilters.loadPreferences = function(component) {
window.DashboardFilters.savePreferences = function(component) { window.DashboardFilters.savePreferences = function(component) {
try { try {
localStorage.setItem('dashboard_chart_prefs', JSON.stringify({ localStorage.setItem('dashboard_chart_prefs', JSON.stringify({
timeRange: component.timeRange,
displayMode: component.displayMode, displayMode: component.displayMode,
selectedFamilies: component.selectedFamilies, selectedFamilies: component.selectedFamilies,
selectedModels: component.selectedModels selectedModels: component.selectedModels
@@ -58,11 +63,78 @@ window.DashboardFilters.savePreferences = function(component) {
*/ */
window.DashboardFilters.setDisplayMode = function(component, mode) { window.DashboardFilters.setDisplayMode = function(component, mode) {
component.displayMode = mode; component.displayMode = mode;
component.showDisplayModeDropdown = false;
window.DashboardFilters.savePreferences(component); window.DashboardFilters.savePreferences(component);
// updateTrendChart uses debounce internally, call directly // updateTrendChart uses debounce internally, call directly
component.updateTrendChart(); component.updateTrendChart();
}; };
/**
* Set time range filter
* @param {object} component - Dashboard component instance
* @param {string} range - '1h', '6h', '24h', '7d', 'all'
*/
window.DashboardFilters.setTimeRange = function(component, range) {
component.timeRange = range;
component.showTimeRangeDropdown = false;
window.DashboardFilters.savePreferences(component);
component.updateTrendChart();
};
/**
* Get time range cutoff timestamp
* @param {string} range - Time range code
* @returns {number|null} Cutoff timestamp or null for 'all'
*/
window.DashboardFilters.getTimeRangeCutoff = function(range) {
const now = Date.now();
switch (range) {
case '1h': return now - 1 * 60 * 60 * 1000;
case '6h': return now - 6 * 60 * 60 * 1000;
case '24h': return now - 24 * 60 * 60 * 1000;
case '7d': return now - 7 * 24 * 60 * 60 * 1000;
default: return null; // 'all'
}
};
/**
* Get filtered history data based on time range
* @param {object} component - Dashboard component instance
* @returns {object} Filtered history data
*/
window.DashboardFilters.getFilteredHistoryData = function(component) {
const history = component.historyData;
if (!history || Object.keys(history).length === 0) return {};
const cutoff = window.DashboardFilters.getTimeRangeCutoff(component.timeRange);
if (!cutoff) return history; // 'all' - return everything
const filtered = {};
Object.entries(history).forEach(([iso, data]) => {
const timestamp = new Date(iso).getTime();
if (timestamp >= cutoff) {
filtered[iso] = data;
}
});
return filtered;
};
/**
* Get time range label for display
* @param {object} component - Dashboard component instance
* @returns {string} Translated label
*/
window.DashboardFilters.getTimeRangeLabel = function(component) {
const store = Alpine.store('global');
switch (component.timeRange) {
case '1h': return store.t('last1Hour');
case '6h': return store.t('last6Hours');
case '24h': return store.t('last24Hours');
case '7d': return store.t('last7Days');
default: return store.t('allTime');
}
};
/** /**
* Toggle family selection * Toggle family selection
* @param {object} component - Dashboard component instance * @param {object} component - Dashboard component instance

View File

@@ -160,9 +160,15 @@ document.addEventListener('alpine:init', () => {
claudeEmpty: "Claude Empty", claudeEmpty: "Claude Empty",
geminiActive: "Gemini Active", geminiActive: "Gemini Active",
geminiEmpty: "Gemini Empty", geminiEmpty: "Gemini Empty",
fix: "FIX",
synced: "SYNCED", synced: "SYNCED",
syncing: "SYNCING...", syncing: "SYNCING...",
// Time range labels
last1Hour: "Last 1H",
last6Hours: "Last 6H",
last24Hours: "Last 24H",
last7Days: "Last 7D",
allTime: "All Time",
groupBy: "Group By",
// Additional // Additional
reloading: "Reloading...", reloading: "Reloading...",
reloaded: "Reloaded", reloaded: "Reloaded",
@@ -401,9 +407,15 @@ document.addEventListener('alpine:init', () => {
claudeEmpty: "Claude 耗尽", claudeEmpty: "Claude 耗尽",
geminiActive: "Gemini 活跃", geminiActive: "Gemini 活跃",
geminiEmpty: "Gemini 耗尽", geminiEmpty: "Gemini 耗尽",
fix: "修复",
synced: "已同步", synced: "已同步",
syncing: "正在同步...", syncing: "正在同步...",
// 时间范围标签
last1Hour: "最近 1 小时",
last6Hours: "最近 6 小时",
last24Hours: "最近 24 小时",
last7Days: "最近 7 天",
allTime: "最后全部",
groupBy: "分组方式",
// Additional // Additional
reloading: "正在重载...", reloading: "正在重载...",
reloaded: "已重载", reloaded: "已重载",

View File

@@ -144,9 +144,9 @@
<!-- Usage Trend Chart --> <!-- Usage Trend Chart -->
<div class="view-card"> <div class="view-card">
<!-- Header with Stats and Filter --> <!-- Header with Stats and Filter -->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6"> <div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-6 mb-8">
<div class="flex flex-wrap items-center gap-4"> <div class="flex flex-wrap items-center gap-5">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2.5">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
class="w-4 h-4 text-neon-purple"> class="w-4 h-4 text-neon-purple">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@@ -157,40 +157,95 @@
</div> </div>
<!-- Usage Stats Pills --> <!-- Usage Stats Pills -->
<div class="flex flex-wrap gap-2 text-[10px] font-mono"> <div class="flex flex-wrap gap-2.5 text-[10px] font-mono">
<div class="px-2.5 py-1 rounded bg-space-800 border border-space-border/50 whitespace-nowrap"> <div class="px-2.5 py-1 rounded bg-space-850 border border-space-border/60 whitespace-nowrap">
<span class="text-gray-500" x-text="$store.global.t('totalColon')">Total:</span> <span class="text-gray-500" x-text="$store.global.t('totalColon')">Total:</span>
<span class="text-white ml-1 font-bold" x-text="usageStats.total"></span> <span class="text-white ml-1 font-bold" x-text="usageStats.total"></span>
</div> </div>
<div class="px-2.5 py-1 rounded bg-space-800 border border-space-border/50 whitespace-nowrap"> <div class="px-2.5 py-1 rounded bg-space-850 border border-space-border/60 whitespace-nowrap">
<span class="text-gray-500" x-text="$store.global.t('todayColon')">Today:</span> <span class="text-gray-500" x-text="$store.global.t('todayColon')">Today:</span>
<span class="text-neon-cyan ml-1 font-bold" x-text="usageStats.today"></span> <span class="text-neon-cyan ml-1 font-bold" x-text="usageStats.today"></span>
</div> </div>
<div class="px-2.5 py-1 rounded bg-space-800 border border-space-border/50 whitespace-nowrap"> <div class="px-2.5 py-1 rounded bg-space-850 border border-space-border/60 whitespace-nowrap">
<span class="text-gray-500" x-text="$store.global.t('hour1Colon')">1H:</span> <span class="text-gray-500" x-text="$store.global.t('hour1Colon')">1H:</span>
<span class="text-neon-green ml-1 font-bold" x-text="usageStats.thisHour"></span> <span class="text-neon-green ml-1 font-bold" x-text="usageStats.thisHour"></span>
</div> </div>
</div> </div>
</div> </div>
<div class="flex items-center gap-3 w-full sm:w-auto justify-end"> <div class="flex items-center gap-3 w-full sm:w-auto justify-end flex-wrap">
<!-- Display Mode Toggle --> <!-- Time Range Dropdown -->
<div class="join"> <div class="relative">
<button @click="setDisplayMode('family')" <button @click="showTimeRangeDropdown = !showTimeRangeDropdown; showDisplayModeDropdown = false; showModelFilter = false"
class="join-item btn btn-xs px-3 border-space-border/50 bg-space-800 text-gray-400 hover:text-white transition-all" class="flex items-center gap-2 px-3 py-1.5 text-[10px] font-mono text-gray-400 bg-space-800 border border-space-border/50 rounded hover:border-neon-cyan/50 transition-colors whitespace-nowrap">
:class="{'bg-neon-purple/20 text-neon-purple border-neon-purple/50': displayMode === 'family'}" <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
x-text="$store.global.t('family')"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span x-text="getTimeRangeLabel()"></span>
<svg class="w-3 h-3 transition-transform" :class="{'rotate-180': showTimeRangeDropdown}" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button> </button>
<button @click="setDisplayMode('model')" <div x-show="showTimeRangeDropdown" @click.outside="showTimeRangeDropdown = false"
class="join-item btn btn-xs px-3 border-space-border/50 bg-space-800 text-gray-400 hover:text-white transition-all" x-transition:enter="transition ease-out duration-100"
:class="{'bg-neon-purple/20 text-neon-purple border-neon-purple/50': displayMode === 'model'}" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
x-text="$store.global.t('model')"> x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-1 w-36 bg-space-900 border border-space-border rounded-lg shadow-xl z-50 overflow-hidden py-1"
style="display: none;">
<button @click="setTimeRange('1h')" class="w-full px-3 py-1.5 text-left text-[10px] font-mono hover:bg-white/5 transition-colors"
:class="timeRange === '1h' ? 'text-neon-cyan' : 'text-gray-400'"
x-text="$store.global.t('last1Hour')"></button>
<button @click="setTimeRange('6h')" class="w-full px-3 py-1.5 text-left text-[10px] font-mono hover:bg-white/5 transition-colors"
:class="timeRange === '6h' ? 'text-neon-cyan' : 'text-gray-400'"
x-text="$store.global.t('last6Hours')"></button>
<button @click="setTimeRange('24h')" class="w-full px-3 py-1.5 text-left text-[10px] font-mono hover:bg-white/5 transition-colors"
:class="timeRange === '24h' ? 'text-neon-cyan' : 'text-gray-400'"
x-text="$store.global.t('last24Hours')"></button>
<button @click="setTimeRange('7d')" class="w-full px-3 py-1.5 text-left text-[10px] font-mono hover:bg-white/5 transition-colors"
:class="timeRange === '7d' ? 'text-neon-cyan' : 'text-gray-400'"
x-text="$store.global.t('last7Days')"></button>
<button @click="setTimeRange('all')" class="w-full px-3 py-1.5 text-left text-[10px] font-mono hover:bg-white/5 transition-colors"
:class="timeRange === 'all' ? 'text-neon-cyan' : 'text-gray-400'"
x-text="$store.global.t('allTime')"></button>
</div>
</div>
<!-- Display Mode Dropdown -->
<div class="relative">
<button @click="showDisplayModeDropdown = !showDisplayModeDropdown; showTimeRangeDropdown = false; showModelFilter = false"
class="flex items-center gap-2 px-3 py-1.5 text-[10px] font-mono text-gray-400 bg-space-800 border border-space-border/50 rounded hover:border-neon-purple/50 transition-colors whitespace-nowrap">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<span x-text="displayMode === 'family' ? $store.global.t('family') : $store.global.t('model')"></span>
<svg class="w-3 h-3 transition-transform" :class="{'rotate-180': showDisplayModeDropdown}" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button> </button>
<div x-show="showDisplayModeDropdown" @click.outside="showDisplayModeDropdown = false"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-1 w-32 bg-space-900 border border-space-border rounded-lg shadow-xl z-50 overflow-hidden py-1"
style="display: none;">
<button @click="setDisplayMode('family')" class="w-full px-3 py-1.5 text-left text-[10px] font-mono hover:bg-white/5 transition-colors"
:class="displayMode === 'family' ? 'text-neon-purple' : 'text-gray-400'"
x-text="$store.global.t('family')"></button>
<button @click="setDisplayMode('model')" class="w-full px-3 py-1.5 text-left text-[10px] font-mono hover:bg-white/5 transition-colors"
:class="displayMode === 'model' ? 'text-neon-purple' : 'text-gray-400'"
x-text="$store.global.t('model')"></button>
</div>
</div> </div>
<!-- Filter Dropdown --> <!-- Filter Dropdown -->
<div class="relative"> <div class="relative">
<button @click="showModelFilter = !showModelFilter" <button @click="showModelFilter = !showModelFilter; showTimeRangeDropdown = false; showDisplayModeDropdown = false"
class="flex items-center gap-2 px-3 py-1.5 text-[10px] font-mono text-gray-400 bg-space-800 border border-space-border/50 rounded hover:border-neon-purple/50 transition-colors whitespace-nowrap"> class="flex items-center gap-2 px-3 py-1.5 text-[10px] font-mono text-gray-400 bg-space-800 border border-space-border/50 rounded hover:border-neon-purple/50 transition-colors whitespace-nowrap">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@@ -293,7 +348,7 @@
</div> </div>
<!-- Dynamic Legend --> <!-- Dynamic Legend -->
<div class="flex flex-wrap gap-3 mb-3" <div class="flex flex-wrap gap-3 mb-5"
x-show="displayMode === 'family' ? selectedFamilies.length > 0 : Object.values(selectedModels).flat().length > 0"> x-show="displayMode === 'family' ? selectedFamilies.length > 0 : Object.values(selectedModels).flat().length > 0">
<!-- Family Mode Legend --> <!-- Family Mode Legend -->
<template x-if="displayMode === 'family'"> <template x-if="displayMode === 'family'">
@@ -322,7 +377,8 @@
<!-- Chart --> <!-- Chart -->
<div class="h-48 w-full relative"> <div class="h-48 w-full relative">
<canvas id="usageTrendChart"></canvas> <canvas id="usageTrendChart"></canvas>
<!-- Loading/Empty State -->
<!-- Overall Loading State -->
<div x-show="!stats.hasTrendData" <div x-show="!stats.hasTrendData"
class="absolute inset-0 flex items-center justify-center bg-space-900/50 backdrop-blur-sm z-10" class="absolute inset-0 flex items-center justify-center bg-space-900/50 backdrop-blur-sm z-10"
style="display: none;"> style="display: none;">
@@ -331,9 +387,29 @@
<span x-text="$store.global.t('syncing')">SYNCING...</span> <span x-text="$store.global.t('syncing')">SYNCING...</span>
</div> </div>
</div> </div>
<!-- No Selection -->
<div x-show="stats.hasTrendData && (displayMode === 'family' ? selectedFamilies.length === 0 : Object.values(selectedModels).flat().length === 0)" <!-- Empty State (After Filtering) -->
class="absolute inset-0 flex items-center justify-center bg-space-900/30 z-10"> <div x-show="stats.hasTrendData && !hasFilteredTrendData"
class="absolute inset-0 flex flex-col items-center justify-center bg-space-900/30 z-10"
style="display: none;">
<div class="flex flex-col items-center gap-4 animate-fade-in">
<div class="w-12 h-12 rounded-full bg-space-850 flex items-center justify-center text-gray-600 border border-space-border/50">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div class="text-xs font-mono text-gray-500 text-center">
<p x-text="$store.global.t('noDataTracked')">No data tracked yet</p>
<p class="text-[10px] opacity-60 mt-1" x-text="'[' + getTimeRangeLabel() + ']'"></p>
</div>
</div>
</div>
<!-- No Selection State -->
<div x-show="stats.hasTrendData && hasFilteredTrendData && (displayMode === 'family' ? selectedFamilies.length === 0 : Object.values(selectedModels).flat().length === 0)"
class="absolute inset-0 flex items-center justify-center bg-space-900/30 z-10"
style="display: none;">
<div class="text-xs font-mono text-gray-500" <div class="text-xs font-mono text-gray-500"
x-text="displayMode === 'family' ? $store.global.t('selectFamilies') : $store.global.t('selectModels')"> x-text="displayMode === 'family' ? $store.global.t('selectFamilies') : $store.global.t('selectModels')">
</div> </div>

View File

@@ -1,13 +1,13 @@
/** /**
* Frontend Test Suite - Accounts Page * Frontend Test Suite - Accounts Page
* Tests the account manager component functionality * Tests the account manager component functionality
* *
* Run: node tests/test-frontend-accounts.cjs * Run: node tests/test-frontend-accounts.cjs
*/ */
const http = require('http'); const http = require('http');
const BASE_URL = 'http://localhost:8090'; const BASE_URL = process.env.TEST_BASE_URL || `http://localhost:${process.env.PORT || 8080}`;
function request(path, options = {}) { function request(path, options = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View File

@@ -1,13 +1,13 @@
/** /**
* Frontend Test Suite - Dashboard Page * Frontend Test Suite - Dashboard Page
* Tests the dashboard component functionality * Tests the dashboard component functionality
* *
* Run: node tests/test-frontend-dashboard.cjs * Run: node tests/test-frontend-dashboard.cjs
*/ */
const http = require('http'); const http = require('http');
const BASE_URL = 'http://localhost:8090'; const BASE_URL = process.env.TEST_BASE_URL || `http://localhost:${process.env.PORT || 8080}`;
// Helper to make HTTP requests // Helper to make HTTP requests
function request(path, options = {}) { function request(path, options = {}) {

View File

@@ -1,13 +1,13 @@
/** /**
* Frontend Test Suite - Logs Page * Frontend Test Suite - Logs Page
* Tests the logs viewer component functionality * Tests the logs viewer component functionality
* *
* Run: node tests/test-frontend-logs.cjs * Run: node tests/test-frontend-logs.cjs
*/ */
const http = require('http'); const http = require('http');
const BASE_URL = 'http://localhost:8090'; const BASE_URL = process.env.TEST_BASE_URL || `http://localhost:${process.env.PORT || 8080}`;
function request(path, options = {}) { function request(path, options = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View File

@@ -1,13 +1,13 @@
/** /**
* Frontend Test Suite - Settings Page * Frontend Test Suite - Settings Page
* Tests the settings and Claude configuration components * Tests the settings and Claude configuration components
* *
* Run: node tests/test-frontend-settings.cjs * Run: node tests/test-frontend-settings.cjs
*/ */
const http = require('http'); const http = require('http');
const BASE_URL = 'http://localhost:8090'; const BASE_URL = process.env.TEST_BASE_URL || `http://localhost:${process.env.PORT || 8080}`;
function request(path, options = {}) { function request(path, options = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {