mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
feat(auth-files): improve credential cards, bulk actions, and dedupe auth file list
This commit is contained in:
@@ -66,6 +66,36 @@ export function IconBot({ size = 20, ...props }: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function IconModelCluster({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<rect x="3" y="5" width="6" height="6" rx="1.5" />
|
||||
<rect x="15" y="5" width="6" height="6" rx="1.5" />
|
||||
<rect x="9" y="13" width="6" height="6" rx="1.5" />
|
||||
<path d="M9 8h6" />
|
||||
<path d="M12 11v2" />
|
||||
<path d="M7.5 11v2" />
|
||||
<path d="M16.5 11v2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconFilterAll({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<rect x="3.5" y="3.5" width="5" height="5" rx="1.4" />
|
||||
<rect x="15.5" y="3.5" width="5" height="5" rx="1.4" />
|
||||
<rect x="3.5" y="15.5" width="5" height="5" rx="1.4" />
|
||||
<rect x="15.5" y="15.5" width="5" height="5" rx="1.4" />
|
||||
<path d="M8.5 8.5 10.75 10.75" />
|
||||
<path d="M15.5 8.5 13.25 10.75" />
|
||||
<path d="M8.5 15.5 10.75 13.25" />
|
||||
<path d="M15.5 15.5 13.25 13.25" />
|
||||
<circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconFileText({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
@@ -404,7 +434,15 @@ export function IconSidebarUsage({ size = 20, ...props }: IconProps) {
|
||||
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M3.5 20h17" />
|
||||
<rect x="5" y="13" width="3.5" height="7" rx="0.5" />
|
||||
<rect x="10.25" y="7" width="3.5" height="13" rx="0.5" fill="currentColor" fillOpacity="0.12" />
|
||||
<rect
|
||||
x="10.25"
|
||||
y="7"
|
||||
width="3.5"
|
||||
height="13"
|
||||
rx="0.5"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.12"
|
||||
/>
|
||||
<rect x="15.5" y="10" width="3.5" height="10" rx="0.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -4,11 +4,10 @@ import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import {
|
||||
IconBot,
|
||||
IconDownload,
|
||||
IconInfo,
|
||||
IconModelCluster,
|
||||
IconSettings,
|
||||
IconShield,
|
||||
IconTrash2,
|
||||
} from '@/components/ui/icons';
|
||||
import { ProviderStatusBar } from '@/components/providers/ProviderStatusBar';
|
||||
@@ -37,6 +36,7 @@ const HEALTHY_STATUS_MESSAGES = new Set(['ok', 'healthy', 'ready', 'success', 'a
|
||||
|
||||
export type AuthFileCardProps = {
|
||||
file: AuthFileItem;
|
||||
compact: boolean;
|
||||
selected: boolean;
|
||||
resolvedTheme: ResolvedTheme;
|
||||
disableControls: boolean;
|
||||
@@ -63,6 +63,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
file,
|
||||
compact,
|
||||
selected,
|
||||
resolvedTheme,
|
||||
disableControls,
|
||||
@@ -90,7 +91,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
const quotaType =
|
||||
quotaFilterType && resolveQuotaType(file) === quotaFilterType ? quotaFilterType : null;
|
||||
|
||||
const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly;
|
||||
const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly && !compact;
|
||||
|
||||
const providerCardClass =
|
||||
quotaType === 'antigravity'
|
||||
@@ -134,7 +135,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.fileCard} ${providerCardClass} ${selected ? styles.fileCardSelected : ''} ${file.disabled ? styles.fileCardDisabled : ''}`}
|
||||
className={`${styles.fileCard} ${compact ? styles.fileCardCompact : ''} ${providerCardClass} ${selected ? styles.fileCardSelected : ''} ${file.disabled ? styles.fileCardDisabled : ''}`}
|
||||
>
|
||||
<div className={styles.fileCardLayout}>
|
||||
<div className={styles.fileCardMain}>
|
||||
@@ -180,8 +181,10 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
</span>
|
||||
<span className={`${styles.stateBadge} ${stateBadgeClass}`}>{stateLabel}</span>
|
||||
</div>
|
||||
<span className={styles.fileName}>{file.name}</span>
|
||||
{noteValue && (
|
||||
<span className={styles.fileName} title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
{!compact && noteValue && (
|
||||
<div className={styles.noteText} title={noteValue}>
|
||||
<span className={styles.noteLabel}>{t('auth_files.note_display')}</span>
|
||||
<span className={styles.noteValue}>{noteValue}</span>
|
||||
@@ -190,7 +193,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.cardMeta}>
|
||||
<div className={`${styles.cardMeta} ${compact ? styles.cardMetaCompact : ''}`}>
|
||||
<div className={styles.metaItem}>
|
||||
<span className={styles.metaLabel}>{t('auth_files.file_size')}</span>
|
||||
<span className={styles.metaValue}>
|
||||
@@ -218,8 +221,8 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.cardInsights}>
|
||||
<div className={styles.cardStats}>
|
||||
<div className={`${styles.cardInsights} ${compact ? styles.cardInsightsCompact : ''}`}>
|
||||
<div className={`${styles.cardStats} ${compact ? styles.cardStatsCompact : ''}`}>
|
||||
<div className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||
<span className={styles.statLabel}>{t('stats.success')}</span>
|
||||
<span className={styles.statValue}>{fileStats.success}</span>
|
||||
@@ -230,9 +233,8 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.statusPanel}>
|
||||
<div className={`${styles.statusPanel} ${compact ? styles.statusPanelCompact : ''}`}>
|
||||
<div className={styles.statusPanelLabel}>
|
||||
<IconShield size={14} />
|
||||
<span>{t('auth_files.health_status_label')}</span>
|
||||
</div>
|
||||
<ProviderStatusBar statusData={statusData} styles={styles} />
|
||||
@@ -254,12 +256,14 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onShowModels(file)}
|
||||
className={styles.primaryActionButton}
|
||||
className={`${styles.primaryActionButton} ${styles.modelsActionButton}`}
|
||||
title={t('auth_files.models_button', { defaultValue: '模型' })}
|
||||
disabled={disableControls}
|
||||
>
|
||||
<>
|
||||
<IconBot className={styles.actionIcon} size={16} />
|
||||
<span className={styles.modelsActionIconWrap}>
|
||||
<IconModelCluster className={styles.actionIcon} size={16} />
|
||||
</span>
|
||||
<span className={styles.actionButtonLabel}>
|
||||
{t('auth_files.models_button', { defaultValue: '模型' })}
|
||||
</span>
|
||||
|
||||
@@ -40,7 +40,9 @@ export type UseAuthFilesDataResult = {
|
||||
handleStatusToggle: (item: AuthFileItem, enabled: boolean) => Promise<void>;
|
||||
toggleSelect: (name: string) => void;
|
||||
selectAllVisible: (visibleFiles: AuthFileItem[]) => void;
|
||||
invertVisibleSelection: (visibleFiles: AuthFileItem[]) => void;
|
||||
deselectAll: () => void;
|
||||
batchDownload: (names: string[]) => Promise<void>;
|
||||
batchSetStatus: (names: string[], enabled: boolean) => Promise<void>;
|
||||
batchDelete: (names: string[]) => void;
|
||||
};
|
||||
@@ -81,7 +83,31 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
const nextSelected = visibleFiles
|
||||
.filter((file) => !isRuntimeOnlyAuthFile(file))
|
||||
.map((file) => file.name);
|
||||
setSelectedFiles(new Set(nextSelected));
|
||||
if (nextSelected.length === 0) return;
|
||||
setSelectedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
nextSelected.forEach((name) => next.add(name));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const invertVisibleSelection = useCallback((visibleFiles: AuthFileItem[]) => {
|
||||
const visibleNames = visibleFiles
|
||||
.filter((file) => !isRuntimeOnlyAuthFile(file))
|
||||
.map((file) => file.name);
|
||||
if (visibleNames.length === 0) return;
|
||||
|
||||
setSelectedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
visibleNames.forEach((name) => {
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const deselectAll = useCallback(() => {
|
||||
@@ -220,7 +246,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[showConfirmation, showNotification, t]
|
||||
@@ -348,7 +374,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
} finally {
|
||||
setDeletingAll(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[deselectAll, files, showConfirmation, showNotification, t]
|
||||
@@ -470,6 +496,43 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
[deselectAll, showNotification, t]
|
||||
);
|
||||
|
||||
const batchDownload = useCallback(
|
||||
async (names: string[]) => {
|
||||
const uniqueNames = Array.from(new Set(names));
|
||||
if (uniqueNames.length === 0) return;
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const name of uniqueNames) {
|
||||
try {
|
||||
const response = await apiClient.getRaw(
|
||||
`/auth-files/download?name=${encodeURIComponent(name)}`,
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
const blob = new Blob([response.data]);
|
||||
downloadBlob({ filename: name, blob });
|
||||
successCount++;
|
||||
} catch {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (failCount === 0) {
|
||||
showNotification(
|
||||
t('auth_files.batch_download_success', { count: successCount }),
|
||||
'success'
|
||||
);
|
||||
} else {
|
||||
showNotification(
|
||||
t('auth_files.batch_download_partial', { success: successCount, failed: failCount }),
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
},
|
||||
[showNotification, t]
|
||||
);
|
||||
|
||||
const batchDelete = useCallback(
|
||||
(names: string[]) => {
|
||||
const uniqueNames = Array.from(new Set(names));
|
||||
@@ -516,18 +579,21 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
});
|
||||
|
||||
if (failCount === 0) {
|
||||
showNotification(`${t('auth_files.delete_all_success')} (${deleted.length})`, 'success');
|
||||
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')
|
||||
type: t('auth_files.filter_all'),
|
||||
}),
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[showConfirmation, showNotification, t]
|
||||
@@ -553,8 +619,10 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
handleStatusToggle,
|
||||
toggleSelect,
|
||||
selectAllVisible,
|
||||
invertVisibleSelection,
|
||||
deselectAll,
|
||||
batchDownload,
|
||||
batchSetStatus,
|
||||
batchDelete
|
||||
batchDelete,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export type AuthFilesSortMode = (typeof AUTH_FILES_SORT_MODES)[number];
|
||||
export type AuthFilesUiState = {
|
||||
filter?: string;
|
||||
problemOnly?: boolean;
|
||||
compactMode?: boolean;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
|
||||
@@ -495,6 +495,8 @@
|
||||
"search_placeholder": "Filter by name, type, or provider",
|
||||
"problem_filter_label": "Problem Filter",
|
||||
"problem_filter_only": "Only show problematic credentials",
|
||||
"display_options_label": "Display options",
|
||||
"compact_mode_label": "Compact mode",
|
||||
"sort_label": "Sort",
|
||||
"sort_default": "Default",
|
||||
"sort_az": "A-Z Name",
|
||||
@@ -549,7 +551,13 @@
|
||||
"batch_delete_confirm": "Are you sure you want to delete {{count}} files?",
|
||||
"batch_selected": "{{count}} selected",
|
||||
"batch_select_all": "Select All",
|
||||
"batch_select_page": "Select page",
|
||||
"batch_select_filtered": "Select filtered",
|
||||
"batch_invert_page": "Invert page",
|
||||
"batch_deselect": "Deselect",
|
||||
"batch_download": "Download selected",
|
||||
"batch_download_success": "Started downloading {{count}} files",
|
||||
"batch_download_partial": "Download finished: {{success}} succeeded, {{failed}} failed",
|
||||
"batch_enable": "Enable",
|
||||
"batch_disable": "Disable",
|
||||
"prefix_proxy_button": "Auth File Details / Edit",
|
||||
|
||||
@@ -495,6 +495,8 @@
|
||||
"search_placeholder": "Фильтр по имени, типу или провайдеру",
|
||||
"problem_filter_label": "Фильтр проблем",
|
||||
"problem_filter_only": "Показывать только проблемные учётные данные",
|
||||
"display_options_label": "Параметры отображения",
|
||||
"compact_mode_label": "Компактный режим",
|
||||
"sort_label": "Сортировка",
|
||||
"sort_default": "По умолчанию",
|
||||
"sort_az": "A-Z Имя",
|
||||
@@ -549,7 +551,13 @@
|
||||
"batch_delete_confirm": "Удалить {{count}} файлов?",
|
||||
"batch_selected": "{{count}} выбрано",
|
||||
"batch_select_all": "Выбрать все",
|
||||
"batch_select_page": "Выбрать страницу",
|
||||
"batch_select_filtered": "Выбрать по фильтру",
|
||||
"batch_invert_page": "Инвертировать страницу",
|
||||
"batch_deselect": "Отменить",
|
||||
"batch_download": "Скачать выбранные",
|
||||
"batch_download_success": "Запущена загрузка {{count}} файлов",
|
||||
"batch_download_partial": "Загрузка завершена: успешно {{success}}, ошибок {{failed}}",
|
||||
"batch_enable": "Включить",
|
||||
"batch_disable": "Отключить",
|
||||
"prefix_proxy_button": "Просмотр / редактирование файла авторизации",
|
||||
|
||||
@@ -495,6 +495,8 @@
|
||||
"search_placeholder": "输入名称、类型或提供方关键字",
|
||||
"problem_filter_label": "问题筛选",
|
||||
"problem_filter_only": "仅显示有问题凭证",
|
||||
"display_options_label": "显示选项",
|
||||
"compact_mode_label": "简略模式",
|
||||
"sort_label": "排序",
|
||||
"sort_default": "默认",
|
||||
"sort_az": "A-Z 名称",
|
||||
@@ -549,7 +551,13 @@
|
||||
"batch_delete_confirm": "确定要删除 {{count}} 个文件吗?",
|
||||
"batch_selected": "已选 {{count}} 项",
|
||||
"batch_select_all": "全选",
|
||||
"batch_select_page": "全选当前页",
|
||||
"batch_select_filtered": "全选筛选结果",
|
||||
"batch_invert_page": "反选当前页",
|
||||
"batch_deselect": "取消选择",
|
||||
"batch_download": "下载选中",
|
||||
"batch_download_success": "已开始下载 {{count}} 个文件",
|
||||
"batch_download_partial": "下载完成:成功 {{success}} 个,失败 {{failed}} 个",
|
||||
"batch_enable": "启用",
|
||||
"batch_disable": "禁用",
|
||||
"prefix_proxy_button": "认证文件详情 / 编辑",
|
||||
|
||||
@@ -160,6 +160,45 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filterAllIconWrap {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-color: color-mix(in srgb, var(--primary-color) 20%, var(--border-color));
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 30% 28%,
|
||||
color-mix(in srgb, var(--primary-color) 22%, #ffffff),
|
||||
transparent 58%
|
||||
),
|
||||
linear-gradient(
|
||||
145deg,
|
||||
color-mix(in srgb, var(--bg-secondary) 94%, var(--primary-color) 8%),
|
||||
color-mix(in srgb, var(--bg-primary) 92%, var(--primary-color) 5%)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, #fff 54%, transparent),
|
||||
0 10px 22px -18px color-mix(in srgb, var(--primary-color) 50%, transparent);
|
||||
}
|
||||
|
||||
.filterAllIconWrap::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: auto 7px 7px auto;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary-color) 18%, transparent);
|
||||
filter: blur(1px);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.filterAllIcon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
color: color-mix(in srgb, var(--primary-color) 70%, var(--text-primary));
|
||||
}
|
||||
|
||||
.filterRailHeroText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -189,9 +228,8 @@
|
||||
}
|
||||
|
||||
.filterRailCount {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 38px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
@@ -201,6 +239,8 @@
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.filterRailMode {
|
||||
@@ -333,9 +373,8 @@
|
||||
}
|
||||
|
||||
.filterTagCount {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 34px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
@@ -346,6 +385,8 @@
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex: 0 0 auto;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.filterTagActive .filterTagCount {
|
||||
@@ -411,6 +452,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
.filterToggleGroup {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.filterToggleCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
padding: 0 2px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 90%, transparent);
|
||||
}
|
||||
|
||||
.filterToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -473,6 +531,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
.fileGridCompact {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
|
||||
@include tablet {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.antigravityGrid {
|
||||
display: grid;
|
||||
gap: $spacing-md;
|
||||
@@ -840,6 +910,108 @@
|
||||
}
|
||||
}
|
||||
|
||||
.fileCardCompact {
|
||||
padding: 14px;
|
||||
border-radius: 14px;
|
||||
|
||||
.fileCardMain {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cardSelection {
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.providerAvatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.providerAvatarImage {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.providerAvatarFallback {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.cardHeaderContent {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cardBadgeRow {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.typeBadge,
|
||||
.stateBadge {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.fileName {
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
.healthStatusMessage {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.quotaSection {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
gap: 8px;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.cardActionsMain {
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.primaryActionButton:global(.btn.btn-sm) {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.cardUtilityActions {
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.iconButton:global(.btn.btn-sm) {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
min-width: 34px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.statusToggle {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.statusToggleLabel {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.fileCardLayout {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
@@ -976,6 +1148,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
.cardMetaCompact {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cardMetaCompact .metaItem {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.cardMetaCompact .metaLabel {
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cardMetaCompact .metaValue {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metaItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1066,6 +1264,12 @@
|
||||
background: color-mix(in srgb, var(--bg-secondary) 88%, transparent);
|
||||
}
|
||||
|
||||
.cardInsightsCompact {
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.cardStats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -1076,6 +1280,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
.cardStatsCompact {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cardStatsCompact .statPill {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.cardStatsCompact .statLabel {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.cardStatsCompact .statValue {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.statPill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1117,6 +1341,23 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.statusPanelCompact {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.statusPanelCompact .statusPanelLabel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.statusPanelCompact .statusBar {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.statusPanelCompact .statusRate {
|
||||
padding: 5px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.statusPanelLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1394,6 +1635,42 @@
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.modelsActionButton:global(.btn.btn-sm) {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--primary-color) 15%, var(--bg-secondary)),
|
||||
color-mix(in srgb, var(--primary-color) 7%, var(--bg-primary))
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--primary-color) 24%, var(--border-color));
|
||||
}
|
||||
|
||||
.modelsActionButton:global(.btn.btn-sm):hover {
|
||||
border-color: color-mix(in srgb, var(--primary-color) 38%, var(--border-color));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--primary-color) 18%, var(--bg-secondary)),
|
||||
color-mix(in srgb, var(--primary-color) 9%, var(--bg-primary))
|
||||
);
|
||||
}
|
||||
|
||||
.modelsActionButton:global(.btn.btn-sm) > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modelsActionIconWrap {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--primary-color) 16%, transparent);
|
||||
}
|
||||
|
||||
.actionButtonLabel {
|
||||
@include text-ellipsis;
|
||||
}
|
||||
@@ -1461,6 +1738,20 @@
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.fileCardCompact {
|
||||
.cardActionsMain {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.primaryActionButton:global(.btn.btn-sm) {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.cardUtilityActions {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 分页
|
||||
|
||||
+99
-37
@@ -20,6 +20,7 @@ import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { IconFilterAll } from '@/components/ui/icons';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
@@ -74,6 +75,7 @@ export function AuthFilesPage() {
|
||||
|
||||
const [filter, setFilter] = useState<'all' | string>('all');
|
||||
const [problemOnly, setProblemOnly] = useState(false);
|
||||
const [compactMode, setCompactMode] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(9);
|
||||
@@ -107,7 +109,9 @@ export function AuthFilesPage() {
|
||||
handleStatusToggle,
|
||||
toggleSelect,
|
||||
selectAllVisible,
|
||||
invertVisibleSelection,
|
||||
deselectAll,
|
||||
batchDownload,
|
||||
batchSetStatus,
|
||||
batchDelete,
|
||||
} = useAuthFilesData({ refreshKeyStats });
|
||||
@@ -174,6 +178,9 @@ export function AuthFilesPage() {
|
||||
if (typeof persisted.problemOnly === 'boolean') {
|
||||
setProblemOnly(persisted.problemOnly);
|
||||
}
|
||||
if (typeof persisted.compactMode === 'boolean') {
|
||||
setCompactMode(persisted.compactMode);
|
||||
}
|
||||
if (typeof persisted.search === 'string') {
|
||||
setSearch(persisted.search);
|
||||
}
|
||||
@@ -189,8 +196,8 @@ export function AuthFilesPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
writeAuthFilesUiState({ filter, problemOnly, search, page, pageSize, sortMode });
|
||||
}, [filter, problemOnly, search, page, pageSize, sortMode]);
|
||||
writeAuthFilesUiState({ filter, problemOnly, compactMode, search, page, pageSize, sortMode });
|
||||
}, [filter, problemOnly, compactMode, search, page, pageSize, sortMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageSizeInput(String(pageSize));
|
||||
@@ -344,6 +351,10 @@ export function AuthFilesPage() {
|
||||
() => pageItems.filter((file) => !isRuntimeOnlyAuthFile(file)),
|
||||
[pageItems]
|
||||
);
|
||||
const selectableFilteredItems = useMemo(
|
||||
() => sorted.filter((file) => !isRuntimeOnlyAuthFile(file)),
|
||||
[sorted]
|
||||
);
|
||||
const selectedNames = useMemo(() => Array.from(selectedFiles), [selectedFiles]);
|
||||
|
||||
const copyTextWithNotification = useCallback(
|
||||
@@ -487,15 +498,21 @@ export function AuthFilesPage() {
|
||||
</span>
|
||||
<div className={styles.filterRailHero}>
|
||||
<div className={styles.filterRailHeroTitle}>
|
||||
<div className={styles.filterRailHeroIcon}>
|
||||
{activeFilterIcon ? (
|
||||
<img src={activeFilterIcon} alt="" className={styles.filterRailHeroIconImage} />
|
||||
) : (
|
||||
<span className={styles.filterRailHeroIconFallback}>
|
||||
{activeFilterLabel.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{filter === 'all' ? (
|
||||
<span className={`${styles.filterRailHeroIcon} ${styles.filterAllIconWrap}`}>
|
||||
<IconFilterAll className={styles.filterAllIcon} size={22} />
|
||||
</span>
|
||||
) : (
|
||||
<div className={styles.filterRailHeroIcon}>
|
||||
{activeFilterIcon ? (
|
||||
<img src={activeFilterIcon} alt="" className={styles.filterRailHeroIconImage} />
|
||||
) : (
|
||||
<span className={styles.filterRailHeroIconFallback}>
|
||||
{activeFilterLabel.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.filterRailHeroText}>
|
||||
<span className={styles.filterRailTitle}>{activeFilterLabel}</span>
|
||||
<span className={styles.filterRailDescription}>{t('auth_files.title_section')}</span>
|
||||
@@ -535,15 +552,21 @@ export function AuthFilesPage() {
|
||||
}}
|
||||
>
|
||||
<span className={styles.filterTagLabel}>
|
||||
<span className={styles.filterTagIconWrap}>
|
||||
{iconSrc ? (
|
||||
<img src={iconSrc} alt="" className={styles.filterTagIcon} />
|
||||
) : (
|
||||
<span className={styles.filterTagIconFallback}>
|
||||
{getTypeLabel(t, type).slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{type === 'all' ? (
|
||||
<span className={`${styles.filterTagIconWrap} ${styles.filterAllIconWrap}`}>
|
||||
<IconFilterAll className={styles.filterAllIcon} size={18} />
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.filterTagIconWrap}>
|
||||
{iconSrc ? (
|
||||
<img src={iconSrc} alt="" className={styles.filterTagIcon} />
|
||||
) : (
|
||||
<span className={styles.filterTagIconFallback}>
|
||||
{getTypeLabel(t, type).slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.filterTagText}>{getTypeLabel(t, type)}</span>
|
||||
</span>
|
||||
<span className={styles.filterTagCount}>{typeCounts[type] ?? 0}</span>
|
||||
@@ -667,21 +690,35 @@ export function AuthFilesPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.filterItem} ${styles.filterToggleItem}`}>
|
||||
<label>{t('auth_files.problem_filter_label')}</label>
|
||||
<div className={styles.filterToggle}>
|
||||
<ToggleSwitch
|
||||
checked={problemOnly}
|
||||
onChange={(value) => {
|
||||
setProblemOnly(value);
|
||||
setPage(1);
|
||||
}}
|
||||
ariaLabel={t('auth_files.problem_filter_only')}
|
||||
label={
|
||||
<span className={styles.filterToggleLabel}>
|
||||
{t('auth_files.problem_filter_only')}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<label>{t('auth_files.display_options_label')}</label>
|
||||
<div className={styles.filterToggleGroup}>
|
||||
<div className={styles.filterToggleCard}>
|
||||
<ToggleSwitch
|
||||
checked={problemOnly}
|
||||
onChange={(value) => {
|
||||
setProblemOnly(value);
|
||||
setPage(1);
|
||||
}}
|
||||
ariaLabel={t('auth_files.problem_filter_only')}
|
||||
label={
|
||||
<span className={styles.filterToggleLabel}>
|
||||
{t('auth_files.problem_filter_only')}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterToggleCard}>
|
||||
<ToggleSwitch
|
||||
checked={compactMode}
|
||||
onChange={(value) => setCompactMode(value)}
|
||||
ariaLabel={t('auth_files.compact_mode_label')}
|
||||
label={
|
||||
<span className={styles.filterToggleLabel}>
|
||||
{t('auth_files.compact_mode_label')}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -696,12 +733,13 @@ export function AuthFilesPage() {
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`${styles.fileGrid} ${quotaFilterType ? styles.fileGridQuotaManaged : ''}`}
|
||||
className={`${styles.fileGrid} ${quotaFilterType ? styles.fileGridQuotaManaged : ''} ${compactMode ? styles.fileGridCompact : ''}`}
|
||||
>
|
||||
{pageItems.map((file) => (
|
||||
<AuthFileCard
|
||||
key={file.name}
|
||||
file={file}
|
||||
compact={compactMode}
|
||||
selected={selectedFiles.has(file.name)}
|
||||
resolvedTheme={resolvedTheme}
|
||||
disableControls={disableControls}
|
||||
@@ -815,13 +853,37 @@ export function AuthFilesPage() {
|
||||
onClick={() => selectAllVisible(pageItems)}
|
||||
disabled={selectablePageItems.length === 0}
|
||||
>
|
||||
{t('auth_files.batch_select_all')}
|
||||
{t('auth_files.batch_select_page')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => selectAllVisible(sorted)}
|
||||
disabled={selectableFilteredItems.length === 0}
|
||||
>
|
||||
{t('auth_files.batch_select_filtered')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => invertVisibleSelection(pageItems)}
|
||||
disabled={selectablePageItems.length === 0}
|
||||
>
|
||||
{t('auth_files.batch_invert_page')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={deselectAll}>
|
||||
{t('auth_files.batch_deselect')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.batchActionRight}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void batchDownload(selectedNames)}
|
||||
disabled={disableControls || selectedNames.length === 0}
|
||||
>
|
||||
{t('auth_files.batch_download')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => batchSetStatus(selectedNames, true)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { OAuthModelAliasEntry } from '@/types';
|
||||
|
||||
type StatusError = { status?: number };
|
||||
type AuthFileStatusResponse = { status: string; disabled: boolean };
|
||||
type AuthFileEntry = AuthFilesResponse['files'][number];
|
||||
|
||||
export const AUTH_FILE_INVALID_JSON_OBJECT_ERROR = 'AUTH_FILE_INVALID_JSON_OBJECT';
|
||||
|
||||
@@ -17,6 +18,122 @@ const getStatusCode = (err: unknown): number | undefined => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const readTextField = (entry: AuthFileEntry, key: string): string => {
|
||||
const value = entry[key];
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
};
|
||||
|
||||
const readDateField = (entry: AuthFileEntry): number => {
|
||||
const candidates = [entry['modtime'], entry.modified, entry['updated_at'], entry['last_refresh']];
|
||||
|
||||
for (const value of candidates) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value < 1e12 ? value * 1000 : value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) continue;
|
||||
const asNumber = Number(trimmed);
|
||||
if (Number.isFinite(asNumber)) {
|
||||
return asNumber < 1e12 ? asNumber * 1000 : asNumber;
|
||||
}
|
||||
const parsed = Date.parse(trimmed);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const isRuntimeOnlyEntry = (entry: AuthFileEntry): boolean => {
|
||||
const value = entry['runtime_only'] ?? entry.runtimeOnly;
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'string') return value.trim().toLowerCase() === 'true';
|
||||
return false;
|
||||
};
|
||||
|
||||
const hasMeaningfulValue = (value: unknown): boolean => {
|
||||
if (value == null) return false;
|
||||
if (typeof value === 'string') return value.trim().length > 0;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
return true;
|
||||
};
|
||||
|
||||
const countMeaningfulFields = (entry: AuthFileEntry): number =>
|
||||
Object.values(entry).reduce<number>(
|
||||
(count, value) => count + (hasMeaningfulValue(value) ? 1 : 0),
|
||||
0
|
||||
);
|
||||
|
||||
const authFilePriorityScore = (entry: AuthFileEntry): number => {
|
||||
let score = 0;
|
||||
if (readTextField(entry, 'source').toLowerCase() === 'file') score += 32;
|
||||
if (readTextField(entry, 'path')) score += 16;
|
||||
if (!isRuntimeOnlyEntry(entry)) score += 8;
|
||||
if (entry.disabled !== true) score += 4;
|
||||
if (readDateField(entry) > 0) score += 2;
|
||||
return score;
|
||||
};
|
||||
|
||||
const compareAuthFileEntries = (left: AuthFileEntry, right: AuthFileEntry): number => {
|
||||
const scoreDiff = authFilePriorityScore(right) - authFilePriorityScore(left);
|
||||
if (scoreDiff !== 0) return scoreDiff;
|
||||
|
||||
const dateDiff = readDateField(right) - readDateField(left);
|
||||
if (dateDiff !== 0) return dateDiff;
|
||||
|
||||
const fieldDiff = countMeaningfulFields(right) - countMeaningfulFields(left);
|
||||
if (fieldDiff !== 0) return fieldDiff;
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const mergeAuthFileEntries = (entries: AuthFileEntry[]): AuthFileEntry => {
|
||||
const [primary, ...rest] = [...entries].sort(compareAuthFileEntries);
|
||||
const merged: AuthFileEntry = { ...primary };
|
||||
|
||||
rest.forEach((entry) => {
|
||||
Object.entries(entry).forEach(([key, value]) => {
|
||||
if (!hasMeaningfulValue(merged[key]) && hasMeaningfulValue(value)) {
|
||||
merged[key] = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
const dedupeAuthFilesResponse = (payload: AuthFilesResponse): AuthFilesResponse => {
|
||||
const files = Array.isArray(payload?.files) ? payload.files : [];
|
||||
const grouped = new Map<string, AuthFileEntry[]>();
|
||||
|
||||
files.forEach((entry) => {
|
||||
const name = readTextField(entry, 'name');
|
||||
const key = name || JSON.stringify(entry);
|
||||
const bucket = grouped.get(key);
|
||||
if (bucket) {
|
||||
bucket.push(entry);
|
||||
return;
|
||||
}
|
||||
grouped.set(key, [entry]);
|
||||
});
|
||||
|
||||
const normalizedFiles = Array.from(grouped.values()).map(mergeAuthFileEntries);
|
||||
normalizedFiles.sort((left, right) =>
|
||||
readTextField(left, 'name').localeCompare(readTextField(right, 'name'), undefined, {
|
||||
sensitivity: 'accent',
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
...payload,
|
||||
files: normalizedFiles,
|
||||
total: normalizedFiles.length,
|
||||
};
|
||||
};
|
||||
|
||||
const parseAuthFileJsonObject = (rawText: string): Record<string, unknown> => {
|
||||
const trimmed = rawText.trim();
|
||||
|
||||
@@ -130,7 +247,7 @@ const normalizeOauthModelAlias = (payload: unknown): Record<string, OAuthModelAl
|
||||
const OAUTH_MODEL_ALIAS_ENDPOINT = '/oauth-model-alias';
|
||||
|
||||
export const authFilesApi = {
|
||||
list: () => apiClient.get<AuthFilesResponse>('/auth-files'),
|
||||
list: async () => dedupeAuthFilesResponse(await apiClient.get<AuthFilesResponse>('/auth-files')),
|
||||
|
||||
setStatus: (name: string, disabled: boolean) =>
|
||||
apiClient.patch<AuthFileStatusResponse>('/auth-files/status', { name, disabled }),
|
||||
|
||||
Reference in New Issue
Block a user