Compare commits

...

10 Commits

16 changed files with 1054 additions and 650 deletions
@@ -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
+17 -2
View File
@@ -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`, {
+50 -5
View File
@@ -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}
/>
))}
+27 -5
View File
@@ -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,
})
);
});
};
+150 -60
View File
@@ -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>
+80 -110
View File
@@ -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 {
+5 -2
View File
@@ -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",
+5 -2
View File
@@ -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": "Информация о центре управления",
+5 -2
View File
@@ -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": "管理中心信息",
File diff suppressed because it is too large Load Diff
+22 -6
View File
@@ -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];
+115 -3
View File
@@ -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);
+10 -7
View File
@@ -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);
}
}
+174 -4
View File
@@ -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 } }),
+12
View File
@@ -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);