feat(auth-files): improve credential cards, bulk actions, and dedupe auth file list

This commit is contained in:
Supra4E8C
2026-03-22 14:41:57 +08:00
Unverified
parent 975d0471f7
commit e44a9fc099
10 changed files with 670 additions and 65 deletions
+39 -1
View File
@@ -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,
};
}
+1
View File
@@ -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;
+8
View File
@@ -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",
+8
View File
@@ -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": "Просмотр / редактирование файла авторизации",
+8
View File
@@ -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": "认证文件详情 / 编辑",
+297 -6
View File
@@ -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
View File
@@ -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)}
+118 -1
View File
@@ -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 }),