mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
10 Commits
@@ -72,6 +72,61 @@
|
||||
}
|
||||
}
|
||||
|
||||
.expandableInputWrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.expandableInputWrapper > .expandableTextarea,
|
||||
.expandableInputWrapper > :global(.input) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
.expandableTextarea {
|
||||
resize: none;
|
||||
min-height: 60px;
|
||||
overflow: hidden;
|
||||
line-height: 1.5;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.expandableToggle {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
padding: 2px;
|
||||
color: var(--text-secondary, #999);
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.expandableInputExpanded .expandableToggle {
|
||||
top: 8px;
|
||||
transform: none;
|
||||
right: 14px;
|
||||
}
|
||||
|
||||
.overview {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useId, useMemo, useState } from 'react';
|
||||
import { memo, useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
@@ -23,6 +23,109 @@ import {
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { isValidApiKeyCharset } from '@/utils/validation';
|
||||
|
||||
/** Minimum character count before the expand/collapse toggle appears. */
|
||||
const EXPAND_THRESHOLD = 30;
|
||||
|
||||
/** Auto-expanding textarea that collapses back to a single-line input on demand. */
|
||||
function ExpandableInput({
|
||||
value,
|
||||
placeholder,
|
||||
ariaLabel,
|
||||
disabled,
|
||||
className,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
ariaLabel?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onChange: (nextValue: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const autoResize = useCallback((el: HTMLTextAreaElement) => {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, []);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
// Strip newlines — these fields are single-line identifiers/paths that
|
||||
// would break YAML serialization if they contained line breaks.
|
||||
const sanitized = e.target.value.replace(/[\r\n]/g, '');
|
||||
onChange(sanitized);
|
||||
// autoResize is handled by useLayoutEffect after React syncs the
|
||||
// sanitized value back to the DOM — calling it here would measure
|
||||
// stale content.
|
||||
};
|
||||
|
||||
// Resize synchronously before paint to avoid visual flicker.
|
||||
useLayoutEffect(() => {
|
||||
if (!collapsed && textareaRef.current) {
|
||||
autoResize(textareaRef.current);
|
||||
}
|
||||
}, [collapsed, value, autoResize]);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className={styles.expandableInputWrapper}>
|
||||
<input
|
||||
className={`input ${className ?? ''}`}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value.replace(/[\r\n]/g, ''))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{value.length > EXPAND_THRESHOLD && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.expandableToggle}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setCollapsed(false);
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.focus();
|
||||
});
|
||||
}}
|
||||
title={t('common.expand')}
|
||||
aria-label={t('common.expand')}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.expandableInputWrapper} ${styles.expandableInputExpanded}`}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={`input ${styles.expandableTextarea} ${className ?? ''}`}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
rows={2}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.expandableToggle}
|
||||
disabled={disabled}
|
||||
onClick={() => setCollapsed(true)}
|
||||
title={t('common.collapse')}
|
||||
aria-label={t('common.collapse')}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getValidationMessage(
|
||||
t: ReturnType<typeof useTranslation>['t'],
|
||||
errorCode?: PayloadParamValidationErrorCode
|
||||
@@ -325,14 +428,12 @@ const StringListEditor = memo(function StringListEditor({
|
||||
<div className={styles.stringList}>
|
||||
{items.map((item, index) => (
|
||||
<div key={renderItemIds[index] ?? `item-${index}`} className={styles.stringListRow}>
|
||||
<input
|
||||
className="input"
|
||||
<ExpandableInput
|
||||
placeholder={placeholder}
|
||||
aria-label={inputAriaLabel ?? placeholder}
|
||||
ariaLabel={inputAriaLabel ?? placeholder}
|
||||
value={item}
|
||||
onChange={(e) => updateItem(index, e.target.value)}
|
||||
onChange={(nextValue) => updateItem(index, nextValue)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeItem(index)} disabled={disabled}>
|
||||
{t('config_management.visual.common.delete')}
|
||||
@@ -508,12 +609,11 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
className="input"
|
||||
<ExpandableInput
|
||||
placeholder={getValuePlaceholder(param.valueType)}
|
||||
aria-label={t('config_management.visual.payload_rules.param_value')}
|
||||
ariaLabel={t('config_management.visual.payload_rules.param_value')}
|
||||
value={param.value}
|
||||
onChange={(e) => updateParam(ruleIndex, paramIndex, { value: e.target.value })}
|
||||
onChange={(nextValue) => updateParam(ruleIndex, paramIndex, { value: nextValue })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
@@ -564,23 +664,21 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
<ExpandableInput
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
aria-label={t('config_management.visual.payload_rules.model_name')}
|
||||
ariaLabel={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||
onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
<ExpandableInput
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
aria-label={t('config_management.visual.payload_rules.model_name')}
|
||||
ariaLabel={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||
onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
@@ -629,12 +727,11 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
|
||||
return (
|
||||
<div key={param.id} className={styles.payloadRuleParamGroup}>
|
||||
<div className={styles.payloadRuleParamRow}>
|
||||
<input
|
||||
className="input"
|
||||
<ExpandableInput
|
||||
placeholder={t('config_management.visual.payload_rules.json_path')}
|
||||
aria-label={t('config_management.visual.payload_rules.json_path')}
|
||||
ariaLabel={t('config_management.visual.payload_rules.json_path')}
|
||||
value={param.path}
|
||||
onChange={(e) => updateParam(ruleIndex, paramIndex, { path: e.target.value })}
|
||||
onChange={(nextValue) => updateParam(ruleIndex, paramIndex, { path: nextValue })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{rawJsonValues ? null : (
|
||||
@@ -767,12 +864,11 @@ export const PayloadFilterRulesEditor = memo(function PayloadFilterRulesEditor({
|
||||
</div>
|
||||
{rule.models.map((model, modelIndex) => (
|
||||
<div key={model.id} className={styles.payloadFilterModelRow}>
|
||||
<input
|
||||
className="input"
|
||||
<ExpandableInput
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
aria-label={t('config_management.visual.payload_rules.model_name')}
|
||||
ariaLabel={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||
onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
|
||||
@@ -64,6 +64,8 @@ interface QuotaCardProps<TState extends QuotaStatusState> {
|
||||
cardIdleMessageKey?: string;
|
||||
cardClassName: string;
|
||||
defaultType: string;
|
||||
canRefresh?: boolean;
|
||||
onRefresh?: () => void;
|
||||
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
|
||||
}
|
||||
|
||||
@@ -75,6 +77,8 @@ export function QuotaCard<TState extends QuotaStatusState>({
|
||||
cardIdleMessageKey,
|
||||
cardClassName,
|
||||
defaultType,
|
||||
canRefresh = false,
|
||||
onRefresh,
|
||||
renderQuotaItems
|
||||
}: QuotaCardProps<TState>) {
|
||||
const { t } = useTranslation();
|
||||
@@ -90,7 +94,7 @@ export function QuotaCard<TState extends QuotaStatusState>({
|
||||
quota?.errorStatus,
|
||||
quota?.error || t('common.unknown_error')
|
||||
);
|
||||
const idleMessageKey = cardIdleMessageKey ?? `${i18nPrefix}.idle`;
|
||||
const idleMessageKey = onRefresh ? `${i18nPrefix}.idle` : (cardIdleMessageKey ?? `${i18nPrefix}.idle`);
|
||||
|
||||
const getTypeLabel = (type: string): string => {
|
||||
const key = `auth_files.filter_${type}`;
|
||||
@@ -120,7 +124,18 @@ export function QuotaCard<TState extends QuotaStatusState>({
|
||||
{quotaStatus === 'loading' ? (
|
||||
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.loading`)}</div>
|
||||
) : quotaStatus === 'idle' ? (
|
||||
<div className={styles.quotaMessage}>{t(idleMessageKey)}</div>
|
||||
onRefresh ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.quotaMessage} ${styles.quotaMessageAction}`}
|
||||
onClick={onRefresh}
|
||||
disabled={!canRefresh}
|
||||
>
|
||||
{t(idleMessageKey)}
|
||||
</button>
|
||||
) : (
|
||||
<div className={styles.quotaMessage}>{t(idleMessageKey)}</div>
|
||||
)
|
||||
) : quotaStatus === 'error' ? (
|
||||
<div className={styles.quotaError}>
|
||||
{t(`${i18nPrefix}.load_failed`, {
|
||||
|
||||
@@ -8,8 +8,9 @@ import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { useQuotaStore, useThemeStore } from '@/stores';
|
||||
import { useNotificationStore, useQuotaStore, useThemeStore } from '@/stores';
|
||||
import type { AuthFileItem, ResolvedTheme } from '@/types';
|
||||
import { getStatusFromError } from '@/utils/quota';
|
||||
import { QuotaCard } from './QuotaCard';
|
||||
import type { QuotaStatusState } from './QuotaCard';
|
||||
import { useQuotaLoader } from './useQuotaLoader';
|
||||
@@ -105,6 +106,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
||||
}: QuotaSectionProps<TState, TData>) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const showNotification = useNotificationStore((state) => state.showNotification);
|
||||
const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter<
|
||||
Record<string, TState>
|
||||
>;
|
||||
@@ -202,6 +204,39 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
||||
});
|
||||
}, [filteredFiles, loading, setQuota]);
|
||||
|
||||
const refreshQuotaForFile = useCallback(
|
||||
async (file: AuthFileItem) => {
|
||||
if (disabled || file.disabled) return;
|
||||
if (quota[file.name]?.status === 'loading') return;
|
||||
|
||||
setQuota((prev) => ({
|
||||
...prev,
|
||||
[file.name]: config.buildLoadingState()
|
||||
}));
|
||||
|
||||
try {
|
||||
const data = await config.fetchQuota(file, t);
|
||||
setQuota((prev) => ({
|
||||
...prev,
|
||||
[file.name]: config.buildSuccessState(data)
|
||||
}));
|
||||
showNotification(t('auth_files.quota_refresh_success', { name: file.name }), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('common.unknown_error');
|
||||
const status = getStatusFromError(err);
|
||||
setQuota((prev) => ({
|
||||
...prev,
|
||||
[file.name]: config.buildErrorState(message, status)
|
||||
}));
|
||||
showNotification(
|
||||
t('auth_files.quota_refresh_failed', { name: file.name, message }),
|
||||
'error'
|
||||
);
|
||||
}
|
||||
},
|
||||
[config, disabled, quota, setQuota, showNotification, t]
|
||||
);
|
||||
|
||||
const titleNode = (
|
||||
<div className={styles.titleWrapper}>
|
||||
<span>{t(`${config.i18nPrefix}.title`)}</span>
|
||||
@@ -222,15 +257,21 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
||||
<div className={styles.headerActions}>
|
||||
<div className={styles.viewModeToggle}>
|
||||
<Button
|
||||
variant={effectiveViewMode === 'paged' ? 'primary' : 'secondary'}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className={`${styles.viewModeButton} ${
|
||||
effectiveViewMode === 'paged' ? styles.viewModeButtonActive : ''
|
||||
}`}
|
||||
onClick={() => setViewMode('paged')}
|
||||
>
|
||||
{t('auth_files.view_mode_paged')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={effectiveViewMode === 'all' ? 'primary' : 'secondary'}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className={`${styles.viewModeButton} ${
|
||||
effectiveViewMode === 'all' ? styles.viewModeButtonActive : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (filteredFiles.length > MAX_SHOW_ALL_THRESHOLD) {
|
||||
setShowTooManyWarning(true);
|
||||
@@ -245,13 +286,15 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className={styles.refreshAllButton}
|
||||
onClick={handleRefresh}
|
||||
disabled={disabled || isRefreshing}
|
||||
loading={isRefreshing}
|
||||
title={t('quota_management.refresh_files_and_quota')}
|
||||
aria-label={t('quota_management.refresh_files_and_quota')}
|
||||
title={t('quota_management.refresh_all_credentials')}
|
||||
aria-label={t('quota_management.refresh_all_credentials')}
|
||||
>
|
||||
{!isRefreshing && <IconRefreshCw size={16} />}
|
||||
{t('quota_management.refresh_all_credentials')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
@@ -274,6 +317,8 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
||||
cardIdleMessageKey={config.cardIdleMessageKey}
|
||||
cardClassName={config.cardClassName}
|
||||
defaultType={config.type}
|
||||
canRefresh={!disabled && !item.disabled}
|
||||
onRefresh={() => void refreshQuotaForFile(item)}
|
||||
renderQuotaItems={config.renderQuotaItems}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -84,6 +84,8 @@ type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||
type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli' | 'kimi';
|
||||
|
||||
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
|
||||
const QUOTA_PROGRESS_HIGH_THRESHOLD = 70;
|
||||
const QUOTA_PROGRESS_MEDIUM_THRESHOLD = 30;
|
||||
const geminiCliSupplementaryRequestIds = new Map<string, number>();
|
||||
const geminiCliSupplementaryCache = new Map<
|
||||
string,
|
||||
@@ -721,7 +723,11 @@ const renderAntigravityItems = (
|
||||
h('span', { className: styleMap.quotaReset }, resetLabel)
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
|
||||
h(QuotaProgressBar, {
|
||||
percent,
|
||||
highThreshold: QUOTA_PROGRESS_HIGH_THRESHOLD,
|
||||
mediumThreshold: QUOTA_PROGRESS_MEDIUM_THRESHOLD,
|
||||
})
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -795,7 +801,11 @@ const renderCodexItems = (
|
||||
h('span', { className: styleMap.quotaReset }, window.resetLabel)
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 })
|
||||
h(QuotaProgressBar, {
|
||||
percent: remaining,
|
||||
highThreshold: QUOTA_PROGRESS_HIGH_THRESHOLD,
|
||||
mediumThreshold: QUOTA_PROGRESS_MEDIUM_THRESHOLD,
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
@@ -886,7 +896,11 @@ const renderGeminiCliItems = (
|
||||
h('span', { className: styleMap.quotaReset }, resetLabel)
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
|
||||
h(QuotaProgressBar, {
|
||||
percent,
|
||||
highThreshold: QUOTA_PROGRESS_HIGH_THRESHOLD,
|
||||
mediumThreshold: QUOTA_PROGRESS_MEDIUM_THRESHOLD,
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
@@ -1078,7 +1092,11 @@ const renderClaudeItems = (
|
||||
h('span', { className: styleMap.quotaReset }, window.resetLabel)
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 })
|
||||
h(QuotaProgressBar, {
|
||||
percent: remaining,
|
||||
highThreshold: QUOTA_PROGRESS_HIGH_THRESHOLD,
|
||||
mediumThreshold: QUOTA_PROGRESS_MEDIUM_THRESHOLD,
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
@@ -1293,7 +1311,11 @@ const renderKimiItems = (
|
||||
: null
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent: remaining, highThreshold: 60, mediumThreshold: 20 })
|
||||
h(QuotaProgressBar, {
|
||||
percent: remaining,
|
||||
highThreshold: QUOTA_PROGRESS_HIGH_THRESHOLD,
|
||||
mediumThreshold: QUOTA_PROGRESS_MEDIUM_THRESHOLD,
|
||||
})
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
collectUsageDetails,
|
||||
@@ -10,11 +11,28 @@ import type { UsagePayload } from './hooks/useUsageData';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
const COLOR_STOPS = [
|
||||
{ r: 239, g: 68, b: 68 }, // #ef4444
|
||||
{ r: 250, g: 204, b: 21 }, // #facc15
|
||||
{ r: 34, g: 197, b: 94 }, // #22c55e
|
||||
{ r: 239, g: 68, b: 68 }, // #ef4444
|
||||
{ r: 250, g: 204, b: 21 }, // #facc15
|
||||
{ r: 34, g: 197, b: 94 }, // #22c55e
|
||||
] as const;
|
||||
|
||||
const TOOLTIP_OFFSET = 8;
|
||||
const TOOLTIP_SAFE_WIDTH = 180;
|
||||
const TOOLTIP_SAFE_HEIGHT = 72;
|
||||
|
||||
type TooltipHorizontalPosition = 'center' | 'left' | 'right';
|
||||
type TooltipVerticalPosition = 'above' | 'below';
|
||||
|
||||
interface ActiveTooltipState {
|
||||
idx: number;
|
||||
anchorEl: HTMLDivElement;
|
||||
horizontal: TooltipHorizontalPosition;
|
||||
vertical: TooltipVerticalPosition;
|
||||
left: number;
|
||||
top: number;
|
||||
transform: string;
|
||||
}
|
||||
|
||||
function rateToColor(rate: number): string {
|
||||
const t = Math.max(0, Math.min(1, rate));
|
||||
const segment = t < 0.5 ? 0 : 1;
|
||||
@@ -43,7 +61,7 @@ export interface ServiceHealthCardProps {
|
||||
|
||||
export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [activeTooltip, setActiveTooltip] = useState<number | null>(null);
|
||||
const [activeTooltip, setActiveTooltip] = useState<ActiveTooltipState | null>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const healthData: ServiceHealthData = useMemo(() => {
|
||||
@@ -64,11 +82,74 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
|
||||
return () => document.removeEventListener('pointerdown', handler);
|
||||
}, [activeTooltip]);
|
||||
|
||||
const handlePointerEnter = useCallback((e: React.PointerEvent, idx: number) => {
|
||||
if (e.pointerType === 'mouse') {
|
||||
setActiveTooltip(idx);
|
||||
}
|
||||
}, []);
|
||||
const buildTooltipState = useCallback(
|
||||
(idx: number, anchorEl: HTMLDivElement): ActiveTooltipState => {
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
|
||||
let horizontal: TooltipHorizontalPosition = 'center';
|
||||
let left = centerX;
|
||||
|
||||
if (centerX <= TOOLTIP_SAFE_WIDTH / 2) {
|
||||
horizontal = 'left';
|
||||
left = rect.left;
|
||||
} else if (centerX >= window.innerWidth - TOOLTIP_SAFE_WIDTH / 2) {
|
||||
horizontal = 'right';
|
||||
left = rect.right;
|
||||
}
|
||||
|
||||
const vertical: TooltipVerticalPosition = rect.top <= TOOLTIP_SAFE_HEIGHT ? 'below' : 'above';
|
||||
const top = vertical === 'below' ? rect.bottom + TOOLTIP_OFFSET : rect.top - TOOLTIP_OFFSET;
|
||||
const translateX = horizontal === 'center' ? '-50%' : horizontal === 'right' ? '-100%' : '0';
|
||||
const translateY = vertical === 'below' ? '0' : '-100%';
|
||||
|
||||
return {
|
||||
idx,
|
||||
anchorEl,
|
||||
horizontal,
|
||||
vertical,
|
||||
left: Math.round(left),
|
||||
top: Math.round(top),
|
||||
transform: `translate(${translateX}, ${translateY})`,
|
||||
};
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTooltip) return;
|
||||
|
||||
const updateTooltipPosition = () => {
|
||||
if (!document.body.contains(activeTooltip.anchorEl)) {
|
||||
setActiveTooltip(null);
|
||||
return;
|
||||
}
|
||||
setActiveTooltip(buildTooltipState(activeTooltip.idx, activeTooltip.anchorEl));
|
||||
};
|
||||
|
||||
window.addEventListener('resize', updateTooltipPosition);
|
||||
window.addEventListener('scroll', updateTooltipPosition, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateTooltipPosition);
|
||||
window.removeEventListener('scroll', updateTooltipPosition, true);
|
||||
};
|
||||
}, [activeTooltip, buildTooltipState]);
|
||||
|
||||
const openTooltip = useCallback(
|
||||
(idx: number, anchorEl: HTMLDivElement) => {
|
||||
setActiveTooltip(buildTooltipState(idx, anchorEl));
|
||||
},
|
||||
[buildTooltipState]
|
||||
);
|
||||
|
||||
const handlePointerEnter = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>, idx: number) => {
|
||||
if (e.pointerType === 'mouse') {
|
||||
openTooltip(idx, e.currentTarget);
|
||||
}
|
||||
},
|
||||
[openTooltip]
|
||||
);
|
||||
|
||||
const handlePointerLeave = useCallback((e: React.PointerEvent) => {
|
||||
if (e.pointerType === 'mouse') {
|
||||
@@ -76,39 +157,49 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent, idx: number) => {
|
||||
if (e.pointerType === 'touch') {
|
||||
e.preventDefault();
|
||||
setActiveTooltip((prev) => (prev === idx ? null : idx));
|
||||
}
|
||||
}, []);
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>, idx: number) => {
|
||||
if (e.pointerType === 'touch') {
|
||||
e.preventDefault();
|
||||
setActiveTooltip((prev) =>
|
||||
prev?.idx === idx ? null : buildTooltipState(idx, e.currentTarget)
|
||||
);
|
||||
}
|
||||
},
|
||||
[buildTooltipState]
|
||||
);
|
||||
|
||||
const getTooltipPositionClass = (idx: number): string => {
|
||||
const col = Math.floor(idx / healthData.rows);
|
||||
if (col <= 2) return styles.healthTooltipLeft;
|
||||
if (col >= healthData.cols - 3) return styles.healthTooltipRight;
|
||||
return '';
|
||||
};
|
||||
|
||||
const getTooltipVerticalClass = (idx: number): string => {
|
||||
const row = idx % healthData.rows;
|
||||
if (row <= 1) return styles.healthTooltipBelow;
|
||||
return '';
|
||||
};
|
||||
|
||||
const renderTooltip = (detail: StatusBlockDetail, idx: number) => {
|
||||
const renderTooltip = (detail: StatusBlockDetail, tooltipState: ActiveTooltipState) => {
|
||||
const total = detail.success + detail.failure;
|
||||
const posClass = getTooltipPositionClass(idx);
|
||||
const vertClass = getTooltipVerticalClass(idx);
|
||||
const posClass =
|
||||
tooltipState.horizontal === 'left'
|
||||
? styles.healthTooltipLeft
|
||||
: tooltipState.horizontal === 'right'
|
||||
? styles.healthTooltipRight
|
||||
: '';
|
||||
const vertClass = tooltipState.vertical === 'below' ? styles.healthTooltipBelow : '';
|
||||
const timeRange = `${formatDateTime(detail.startTime)} – ${formatDateTime(detail.endTime)}`;
|
||||
|
||||
return (
|
||||
<div className={`${styles.healthTooltip} ${posClass} ${vertClass}`}>
|
||||
const tooltip = (
|
||||
<div
|
||||
className={`${styles.healthTooltip} ${posClass} ${vertClass}`}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${tooltipState.left}px`,
|
||||
top: `${tooltipState.top}px`,
|
||||
bottom: 'auto',
|
||||
right: 'auto',
|
||||
transform: tooltipState.transform,
|
||||
}}
|
||||
>
|
||||
<span className={styles.healthTooltipTime}>{timeRange}</span>
|
||||
{total > 0 ? (
|
||||
<span className={styles.healthTooltipStats}>
|
||||
<span className={styles.healthTooltipSuccess}>{t('status_bar.success_short')} {detail.success}</span>
|
||||
<span className={styles.healthTooltipFailure}>{t('status_bar.failure_short')} {detail.failure}</span>
|
||||
<span className={styles.healthTooltipSuccess}>
|
||||
{t('status_bar.success_short')} {detail.success}
|
||||
</span>
|
||||
<span className={styles.healthTooltipFailure}>
|
||||
{t('status_bar.failure_short')} {detail.failure}
|
||||
</span>
|
||||
<span className={styles.healthTooltipRate}>({(detail.rate * 100).toFixed(1)}%)</span>
|
||||
</span>
|
||||
) : (
|
||||
@@ -116,6 +207,8 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof document === 'undefined' ? tooltip : createPortal(tooltip, document.body);
|
||||
};
|
||||
|
||||
const rateClass = !hasData
|
||||
@@ -138,32 +231,29 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.healthGridScroller}>
|
||||
<div
|
||||
className={styles.healthGrid}
|
||||
ref={gridRef}
|
||||
>
|
||||
{healthData.blockDetails.map((detail, idx) => {
|
||||
const isIdle = detail.rate === -1;
|
||||
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
|
||||
const isActive = activeTooltip === idx;
|
||||
<div className={styles.healthGrid} ref={gridRef}>
|
||||
{healthData.blockDetails.map((detail, idx) => {
|
||||
const isIdle = detail.rate === -1;
|
||||
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
|
||||
const isActive = activeTooltip?.idx === idx;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`${styles.healthBlockWrapper} ${isActive ? styles.healthBlockActive : ''}`}
|
||||
onPointerEnter={(e) => handlePointerEnter(e, idx)}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
onPointerDown={(e) => handlePointerDown(e, idx)}
|
||||
>
|
||||
return (
|
||||
<div
|
||||
className={`${styles.healthBlock} ${isIdle ? styles.healthBlockIdle : ''}`}
|
||||
style={blockStyle}
|
||||
/>
|
||||
{isActive && renderTooltip(detail, idx)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
key={idx}
|
||||
className={`${styles.healthBlockWrapper} ${isActive ? styles.healthBlockActive : ''}`}
|
||||
onPointerEnter={(e) => handlePointerEnter(e, idx)}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
onPointerDown={(e) => handlePointerDown(e, idx)}
|
||||
>
|
||||
<div
|
||||
className={`${styles.healthBlock} ${isIdle ? styles.healthBlockIdle : ''}`}
|
||||
style={blockStyle}
|
||||
/>
|
||||
{isActive && activeTooltip && renderTooltip(detail, activeTooltip)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.healthLegend}>
|
||||
<span className={styles.healthLegendLabel}>{t('service_health.oldest')}</span>
|
||||
|
||||
@@ -117,6 +117,33 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
setSelectedFiles(new Set());
|
||||
}, []);
|
||||
|
||||
const applyDeletedFiles = useCallback((names: string[]) => {
|
||||
const deletedNames = Array.from(
|
||||
new Set(
|
||||
names
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
if (deletedNames.length === 0) return;
|
||||
|
||||
const deletedSet = new Set(deletedNames);
|
||||
setFiles((prev) => prev.filter((file) => !deletedSet.has(file.name)));
|
||||
setSelectedFiles((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (deletedSet.has(name)) {
|
||||
changed = true;
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFiles.size === 0) return;
|
||||
const existingNames = new Set(files.map((file) => file.name));
|
||||
@@ -190,36 +217,33 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
let successCount = 0;
|
||||
const failed: { name: string; message: string }[] = [];
|
||||
try {
|
||||
const result = await authFilesApi.uploadFiles(validFiles);
|
||||
const successCount = result.uploaded;
|
||||
|
||||
for (const file of validFiles) {
|
||||
try {
|
||||
await authFilesApi.upload(file);
|
||||
successCount++;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
failed.push({ name: file.name, message: errorMessage });
|
||||
if (successCount > 0) {
|
||||
const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : '';
|
||||
showNotification(
|
||||
`${t('auth_files.upload_success')}${suffix}`,
|
||||
result.failed.length ? 'warning' : 'success'
|
||||
);
|
||||
await loadFiles();
|
||||
await refreshKeyStats();
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : '';
|
||||
showNotification(
|
||||
`${t('auth_files.upload_success')}${suffix}`,
|
||||
failed.length ? 'warning' : 'success'
|
||||
);
|
||||
await loadFiles();
|
||||
await refreshKeyStats();
|
||||
if (result.failed.length > 0) {
|
||||
const details = result.failed
|
||||
.map((item) => `${item.name}: ${item.error}`)
|
||||
.join('; ');
|
||||
showNotification(`${t('notification.upload_failed')}: ${details}`, 'error');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
showNotification(`${t('notification.upload_failed')}: ${errorMessage}`, 'error');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
const details = failed.map((item) => `${item.name}: ${item.message}`).join('; ');
|
||||
showNotification(`${t('notification.upload_failed')}: ${details}`, 'error');
|
||||
}
|
||||
|
||||
setUploading(false);
|
||||
event.target.value = '';
|
||||
},
|
||||
[loadFiles, refreshKeyStats, showNotification, t]
|
||||
);
|
||||
@@ -234,15 +258,9 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
onConfirm: async () => {
|
||||
setDeleting(name);
|
||||
try {
|
||||
await authFilesApi.deleteFile(name);
|
||||
const result = await authFilesApi.deleteFile(name);
|
||||
showNotification(t('auth_files.delete_success'), 'success');
|
||||
setFiles((prev) => prev.filter((item) => item.name !== name));
|
||||
setSelectedFiles((prev) => {
|
||||
if (!prev.has(name)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(name);
|
||||
return next;
|
||||
});
|
||||
applyDeletedFiles(result.files.length > 0 ? result.files : [name]);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
|
||||
@@ -252,7 +270,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
},
|
||||
});
|
||||
},
|
||||
[showConfirmation, showNotification, t]
|
||||
[applyDeletedFiles, showConfirmation, showNotification, t]
|
||||
);
|
||||
|
||||
const handleDeleteAll = useCallback(
|
||||
@@ -301,35 +319,13 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
return;
|
||||
}
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
const deletedNames: string[] = [];
|
||||
const result = await authFilesApi.deleteFiles(
|
||||
filesToDelete.map((file) => file.name)
|
||||
);
|
||||
const success = result.deleted;
|
||||
const failed = result.failed.length;
|
||||
|
||||
for (const file of filesToDelete) {
|
||||
try {
|
||||
await authFilesApi.deleteFile(file.name);
|
||||
success++;
|
||||
deletedNames.push(file.name);
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
setFiles((prev) => prev.filter((f) => !deletedNames.includes(f.name)));
|
||||
setSelectedFiles((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const deletedSet = new Set(deletedNames);
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (deletedSet.has(name)) {
|
||||
changed = true;
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
applyDeletedFiles(result.files);
|
||||
|
||||
if (failed === 0 && isProblemOnly) {
|
||||
showNotification(
|
||||
@@ -380,7 +376,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
},
|
||||
});
|
||||
},
|
||||
[deselectAll, files, showConfirmation, showNotification, t]
|
||||
[applyDeletedFiles, deselectAll, files, showConfirmation, showNotification, t]
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
@@ -579,59 +575,33 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
variant: 'danger',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: async () => {
|
||||
const results = await Promise.allSettled(
|
||||
uniqueNames.map((name) => authFilesApi.deleteFile(name))
|
||||
);
|
||||
try {
|
||||
const result = await authFilesApi.deleteFiles(uniqueNames);
|
||||
applyDeletedFiles(result.files);
|
||||
|
||||
const deleted: string[] = [];
|
||||
let failCount = 0;
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
deleted.push(uniqueNames[index]);
|
||||
if (result.failed.length === 0) {
|
||||
showNotification(
|
||||
`${t('auth_files.delete_all_success')} (${result.deleted})`,
|
||||
'success'
|
||||
);
|
||||
} else {
|
||||
failCount++;
|
||||
showNotification(
|
||||
t('auth_files.delete_filtered_partial', {
|
||||
success: result.deleted,
|
||||
failed: result.failed.length,
|
||||
type: t('auth_files.filter_all'),
|
||||
}),
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (deleted.length > 0) {
|
||||
const deletedSet = new Set(deleted);
|
||||
setFiles((prev) => prev.filter((file) => !deletedSet.has(file.name)));
|
||||
}
|
||||
|
||||
setSelectedFiles((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const deletedSet = new Set(deleted);
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (deletedSet.has(name)) {
|
||||
changed = true;
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
|
||||
if (failCount === 0) {
|
||||
showNotification(
|
||||
`${t('auth_files.delete_all_success')} (${deleted.length})`,
|
||||
'success'
|
||||
);
|
||||
} else {
|
||||
showNotification(
|
||||
t('auth_files.delete_filtered_partial', {
|
||||
success: deleted.length,
|
||||
failed: failCount,
|
||||
type: t('auth_files.filter_all'),
|
||||
}),
|
||||
'warning'
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[showConfirmation, showNotification, t]
|
||||
[applyDeletedFiles, showConfirmation, showNotification, t]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"quota_update_required": "Please update the CPA version or check for updates",
|
||||
"quota_check_credential": "Please check the credential status",
|
||||
"copy": "Copy",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse",
|
||||
"status": "Status",
|
||||
"action": "Action",
|
||||
"custom_headers_label": "Custom Headers",
|
||||
@@ -495,7 +497,7 @@
|
||||
"pagination_next": "Next",
|
||||
"pagination_info": "Page {{current}} / {{total}} · {{count}} files",
|
||||
"search_label": "Search configs",
|
||||
"search_placeholder": "Filter by name, type, or provider",
|
||||
"search_placeholder": "Filter by name, type, or provider. Use * as a wildcard",
|
||||
"problem_filter_label": "Problem Filter",
|
||||
"problem_filter_only": "Only show problematic credentials",
|
||||
"display_options_label": "Display options",
|
||||
@@ -1341,7 +1343,8 @@
|
||||
"description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.",
|
||||
"refresh_files": "Refresh auth files",
|
||||
"refresh_files_and_quota": "Refresh files & quota",
|
||||
"card_idle_hint": "Use the top \"Refresh files & quota\" button to fetch the latest quota data."
|
||||
"refresh_all_credentials": "Refresh all credentials",
|
||||
"card_idle_hint": "Use the top \"Refresh all credentials\" button to fetch the latest quota data."
|
||||
},
|
||||
"system_info": {
|
||||
"title": "Management Center Info",
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"quota_update_required": "Пожалуйста, обновите CPA или проверьте наличие обновлений",
|
||||
"quota_check_credential": "Пожалуйста, проверьте статус учётных данных",
|
||||
"copy": "Копировать",
|
||||
"expand": "Развернуть",
|
||||
"collapse": "Свернуть",
|
||||
"status": "Статус",
|
||||
"action": "Действие",
|
||||
"custom_headers_label": "Пользовательские заголовки",
|
||||
@@ -495,7 +497,7 @@
|
||||
"pagination_next": "Следующая",
|
||||
"pagination_info": "Страница {{current}} / {{total}} · {{count}} файлов",
|
||||
"search_label": "Поиск конфигов",
|
||||
"search_placeholder": "Фильтр по имени, типу или провайдеру",
|
||||
"search_placeholder": "Фильтр по имени, типу или провайдеру, поддерживается wildcard *",
|
||||
"problem_filter_label": "Фильтр проблем",
|
||||
"problem_filter_only": "Показывать только проблемные учётные данные",
|
||||
"display_options_label": "Параметры отображения",
|
||||
@@ -1346,7 +1348,8 @@
|
||||
"description": "Следите за статусом квот OAuth для учётных данных Antigravity, Codex и Gemini CLI.",
|
||||
"refresh_files": "Обновить файлы авторизации",
|
||||
"refresh_files_and_quota": "Обновить файлы и квоты",
|
||||
"card_idle_hint": "Используйте кнопку «Обновить файлы и квоты» сверху, чтобы загрузить актуальные данные по квотам."
|
||||
"refresh_all_credentials": "Обновить все учётные данные",
|
||||
"card_idle_hint": "Используйте кнопку «Обновить все учётные данные» сверху, чтобы загрузить актуальные данные по квотам."
|
||||
},
|
||||
"system_info": {
|
||||
"title": "Информация о центре управления",
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"quota_update_required": "请更新 CPA 版本或检查更新",
|
||||
"quota_check_credential": "请检查凭证状态",
|
||||
"copy": "复制",
|
||||
"expand": "展开",
|
||||
"collapse": "收起",
|
||||
"status": "状态",
|
||||
"action": "操作",
|
||||
"custom_headers_label": "自定义请求头",
|
||||
@@ -495,7 +497,7 @@
|
||||
"pagination_next": "下一页",
|
||||
"pagination_info": "第 {{current}} / {{total}} 页 · 共 {{count}} 个文件",
|
||||
"search_label": "搜索配置文件",
|
||||
"search_placeholder": "输入名称、类型或提供方关键字",
|
||||
"search_placeholder": "输入名称、类型或提供方关键字,支持 * 通配",
|
||||
"problem_filter_label": "问题筛选",
|
||||
"problem_filter_only": "仅显示有问题凭证",
|
||||
"display_options_label": "显示选项",
|
||||
@@ -1341,7 +1343,8 @@
|
||||
"description": "集中查看 OAuth 额度与剩余情况",
|
||||
"refresh_files": "刷新认证文件",
|
||||
"refresh_files_and_quota": "刷新认证文件&额度",
|
||||
"card_idle_hint": "请使用顶部“刷新认证文件&额度”按钮获取最新额度。"
|
||||
"refresh_all_credentials": "刷新全部凭证",
|
||||
"card_idle_hint": "请使用顶部“刷新全部凭证”按钮获取最新额度。"
|
||||
},
|
||||
"system_info": {
|
||||
"title": "管理中心信息",
|
||||
|
||||
+205
-416
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,15 @@ const BATCH_BAR_HIDDEN_TRANSFORM = 'translateX(-50%) translateY(56px)';
|
||||
const DEFAULT_REGULAR_PAGE_SIZE = 9;
|
||||
const DEFAULT_COMPACT_PAGE_SIZE = 12;
|
||||
|
||||
const escapeWildcardSearchSegment = (value: string) =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
const buildWildcardSearch = (value: string): RegExp | null => {
|
||||
if (!value.includes('*')) return null;
|
||||
const pattern = value.split('*').map(escapeWildcardSearchSegment).join('.*');
|
||||
return new RegExp(pattern, 'i');
|
||||
};
|
||||
|
||||
export function AuthFilesPage() {
|
||||
const { t } = useTranslation();
|
||||
const showNotification = useNotificationStore((state) => state.showNotification);
|
||||
@@ -368,18 +377,25 @@ export function AuthFilesPage() {
|
||||
return counts;
|
||||
}, [filesMatchingProblemFilter]);
|
||||
|
||||
const normalizedSearch = search.trim();
|
||||
const wildcardSearch = useMemo(() => buildWildcardSearch(normalizedSearch), [normalizedSearch]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const normalizedTerm = normalizedSearch.toLowerCase();
|
||||
|
||||
return filesMatchingProblemFilter.filter((item) => {
|
||||
const matchType = filter === 'all' || item.type === filter;
|
||||
const term = search.trim().toLowerCase();
|
||||
const matchSearch =
|
||||
!term ||
|
||||
item.name.toLowerCase().includes(term) ||
|
||||
(item.type || '').toString().toLowerCase().includes(term) ||
|
||||
(item.provider || '').toString().toLowerCase().includes(term);
|
||||
!normalizedSearch ||
|
||||
[item.name, item.type, item.provider].some((value) => {
|
||||
const content = (value || '').toString();
|
||||
return wildcardSearch
|
||||
? wildcardSearch.test(content)
|
||||
: content.toLowerCase().includes(normalizedTerm);
|
||||
});
|
||||
return matchType && matchSearch;
|
||||
});
|
||||
}, [filesMatchingProblemFilter, filter, search]);
|
||||
}, [filesMatchingProblemFilter, filter, normalizedSearch, wildcardSearch]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const copy = [...filtered];
|
||||
|
||||
@@ -31,14 +31,22 @@
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
|
||||
:global(.btn-sm) {
|
||||
:global(.btn.btn-sm) {
|
||||
line-height: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(svg) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.titleWrapper {
|
||||
@@ -146,9 +154,95 @@
|
||||
}
|
||||
|
||||
.viewModeToggle {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
gap: $spacing-xs;
|
||||
align-items: center;
|
||||
padding: 3px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 92%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16);
|
||||
|
||||
@include mobile {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.viewModeButton:global(.btn.btn-sm) {
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
box-shadow: none;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: transparent;
|
||||
background: color-mix(in srgb, var(--bg-hover) 72%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
> span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
}
|
||||
|
||||
.viewModeButtonActive:global(.btn.btn-sm) {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-contrast, #fff);
|
||||
box-shadow: 0 8px 18px -14px rgba(0, 0, 0, 0.45);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
color: var(--primary-contrast, #fff);
|
||||
}
|
||||
}
|
||||
|
||||
.refreshAllButton:global(.btn.btn-sm) {
|
||||
padding-inline: 14px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-secondary));
|
||||
border-color: color-mix(in srgb, var(--primary-color) 22%, var(--border-color));
|
||||
color: var(--text-primary);
|
||||
|
||||
> span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--primary-color) 16%, var(--bg-secondary));
|
||||
border-color: color-mix(in srgb, var(--primary-color) 34%, var(--border-color));
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
:global([data-theme='dark']) .viewModeToggle {
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
:global([data-theme='dark']) .refreshAllButton:global(.btn.btn-sm) {
|
||||
background: color-mix(in srgb, var(--primary-color) 18%, var(--bg-secondary));
|
||||
border-color: color-mix(in srgb, var(--primary-color) 32%, var(--border-color));
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.headerActions {
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
}
|
||||
|
||||
// 卡片渐变背景 — 基于 TYPE_COLORS light.bg 转 rgba
|
||||
@@ -243,7 +337,7 @@
|
||||
}
|
||||
|
||||
.quotaBarFillMedium {
|
||||
background-color: var(--warning-color);
|
||||
background-color: var(--quota-medium-color, #e0aa14);
|
||||
}
|
||||
|
||||
.quotaBarFillLow {
|
||||
@@ -283,6 +377,24 @@
|
||||
padding: $spacing-sm 0;
|
||||
}
|
||||
|
||||
.quotaMessageAction {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.quotaError {
|
||||
font-size: 12px;
|
||||
color: var(--danger-color);
|
||||
|
||||
@@ -1112,17 +1112,20 @@
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-secondary, #e5e7eb);
|
||||
background-color: var(--floating-surface, #ffffff);
|
||||
background-image: none;
|
||||
border: 1px solid var(--floating-border, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
box-shadow: var(--floating-shadow, 0 10px 24px rgba(0, 0, 0, 0.16));
|
||||
z-index: $z-dropdown;
|
||||
pointer-events: none;
|
||||
color: var(--text-primary);
|
||||
opacity: 1;
|
||||
background-clip: padding-box;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
@@ -1131,7 +1134,7 @@
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: var(--bg-primary, #fff);
|
||||
border-top-color: var(--floating-surface, #ffffff);
|
||||
}
|
||||
|
||||
&::before {
|
||||
@@ -1141,7 +1144,7 @@
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: var(--border-secondary, #e5e7eb);
|
||||
border-top-color: var(--floating-border, #e5e7eb);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1154,14 +1157,14 @@
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
border-top-color: transparent;
|
||||
border-bottom-color: var(--bg-primary, #fff);
|
||||
border-bottom-color: var(--floating-surface, #ffffff);
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
border-top-color: transparent;
|
||||
border-bottom-color: var(--border-secondary, #e5e7eb);
|
||||
border-bottom-color: var(--floating-border, #e5e7eb);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,31 @@ import type { OAuthModelAliasEntry } from '@/types';
|
||||
type StatusError = { status?: number };
|
||||
type AuthFileStatusResponse = { status: string; disabled: boolean };
|
||||
type AuthFileEntry = AuthFilesResponse['files'][number];
|
||||
type AuthFileBatchFailure = { name: string; error: string };
|
||||
type AuthFileBatchUploadResponse = {
|
||||
status?: string;
|
||||
uploaded?: number;
|
||||
files?: unknown;
|
||||
failed?: unknown;
|
||||
};
|
||||
type AuthFileBatchDeleteResponse = {
|
||||
status?: string;
|
||||
deleted?: number;
|
||||
files?: unknown;
|
||||
failed?: unknown;
|
||||
};
|
||||
type AuthFileBatchUploadResult = {
|
||||
status: string;
|
||||
uploaded: number;
|
||||
files: string[];
|
||||
failed: AuthFileBatchFailure[];
|
||||
};
|
||||
type AuthFileBatchDeleteResult = {
|
||||
status: string;
|
||||
deleted: number;
|
||||
files: string[];
|
||||
failed: AuthFileBatchFailure[];
|
||||
};
|
||||
|
||||
export const AUTH_FILE_INVALID_JSON_OBJECT_ERROR = 'AUTH_FILE_INVALID_JSON_OBJECT';
|
||||
|
||||
@@ -18,6 +43,129 @@ const getStatusCode = (err: unknown): number | undefined => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizeRequestedAuthFileNames = (names: string[]): string[] => {
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
|
||||
names.forEach((name) => {
|
||||
const trimmed = String(name ?? '').trim();
|
||||
if (!trimmed || seen.has(trimmed)) return;
|
||||
seen.add(trimmed);
|
||||
normalized.push(trimmed);
|
||||
});
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const normalizeBatchFileNames = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return normalizeRequestedAuthFileNames(value.map((item) => String(item ?? '')));
|
||||
};
|
||||
|
||||
const normalizeBatchFailures = (value: unknown): AuthFileBatchFailure[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
return value.reduce<AuthFileBatchFailure[]>((result, item) => {
|
||||
if (!item || typeof item !== 'object') return result;
|
||||
const entry = item as Record<string, unknown>;
|
||||
const name = String(entry.name ?? '').trim();
|
||||
const error =
|
||||
typeof entry.error === 'string'
|
||||
? entry.error.trim()
|
||||
: typeof entry.message === 'string'
|
||||
? entry.message.trim()
|
||||
: '';
|
||||
|
||||
if (!name && !error) return result;
|
||||
result.push({ name, error: error || 'Unknown error' });
|
||||
return result;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const deriveSuccessfulFileNames = (requestedNames: string[], failed: AuthFileBatchFailure[]): string[] => {
|
||||
const failedNames = new Set(
|
||||
failed
|
||||
.map((entry) => entry.name.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
if (failedNames.size === 0) {
|
||||
return [...requestedNames];
|
||||
}
|
||||
|
||||
return requestedNames.filter((name) => !failedNames.has(name));
|
||||
};
|
||||
|
||||
const normalizeBatchUploadResponse = (
|
||||
payload: AuthFileBatchUploadResponse | undefined,
|
||||
requestedNames: string[]
|
||||
): AuthFileBatchUploadResult => {
|
||||
const failed = normalizeBatchFailures(payload?.failed);
|
||||
const uploadedFilesFromPayload = normalizeBatchFileNames(payload?.files);
|
||||
const uploaded =
|
||||
typeof payload?.uploaded === 'number'
|
||||
? payload.uploaded
|
||||
: uploadedFilesFromPayload.length > 0
|
||||
? uploadedFilesFromPayload.length
|
||||
: requestedNames.length === 1 && failed.length === 0
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
let uploadedFiles = uploadedFilesFromPayload;
|
||||
if (uploadedFiles.length === 0 && uploaded > 0) {
|
||||
if (failed.length === 0 && uploaded === requestedNames.length) {
|
||||
uploadedFiles = [...requestedNames];
|
||||
} else {
|
||||
const derivedNames = deriveSuccessfulFileNames(requestedNames, failed);
|
||||
if (derivedNames.length === uploaded) {
|
||||
uploadedFiles = derivedNames;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: typeof payload?.status === 'string' ? payload.status : failed.length > 0 ? 'partial' : 'ok',
|
||||
uploaded,
|
||||
files: uploadedFiles,
|
||||
failed,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeBatchDeleteResponse = (
|
||||
payload: AuthFileBatchDeleteResponse | undefined,
|
||||
requestedNames: string[]
|
||||
): AuthFileBatchDeleteResult => {
|
||||
const failed = normalizeBatchFailures(payload?.failed);
|
||||
const deletedFilesFromPayload = normalizeBatchFileNames(payload?.files);
|
||||
const deleted =
|
||||
typeof payload?.deleted === 'number'
|
||||
? payload.deleted
|
||||
: deletedFilesFromPayload.length > 0
|
||||
? deletedFilesFromPayload.length
|
||||
: requestedNames.length === 1 && failed.length === 0
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
let deletedFiles = deletedFilesFromPayload;
|
||||
if (deletedFiles.length === 0 && deleted > 0) {
|
||||
if (failed.length === 0 && deleted === requestedNames.length) {
|
||||
deletedFiles = [...requestedNames];
|
||||
} else {
|
||||
const derivedNames = deriveSuccessfulFileNames(requestedNames, failed);
|
||||
if (derivedNames.length === deleted) {
|
||||
deletedFiles = derivedNames;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: typeof payload?.status === 'string' ? payload.status : failed.length > 0 ? 'partial' : 'ok',
|
||||
deleted,
|
||||
files: deletedFiles,
|
||||
failed,
|
||||
};
|
||||
};
|
||||
|
||||
const readTextField = (entry: AuthFileEntry, key: string): string => {
|
||||
const value = entry[key];
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
@@ -252,13 +400,35 @@ export const authFilesApi = {
|
||||
setStatus: (name: string, disabled: boolean) =>
|
||||
apiClient.patch<AuthFileStatusResponse>('/auth-files/status', { name, disabled }),
|
||||
|
||||
upload: (file: File) => {
|
||||
uploadFiles: async (files: File[]): Promise<AuthFileBatchUploadResult> => {
|
||||
const requestedNames = files.map((file) => file.name);
|
||||
if (requestedNames.length === 0) {
|
||||
return { status: 'ok', uploaded: 0, files: [], failed: [] };
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name);
|
||||
return apiClient.postForm('/auth-files', formData);
|
||||
files.forEach((file) => {
|
||||
formData.append('file', file, file.name);
|
||||
});
|
||||
const payload = await apiClient.postForm<AuthFileBatchUploadResponse>('/auth-files', formData);
|
||||
return normalizeBatchUploadResponse(payload, requestedNames);
|
||||
},
|
||||
|
||||
deleteFile: (name: string) => apiClient.delete(`/auth-files?name=${encodeURIComponent(name)}`),
|
||||
upload: (file: File) => authFilesApi.uploadFiles([file]),
|
||||
|
||||
deleteFiles: async (names: string[]): Promise<AuthFileBatchDeleteResult> => {
|
||||
const requestedNames = normalizeRequestedAuthFileNames(names);
|
||||
if (requestedNames.length === 0) {
|
||||
return { status: 'ok', deleted: 0, files: [], failed: [] };
|
||||
}
|
||||
|
||||
const payload = await apiClient.delete<AuthFileBatchDeleteResponse>('/auth-files', {
|
||||
data: { names: requestedNames },
|
||||
});
|
||||
return normalizeBatchDeleteResponse(payload, requestedNames);
|
||||
},
|
||||
|
||||
deleteFile: (name: string) => authFilesApi.deleteFiles([name]),
|
||||
|
||||
deleteAll: () => apiClient.delete('/auth-files', { params: { all: true } }),
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
--bg-hover: var(--bg-tertiary);
|
||||
--bg-quinary: #f6f4ee;
|
||||
--bg-error-light: rgba(198, 87, 70, 0.1);
|
||||
--floating-surface: #fffdf9;
|
||||
--floating-border: #d8d3ca;
|
||||
--floating-shadow: 0 12px 26px rgba(0, 0, 0, 0.14);
|
||||
|
||||
--text-primary: #2d2a26;
|
||||
--text-secondary: #6d6760;
|
||||
@@ -29,6 +32,7 @@
|
||||
--primary-contrast: #ffffff;
|
||||
|
||||
--success-color: #10b981;
|
||||
--quota-medium-color: #e0aa14;
|
||||
--warning-color: #c65746; // 错误/警告色
|
||||
--error-color: #c65746;
|
||||
--danger-color: var(--error-color);
|
||||
@@ -64,6 +68,9 @@
|
||||
--bg-hover: var(--bg-tertiary);
|
||||
--bg-quinary: #ffffff;
|
||||
--bg-error-light: rgba(198, 87, 70, 0.08);
|
||||
--floating-surface: #ffffff;
|
||||
--floating-border: #d9d9d9;
|
||||
--floating-shadow: 0 12px 26px rgba(0, 0, 0, 0.12);
|
||||
|
||||
--text-primary: #2d2a26;
|
||||
--text-secondary: #6d6760;
|
||||
@@ -82,6 +89,7 @@
|
||||
--primary-contrast: #ffffff;
|
||||
|
||||
--success-color: #10b981;
|
||||
--quota-medium-color: #e0aa14;
|
||||
--warning-color: #c65746;
|
||||
--error-color: #c65746;
|
||||
--danger-color: var(--error-color);
|
||||
@@ -118,6 +126,9 @@
|
||||
--bg-hover: #2e2a26;
|
||||
--bg-quinary: #191714;
|
||||
--bg-error-light: rgba(198, 87, 70, 0.18);
|
||||
--floating-surface: #2a2723;
|
||||
--floating-border: #4a443d;
|
||||
--floating-shadow: 0 14px 30px rgba(0, 0, 0, 0.4);
|
||||
|
||||
--text-primary: #f6f4f1;
|
||||
--text-secondary: #c9c3bb;
|
||||
@@ -136,6 +147,7 @@
|
||||
--primary-contrast: #ffffff;
|
||||
|
||||
--success-color: #10b981;
|
||||
--quota-medium-color: #ffd862;
|
||||
--warning-color: #c65746;
|
||||
--error-color: #c65746;
|
||||
--danger-color: var(--error-color);
|
||||
|
||||
Reference in New Issue
Block a user