Compare commits

...

6 Commits

Author SHA1 Message Date
Supra4E8C
ec4b5ab46a feat: add quick links section to System page 2025-12-17 18:16:59 +08:00
Supra4E8C
cd6c142324 fix: fix log page timestamp display and optimize AuthFiles layout
- Add formatUnixTimestamp utility to auto-detect timestamp precision (s/ms/μs/ns)
  - Fix incorrect file modification time display in logs page
  - Remove fixed height constraint from AuthFilesPage model list
2025-12-17 18:03:25 +08:00
Supra4E8C
0ebf62b564 fix: usage layout 2025-12-17 12:14:35 +08:00
Supra4E8C
16f3442a11 fix: refactor usage 2025-12-17 00:35:02 +08:00
Supra4E8C
3328e686ee fix: ip address in the log is displayed incorrectly 2025-12-16 23:32:49 +08:00
Supra4E8C
f60bdb0a8e fix: change the dark mode color 2025-12-16 23:10:17 +08:00
13 changed files with 773 additions and 369 deletions

2
.gitignore vendored
View File

@@ -6,6 +6,8 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
api.md
usage.json
node_modules
dist

View File

@@ -266,3 +266,40 @@ export function IconDollarSign({ size = 20, ...props }: IconProps) {
</svg>
);
}
export function IconGithub({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
<path d="M9 18c-4.51 2-5-2-7-2" />
</svg>
);
}
export function IconExternalLink({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M15 3h6v6" />
<path d="M10 14 21 3" />
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</svg>
);
}
export function IconBookOpen({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M12 7v14" />
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
</svg>
);
}
export function IconCode({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
);
}

View File

@@ -630,7 +630,15 @@
"version_is_latest": "You are on the latest version",
"version_check_error": "Update check failed",
"version_current_missing": "Server version is unavailable; cannot compare",
"version_unknown": "Unknown"
"version_unknown": "Unknown",
"quick_links_title": "Quick Links",
"quick_links_desc": "Access project repositories and documentation for help and updates.",
"link_main_repo": "Main Repository",
"link_main_repo_desc": "CLI Proxy API core program source code",
"link_webui_repo": "WebUI Repository",
"link_webui_repo_desc": "Management Center frontend source code",
"link_docs": "Documentation",
"link_docs_desc": "Usage tutorials and configuration guides"
},
"notification": {
"debug_updated": "Debug settings updated",

View File

@@ -630,7 +630,15 @@
"version_is_latest": "当前已是最新版本",
"version_check_error": "检查更新失败",
"version_current_missing": "未获取到服务器版本号,暂无法比对",
"version_unknown": "未知"
"version_unknown": "未知",
"quick_links_title": "快捷链接",
"quick_links_desc": "访问项目仓库和文档,获取帮助和更新。",
"link_main_repo": "主程序仓库",
"link_main_repo_desc": "CLI Proxy API 核心程序源代码",
"link_webui_repo": "WebUI 仓库",
"link_webui_repo_desc": "管理中心前端界面源代码",
"link_docs": "使用教程",
"link_docs_desc": "配置指南和使用说明"
},
"notification": {
"debug_updated": "调试设置已更新",

View File

@@ -417,8 +417,6 @@
display: flex;
flex-direction: column;
gap: $spacing-sm;
max-height: 400px;
overflow-y: auto;
}
.modelItem {

View File

@@ -7,6 +7,7 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconDownload, IconRefreshCw, IconTimer, IconTrash2 } from '@/components/ui/icons';
import { useNotificationStore, useAuthStore } from '@/stores';
import { logsApi } from '@/services/api/logs';
import { formatUnixTimestamp } from '@/utils/format';
import styles from './LogsPage.module.scss';
interface ErrorLogItem {
@@ -38,6 +39,7 @@ const LOG_SOURCE_REGEX = /^\[([^\]]+)\]/;
const LOG_LATENCY_REGEX = /\b(\d+(?:\.\d+)?)(?:\s*)(µs|us|ms|s)\b/i;
const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;
const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i;
const LOG_TIME_OF_DAY_REGEX = /^\d{1,2}:\d{2}:\d{2}(?:\.\d{1,3})?$/;
const HTTP_STATUS_PATTERNS: RegExp[] = [
/\|\s*([1-5]\d{2})\s*\|/,
@@ -58,6 +60,24 @@ const detectHttpStatusCode = (text: string): number | undefined => {
return undefined;
};
const extractIp = (text: string): string | undefined => {
const ipv4Match = text.match(LOG_IPV4_REGEX);
if (ipv4Match) return ipv4Match[0];
const ipv6Match = text.match(LOG_IPV6_REGEX);
if (!ipv6Match) return undefined;
const candidate = ipv6Match[0];
// Avoid treating time strings like "12:34:56" as IPv6 addresses.
if (LOG_TIME_OF_DAY_REGEX.test(candidate)) return undefined;
// If no compression marker is present, a valid IPv6 address must contain 8 hextets.
if (!candidate.includes('::') && candidate.split(':').length !== 8) return undefined;
return candidate;
};
type ParsedLogLine = {
raw: string;
timestamp?: string;
@@ -168,12 +188,12 @@ const parseLogLine = (raw: string): ParsedLogLine => {
// ip
const ipIndex = segments.findIndex(
(segment) => LOG_IPV4_REGEX.test(segment) || LOG_IPV6_REGEX.test(segment)
(segment) => Boolean(extractIp(segment))
);
if (ipIndex >= 0) {
const match = segments[ipIndex].match(LOG_IPV4_REGEX) ?? segments[ipIndex].match(LOG_IPV6_REGEX);
if (match) {
ip = match[0];
const extracted = extractIp(segments[ipIndex]);
if (extracted) {
ip = extracted;
consumed.add(ipIndex);
}
}
@@ -197,8 +217,7 @@ const parseLogLine = (raw: string): ParsedLogLine => {
const latencyMatch = remaining.match(LOG_LATENCY_REGEX);
if (latencyMatch) latency = `${latencyMatch[1]}${latencyMatch[2]}`;
const ipMatch = remaining.match(LOG_IPV4_REGEX) ?? remaining.match(LOG_IPV6_REGEX);
if (ipMatch) ip = ipMatch[0];
ip = extractIp(remaining);
const parsed = extractHttpMethodAndPath(remaining);
method = parsed.method;
@@ -611,7 +630,7 @@ export function LogsPage() {
<div className="item-title">{item.name}</div>
<div className="item-subtitle">
{item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''}{' '}
{item.modified ? new Date(item.modified).toLocaleString() : ''}
{item.modified ? formatUnixTimestamp(item.modified) : ''}
</div>
</div>
<div className="item-actions">

View File

@@ -135,3 +135,81 @@
}
}
}
.quickLinks {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: $spacing-md;
}
.linkCard {
display: flex;
align-items: center;
gap: $spacing-md;
padding: $spacing-md $spacing-lg;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
text-decoration: none;
color: inherit;
transition: all 0.2s ease;
&:hover {
background-color: var(--bg-hover);
border-color: var(--primary-color);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&:active {
transform: translateY(0);
}
}
.linkIcon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: $radius-md;
background-color: var(--primary-color);
color: white;
flex-shrink: 0;
&.github {
background-color: #24292f;
}
&.docs {
background-color: #10b981;
}
}
.linkContent {
flex: 1;
min-width: 0;
}
.linkTitle {
display: flex;
align-items: center;
gap: $spacing-xs;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 2px;
svg {
opacity: 0.5;
flex-shrink: 0;
}
}
.linkDesc {
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { IconGithub, IconBookOpen, IconExternalLink, IconCode } from '@/components/ui/icons';
import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore } from '@/stores';
import { apiKeysApi } from '@/services/api/apiKeys';
import { classifyModels } from '@/utils/models';
@@ -148,6 +149,65 @@ export function SystemPage() {
</div>
</Card>
<Card title={t('system_info.quick_links_title')}>
<p className={styles.sectionDescription}>{t('system_info.quick_links_desc')}</p>
<div className={styles.quickLinks}>
<a
href="https://github.com/router-for-me/CLIProxyAPI"
target="_blank"
rel="noopener noreferrer"
className={styles.linkCard}
>
<div className={`${styles.linkIcon} ${styles.github}`}>
<IconGithub size={22} />
</div>
<div className={styles.linkContent}>
<div className={styles.linkTitle}>
{t('system_info.link_main_repo')}
<IconExternalLink size={14} />
</div>
<div className={styles.linkDesc}>{t('system_info.link_main_repo_desc')}</div>
</div>
</a>
<a
href="https://github.com/router-for-me/Cli-Proxy-API-Management-Center"
target="_blank"
rel="noopener noreferrer"
className={styles.linkCard}
>
<div className={`${styles.linkIcon} ${styles.github}`}>
<IconCode size={22} />
</div>
<div className={styles.linkContent}>
<div className={styles.linkTitle}>
{t('system_info.link_webui_repo')}
<IconExternalLink size={14} />
</div>
<div className={styles.linkDesc}>{t('system_info.link_webui_repo_desc')}</div>
</div>
</a>
<a
href="https://help.router-for.me/"
target="_blank"
rel="noopener noreferrer"
className={styles.linkCard}
>
<div className={`${styles.linkIcon} ${styles.docs}`}>
<IconBookOpen size={22} />
</div>
<div className={styles.linkContent}>
<div className={styles.linkTitle}>
{t('system_info.link_docs')}
<IconExternalLink size={14} />
</div>
<div className={styles.linkDesc}>{t('system_info.link_docs_desc')}</div>
</div>
</a>
</div>
</Card>
<Card
title={t('system_info.models_title')}
extra={

View File

@@ -5,21 +5,7 @@
width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
// 覆盖Card组件样式 (80%比例)
:global(.card) {
padding: 12px;
border-radius: $radius-md;
}
:global(.card-header) {
margin-bottom: 10px;
.title {
font-size: 14px;
}
}
gap: 20px;
}
.header {
@@ -40,9 +26,9 @@
.errorBox {
padding: 10px;
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid var(--danger-color);
border: 1px solid var(--error-color);
border-radius: $radius-sm;
color: var(--danger-color);
color: var(--error-color);
font-size: 12px;
}
@@ -53,15 +39,11 @@
padding: 16px;
}
// Stats Grid - 五个卡片并排显示 (88%比例放大10%)
// Stats Grid
.statsGrid {
display: grid;
gap: 8px;
grid-template-columns: repeat(5, minmax(0, 1fr));
@include tablet {
grid-template-columns: repeat(3, 1fr);
}
gap: 14px;
grid-template-columns: repeat(12, minmax(0, 1fr));
@include mobile {
grid-template-columns: 1fr;
@@ -69,22 +51,69 @@
}
.statCard {
padding: 13px;
background-color: var(--bg-primary);
border-radius: $radius-md;
--accent: #3b82f6;
--accent-soft: rgba(59, 130, 246, 0.18);
--accent-border: rgba(59, 130, 246, 0.35);
grid-column: span 4;
position: relative;
padding: 18px;
background:
radial-gradient(120% 140% at 12% 0%, var(--accent-soft) 0%, rgba(0, 0, 0, 0) 62%),
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)),
var(--bg-primary);
border-radius: $radius-lg;
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 5px;
min-height: 143px;
box-shadow: $shadow-sm;
gap: 10px;
min-height: 176px;
box-shadow: var(--shadow-lg);
transition: transform $transition-fast, box-shadow $transition-fast, border-color $transition-fast;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
height: 3px;
width: 100%;
background: linear-gradient(90deg, var(--accent), rgba(0, 0, 0, 0));
opacity: 0.95;
}
&:hover {
transform: translateY(-2px);
box-shadow: $shadow-md;
border-color: rgba(37, 99, 235, 0.2);
border-color: var(--accent-border);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.22);
}
@include tablet {
grid-column: span 6;
}
@include mobile {
grid-column: auto;
min-height: 168px;
}
}
.statCard:nth-child(-n + 2) {
grid-column: span 6;
.statValue {
font-size: 32px;
}
}
@include mobile {
.statCard:nth-child(-n + 2) {
grid-column: auto;
.statValue {
font-size: 28px;
}
}
}
@@ -92,7 +121,7 @@
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 5px;
gap: 12px;
}
.statLabelGroup {
@@ -102,14 +131,16 @@
}
.statIconBadge {
width: 29px;
height: 29px;
border-radius: $radius-sm;
width: 34px;
height: 34px;
border-radius: $radius-md;
display: grid;
place-items: center;
color: #fff;
font-size: 13px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.08);
background: var(--accent);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.25);
flex-shrink: 0;
svg {
@@ -128,18 +159,18 @@
}
.statLabel {
font-size: 11px;
color: var(--text-secondary);
font-weight: 600;
letter-spacing: 0.01em;
text-transform: uppercase;
font-size: 12px;
color: var(--text-tertiary);
font-weight: 700;
letter-spacing: 0.02em;
}
.statValue {
font-size: 20px;
font-weight: 700;
font-size: 28px;
font-weight: 800;
color: var(--text-primary);
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.statValueRow {
@@ -186,8 +217,8 @@
.statMetaRow {
display: flex;
flex-wrap: wrap;
gap: 4px;
font-size: 10px;
gap: 8px 10px;
font-size: 12px;
color: var(--text-secondary);
}
@@ -198,8 +229,8 @@
}
.statMetaDot {
width: 6px;
height: 6px;
width: 7px;
height: 7px;
border-radius: 50%;
background-color: var(--text-secondary);
}
@@ -210,18 +241,18 @@
.statTrend {
margin-top: auto;
background: var(--bg-secondary, #f6f8fb);
border-radius: $radius-sm;
padding: 4px;
height: 44px;
background: var(--bg-tertiary);
border-radius: $radius-md;
padding: 8px;
height: 58px;
border: 1px solid var(--border-color);
}
.statTrendPlaceholder {
width: 100%;
height: 100%;
background: var(--bg-tertiary, #eef1f6);
border-radius: $radius-sm;
background: var(--bg-secondary);
border-radius: $radius-md;
}
.sparkline {
@@ -257,7 +288,7 @@
transition: background-color 0.15s ease;
&:hover {
background-color: var(--bg-hover);
background-color: var(--bg-tertiary);
}
}
@@ -272,7 +303,7 @@
.apiEndpoint {
font-weight: 600;
color: var(--text-primary);
font-size: 12px;
font-size: 13px;
word-break: break-all;
}
@@ -283,16 +314,17 @@
}
.apiBadge {
font-size: 10px;
font-size: 11px;
color: var(--text-secondary);
background-color: var(--bg-tertiary);
padding: 1px 6px;
border-radius: $radius-sm;
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
padding: 2px 8px;
border-radius: $radius-full;
}
.expandIcon {
color: var(--text-secondary);
font-size: 10px;
font-size: 12px;
margin-left: 6px;
}
@@ -311,10 +343,11 @@
display: grid;
grid-template-columns: 1fr auto auto;
gap: 10px;
padding: 3px 6px;
padding: 8px 10px;
background-color: var(--bg-primary);
border-radius: $radius-sm;
font-size: 11px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
font-size: 12px;
@include mobile {
grid-template-columns: 1fr;
@@ -345,17 +378,17 @@
.table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
font-size: 12px;
th, td {
padding: 6px 10px;
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
font-weight: 600;
color: var(--text-secondary);
color: var(--text-tertiary);
background-color: var(--bg-secondary);
white-space: nowrap;
}
@@ -365,7 +398,7 @@
}
tbody tr:hover {
background-color: var(--bg-hover);
background-color: var(--bg-tertiary);
}
}
@@ -535,13 +568,13 @@
}
.chartWrapper {
padding: 12px;
background-color: var(--bg-primary);
border-radius: $radius-md;
padding: 14px;
background-color: var(--bg-secondary);
border-radius: $radius-lg;
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 10px;
gap: 12px;
}
.chartLegend {
@@ -566,7 +599,11 @@
gap: 6px;
min-width: 0;
max-width: 240px;
font-size: 11px;
padding: 4px 10px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
background: var(--bg-primary);
font-size: 12px;
color: var(--text-secondary);
@include mobile {
@@ -588,10 +625,10 @@
}
.chartArea {
height: 240px;
height: 280px;
@include mobile {
height: 280px;
height: 320px;
}
}
@@ -616,63 +653,85 @@
.periodButtons {
display: flex;
gap: 3px;
gap: 6px;
}
// Chart Line Controls (80%比例)
.chartLineControls {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
.chartsGrid {
display: grid;
gap: 20px;
grid-template-columns: minmax(0, 1fr);
@include mobile {
flex-direction: column;
> * {
min-width: 0;
}
@include desktop {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.detailsGrid {
display: grid;
gap: 20px;
grid-template-columns: minmax(0, 1fr);
> * {
min-width: 0;
}
@include desktop {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.chartLineHeader {
display: inline-flex;
align-items: center;
gap: 10px;
}
.chartLineList {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
@include mobile {
grid-template-columns: 1fr;
}
}
.chartLineItem {
display: flex;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 6px;
flex-wrap: wrap;
gap: 10px;
padding: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
@include mobile {
flex-direction: column;
align-items: flex-start;
grid-template-columns: 1fr;
align-items: stretch;
gap: 8px;
}
}
.chartLineLabel {
font-size: 11px;
font-size: 12px;
color: var(--text-secondary);
min-width: 48px;
}
.chartLineActions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
font-weight: 600;
min-width: 64px;
}
.chartLineCount {
font-size: 11px;
font-size: 12px;
color: var(--text-secondary);
font-weight: 500;
}
.chartLineHint {
font-size: 10px;
font-size: 12px;
color: var(--text-tertiary);
margin: 6px 0 0 0;
font-style: italic;
margin: 10px 0 0 0;
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useEffect, useState, useCallback, useMemo, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import {
Chart as ChartJS,
@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useThemeStore } from '@/stores';
import { usageApi } from '@/services/api/usage';
import {
formatTokensInMillions,
@@ -55,13 +56,15 @@ interface UsagePayload {
success_count?: number;
failure_count?: number;
total_tokens?: number;
apis?: Record<string, any>;
[key: string]: any;
apis?: Record<string, unknown>;
[key: string]: unknown;
}
export function UsagePage() {
const { t } = useTranslation();
const isMobile = useMediaQuery('(max-width: 768px)');
const theme = useThemeStore((state) => state.theme);
const isDark = theme === 'dark';
const [usage, setUsage] = useState<UsagePayload | null>(null);
const [loading, setLoading] = useState(true);
@@ -90,8 +93,9 @@ export function UsagePage() {
const data = await usageApi.getUsage();
const payload = data?.usage ?? data;
setUsage(payload);
} catch (err: any) {
setError(err?.message || t('usage_stats.loading_error'));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('usage_stats.loading_error');
setError(message);
} finally {
setLoading(false);
}
@@ -200,23 +204,23 @@ export function UsagePage() {
);
const requestsSparkline = useMemo(
() => buildSparkline(buildLastHourSeries('requests'), '#2563eb', 'rgba(37, 99, 235, 0.12)'),
() => buildSparkline(buildLastHourSeries('requests'), '#3b82f6', 'rgba(59, 130, 246, 0.18)'),
[buildLastHourSeries, buildSparkline]
);
const tokensSparkline = useMemo(
() => buildSparkline(buildLastHourSeries('tokens'), '#8b5cf6', 'rgba(139, 92, 246, 0.12)'),
() => buildSparkline(buildLastHourSeries('tokens'), '#8b5cf6', 'rgba(139, 92, 246, 0.18)'),
[buildLastHourSeries, buildSparkline]
);
const rpmSparkline = useMemo(
() => buildSparkline(buildLastHourSeries('requests'), '#22c55e', 'rgba(34, 197, 94, 0.12)'),
() => buildSparkline(buildLastHourSeries('requests'), '#22c55e', 'rgba(34, 197, 94, 0.18)'),
[buildLastHourSeries, buildSparkline]
);
const tpmSparkline = useMemo(
() => buildSparkline(buildLastHourSeries('tokens'), '#f97316', 'rgba(249, 115, 22, 0.12)'),
() => buildSparkline(buildLastHourSeries('tokens'), '#f97316', 'rgba(249, 115, 22, 0.18)'),
[buildLastHourSeries, buildSparkline]
);
const costSparkline = useMemo(
() => buildSparkline(buildLastHourSeries('tokens'), '#f59e0b', 'rgba(245, 158, 11, 0.12)'),
() => buildSparkline(buildLastHourSeries('tokens'), '#f59e0b', 'rgba(245, 158, 11, 0.18)'),
[buildLastHourSeries, buildSparkline]
);
@@ -225,6 +229,13 @@ export function UsagePage() {
const pointRadius = isMobile && period === 'hour' ? 0 : isMobile ? 2 : 4;
const tickFontSize = isMobile ? 10 : 12;
const maxTickLabelCount = isMobile ? (period === 'hour' ? 8 : 6) : period === 'hour' ? 12 : 10;
const gridColor = isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(17, 24, 39, 0.06)';
const axisBorderColor = isDark ? 'rgba(255, 255, 255, 0.10)' : 'rgba(17, 24, 39, 0.10)';
const tickColor = isDark ? 'rgba(255, 255, 255, 0.72)' : 'rgba(17, 24, 39, 0.72)';
const tooltipBg = isDark ? 'rgba(17, 24, 39, 0.92)' : 'rgba(255, 255, 255, 0.98)';
const tooltipTitle = isDark ? '#ffffff' : '#111827';
const tooltipBody = isDark ? 'rgba(255, 255, 255, 0.86)' : '#374151';
const tooltipBorder = isDark ? 'rgba(255, 255, 255, 0.10)' : 'rgba(17, 24, 39, 0.10)';
return {
responsive: true,
@@ -234,11 +245,29 @@ export function UsagePage() {
intersect: false
},
plugins: {
legend: { display: false }
legend: { display: false },
tooltip: {
backgroundColor: tooltipBg,
titleColor: tooltipTitle,
bodyColor: tooltipBody,
borderColor: tooltipBorder,
borderWidth: 1,
padding: 10,
displayColors: true,
usePointStyle: true
}
},
scales: {
x: {
grid: {
color: gridColor,
drawTicks: false
},
border: {
color: axisBorderColor
},
ticks: {
color: tickColor,
font: { size: tickFontSize },
maxRotation: isMobile ? 0 : 45,
minRotation: isMobile ? 0 : 0,
@@ -270,7 +299,14 @@ export function UsagePage() {
},
y: {
beginAtZero: true,
grid: {
color: gridColor
},
border: {
color: axisBorderColor
},
ticks: {
color: tickColor,
font: { size: tickFontSize }
}
}
@@ -288,7 +324,7 @@ export function UsagePage() {
}
};
},
[isMobile]
[isDark, isMobile]
);
const requestsChartOptions = useMemo(
@@ -386,7 +422,9 @@ export function UsagePage() {
key: 'requests',
label: t('usage_stats.total_requests'),
icon: <IconSatellite size={16} />,
accent: '#2563eb',
accent: '#3b82f6',
accentSoft: 'rgba(59, 130, 246, 0.18)',
accentBorder: 'rgba(59, 130, 246, 0.35)',
value: loading ? '-' : (usage?.total_requests ?? 0).toLocaleString(),
meta: (
<>
@@ -407,6 +445,8 @@ export function UsagePage() {
label: t('usage_stats.total_tokens'),
icon: <IconDiamond size={16} />,
accent: '#8b5cf6',
accentSoft: 'rgba(139, 92, 246, 0.18)',
accentBorder: 'rgba(139, 92, 246, 0.35)',
value: loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0),
meta: (
<>
@@ -425,6 +465,8 @@ export function UsagePage() {
label: t('usage_stats.rpm_30m'),
icon: <IconTimer size={16} />,
accent: '#22c55e',
accentSoft: 'rgba(34, 197, 94, 0.18)',
accentBorder: 'rgba(34, 197, 94, 0.32)',
value: loading ? '-' : formatPerMinuteValue(rateStats.rpm),
meta: (
<span className={styles.statMetaItem}>
@@ -438,6 +480,8 @@ export function UsagePage() {
label: t('usage_stats.tpm_30m'),
icon: <IconTrendingUp size={16} />,
accent: '#f97316',
accentSoft: 'rgba(249, 115, 22, 0.18)',
accentBorder: 'rgba(249, 115, 22, 0.32)',
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
meta: (
<span className={styles.statMetaItem}>
@@ -451,6 +495,8 @@ export function UsagePage() {
label: t('usage_stats.total_cost'),
icon: <IconDollarSign size={16} />,
accent: '#f59e0b',
accentSoft: 'rgba(245, 158, 11, 0.18)',
accentBorder: 'rgba(245, 158, 11, 0.32)',
value: loading ? '-' : hasPrices ? formatUsd(totalCost) : '--',
meta: (
<>
@@ -487,15 +533,22 @@ export function UsagePage() {
{/* Stats Overview Cards */}
<div className={styles.statsGrid}>
{statsCards.map(card => (
<div key={card.key} className={styles.statCard}>
<div
key={card.key}
className={styles.statCard}
style={
{
'--accent': card.accent,
'--accent-soft': card.accentSoft,
'--accent-border': card.accentBorder
} as CSSProperties
}
>
<div className={styles.statCardHeader}>
<div className={styles.statLabelGroup}>
<span className={styles.statLabel}>{card.label}</span>
</div>
<span
className={styles.statIconBadge}
style={{ backgroundColor: card.accent }}
>
<span className={styles.statIconBadge}>
{card.icon}
</span>
</div>
@@ -513,37 +566,10 @@ export function UsagePage() {
</div>
{/* Chart Line Selection */}
<Card title={t('usage_stats.chart_line_actions_label')}>
<div className={styles.chartLineControls}>
<div className={styles.chartLineList}>
{chartLines.map((line, index) => (
<div key={index} className={styles.chartLineItem}>
<span className={styles.chartLineLabel}>
{t(`usage_stats.chart_line_label_${index + 1}`)}:
</span>
<select
value={line}
onChange={(e) => handleChartLineChange(index, e.target.value)}
className={styles.select}
>
<option value="all">{t('usage_stats.chart_line_all')}</option>
{modelNames.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
{chartLines.length > 1 && (
<Button
variant="danger"
size="sm"
onClick={() => handleRemoveChartLine(index)}
>
{t('usage_stats.chart_line_delete')}
</Button>
)}
</div>
))}
</div>
<div className={styles.chartLineActions}>
<Card
title={t('usage_stats.chart_line_actions_label')}
extra={
<div className={styles.chartLineHeader}>
<span className={styles.chartLineCount}>
{chartLines.length}/{MAX_CHART_LINES}
</span>
@@ -556,208 +582,241 @@ export function UsagePage() {
{t('usage_stats.chart_line_add')}
</Button>
</div>
}
>
<div className={styles.chartLineList}>
{chartLines.map((line, index) => (
<div key={index} className={styles.chartLineItem}>
<span className={styles.chartLineLabel}>
{t(`usage_stats.chart_line_label_${index + 1}`)}
</span>
<select
value={line}
onChange={(e) => handleChartLineChange(index, e.target.value)}
className={styles.select}
>
<option value="all">{t('usage_stats.chart_line_all')}</option>
{modelNames.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
{chartLines.length > 1 && (
<Button
variant="danger"
size="sm"
onClick={() => handleRemoveChartLine(index)}
>
{t('usage_stats.chart_line_delete')}
</Button>
)}
</div>
))}
</div>
<p className={styles.chartLineHint}>{t('usage_stats.chart_line_hint')}</p>
</Card>
{/* Requests Chart */}
<Card
title={t('usage_stats.requests_trend')}
extra={
<div className={styles.periodButtons}>
<Button
variant={requestsPeriod === 'hour' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setRequestsPeriod('hour')}
>
{t('usage_stats.by_hour')}
</Button>
<Button
variant={requestsPeriod === 'day' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setRequestsPeriod('day')}
>
{t('usage_stats.by_day')}
</Button>
</div>
}
>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : requestsChartData.labels.length > 0 ? (
<div className={styles.chartWrapper}>
<div className={styles.chartLegend} aria-label="Chart legend">
{requestsChartData.datasets.map((dataset, index) => (
<div
key={`${dataset.label}-${index}`}
className={styles.legendItem}
title={dataset.label}
>
<span className={styles.legendDot} style={{ backgroundColor: dataset.borderColor }} />
<span className={styles.legendLabel}>{dataset.label}</span>
</div>
))}
<div className={styles.chartsGrid}>
{/* Requests Chart */}
<Card
title={t('usage_stats.requests_trend')}
extra={
<div className={styles.periodButtons}>
<Button
variant={requestsPeriod === 'hour' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setRequestsPeriod('hour')}
>
{t('usage_stats.by_hour')}
</Button>
<Button
variant={requestsPeriod === 'day' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setRequestsPeriod('day')}
>
{t('usage_stats.by_day')}
</Button>
</div>
<div className={styles.chartArea}>
<div className={styles.chartScroller}>
<div
className={styles.chartCanvas}
style={
requestsPeriod === 'hour'
? { minWidth: getHourChartMinWidth(requestsChartData.labels.length) }
: undefined
}
>
<Line data={requestsChartData} options={requestsChartOptions} />
</div>
</div>
</div>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
{/* Tokens Chart */}
<Card
title={t('usage_stats.tokens_trend')}
extra={
<div className={styles.periodButtons}>
<Button
variant={tokensPeriod === 'hour' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setTokensPeriod('hour')}
>
{t('usage_stats.by_hour')}
</Button>
<Button
variant={tokensPeriod === 'day' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setTokensPeriod('day')}
>
{t('usage_stats.by_day')}
</Button>
</div>
}
>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : tokensChartData.labels.length > 0 ? (
<div className={styles.chartWrapper}>
<div className={styles.chartLegend} aria-label="Chart legend">
{tokensChartData.datasets.map((dataset, index) => (
<div
key={`${dataset.label}-${index}`}
className={styles.legendItem}
title={dataset.label}
>
<span className={styles.legendDot} style={{ backgroundColor: dataset.borderColor }} />
<span className={styles.legendLabel}>{dataset.label}</span>
</div>
))}
</div>
<div className={styles.chartArea}>
<div className={styles.chartScroller}>
<div
className={styles.chartCanvas}
style={
tokensPeriod === 'hour'
? { minWidth: getHourChartMinWidth(tokensChartData.labels.length) }
: undefined
}
>
<Line data={tokensChartData} options={tokensChartOptions} />
</div>
</div>
</div>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
{/* API Key Statistics */}
<Card title={t('usage_stats.api_details')}>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : apiStats.length > 0 ? (
<div className={styles.apiList}>
{apiStats.map((api) => (
<div key={api.endpoint} className={styles.apiItem}>
<div
className={styles.apiHeader}
onClick={() => toggleApiExpand(api.endpoint)}
>
<div className={styles.apiInfo}>
<span className={styles.apiEndpoint}>{api.endpoint}</span>
<div className={styles.apiStats}>
<span className={styles.apiBadge}>
{t('usage_stats.requests_count')}: {api.totalRequests}
</span>
<span className={styles.apiBadge}>
Tokens: {formatTokensInMillions(api.totalTokens)}
</span>
{hasPrices && api.totalCost > 0 && (
<span className={styles.apiBadge}>
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)}
</span>
)}
</div>
}
>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : requestsChartData.labels.length > 0 ? (
<div className={styles.chartWrapper}>
<div className={styles.chartLegend} aria-label="Chart legend">
{requestsChartData.datasets.map((dataset, index) => (
<div
key={`${dataset.label}-${index}`}
className={styles.legendItem}
title={dataset.label}
>
<span className={styles.legendDot} style={{ backgroundColor: dataset.borderColor }} />
<span className={styles.legendLabel}>{dataset.label}</span>
</div>
<span className={styles.expandIcon}>
{expandedApis.has(api.endpoint) ? '▼' : '▶'}
</span>
</div>
{expandedApis.has(api.endpoint) && (
<div className={styles.apiModels}>
{Object.entries(api.models).map(([model, stats]) => (
<div key={model} className={styles.modelRow}>
<span className={styles.modelName}>{model}</span>
<span className={styles.modelStat}>{stats.requests} {t('usage_stats.requests_count')}</span>
<span className={styles.modelStat}>{formatTokensInMillions(stats.tokens)}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
{/* Model Statistics */}
<Card title={t('usage_stats.models')}>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : modelStats.length > 0 ? (
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>{t('usage_stats.model_name')}</th>
<th>{t('usage_stats.requests_count')}</th>
<th>{t('usage_stats.tokens_count')}</th>
{hasPrices && <th>{t('usage_stats.total_cost')}</th>}
</tr>
</thead>
<tbody>
{modelStats.map((stat) => (
<tr key={stat.model}>
<td className={styles.modelCell}>{stat.model}</td>
<td>{stat.requests.toLocaleString()}</td>
<td>{formatTokensInMillions(stat.tokens)}</td>
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
</div>
<div className={styles.chartArea}>
<div className={styles.chartScroller}>
<div
className={styles.chartCanvas}
style={
requestsPeriod === 'hour'
? { minWidth: getHourChartMinWidth(requestsChartData.labels.length) }
: undefined
}
>
<Line data={requestsChartData} options={requestsChartOptions} />
</div>
</div>
</div>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
{/* Tokens Chart */}
<Card
title={t('usage_stats.tokens_trend')}
extra={
<div className={styles.periodButtons}>
<Button
variant={tokensPeriod === 'hour' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setTokensPeriod('hour')}
>
{t('usage_stats.by_hour')}
</Button>
<Button
variant={tokensPeriod === 'day' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setTokensPeriod('day')}
>
{t('usage_stats.by_day')}
</Button>
</div>
}
>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : tokensChartData.labels.length > 0 ? (
<div className={styles.chartWrapper}>
<div className={styles.chartLegend} aria-label="Chart legend">
{tokensChartData.datasets.map((dataset, index) => (
<div
key={`${dataset.label}-${index}`}
className={styles.legendItem}
title={dataset.label}
>
<span className={styles.legendDot} style={{ backgroundColor: dataset.borderColor }} />
<span className={styles.legendLabel}>{dataset.label}</span>
</div>
))}
</div>
<div className={styles.chartArea}>
<div className={styles.chartScroller}>
<div
className={styles.chartCanvas}
style={
tokensPeriod === 'hour'
? { minWidth: getHourChartMinWidth(tokensChartData.labels.length) }
: undefined
}
>
<Line data={tokensChartData} options={tokensChartOptions} />
</div>
</div>
</div>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
</div>
<div className={styles.detailsGrid}>
{/* API Key Statistics */}
<Card title={t('usage_stats.api_details')}>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : apiStats.length > 0 ? (
<div className={styles.apiList}>
{apiStats.map((api) => (
<div key={api.endpoint} className={styles.apiItem}>
<div
className={styles.apiHeader}
onClick={() => toggleApiExpand(api.endpoint)}
>
<div className={styles.apiInfo}>
<span className={styles.apiEndpoint}>{api.endpoint}</span>
<div className={styles.apiStats}>
<span className={styles.apiBadge}>
{t('usage_stats.requests_count')}: {api.totalRequests}
</span>
<span className={styles.apiBadge}>
Tokens: {formatTokensInMillions(api.totalTokens)}
</span>
{hasPrices && api.totalCost > 0 && (
<span className={styles.apiBadge}>
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)}
</span>
)}
</div>
</div>
<span className={styles.expandIcon}>
{expandedApis.has(api.endpoint) ? '▼' : '▶'}
</span>
</div>
{expandedApis.has(api.endpoint) && (
<div className={styles.apiModels}>
{Object.entries(api.models).map(([model, stats]) => (
<div key={model} className={styles.modelRow}>
<span className={styles.modelName}>{model}</span>
<span className={styles.modelStat}>{stats.requests} {t('usage_stats.requests_count')}</span>
<span className={styles.modelStat}>{formatTokensInMillions(stats.tokens)}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
{/* Model Statistics */}
<Card title={t('usage_stats.models')}>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : modelStats.length > 0 ? (
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>{t('usage_stats.model_name')}</th>
<th>{t('usage_stats.requests_count')}</th>
<th>{t('usage_stats.tokens_count')}</th>
{hasPrices && <th>{t('usage_stats.total_cost')}</th>}
</tr>
</thead>
<tbody>
{modelStats.map((stat) => (
<tr key={stat.model}>
<td className={styles.modelCell}>{stat.model}</td>
<td>{stat.requests.toLocaleString()}</td>
<td>{formatTokensInMillions(stat.tokens)}</td>
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
</div>
{/* Model Pricing Configuration */}
<Card title={t('usage_stats.model_price_settings')}>

View File

@@ -36,11 +36,11 @@
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
}
// 深色主题(纯黑
// 深色主题(#191919
[data-theme='dark'] {
--bg-primary: #0a0a0a;
--bg-secondary: #000000;
--bg-tertiary: #171717;
--bg-primary: #202020;
--bg-secondary: #191919;
--bg-tertiary: #262626;
--text-primary: #fafafa;
--text-secondary: #a3a3a3;

View File

@@ -52,6 +52,37 @@ export function formatDateTime(date: string | Date): string {
});
}
/**
* 将 Unix 时间戳(秒/毫秒/微秒/纳秒)格式化为本地时间字符串
*/
export function formatUnixTimestamp(value: unknown, locale?: string): string {
if (value === null || value === undefined || value === '') return '';
const asNumber = typeof value === 'number' ? value : Number(value);
const date = (() => {
if (!Number.isFinite(asNumber) || Number.isNaN(asNumber)) {
return new Date(String(value));
}
const abs = Math.abs(asNumber);
// 秒:常见 10 位(~1e9
if (abs < 1e11) return new Date(asNumber * 1000);
// 毫秒:常见 13 位(~1e12
if (abs < 1e14) return new Date(asNumber);
// 微秒:常见 16 位(~1e15
if (abs < 1e17) return new Date(Math.round(asNumber / 1000));
// 纳秒:常见 19 位(~1e18
return new Date(Math.round(asNumber / 1e6));
})();
if (Number.isNaN(date.getTime())) return '';
return locale ? date.toLocaleString(locale) : date.toLocaleString();
}
/**
* 格式化数字(添加千位分隔符)
*/

View File

@@ -3,6 +3,7 @@
* 迁移自基线 modules/usage.js 的纯逻辑部分
*/
import type { ScriptableContext } from 'chart.js';
import { maskApiKey } from './format';
export interface KeyStatBucket {
@@ -636,7 +637,7 @@ export interface ChartDataset {
label: string;
data: number[];
borderColor: string;
backgroundColor: string;
backgroundColor: string | CanvasGradient | ((context: ScriptableContext<'line'>) => string | CanvasGradient);
fill: boolean;
tension: number;
}
@@ -658,6 +659,47 @@ const CHART_COLORS = [
{ borderColor: '#f97316', backgroundColor: 'rgba(249, 115, 22, 0.15)' },
];
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
const hexToRgb = (hex: string): { r: number; g: number; b: number } | null => {
const normalized = hex.trim().replace('#', '');
if (normalized.length !== 6) {
return null;
}
const r = Number.parseInt(normalized.slice(0, 2), 16);
const g = Number.parseInt(normalized.slice(2, 4), 16);
const b = Number.parseInt(normalized.slice(4, 6), 16);
if (![r, g, b].every((channel) => Number.isFinite(channel))) {
return null;
}
return { r, g, b };
};
const withAlpha = (hex: string, alpha: number) => {
const rgb = hexToRgb(hex);
if (!rgb) {
return hex;
}
const clamped = clamp(alpha, 0, 1);
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${clamped})`;
};
const buildAreaGradient = (context: ScriptableContext<'line'>, baseHex: string, fallback: string) => {
const chart = context.chart;
const ctx = chart.ctx;
const area = chart.chartArea;
if (!area) {
return fallback;
}
const gradient = ctx.createLinearGradient(0, area.top, 0, area.bottom);
gradient.addColorStop(0, withAlpha(baseHex, 0.28));
gradient.addColorStop(0.6, withAlpha(baseHex, 0.12));
gradient.addColorStop(1, withAlpha(baseHex, 0.02));
return gradient;
};
/**
* 构建图表数据
*/
@@ -692,13 +734,16 @@ export function buildChartData(
const data = isAll ? getAllSeries() : (dataByModel.get(model) || new Array(labels.length).fill(0));
const colorIndex = index % CHART_COLORS.length;
const style = CHART_COLORS[colorIndex];
const shouldFill = modelsToShow.length === 1 || (isAll && modelsToShow.length > 1);
return {
label: isAll ? 'All Models' : model,
data,
borderColor: style.borderColor,
backgroundColor: style.backgroundColor,
fill: false,
backgroundColor: shouldFill
? (ctx) => buildAreaGradient(ctx, style.borderColor, style.backgroundColor)
: style.backgroundColor,
fill: shouldFill,
tension: 0.35
};
});