mirror of
https://github.com/YouXam/claude-code-usage-dashboard.git
synced 2025-12-20 21:26:58 +08:00
improve ui
This commit is contained in:
@@ -40,12 +40,66 @@ interface HistoricalPeriodsProps {
|
|||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PeriodOption {
|
||||||
|
period: Period;
|
||||||
|
totalCost: number | null; // null means still loading
|
||||||
|
}
|
||||||
|
|
||||||
export function HistoricalPeriods({ periods, apiKey, userId }: HistoricalPeriodsProps) {
|
export function HistoricalPeriods({ periods, apiKey, userId }: HistoricalPeriodsProps) {
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState<Period | null>(null);
|
const [selectedPeriod, setSelectedPeriod] = useState<Period | null>(null);
|
||||||
const [summary, setSummary] = useState<PeriodSummary | null>(null);
|
const [summary, setSummary] = useState<PeriodSummary | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string>('');
|
const [error, setError] = useState<string>('');
|
||||||
|
const [periodOptions, setPeriodOptions] = useState<PeriodOption[]>([]);
|
||||||
|
const [isSelectOpen, setIsSelectOpen] = useState(false);
|
||||||
|
|
||||||
|
// Fetch total costs for all periods
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAllPeriodCosts = async () => {
|
||||||
|
if (periods.length === 0) return;
|
||||||
|
|
||||||
|
// Initialize with loading state (totalCost: null)
|
||||||
|
const initialOptions: PeriodOption[] = periods.map(period => ({
|
||||||
|
period,
|
||||||
|
totalCost: null
|
||||||
|
}));
|
||||||
|
setPeriodOptions(initialOptions);
|
||||||
|
|
||||||
|
if (!selectedPeriod && initialOptions.length > 0) {
|
||||||
|
setSelectedPeriod(initialOptions[0].period);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch costs individually to update progressively
|
||||||
|
const updatedOptions = [...initialOptions];
|
||||||
|
|
||||||
|
for (let i = 0; i < periods.length; i++) {
|
||||||
|
const period = periods[i];
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/periods/${period.index}/summary`, {
|
||||||
|
headers: { 'X-API-Key': apiKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
updatedOptions[i] = {
|
||||||
|
period,
|
||||||
|
totalCost: data.totals?.totalCost || 0
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
updatedOptions[i] = { period, totalCost: 0 };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
updatedOptions[i] = { period, totalCost: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state after each fetch for progressive loading
|
||||||
|
setPeriodOptions([...updatedOptions]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAllPeriodCosts();
|
||||||
|
}, [periods, apiKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (periods.length > 0 && !selectedPeriod) {
|
if (periods.length > 0 && !selectedPeriod) {
|
||||||
setSelectedPeriod(periods[0] || null); // Select the most recent historical period
|
setSelectedPeriod(periods[0] || null); // Select the most recent historical period
|
||||||
@@ -83,8 +137,8 @@ export function HistoricalPeriods({ periods, apiKey, userId }: HistoricalPeriods
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string | null) => {
|
const formatDate = (dateString: string | null, isFirstPeriod: boolean = false) => {
|
||||||
if (!dateString) return 'Unknown';
|
if (!dateString) return isFirstPeriod ? 'Beginning' : 'Unknown';
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleString('zh-CN', {
|
return date.toLocaleString('zh-CN', {
|
||||||
timeZone: 'Asia/Shanghai',
|
timeZone: 'Asia/Shanghai',
|
||||||
@@ -97,8 +151,8 @@ export function HistoricalPeriods({ periods, apiKey, userId }: HistoricalPeriods
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDateRange = (startAt: string | null, endAt: string | null) => {
|
const formatDateRange = (startAt: string | null, endAt: string | null, isFirstPeriod: boolean = false) => {
|
||||||
return `${formatDate(startAt)} → ${formatDate(endAt)}`;
|
return `${formatDate(startAt, isFirstPeriod)} → ${formatDate(endAt)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
@@ -130,25 +184,99 @@ export function HistoricalPeriods({ periods, apiKey, userId }: HistoricalPeriods
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Period Selector */}
|
{/* Period Selector */}
|
||||||
<div className="bg-card p-6 rounded-lg shadow-sm border border-border">
|
<div className="bg-card p-6 rounded-lg shadow-sm border border-border">
|
||||||
<label htmlFor="period-select" className="block text-lg font-medium text-card-foreground mb-4">
|
<label className="block text-lg font-medium text-card-foreground mb-4">
|
||||||
Select Historical Period
|
Select Historical Period
|
||||||
</label>
|
</label>
|
||||||
<select
|
<div className="relative">
|
||||||
id="period-select"
|
<button
|
||||||
value={selectedPeriod?.index ?? ''}
|
type="button"
|
||||||
onChange={(e) => {
|
onClick={() => setIsSelectOpen(!isSelectOpen)}
|
||||||
const periodIndex = parseInt(e.target.value);
|
className="flex w-full items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
const period = periods.find(p => p.index === periodIndex);
|
>
|
||||||
setSelectedPeriod(period || null);
|
<span className="text-foreground">
|
||||||
}}
|
{selectedPeriod ? (
|
||||||
className="block w-full px-3 py-2 border border-border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring sm:text-sm bg-background text-foreground"
|
<span className="flex items-center justify-between w-full">
|
||||||
>
|
<span>
|
||||||
{periods.map((period) => (
|
Period #{selectedPeriod.index} - {formatDateRange(
|
||||||
<option key={period.index} value={period.index}>
|
selectedPeriod.startAt,
|
||||||
Period #{period.index} - {formatDateRange(period.startAt, period.endAt)}
|
selectedPeriod.endAt,
|
||||||
</option>
|
selectedPeriod.index === 0
|
||||||
))}
|
)}
|
||||||
</select>
|
</span>
|
||||||
|
<span className="ml-2 text-primary font-medium">
|
||||||
|
{(() => {
|
||||||
|
const option = periodOptions.find(opt => opt.period.index === selectedPeriod.index);
|
||||||
|
if (!option || option.totalCost === null) {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center">
|
||||||
|
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-primary mr-1"></div>
|
||||||
|
Loading...
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return formatCurrency(option.totalCost);
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Select a period...'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`h-4 w-4 text-muted-foreground transition-transform duration-200 ${isSelectOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isSelectOpen && (
|
||||||
|
<div className="absolute top-full left-0 right-0 z-50 mt-2 max-h-60 overflow-auto rounded-md border border-border bg-card shadow-lg">
|
||||||
|
{periodOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.period.index}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedPeriod(option.period);
|
||||||
|
setIsSelectOpen(false);
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center justify-between px-3 py-3 text-sm hover:bg-accent hover:text-accent-foreground ${
|
||||||
|
selectedPeriod?.index === option.period.index
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-card-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
Period #{option.period.index} - {formatDateRange(
|
||||||
|
option.period.startAt,
|
||||||
|
option.period.endAt,
|
||||||
|
option.period.index === 0
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 text-primary font-medium">
|
||||||
|
{option.totalCost === null ? (
|
||||||
|
<span className="flex items-center">
|
||||||
|
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-primary mr-1"></div>
|
||||||
|
Loading...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
formatCurrency(option.totalCost)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Click overlay to close dropdown */}
|
||||||
|
{isSelectOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40"
|
||||||
|
onClick={() => setIsSelectOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedPeriod && (
|
{selectedPeriod && (
|
||||||
@@ -183,7 +311,7 @@ export function HistoricalPeriods({ periods, apiKey, userId }: HistoricalPeriods
|
|||||||
<>
|
<>
|
||||||
<div className="bg-card p-6 rounded-lg shadow-sm border border-border">
|
<div className="bg-card p-6 rounded-lg shadow-sm border border-border">
|
||||||
<h2 className="text-lg font-semibold text-card-foreground mb-4">
|
<h2 className="text-lg font-semibold text-card-foreground mb-4">
|
||||||
Period #{selectedPeriod.index}: {formatDateRange(summary.period.startAt, summary.period.endAt)}
|
Period #{selectedPeriod.index}: {formatDateRange(summary.period.startAt, summary.period.endAt, selectedPeriod.index === 0)}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function LoginPage({ onLogin, isLoading, error }: LoginPageProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
<div className="min-h-screen bg-background flex flex-col justify-center py-12 px-2 sm:px-6 lg:px-8">
|
||||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-3xl font-bold text-foreground">Claude Code Usage Dashboard</h1>
|
<h1 className="text-3xl font-bold text-foreground">Claude Code Usage Dashboard</h1>
|
||||||
|
|||||||
Reference in New Issue
Block a user