mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 11:20:50 +08:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71556a51c5 | ||
|
|
2a92ea8862 | ||
|
|
681fc3cee5 | ||
|
|
916dd3ec26 | ||
|
|
692f7f3cde | ||
|
|
bf20f3d86e | ||
|
|
b7e720133d | ||
|
|
e914337e57 | ||
|
|
6364bac1f2 | ||
|
|
38a3e20427 | ||
|
|
334d75f2dd | ||
|
|
42eb783395 | ||
|
|
84b219957e | ||
|
|
f5c1ef36ce | ||
|
|
fae4fb0fed | ||
|
|
1d8729ec53 | ||
|
|
c6ef8a259f | ||
|
|
0efef5a789 |
@@ -7,7 +7,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives",
|
||||||
"format": "prettier --write \"src/**/*.{ts,tsx,css,scss}\"",
|
"format": "prettier --write \"src/**/*.{ts,tsx,css,scss}\"",
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export function PageTransition({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAnimating) return;
|
if (isAnimating) return;
|
||||||
if (location.key === currentLayerKey) return;
|
if (location.key === currentLayerKey) return;
|
||||||
|
if (currentLayerPathname === location.pathname) return;
|
||||||
const scrollContainer = resolveScrollContainer();
|
const scrollContainer = resolveScrollContainer();
|
||||||
exitScrollOffsetRef.current = scrollContainer?.scrollTop ?? 0;
|
exitScrollOffsetRef.current = scrollContainer?.scrollTop ?? 0;
|
||||||
const resolveOrderIndex = (pathname?: string) => {
|
const resolveOrderIndex = (pathname?: string) => {
|
||||||
@@ -69,17 +70,27 @@ export function PageTransition({
|
|||||||
: toIndex > fromIndex
|
: toIndex > fromIndex
|
||||||
? 'forward'
|
? 'forward'
|
||||||
: 'backward';
|
: 'backward';
|
||||||
setTransitionDirection(nextDirection);
|
|
||||||
setLayers((prev) => {
|
let cancelled = false;
|
||||||
const prevCurrent = prev[prev.length - 1];
|
|
||||||
return [
|
queueMicrotask(() => {
|
||||||
prevCurrent
|
if (cancelled) return;
|
||||||
? { ...prevCurrent, status: 'exiting' }
|
setTransitionDirection(nextDirection);
|
||||||
: { key: location.key, location, status: 'exiting' },
|
setLayers((prev) => {
|
||||||
{ key: location.key, location, status: 'current' },
|
const prevCurrent = prev[prev.length - 1];
|
||||||
];
|
return [
|
||||||
|
prevCurrent
|
||||||
|
? { ...prevCurrent, status: 'exiting' }
|
||||||
|
: { key: location.key, location, status: 'exiting' },
|
||||||
|
{ key: location.key, location, status: 'current' },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
setIsAnimating(true);
|
||||||
});
|
});
|
||||||
setIsAnimating(true);
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [
|
}, [
|
||||||
isAnimating,
|
isAnimating,
|
||||||
location,
|
location,
|
||||||
|
|||||||
@@ -2,28 +2,30 @@
|
|||||||
* Generic quota section component.
|
* Generic quota section component.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { EmptyState } from '@/components/ui/EmptyState';
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||||
import { useQuotaStore, useThemeStore } from '@/stores';
|
import { useQuotaStore, useThemeStore } from '@/stores';
|
||||||
import type { AuthFileItem, ResolvedTheme } from '@/types';
|
import type { AuthFileItem, ResolvedTheme } from '@/types';
|
||||||
import { QuotaCard } from './QuotaCard';
|
import { QuotaCard } from './QuotaCard';
|
||||||
import type { QuotaStatusState } from './QuotaCard';
|
import type { QuotaStatusState } from './QuotaCard';
|
||||||
import { useQuotaLoader } from './useQuotaLoader';
|
import { useQuotaLoader } from './useQuotaLoader';
|
||||||
import type { QuotaConfig } from './quotaConfigs';
|
import type { QuotaConfig } from './quotaConfigs';
|
||||||
|
import { useGridColumns } from './useGridColumns';
|
||||||
|
import { IconRefreshCw } from '@/components/ui/icons';
|
||||||
import styles from '@/pages/QuotaPage.module.scss';
|
import styles from '@/pages/QuotaPage.module.scss';
|
||||||
|
|
||||||
type QuotaUpdater<T> = T | ((prev: T) => T);
|
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||||
|
|
||||||
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
|
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
|
||||||
|
|
||||||
const MIN_CARD_PAGE_SIZE = 3;
|
type ViewMode = 'paged' | 'all';
|
||||||
const MAX_CARD_PAGE_SIZE = 30;
|
|
||||||
|
|
||||||
const clampCardPageSize = (value: number) =>
|
const MAX_ITEMS_PER_PAGE = 14;
|
||||||
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
|
const MAX_SHOW_ALL_THRESHOLD = 30;
|
||||||
|
|
||||||
interface QuotaPaginationState<T> {
|
interface QuotaPaginationState<T> {
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
@@ -40,7 +42,7 @@ interface QuotaPaginationState<T> {
|
|||||||
|
|
||||||
const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginationState<T> => {
|
const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginationState<T> => {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSizeState] = useState(() => clampCardPageSize(defaultPageSize));
|
const [pageSize, setPageSizeState] = useState(defaultPageSize);
|
||||||
const [loading, setLoadingState] = useState(false);
|
const [loading, setLoadingState] = useState(false);
|
||||||
const [loadingScope, setLoadingScope] = useState<'page' | 'all' | null>(null);
|
const [loadingScope, setLoadingScope] = useState<'page' | 'all' | null>(null);
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@ const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginatio
|
|||||||
}, [items, currentPage, pageSize]);
|
}, [items, currentPage, pageSize]);
|
||||||
|
|
||||||
const setPageSize = useCallback((size: number) => {
|
const setPageSize = useCallback((size: number) => {
|
||||||
setPageSizeState(clampCardPageSize(size));
|
setPageSizeState(size);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -107,10 +109,17 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
|||||||
Record<string, TState>
|
Record<string, TState>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
/* Removed useRef */
|
||||||
|
const [columns, gridRef] = useGridColumns(380); // Min card width 380px matches SCSS
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('paged');
|
||||||
|
const [showTooManyWarning, setShowTooManyWarning] = useState(false);
|
||||||
|
|
||||||
const filteredFiles = useMemo(() => files.filter((file) => config.filterFn(file)), [
|
const filteredFiles = useMemo(() => files.filter((file) => config.filterFn(file)), [
|
||||||
files,
|
files,
|
||||||
config.filterFn
|
config
|
||||||
]);
|
]);
|
||||||
|
const showAllAllowed = filteredFiles.length <= MAX_SHOW_ALL_THRESHOLD;
|
||||||
|
const effectiveViewMode: ViewMode = viewMode === 'all' && !showAllAllowed ? 'paged' : viewMode;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -121,19 +130,59 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
|||||||
goToPrev,
|
goToPrev,
|
||||||
goToNext,
|
goToNext,
|
||||||
loading: sectionLoading,
|
loading: sectionLoading,
|
||||||
loadingScope,
|
|
||||||
setLoading
|
setLoading
|
||||||
} = useQuotaPagination(filteredFiles);
|
} = useQuotaPagination(filteredFiles);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showAllAllowed) return;
|
||||||
|
if (viewMode !== 'all') return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setViewMode('paged');
|
||||||
|
setShowTooManyWarning(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [showAllAllowed, viewMode]);
|
||||||
|
|
||||||
|
// Update page size based on view mode and columns
|
||||||
|
useEffect(() => {
|
||||||
|
if (effectiveViewMode === 'all') {
|
||||||
|
setPageSize(Math.max(1, filteredFiles.length));
|
||||||
|
} else {
|
||||||
|
// Paged mode: 3 rows * columns, capped to avoid oversized pages.
|
||||||
|
setPageSize(Math.min(columns * 3, MAX_ITEMS_PER_PAGE));
|
||||||
|
}
|
||||||
|
}, [effectiveViewMode, columns, filteredFiles.length, setPageSize]);
|
||||||
|
|
||||||
const { quota, loadQuota } = useQuotaLoader(config);
|
const { quota, loadQuota } = useQuotaLoader(config);
|
||||||
|
|
||||||
const handleRefreshPage = useCallback(() => {
|
const pendingQuotaRefreshRef = useRef(false);
|
||||||
loadQuota(pageItems, 'page', setLoading);
|
const prevFilesLoadingRef = useRef(loading);
|
||||||
}, [loadQuota, pageItems, setLoading]);
|
|
||||||
|
|
||||||
const handleRefreshAll = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
loadQuota(filteredFiles, 'all', setLoading);
|
pendingQuotaRefreshRef.current = true;
|
||||||
}, [loadQuota, filteredFiles, setLoading]);
|
void triggerHeaderRefresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wasLoading = prevFilesLoadingRef.current;
|
||||||
|
prevFilesLoadingRef.current = loading;
|
||||||
|
|
||||||
|
if (!pendingQuotaRefreshRef.current) return;
|
||||||
|
if (loading) return;
|
||||||
|
if (!wasLoading) return;
|
||||||
|
|
||||||
|
pendingQuotaRefreshRef.current = false;
|
||||||
|
const scope = effectiveViewMode === 'all' ? 'all' : 'page';
|
||||||
|
const targets = effectiveViewMode === 'all' ? filteredFiles : pageItems;
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
loadQuota(targets, scope, setLoading);
|
||||||
|
}, [loading, effectiveViewMode, filteredFiles, pageItems, loadQuota, setLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
@@ -153,28 +202,56 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
|||||||
});
|
});
|
||||||
}, [filteredFiles, loading, setQuota]);
|
}, [filteredFiles, loading, setQuota]);
|
||||||
|
|
||||||
|
const titleNode = (
|
||||||
|
<div className={styles.titleWrapper}>
|
||||||
|
<span>{t(`${config.i18nPrefix}.title`)}</span>
|
||||||
|
{filteredFiles.length > 0 && (
|
||||||
|
<span className={styles.countBadge}>
|
||||||
|
{filteredFiles.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const isRefreshing = sectionLoading || loading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
title={t(`${config.i18nPrefix}.title`)}
|
title={titleNode}
|
||||||
extra={
|
extra={
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
|
<div className={styles.viewModeToggle}>
|
||||||
|
<Button
|
||||||
|
variant={effectiveViewMode === 'paged' ? 'primary' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode('paged')}
|
||||||
|
>
|
||||||
|
{t('auth_files.view_mode_paged')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={effectiveViewMode === 'all' ? 'primary' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (filteredFiles.length > MAX_SHOW_ALL_THRESHOLD) {
|
||||||
|
setShowTooManyWarning(true);
|
||||||
|
} else {
|
||||||
|
setViewMode('all');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('auth_files.view_mode_all')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleRefreshPage}
|
onClick={handleRefresh}
|
||||||
disabled={disabled || sectionLoading || pageItems.length === 0}
|
disabled={disabled || isRefreshing}
|
||||||
loading={sectionLoading && loadingScope === 'page'}
|
loading={isRefreshing}
|
||||||
|
title={t('quota_management.refresh_files_and_quota')}
|
||||||
|
aria-label={t('quota_management.refresh_files_and_quota')}
|
||||||
>
|
>
|
||||||
{t(`${config.i18nPrefix}.refresh_button`)}
|
{!isRefreshing && <IconRefreshCw size={16} />}
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRefreshAll}
|
|
||||||
disabled={disabled || sectionLoading || filteredFiles.length === 0}
|
|
||||||
loading={sectionLoading && loadingScope === 'all'}
|
|
||||||
>
|
|
||||||
{t(`${config.i18nPrefix}.fetch_all`)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -186,31 +263,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className={config.controlsClassName}>
|
<div ref={gridRef} className={config.gridClassName}>
|
||||||
<div className={config.controlClassName}>
|
|
||||||
<label>{t('auth_files.page_size_label')}</label>
|
|
||||||
<input
|
|
||||||
className={styles.pageSizeSelect}
|
|
||||||
type="number"
|
|
||||||
min={MIN_CARD_PAGE_SIZE}
|
|
||||||
max={MAX_CARD_PAGE_SIZE}
|
|
||||||
step={1}
|
|
||||||
value={pageSize}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.currentTarget.valueAsNumber;
|
|
||||||
if (!Number.isFinite(value)) return;
|
|
||||||
setPageSize(value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={config.controlClassName}>
|
|
||||||
<label>{t('common.info')}</label>
|
|
||||||
<div className={styles.statsInfo}>
|
|
||||||
{filteredFiles.length} {t('auth_files.files_count')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={config.gridClassName}>
|
|
||||||
{pageItems.map((item) => (
|
{pageItems.map((item) => (
|
||||||
<QuotaCard
|
<QuotaCard
|
||||||
key={item.name}
|
key={item.name}
|
||||||
@@ -224,7 +277,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{filteredFiles.length > pageSize && (
|
{filteredFiles.length > pageSize && effectiveViewMode === 'paged' && (
|
||||||
<div className={styles.pagination}>
|
<div className={styles.pagination}>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -253,6 +306,16 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{showTooManyWarning && (
|
||||||
|
<div className={styles.warningOverlay} onClick={() => setShowTooManyWarning(false)}>
|
||||||
|
<div className={styles.warningModal} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<p>{t('auth_files.too_many_files_warning')}</p>
|
||||||
|
<Button variant="primary" size="sm" onClick={() => setShowTooManyWarning(false)}>
|
||||||
|
{t('common.confirm')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
40
src/components/quota/useGridColumns.ts
Normal file
40
src/components/quota/useGridColumns.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to calculate the number of grid columns based on container width and item min-width.
|
||||||
|
* Returns [columns, refCallback].
|
||||||
|
*/
|
||||||
|
export function useGridColumns(
|
||||||
|
itemMinWidth: number,
|
||||||
|
gap: number = 16
|
||||||
|
): [number, (node: HTMLDivElement | null) => void] {
|
||||||
|
const [columns, setColumns] = useState(1);
|
||||||
|
const [element, setElement] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const refCallback = useCallback((node: HTMLDivElement | null) => {
|
||||||
|
setElement(node);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const updateColumns = () => {
|
||||||
|
const containerWidth = element.clientWidth;
|
||||||
|
const effectiveItemWidth = itemMinWidth + gap;
|
||||||
|
const count = Math.floor((containerWidth + gap) / effectiveItemWidth);
|
||||||
|
setColumns(Math.max(1, count));
|
||||||
|
};
|
||||||
|
|
||||||
|
updateColumns();
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
updateColumns();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(element);
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [element, itemMinWidth, gap]);
|
||||||
|
|
||||||
|
return [columns, refCallback];
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ export function Button({
|
|||||||
disabled,
|
disabled,
|
||||||
...rest
|
...rest
|
||||||
}: PropsWithChildren<ButtonProps>) {
|
}: PropsWithChildren<ButtonProps>) {
|
||||||
|
const hasChildren = children !== null && children !== undefined && children !== false;
|
||||||
const classes = [
|
const classes = [
|
||||||
'btn',
|
'btn',
|
||||||
`btn-${variant}`,
|
`btn-${variant}`,
|
||||||
@@ -33,7 +34,7 @@ export function Button({
|
|||||||
return (
|
return (
|
||||||
<button className={classes} disabled={disabled || loading} {...rest}>
|
<button className={classes} disabled={disabled || loading} {...rest}>
|
||||||
{loading && <span className="loading-spinner" aria-hidden="true" />}
|
{loading && <span className="loading-spinner" aria-hidden="true" />}
|
||||||
<span>{children}</span>
|
{hasChildren && <span>{children}</span>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,19 +54,28 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
if (open) {
|
if (open) {
|
||||||
if (closeTimerRef.current !== null) {
|
if (closeTimerRef.current !== null) {
|
||||||
window.clearTimeout(closeTimerRef.current);
|
window.clearTimeout(closeTimerRef.current);
|
||||||
closeTimerRef.current = null;
|
closeTimerRef.current = null;
|
||||||
}
|
}
|
||||||
setIsVisible(true);
|
queueMicrotask(() => {
|
||||||
setIsClosing(false);
|
if (cancelled) return;
|
||||||
return;
|
setIsVisible(true);
|
||||||
|
setIsClosing(false);
|
||||||
|
});
|
||||||
|
} else if (isVisible) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
startClose(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isVisible) {
|
return () => {
|
||||||
startClose(false);
|
cancelled = true;
|
||||||
}
|
};
|
||||||
}, [open, isVisible, startClose]);
|
}, [open, isVisible, startClose]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
|
|||||||
@@ -312,6 +312,7 @@
|
|||||||
"delete_all_confirm": "Are you sure you want to delete all auth files? This operation cannot be undone!",
|
"delete_all_confirm": "Are you sure you want to delete all auth files? This operation cannot be undone!",
|
||||||
"delete_filtered_confirm": "Are you sure you want to delete all {{type}} auth files? This operation cannot be undone!",
|
"delete_filtered_confirm": "Are you sure you want to delete all {{type}} auth files? This operation cannot be undone!",
|
||||||
"upload_error_json": "Only JSON files are allowed",
|
"upload_error_json": "Only JSON files are allowed",
|
||||||
|
"upload_error_size": "File size cannot exceed {{maxSize}}",
|
||||||
"upload_success": "File uploaded successfully",
|
"upload_success": "File uploaded successfully",
|
||||||
"download_success": "File downloaded successfully",
|
"download_success": "File downloaded successfully",
|
||||||
"delete_success": "File deleted successfully",
|
"delete_success": "File deleted successfully",
|
||||||
@@ -327,6 +328,9 @@
|
|||||||
"search_placeholder": "Filter by name, type, or provider",
|
"search_placeholder": "Filter by name, type, or provider",
|
||||||
"page_size_label": "Per page",
|
"page_size_label": "Per page",
|
||||||
"page_size_unit": "items",
|
"page_size_unit": "items",
|
||||||
|
"view_mode_paged": "Paged",
|
||||||
|
"view_mode_all": "Show all",
|
||||||
|
"too_many_files_warning": "Too many credentials. Showing all may cause performance issues, please use paged view.",
|
||||||
"filter_all": "All",
|
"filter_all": "All",
|
||||||
"filter_qwen": "Qwen",
|
"filter_qwen": "Qwen",
|
||||||
"filter_gemini": "Gemini",
|
"filter_gemini": "Gemini",
|
||||||
@@ -709,7 +713,8 @@
|
|||||||
"quota_management": {
|
"quota_management": {
|
||||||
"title": "Quota Management",
|
"title": "Quota Management",
|
||||||
"description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.",
|
"description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.",
|
||||||
"refresh_files": "Refresh auth files"
|
"refresh_files": "Refresh auth files",
|
||||||
|
"refresh_files_and_quota": "Refresh files & quota"
|
||||||
},
|
},
|
||||||
"system_info": {
|
"system_info": {
|
||||||
"title": "Management Center Info",
|
"title": "Management Center Info",
|
||||||
@@ -767,6 +772,7 @@
|
|||||||
"api_key_added": "API key added successfully",
|
"api_key_added": "API key added successfully",
|
||||||
"api_key_updated": "API key updated successfully",
|
"api_key_updated": "API key updated successfully",
|
||||||
"api_key_deleted": "API key deleted successfully",
|
"api_key_deleted": "API key deleted successfully",
|
||||||
|
"api_key_invalid_chars": "API key can only contain letters, numbers, and symbols",
|
||||||
"gemini_key_added": "Gemini key added successfully",
|
"gemini_key_added": "Gemini key added successfully",
|
||||||
"gemini_key_updated": "Gemini key updated successfully",
|
"gemini_key_updated": "Gemini key updated successfully",
|
||||||
"gemini_key_deleted": "Gemini key deleted successfully",
|
"gemini_key_deleted": "Gemini key deleted successfully",
|
||||||
|
|||||||
@@ -312,6 +312,7 @@
|
|||||||
"delete_all_confirm": "确定要删除所有认证文件吗?此操作不可恢复!",
|
"delete_all_confirm": "确定要删除所有认证文件吗?此操作不可恢复!",
|
||||||
"delete_filtered_confirm": "确定要删除筛选出的 {{type}} 认证文件吗?此操作不可恢复!",
|
"delete_filtered_confirm": "确定要删除筛选出的 {{type}} 认证文件吗?此操作不可恢复!",
|
||||||
"upload_error_json": "只能上传JSON文件",
|
"upload_error_json": "只能上传JSON文件",
|
||||||
|
"upload_error_size": "文件大小不能超过 {{maxSize}}",
|
||||||
"upload_success": "文件上传成功",
|
"upload_success": "文件上传成功",
|
||||||
"download_success": "文件下载成功",
|
"download_success": "文件下载成功",
|
||||||
"delete_success": "文件删除成功",
|
"delete_success": "文件删除成功",
|
||||||
@@ -327,6 +328,9 @@
|
|||||||
"search_placeholder": "输入名称、类型或提供方关键字",
|
"search_placeholder": "输入名称、类型或提供方关键字",
|
||||||
"page_size_label": "单页数量",
|
"page_size_label": "单页数量",
|
||||||
"page_size_unit": "个/页",
|
"page_size_unit": "个/页",
|
||||||
|
"view_mode_paged": "按页显示",
|
||||||
|
"view_mode_all": "显示全部",
|
||||||
|
"too_many_files_warning": "您的凭证总数过多,全部加载会导致页面卡顿,请保持单页浏览。",
|
||||||
"filter_all": "全部",
|
"filter_all": "全部",
|
||||||
"filter_qwen": "Qwen",
|
"filter_qwen": "Qwen",
|
||||||
"filter_gemini": "Gemini",
|
"filter_gemini": "Gemini",
|
||||||
@@ -709,7 +713,8 @@
|
|||||||
"quota_management": {
|
"quota_management": {
|
||||||
"title": "配额管理",
|
"title": "配额管理",
|
||||||
"description": "集中查看 OAuth 额度与剩余情况",
|
"description": "集中查看 OAuth 额度与剩余情况",
|
||||||
"refresh_files": "刷新认证文件"
|
"refresh_files": "刷新认证文件",
|
||||||
|
"refresh_files_and_quota": "刷新认证文件&额度"
|
||||||
},
|
},
|
||||||
"system_info": {
|
"system_info": {
|
||||||
"title": "管理中心信息",
|
"title": "管理中心信息",
|
||||||
@@ -767,6 +772,7 @@
|
|||||||
"api_key_added": "API密钥添加成功",
|
"api_key_added": "API密钥添加成功",
|
||||||
"api_key_updated": "API密钥更新成功",
|
"api_key_updated": "API密钥更新成功",
|
||||||
"api_key_deleted": "API密钥删除成功",
|
"api_key_deleted": "API密钥删除成功",
|
||||||
|
"api_key_invalid_chars": "API密钥仅支持英文字母、数字和符号",
|
||||||
"gemini_key_added": "Gemini密钥添加成功",
|
"gemini_key_added": "Gemini密钥添加成功",
|
||||||
"gemini_key_updated": "Gemini密钥更新成功",
|
"gemini_key_updated": "Gemini密钥更新成功",
|
||||||
"gemini_key_deleted": "Gemini密钥删除成功",
|
"gemini_key_deleted": "Gemini密钥删除成功",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
|||||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||||
import { apiKeysApi } from '@/services/api';
|
import { apiKeysApi } from '@/services/api';
|
||||||
import { maskApiKey } from '@/utils/format';
|
import { maskApiKey } from '@/utils/format';
|
||||||
|
import { isValidApiKeyCharset } from '@/utils/validation';
|
||||||
import styles from './ApiKeysPage.module.scss';
|
import styles from './ApiKeysPage.module.scss';
|
||||||
|
|
||||||
export function ApiKeysPage() {
|
export function ApiKeysPage() {
|
||||||
@@ -83,6 +84,10 @@ export function ApiKeysPage() {
|
|||||||
showNotification(`${t('notification.please_enter')} ${t('notification.api_key')}`, 'error');
|
showNotification(`${t('notification.please_enter')} ${t('notification.api_key')}`, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!isValidApiKeyCharset(trimmed)) {
|
||||||
|
showNotification(t('notification.api_key_invalid_chars'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isEdit = editingIndex !== null;
|
const isEdit = editingIndex !== null;
|
||||||
const nextKeys = isEdit
|
const nextKeys = isEdit
|
||||||
|
|||||||
@@ -32,6 +32,28 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.titleWrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 24px;
|
||||||
|
min-width: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--count-badge-text);
|
||||||
|
background-color: var(--count-badge-bg);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.errorBox {
|
.errorBox {
|
||||||
padding: $spacing-md;
|
padding: $spacing-md;
|
||||||
background-color: rgba(239, 68, 68, 0.1);
|
background-color: rgba(239, 68, 68, 0.1);
|
||||||
@@ -134,19 +156,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.statsInfo {
|
|
||||||
padding: 8px 12px;
|
|
||||||
background-color: var(--bg-secondary);
|
|
||||||
border-radius: $radius-md;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
height: 38px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 卡片网格
|
// 卡片网格
|
||||||
.fileGrid {
|
.fileGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ const OAUTH_PROVIDER_PRESETS = [
|
|||||||
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
|
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
|
||||||
const MIN_CARD_PAGE_SIZE = 3;
|
const MIN_CARD_PAGE_SIZE = 3;
|
||||||
const MAX_CARD_PAGE_SIZE = 30;
|
const MAX_CARD_PAGE_SIZE = 30;
|
||||||
|
const MAX_AUTH_FILE_SIZE = 50 * 1024;
|
||||||
|
|
||||||
const clampCardPageSize = (value: number) =>
|
const clampCardPageSize = (value: number) =>
|
||||||
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
|
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
|
||||||
@@ -356,34 +357,42 @@ export function AuthFilesPage() {
|
|||||||
const start = (currentPage - 1) * pageSize;
|
const start = (currentPage - 1) * pageSize;
|
||||||
const pageItems = filtered.slice(start, start + pageSize);
|
const pageItems = filtered.slice(start, start + pageSize);
|
||||||
|
|
||||||
// 统计信息
|
// 点击上传
|
||||||
const totalSize = useMemo(() => files.reduce((sum, item) => sum + (item.size || 0), 0), [files]);
|
const handleUploadClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
// 点击上传
|
};
|
||||||
const handleUploadClick = () => {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理文件上传(支持多选)
|
// 处理文件上传(支持多选)
|
||||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const fileList = event.target.files;
|
const fileList = event.target.files;
|
||||||
if (!fileList || fileList.length === 0) return;
|
if (!fileList || fileList.length === 0) return;
|
||||||
|
|
||||||
const filesToUpload = Array.from(fileList);
|
const filesToUpload = Array.from(fileList);
|
||||||
const validFiles: File[] = [];
|
const validFiles: File[] = [];
|
||||||
const invalidFiles: string[] = [];
|
const invalidFiles: string[] = [];
|
||||||
|
const oversizedFiles: string[] = [];
|
||||||
filesToUpload.forEach((file) => {
|
|
||||||
if (file.name.endsWith('.json')) {
|
filesToUpload.forEach((file) => {
|
||||||
validFiles.push(file);
|
if (!file.name.endsWith('.json')) {
|
||||||
} else {
|
invalidFiles.push(file.name);
|
||||||
invalidFiles.push(file.name);
|
return;
|
||||||
}
|
}
|
||||||
});
|
if (file.size > MAX_AUTH_FILE_SIZE) {
|
||||||
|
oversizedFiles.push(file.name);
|
||||||
if (invalidFiles.length > 0) {
|
return;
|
||||||
showNotification(t('auth_files.upload_error_json'), 'error');
|
}
|
||||||
}
|
validFiles.push(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (invalidFiles.length > 0) {
|
||||||
|
showNotification(t('auth_files.upload_error_json'), 'error');
|
||||||
|
}
|
||||||
|
if (oversizedFiles.length > 0) {
|
||||||
|
showNotification(
|
||||||
|
t('auth_files.upload_error_size', { maxSize: formatFileSize(MAX_AUTH_FILE_SIZE) }),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (validFiles.length === 0) {
|
if (validFiles.length === 0) {
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
@@ -822,15 +831,22 @@ export function AuthFilesPage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const titleNode = (
|
||||||
|
<div className={styles.titleWrapper}>
|
||||||
|
<span>{t('auth_files.title_section')}</span>
|
||||||
|
{files.length > 0 && <span className={styles.countBadge}>{files.length}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<h1 className={styles.pageTitle}>{t('auth_files.title')}</h1>
|
<h1 className={styles.pageTitle}>{t('auth_files.title')}</h1>
|
||||||
<p className={styles.description}>{t('auth_files.description')}</p>
|
<p className={styles.description}>{t('auth_files.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
title={t('auth_files.title_section')}
|
title={titleNode}
|
||||||
extra={
|
extra={
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
<Button
|
<Button
|
||||||
@@ -894,14 +910,8 @@ export function AuthFilesPage() {
|
|||||||
onChange={handlePageSizeChange}
|
onChange={handlePageSizeChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.filterItem}>
|
</div>
|
||||||
<label>{t('common.info')}</label>
|
</div>
|
||||||
<div className={styles.statsInfo}>
|
|
||||||
{files.length} {t('auth_files.files_count')} · {formatFileSize(totalSize)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 卡片网格 */}
|
{/* 卡片网格 */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
@@ -133,14 +133,18 @@
|
|||||||
|
|
||||||
.editorWrapper {
|
.editorWrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex: 1;
|
flex: 0 0 auto;
|
||||||
min-height: 800px;
|
height: clamp(360px, 60vh, 920px);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: $radius-lg;
|
border-radius: $radius-lg;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
--floating-controls-height: 0px;
|
--floating-controls-height: 0px;
|
||||||
|
|
||||||
|
@supports (height: 100dvh) {
|
||||||
|
height: clamp(360px, 60dvh, 920px);
|
||||||
|
}
|
||||||
|
|
||||||
// Floating search toolbar on top of the editor (but not covering content).
|
// Floating search toolbar on top of the editor (but not covering content).
|
||||||
.floatingControls {
|
.floatingControls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -219,8 +223,8 @@
|
|||||||
.configCard {
|
.configCard {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 1120px;
|
flex: 1;
|
||||||
flex-shrink: 0;
|
min-height: 0;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,11 +257,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.configCard {
|
.configCard {
|
||||||
height: 880px;
|
|
||||||
padding: $spacing-md;
|
padding: $spacing-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editorWrapper {
|
|
||||||
min-height: 600px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,37 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
:global(.btn-sm) {
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleWrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 24px;
|
||||||
|
min-width: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--count-badge-text);
|
||||||
|
background-color: var(--count-badge-bg);
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.errorBox {
|
.errorBox {
|
||||||
@@ -76,11 +107,7 @@
|
|||||||
.geminiCliGrid {
|
.geminiCliGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: $spacing-md;
|
gap: $spacing-md;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||||
|
|
||||||
@include tablet {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
@include mobile {
|
@include mobile {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -112,28 +139,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.viewModeToggle {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.antigravityCard {
|
.antigravityCard {
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(180deg,
|
||||||
180deg,
|
rgba(224, 247, 250, 0.12),
|
||||||
rgba(224, 247, 250, 0.12),
|
rgba(224, 247, 250, 0));
|
||||||
rgba(224, 247, 250, 0)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.codexCard {
|
.codexCard {
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(180deg,
|
||||||
180deg,
|
rgba(255, 243, 224, 0.18),
|
||||||
rgba(255, 243, 224, 0.18),
|
rgba(255, 243, 224, 0));
|
||||||
rgba(255, 243, 224, 0)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.geminiCliCard {
|
.geminiCliCard {
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(180deg,
|
||||||
180deg,
|
rgba(231, 239, 255, 0.2),
|
||||||
rgba(231, 239, 255, 0.2),
|
rgba(231, 239, 255, 0));
|
||||||
rgba(231, 239, 255, 0)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.quotaSection {
|
.quotaSection {
|
||||||
@@ -331,3 +358,32 @@
|
|||||||
background-color: var(--bg-secondary);
|
background-color: var(--bg-secondary);
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.warningOverlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warningModal {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
padding: $spacing-lg;
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: $shadow-lg;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 $spacing-md 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@
|
|||||||
--failure-badge-text: #991b1b;
|
--failure-badge-text: #991b1b;
|
||||||
--failure-badge-border: #fca5a5;
|
--failure-badge-border: #fca5a5;
|
||||||
|
|
||||||
|
--count-badge-bg: rgba(59, 130, 246, 0.14);
|
||||||
|
--count-badge-text: var(--primary-active);
|
||||||
|
|
||||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||||||
}
|
}
|
||||||
@@ -66,6 +69,9 @@
|
|||||||
--failure-badge-text: #fca5a5;
|
--failure-badge-text: #fca5a5;
|
||||||
--failure-badge-border: #dc2626;
|
--failure-badge-border: #dc2626;
|
||||||
|
|
||||||
|
--count-badge-bg: rgba(59, 130, 246, 0.25);
|
||||||
|
--count-badge-text: var(--primary-active);
|
||||||
|
|
||||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
|
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
|
||||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3);
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 隐藏 API Key 中间部分
|
* 隐藏 API Key 中间部分,仅保留前后两位
|
||||||
*/
|
*/
|
||||||
export function maskApiKey(key: string, visibleChars: number = 4): string {
|
export function maskApiKey(key: string): string {
|
||||||
if (!key || key.length <= visibleChars * 2) {
|
if (!key) {
|
||||||
return key;
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const visibleChars = 2;
|
||||||
const start = key.slice(0, visibleChars);
|
const start = key.slice(0, visibleChars);
|
||||||
const end = key.slice(-visibleChars);
|
const end = key.slice(-visibleChars);
|
||||||
const maskedLength = Math.min(key.length - visibleChars * 2, 20);
|
const maskedLength = Math.max(key.length - visibleChars * 2, 1);
|
||||||
const masked = '*'.repeat(maskedLength);
|
const masked = '*'.repeat(maskedLength);
|
||||||
|
|
||||||
return `${start}${masked}${end}`;
|
return `${start}${masked}${end}`;
|
||||||
|
|||||||
@@ -638,6 +638,8 @@ export interface ChartDataset {
|
|||||||
data: number[];
|
data: number[];
|
||||||
borderColor: string;
|
borderColor: string;
|
||||||
backgroundColor: string | CanvasGradient | ((context: ScriptableContext<'line'>) => string | CanvasGradient);
|
backgroundColor: string | CanvasGradient | ((context: ScriptableContext<'line'>) => string | CanvasGradient);
|
||||||
|
pointBackgroundColor?: string;
|
||||||
|
pointBorderColor?: string;
|
||||||
fill: boolean;
|
fill: boolean;
|
||||||
tension: number;
|
tension: number;
|
||||||
}
|
}
|
||||||
@@ -743,6 +745,8 @@ export function buildChartData(
|
|||||||
backgroundColor: shouldFill
|
backgroundColor: shouldFill
|
||||||
? (ctx) => buildAreaGradient(ctx, style.borderColor, style.backgroundColor)
|
? (ctx) => buildAreaGradient(ctx, style.borderColor, style.backgroundColor)
|
||||||
: style.backgroundColor,
|
: style.backgroundColor,
|
||||||
|
pointBackgroundColor: style.borderColor,
|
||||||
|
pointBorderColor: style.borderColor,
|
||||||
fill: shouldFill,
|
fill: shouldFill,
|
||||||
tension: 0.35
|
tension: 0.35
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ export function isValidApiKey(key: string): boolean {
|
|||||||
return !/\s/.test(key);
|
return !/\s/.test(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 API Key 字符集(仅允许 ASCII 可见字符)
|
||||||
|
*/
|
||||||
|
export function isValidApiKeyCharset(key: string): boolean {
|
||||||
|
if (!key) return false;
|
||||||
|
return /^[\x21-\x7E]+$/.test(key);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证 JSON 格式
|
* 验证 JSON 格式
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user