mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-19 03:00:49 +08:00
feat: add Ampcode (Amp CLI Integration) support with configuration UI and i18n
- Add ampcodeApi service for upstream URL, API key, and model mappings management - Implement Ampcode configuration modal in AiProvidersPage - Add complete i18n translations for Ampcode features (en and zh-CN) - Enhance UsagePage with mobile-responsive chart improvements and legend display - Optimize chart rendering for smaller screens - Improve page layout styles (SystemPage, AiProvidersPage alignment)
This commit is contained in:
@@ -374,7 +374,13 @@
|
||||
}
|
||||
|
||||
// 连通性测试按钮高度对齐
|
||||
.openaiTestSelect {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.openaiTestButton {
|
||||
flex: 1 1 0;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
|
||||
@@ -7,14 +7,17 @@ import { Modal } from '@/components/ui/Modal';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { ModelInputList, modelsToEntries, entriesToModels } from '@/components/ui/ModelInputList';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { IconCheck, IconX } from '@/components/ui/icons';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import { modelsApi, providersApi, usageApi } from '@/services/api';
|
||||
import { ampcodeApi, modelsApi, providersApi, usageApi } from '@/services/api';
|
||||
import type {
|
||||
GeminiKeyConfig,
|
||||
ProviderKeyConfig,
|
||||
OpenAIProviderConfig,
|
||||
ApiKeyEntry
|
||||
ApiKeyEntry,
|
||||
AmpcodeConfig,
|
||||
AmpcodeModelMapping
|
||||
} from '@/types';
|
||||
import type { KeyStats, KeyStatBucket } from '@/utils/usage';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
@@ -26,6 +29,7 @@ type ProviderModal =
|
||||
| { type: 'gemini'; index: number | null }
|
||||
| { type: 'codex'; index: number | null }
|
||||
| { type: 'claude'; index: number | null }
|
||||
| { type: 'ampcode'; index: null }
|
||||
| { type: 'openai'; index: number | null };
|
||||
|
||||
interface ModelEntry {
|
||||
@@ -42,6 +46,14 @@ interface OpenAIFormState {
|
||||
apiKeyEntries: ApiKeyEntry[];
|
||||
}
|
||||
|
||||
interface AmpcodeFormState {
|
||||
upstreamUrl: string;
|
||||
upstreamApiKey: string;
|
||||
restrictManagementToLocalhost: boolean;
|
||||
forceModelMappings: boolean;
|
||||
mappingEntries: ModelEntry[];
|
||||
}
|
||||
|
||||
const parseExcludedModels = (text: string): string[] =>
|
||||
text
|
||||
.split(/[\n,]+/)
|
||||
@@ -104,6 +116,41 @@ const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({
|
||||
headers: input?.headers ?? {}
|
||||
});
|
||||
|
||||
const ampcodeMappingsToEntries = (mappings?: AmpcodeModelMapping[]): ModelEntry[] => {
|
||||
if (!Array.isArray(mappings) || mappings.length === 0) {
|
||||
return [{ name: '', alias: '' }];
|
||||
}
|
||||
return mappings.map((mapping) => ({
|
||||
name: mapping.from ?? '',
|
||||
alias: mapping.to ?? ''
|
||||
}));
|
||||
};
|
||||
|
||||
const entriesToAmpcodeMappings = (entries: ModelEntry[]): AmpcodeModelMapping[] => {
|
||||
const seen = new Set<string>();
|
||||
const mappings: AmpcodeModelMapping[] = [];
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const from = entry.name.trim();
|
||||
const to = entry.alias.trim();
|
||||
if (!from || !to) return;
|
||||
const key = from.toLowerCase();
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
mappings.push({ from, to });
|
||||
});
|
||||
|
||||
return mappings;
|
||||
};
|
||||
|
||||
const buildAmpcodeFormState = (ampcode?: AmpcodeConfig | null): AmpcodeFormState => ({
|
||||
upstreamUrl: ampcode?.upstreamUrl ?? '',
|
||||
upstreamApiKey: '',
|
||||
restrictManagementToLocalhost: ampcode?.restrictManagementToLocalhost ?? true,
|
||||
forceModelMappings: ampcode?.forceModelMappings ?? false,
|
||||
mappingEntries: ampcodeMappingsToEntries(ampcode?.modelMappings)
|
||||
});
|
||||
|
||||
export function AiProvidersPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
@@ -149,6 +196,12 @@ export function AiProvidersPage() {
|
||||
apiKeyEntries: [buildApiKeyEntry()],
|
||||
modelEntries: [{ name: '', alias: '' }]
|
||||
});
|
||||
const [ampcodeForm, setAmpcodeForm] = useState<AmpcodeFormState>(() => buildAmpcodeFormState(null));
|
||||
const [ampcodeModalLoading, setAmpcodeModalLoading] = useState(false);
|
||||
const [ampcodeLoaded, setAmpcodeLoaded] = useState(false);
|
||||
const [ampcodeMappingsDirty, setAmpcodeMappingsDirty] = useState(false);
|
||||
const [ampcodeModalError, setAmpcodeModalError] = useState('');
|
||||
const [ampcodeSaving, setAmpcodeSaving] = useState(false);
|
||||
const [openaiDiscoveryOpen, setOpenaiDiscoveryOpen] = useState(false);
|
||||
const [openaiDiscoveryEndpoint, setOpenaiDiscoveryEndpoint] = useState('');
|
||||
const [openaiDiscoveryModels, setOpenaiDiscoveryModels] = useState<ModelInfo[]>([]);
|
||||
@@ -199,6 +252,13 @@ export function AiProvidersPage() {
|
||||
setCodexConfigs(data?.codexApiKeys || []);
|
||||
setClaudeConfigs(data?.claudeApiKeys || []);
|
||||
setOpenaiProviders(data?.openaiCompatibility || []);
|
||||
try {
|
||||
const ampcode = await ampcodeApi.getAmpcode();
|
||||
updateConfigValue('ampcode', ampcode);
|
||||
clearCache('ampcode');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message || t('notification.refresh_failed'));
|
||||
} finally {
|
||||
@@ -245,6 +305,12 @@ export function AiProvidersPage() {
|
||||
modelEntries: [{ name: '', alias: '' }],
|
||||
testModel: undefined
|
||||
});
|
||||
setAmpcodeForm(buildAmpcodeFormState(null));
|
||||
setAmpcodeModalLoading(false);
|
||||
setAmpcodeLoaded(false);
|
||||
setAmpcodeMappingsDirty(false);
|
||||
setAmpcodeModalError('');
|
||||
setAmpcodeSaving(false);
|
||||
setOpenaiDiscoveryOpen(false);
|
||||
setOpenaiDiscoveryModels([]);
|
||||
setOpenaiDiscoverySelected(new Set());
|
||||
@@ -280,6 +346,29 @@ export function AiProvidersPage() {
|
||||
setModal({ type, index });
|
||||
};
|
||||
|
||||
const openAmpcodeModal = () => {
|
||||
setAmpcodeModalLoading(true);
|
||||
setAmpcodeLoaded(false);
|
||||
setAmpcodeMappingsDirty(false);
|
||||
setAmpcodeModalError('');
|
||||
setAmpcodeForm(buildAmpcodeFormState(config?.ampcode ?? null));
|
||||
setModal({ type: 'ampcode', index: null });
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const ampcode = await ampcodeApi.getAmpcode();
|
||||
setAmpcodeLoaded(true);
|
||||
updateConfigValue('ampcode', ampcode);
|
||||
clearCache('ampcode');
|
||||
setAmpcodeForm(buildAmpcodeFormState(ampcode));
|
||||
} catch (err: any) {
|
||||
setAmpcodeModalError(err?.message || t('notification.refresh_failed'));
|
||||
} finally {
|
||||
setAmpcodeModalLoading(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const openOpenaiModal = (index: number | null) => {
|
||||
if (index !== null) {
|
||||
const entry = openaiProviders[index];
|
||||
@@ -506,6 +595,94 @@ export function AiProvidersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const clearAmpcodeUpstreamApiKey = async () => {
|
||||
if (!window.confirm(t('ai_providers.ampcode_clear_upstream_api_key_confirm'))) return;
|
||||
setAmpcodeSaving(true);
|
||||
setAmpcodeModalError('');
|
||||
try {
|
||||
await ampcodeApi.clearUpstreamApiKey();
|
||||
const previous = config?.ampcode ?? {};
|
||||
const next: AmpcodeConfig = { ...previous };
|
||||
delete (next as any).upstreamApiKey;
|
||||
updateConfigValue('ampcode', next);
|
||||
clearCache('ampcode');
|
||||
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
|
||||
} catch (err: any) {
|
||||
const message = err?.message || '';
|
||||
setAmpcodeModalError(message);
|
||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||
} finally {
|
||||
setAmpcodeSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveAmpcode = async () => {
|
||||
if (!ampcodeLoaded && ampcodeMappingsDirty) {
|
||||
const confirmed = window.confirm(t('ai_providers.ampcode_mappings_overwrite_confirm'));
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
setAmpcodeSaving(true);
|
||||
setAmpcodeModalError('');
|
||||
try {
|
||||
const upstreamUrl = ampcodeForm.upstreamUrl.trim();
|
||||
const overrideKey = ampcodeForm.upstreamApiKey.trim();
|
||||
const modelMappings = entriesToAmpcodeMappings(ampcodeForm.mappingEntries);
|
||||
|
||||
if (upstreamUrl) {
|
||||
await ampcodeApi.updateUpstreamUrl(upstreamUrl);
|
||||
} else {
|
||||
await ampcodeApi.clearUpstreamUrl();
|
||||
}
|
||||
|
||||
await ampcodeApi.updateRestrictManagementToLocalhost(ampcodeForm.restrictManagementToLocalhost);
|
||||
await ampcodeApi.updateForceModelMappings(ampcodeForm.forceModelMappings);
|
||||
|
||||
if (ampcodeLoaded || ampcodeMappingsDirty) {
|
||||
if (modelMappings.length) {
|
||||
await ampcodeApi.saveModelMappings(modelMappings);
|
||||
} else {
|
||||
await ampcodeApi.clearModelMappings();
|
||||
}
|
||||
}
|
||||
|
||||
if (overrideKey) {
|
||||
await ampcodeApi.updateUpstreamApiKey(overrideKey);
|
||||
}
|
||||
|
||||
const previous = config?.ampcode ?? {};
|
||||
const next: AmpcodeConfig = {
|
||||
...previous,
|
||||
upstreamUrl: upstreamUrl || undefined,
|
||||
restrictManagementToLocalhost: ampcodeForm.restrictManagementToLocalhost,
|
||||
forceModelMappings: ampcodeForm.forceModelMappings
|
||||
};
|
||||
|
||||
if (overrideKey) {
|
||||
next.upstreamApiKey = overrideKey;
|
||||
}
|
||||
|
||||
if (ampcodeLoaded || ampcodeMappingsDirty) {
|
||||
if (modelMappings.length) {
|
||||
next.modelMappings = modelMappings;
|
||||
} else {
|
||||
delete (next as any).modelMappings;
|
||||
}
|
||||
}
|
||||
|
||||
updateConfigValue('ampcode', next);
|
||||
clearCache('ampcode');
|
||||
showNotification(t('notification.ampcode_updated'), 'success');
|
||||
closeModal();
|
||||
} catch (err: any) {
|
||||
const message = err?.message || '';
|
||||
setAmpcodeModalError(message);
|
||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||
} finally {
|
||||
setAmpcodeSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveGemini = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -1022,6 +1199,63 @@ export function AiProvidersPage() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={t('ai_providers.ampcode_title')}
|
||||
extra={
|
||||
<Button size="sm" onClick={openAmpcodeModal} disabled={disableControls}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="hint">{t('common.loading')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_upstream_url_label')}:</span>
|
||||
<span className={styles.fieldValue}>{config?.ampcode?.upstreamUrl || t('common.not_set')}</span>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_upstream_api_key_label')}:</span>
|
||||
<span className={styles.fieldValue}>
|
||||
{config?.ampcode?.upstreamApiKey ? maskApiKey(config.ampcode.upstreamApiKey) : t('common.not_set')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_restrict_management_label')}:</span>
|
||||
<span className={styles.fieldValue}>
|
||||
{(config?.ampcode?.restrictManagementToLocalhost ?? true) ? t('common.yes') : t('common.no')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_force_model_mappings_label')}:</span>
|
||||
<span className={styles.fieldValue}>
|
||||
{(config?.ampcode?.forceModelMappings ?? false) ? t('common.yes') : t('common.no')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.fieldRow} style={{ marginTop: 8 }}>
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_model_mappings_count')}:</span>
|
||||
<span className={styles.fieldValue}>{config?.ampcode?.modelMappings?.length || 0}</span>
|
||||
</div>
|
||||
{config?.ampcode?.modelMappings?.length ? (
|
||||
<div className={styles.modelTagList}>
|
||||
{config.ampcode.modelMappings.slice(0, 5).map((mapping) => (
|
||||
<span key={`${mapping.from}→${mapping.to}`} className={styles.modelTag}>
|
||||
<span className={styles.modelName}>{mapping.from}</span>
|
||||
<span className={styles.modelAlias}>{mapping.to}</span>
|
||||
</span>
|
||||
))}
|
||||
{config.ampcode.modelMappings.length > 5 && (
|
||||
<span className={styles.modelTag}>
|
||||
<span className={styles.modelName}>+{config.ampcode.modelMappings.length - 5}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={t('ai_providers.openai_title')}
|
||||
extra={
|
||||
@@ -1128,6 +1362,93 @@ export function AiProvidersPage() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Ampcode Modal */}
|
||||
<Modal
|
||||
open={modal?.type === 'ampcode'}
|
||||
onClose={closeModal}
|
||||
title={t('ai_providers.ampcode_modal_title')}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={closeModal} disabled={ampcodeSaving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={saveAmpcode} loading={ampcodeSaving} disabled={disableControls || ampcodeModalLoading}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{ampcodeModalError && <div className="error-box">{ampcodeModalError}</div>}
|
||||
<Input
|
||||
label={t('ai_providers.ampcode_upstream_url_label')}
|
||||
placeholder={t('ai_providers.ampcode_upstream_url_placeholder')}
|
||||
value={ampcodeForm.upstreamUrl}
|
||||
onChange={(e) => setAmpcodeForm((prev) => ({ ...prev, upstreamUrl: e.target.value }))}
|
||||
disabled={ampcodeModalLoading || ampcodeSaving}
|
||||
hint={t('ai_providers.ampcode_upstream_url_hint')}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.ampcode_upstream_api_key_label')}
|
||||
placeholder={t('ai_providers.ampcode_upstream_api_key_placeholder')}
|
||||
type="password"
|
||||
value={ampcodeForm.upstreamApiKey}
|
||||
onChange={(e) => setAmpcodeForm((prev) => ({ ...prev, upstreamApiKey: e.target.value }))}
|
||||
disabled={ampcodeModalLoading || ampcodeSaving}
|
||||
hint={t('ai_providers.ampcode_upstream_api_key_hint')}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: -8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<div className="hint" style={{ margin: 0 }}>
|
||||
{t('ai_providers.ampcode_upstream_api_key_current', {
|
||||
key: config?.ampcode?.upstreamApiKey ? maskApiKey(config.ampcode.upstreamApiKey) : t('common.not_set')
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={clearAmpcodeUpstreamApiKey}
|
||||
disabled={ampcodeModalLoading || ampcodeSaving || !config?.ampcode?.upstreamApiKey}
|
||||
>
|
||||
{t('ai_providers.ampcode_clear_upstream_api_key')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<ToggleSwitch
|
||||
label={t('ai_providers.ampcode_restrict_management_label')}
|
||||
checked={ampcodeForm.restrictManagementToLocalhost}
|
||||
onChange={(value) => setAmpcodeForm((prev) => ({ ...prev, restrictManagementToLocalhost: value }))}
|
||||
disabled={ampcodeModalLoading || ampcodeSaving}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.ampcode_restrict_management_hint')}</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<ToggleSwitch
|
||||
label={t('ai_providers.ampcode_force_model_mappings_label')}
|
||||
checked={ampcodeForm.forceModelMappings}
|
||||
onChange={(value) => setAmpcodeForm((prev) => ({ ...prev, forceModelMappings: value }))}
|
||||
disabled={ampcodeModalLoading || ampcodeSaving}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.ampcode_force_model_mappings_hint')}</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.ampcode_model_mappings_label')}</label>
|
||||
<ModelInputList
|
||||
entries={ampcodeForm.mappingEntries}
|
||||
onChange={(entries) => {
|
||||
setAmpcodeMappingsDirty(true);
|
||||
setAmpcodeForm((prev) => ({ ...prev, mappingEntries: entries }));
|
||||
}}
|
||||
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
|
||||
namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')}
|
||||
aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')}
|
||||
disabled={ampcodeModalLoading || ampcodeSaving}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Gemini Modal */}
|
||||
<Modal
|
||||
open={modal?.type === 'gemini'}
|
||||
@@ -1322,7 +1643,7 @@ export function AiProvidersPage() {
|
||||
<div className="hint">{t('ai_providers.openai_test_hint')}</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<select
|
||||
className="input"
|
||||
className={`input ${styles.openaiTestSelect}`}
|
||||
value={openaiTestModel}
|
||||
onChange={(e) => {
|
||||
setOpenaiTestModel(e.target.value);
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
.modelTags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex: 0 0 100%;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
|
||||
@@ -535,12 +535,83 @@
|
||||
}
|
||||
|
||||
.chartWrapper {
|
||||
padding: 16px;
|
||||
padding: 12px;
|
||||
background-color: var(--bg-primary);
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid var(--border-color);
|
||||
min-height: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chartLegend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
|
||||
@include mobile {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.legendItem {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
max-width: 240px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@include mobile {
|
||||
max-width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
.legendDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.legendLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chartArea {
|
||||
height: 240px;
|
||||
|
||||
@include mobile {
|
||||
height: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
.chartScroller {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
// Chart.js 默认会设置 canvas 的 touch-action: none,导致移动端无法横向滚动
|
||||
:global(canvas) {
|
||||
touch-action: pan-x pan-y !important;
|
||||
}
|
||||
}
|
||||
|
||||
.chartCanvas {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.periodButtons {
|
||||
|
||||
@@ -9,13 +9,15 @@ import {
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
Filler,
|
||||
type ChartOptions
|
||||
} from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
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 { usageApi } from '@/services/api/usage';
|
||||
import {
|
||||
formatTokensInMillions,
|
||||
@@ -59,6 +61,7 @@ interface UsagePayload {
|
||||
|
||||
export function UsagePage() {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
const [usage, setUsage] = useState<UsagePayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -216,39 +219,97 @@ export function UsagePage() {
|
||||
[buildLastHourSeries, buildSparkline]
|
||||
);
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index' as const,
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top' as const,
|
||||
align: 'start' as const,
|
||||
labels: {
|
||||
usePointStyle: true
|
||||
const buildChartOptions = useCallback(
|
||||
(period: 'hour' | 'day', labels: string[]): ChartOptions<'line'> => {
|
||||
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;
|
||||
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false }
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
font: { size: tickFontSize },
|
||||
maxRotation: isMobile ? 0 : 45,
|
||||
minRotation: isMobile ? 0 : 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: maxTickLabelCount,
|
||||
callback: (value) => {
|
||||
const index = typeof value === 'number' ? value : Number(value);
|
||||
const raw =
|
||||
Number.isFinite(index) && labels[index] ? labels[index] : typeof value === 'string' ? value : '';
|
||||
|
||||
if (period === 'hour') {
|
||||
const [md, time] = raw.split(' ');
|
||||
if (!time) return raw;
|
||||
if (time.startsWith('00:')) {
|
||||
return md ? [md, time] : time;
|
||||
}
|
||||
return time;
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
const parts = raw.split('-');
|
||||
if (parts.length === 3) {
|
||||
return `${parts[1]}-${parts[2]}`;
|
||||
}
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
font: { size: tickFontSize }
|
||||
}
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
tension: 0.35,
|
||||
borderWidth: isMobile ? 1.5 : 2
|
||||
},
|
||||
point: {
|
||||
borderWidth: 2,
|
||||
radius: pointRadius,
|
||||
hoverRadius: 4
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
[isMobile]
|
||||
);
|
||||
|
||||
const requestsChartOptions = useMemo(
|
||||
() => buildChartOptions(requestsPeriod, requestsChartData.labels),
|
||||
[buildChartOptions, requestsPeriod, requestsChartData.labels]
|
||||
);
|
||||
|
||||
const tokensChartOptions = useMemo(
|
||||
() => buildChartOptions(tokensPeriod, tokensChartData.labels),
|
||||
[buildChartOptions, tokensPeriod, tokensChartData.labels]
|
||||
);
|
||||
|
||||
const getHourChartMinWidth = useCallback(
|
||||
(labelCount: number) => {
|
||||
if (!isMobile || labelCount <= 0) return undefined;
|
||||
// 24 小时标签在移动端需要更宽的画布,避免 X 轴与点位过度挤压
|
||||
const perPoint = 56;
|
||||
const minWidth = Math.min(labelCount * perPoint, 3000);
|
||||
return `${minWidth}px`;
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
tension: 0.35,
|
||||
borderWidth: 2
|
||||
},
|
||||
point: {
|
||||
borderWidth: 2,
|
||||
radius: 4
|
||||
}
|
||||
}
|
||||
};
|
||||
[isMobile]
|
||||
);
|
||||
|
||||
// Chart line management
|
||||
const handleAddChartLine = () => {
|
||||
@@ -521,7 +582,32 @@ export function UsagePage() {
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : requestsChartData.labels.length > 0 ? (
|
||||
<div className={styles.chartWrapper}>
|
||||
<Line data={requestsChartData} options={chartOptions} />
|
||||
<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>
|
||||
<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>
|
||||
@@ -554,7 +640,32 @@ export function UsagePage() {
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : tokensChartData.labels.length > 0 ? (
|
||||
<div className={styles.chartWrapper}>
|
||||
<Line data={tokensChartData} options={chartOptions} />
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user