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:
@@ -93,8 +93,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@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 {
|
||||||
@@ -103,27 +109,30 @@
|
|||||||
|
|
||||||
/* Utility */
|
/* Utility */
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
background: linear-gradient(135deg,
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
rgba(15, 15, 17, 0.75) 0%,
|
rgba(15, 15, 17, 0.75) 0%,
|
||||||
rgba(18, 18, 20, 0.70) 100%
|
rgba(18, 18, 20, 0.7) 100%
|
||||||
);
|
);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
box-shadow:
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.02) inset,
|
||||||
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(
|
||||||
|
90deg,
|
||||||
|
theme("colors.neon.purple / 15%") 0%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
@apply border-l-4 border-neon-purple text-white;
|
@apply border-l-4 border-neon-purple text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,15 +141,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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 */
|
||||||
@@ -166,20 +187,24 @@
|
|||||||
/* 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 */
|
||||||
@@ -207,10 +232,20 @@
|
|||||||
|
|
||||||
/* 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;
|
||||||
}
|
}
|
||||||
@@ -229,7 +264,27 @@
|
|||||||
|
|
||||||
/* 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 {
|
||||||
@@ -248,7 +303,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.standard-table tbody tr:hover {
|
.standard-table tbody tr:hover {
|
||||||
background: linear-gradient(90deg,
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
rgba(255, 255, 255, 0.03) 0%,
|
rgba(255, 255, 255, 0.03) 0%,
|
||||||
rgba(255, 255, 255, 0.05) 50%,
|
rgba(255, 255, 255, 0.05) 50%,
|
||||||
rgba(255, 255, 255, 0.03) 100%
|
rgba(255, 255, 255, 0.03) 100%
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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] || {};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: "已重载",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
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) => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
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 = {}) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
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) => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user