mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
6 Commits
@@ -72,6 +72,61 @@
|
||||
}
|
||||
}
|
||||
|
||||
.expandableInputWrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.expandableInputWrapper > .expandableTextarea,
|
||||
.expandableInputWrapper > :global(.input) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
.expandableTextarea {
|
||||
resize: none;
|
||||
min-height: 60px;
|
||||
overflow: hidden;
|
||||
line-height: 1.5;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.expandableToggle {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
padding: 2px;
|
||||
color: var(--text-secondary, #999);
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.expandableInputExpanded .expandableToggle {
|
||||
top: 8px;
|
||||
transform: none;
|
||||
right: 14px;
|
||||
}
|
||||
|
||||
.overview {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useId, useMemo, useState } from 'react';
|
||||
import { memo, useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
@@ -23,6 +23,109 @@ import {
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { isValidApiKeyCharset } from '@/utils/validation';
|
||||
|
||||
/** Minimum character count before the expand/collapse toggle appears. */
|
||||
const EXPAND_THRESHOLD = 30;
|
||||
|
||||
/** Auto-expanding textarea that collapses back to a single-line input on demand. */
|
||||
function ExpandableInput({
|
||||
value,
|
||||
placeholder,
|
||||
ariaLabel,
|
||||
disabled,
|
||||
className,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
ariaLabel?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onChange: (nextValue: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const autoResize = useCallback((el: HTMLTextAreaElement) => {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, []);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
// Strip newlines — these fields are single-line identifiers/paths that
|
||||
// would break YAML serialization if they contained line breaks.
|
||||
const sanitized = e.target.value.replace(/[\r\n]/g, '');
|
||||
onChange(sanitized);
|
||||
// autoResize is handled by useLayoutEffect after React syncs the
|
||||
// sanitized value back to the DOM — calling it here would measure
|
||||
// stale content.
|
||||
};
|
||||
|
||||
// Resize synchronously before paint to avoid visual flicker.
|
||||
useLayoutEffect(() => {
|
||||
if (!collapsed && textareaRef.current) {
|
||||
autoResize(textareaRef.current);
|
||||
}
|
||||
}, [collapsed, value, autoResize]);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className={styles.expandableInputWrapper}>
|
||||
<input
|
||||
className={`input ${className ?? ''}`}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value.replace(/[\r\n]/g, ''))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{value.length > EXPAND_THRESHOLD && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.expandableToggle}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setCollapsed(false);
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.focus();
|
||||
});
|
||||
}}
|
||||
title={t('common.expand')}
|
||||
aria-label={t('common.expand')}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.expandableInputWrapper} ${styles.expandableInputExpanded}`}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={`input ${styles.expandableTextarea} ${className ?? ''}`}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
rows={2}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.expandableToggle}
|
||||
disabled={disabled}
|
||||
onClick={() => setCollapsed(true)}
|
||||
title={t('common.collapse')}
|
||||
aria-label={t('common.collapse')}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getValidationMessage(
|
||||
t: ReturnType<typeof useTranslation>['t'],
|
||||
errorCode?: PayloadParamValidationErrorCode
|
||||
@@ -325,14 +428,12 @@ const StringListEditor = memo(function StringListEditor({
|
||||
<div className={styles.stringList}>
|
||||
{items.map((item, index) => (
|
||||
<div key={renderItemIds[index] ?? `item-${index}`} className={styles.stringListRow}>
|
||||
<input
|
||||
className="input"
|
||||
<ExpandableInput
|
||||
placeholder={placeholder}
|
||||
aria-label={inputAriaLabel ?? placeholder}
|
||||
ariaLabel={inputAriaLabel ?? placeholder}
|
||||
value={item}
|
||||
onChange={(e) => updateItem(index, e.target.value)}
|
||||
onChange={(nextValue) => updateItem(index, nextValue)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeItem(index)} disabled={disabled}>
|
||||
{t('config_management.visual.common.delete')}
|
||||
@@ -508,12 +609,11 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
className="input"
|
||||
<ExpandableInput
|
||||
placeholder={getValuePlaceholder(param.valueType)}
|
||||
aria-label={t('config_management.visual.payload_rules.param_value')}
|
||||
ariaLabel={t('config_management.visual.payload_rules.param_value')}
|
||||
value={param.value}
|
||||
onChange={(e) => updateParam(ruleIndex, paramIndex, { value: e.target.value })}
|
||||
onChange={(nextValue) => updateParam(ruleIndex, paramIndex, { value: nextValue })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
@@ -564,23 +664,21 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
<ExpandableInput
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
aria-label={t('config_management.visual.payload_rules.model_name')}
|
||||
ariaLabel={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||
onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
<ExpandableInput
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
aria-label={t('config_management.visual.payload_rules.model_name')}
|
||||
ariaLabel={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||
onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
@@ -629,12 +727,11 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
|
||||
return (
|
||||
<div key={param.id} className={styles.payloadRuleParamGroup}>
|
||||
<div className={styles.payloadRuleParamRow}>
|
||||
<input
|
||||
className="input"
|
||||
<ExpandableInput
|
||||
placeholder={t('config_management.visual.payload_rules.json_path')}
|
||||
aria-label={t('config_management.visual.payload_rules.json_path')}
|
||||
ariaLabel={t('config_management.visual.payload_rules.json_path')}
|
||||
value={param.path}
|
||||
onChange={(e) => updateParam(ruleIndex, paramIndex, { path: e.target.value })}
|
||||
onChange={(nextValue) => updateParam(ruleIndex, paramIndex, { path: nextValue })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{rawJsonValues ? null : (
|
||||
@@ -767,12 +864,11 @@ export const PayloadFilterRulesEditor = memo(function PayloadFilterRulesEditor({
|
||||
</div>
|
||||
{rule.models.map((model, modelIndex) => (
|
||||
<div key={model.id} className={styles.payloadFilterModelRow}>
|
||||
<input
|
||||
className="input"
|
||||
<ExpandableInput
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
aria-label={t('config_management.visual.payload_rules.model_name')}
|
||||
ariaLabel={t('config_management.visual.payload_rules.model_name')}
|
||||
value={model.name}
|
||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||
onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
collectUsageDetails,
|
||||
@@ -10,11 +11,28 @@ import type { UsagePayload } from './hooks/useUsageData';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
const COLOR_STOPS = [
|
||||
{ r: 239, g: 68, b: 68 }, // #ef4444
|
||||
{ r: 250, g: 204, b: 21 }, // #facc15
|
||||
{ r: 34, g: 197, b: 94 }, // #22c55e
|
||||
{ r: 239, g: 68, b: 68 }, // #ef4444
|
||||
{ r: 250, g: 204, b: 21 }, // #facc15
|
||||
{ r: 34, g: 197, b: 94 }, // #22c55e
|
||||
] as const;
|
||||
|
||||
const TOOLTIP_OFFSET = 8;
|
||||
const TOOLTIP_SAFE_WIDTH = 180;
|
||||
const TOOLTIP_SAFE_HEIGHT = 72;
|
||||
|
||||
type TooltipHorizontalPosition = 'center' | 'left' | 'right';
|
||||
type TooltipVerticalPosition = 'above' | 'below';
|
||||
|
||||
interface ActiveTooltipState {
|
||||
idx: number;
|
||||
anchorEl: HTMLDivElement;
|
||||
horizontal: TooltipHorizontalPosition;
|
||||
vertical: TooltipVerticalPosition;
|
||||
left: number;
|
||||
top: number;
|
||||
transform: string;
|
||||
}
|
||||
|
||||
function rateToColor(rate: number): string {
|
||||
const t = Math.max(0, Math.min(1, rate));
|
||||
const segment = t < 0.5 ? 0 : 1;
|
||||
@@ -43,7 +61,7 @@ export interface ServiceHealthCardProps {
|
||||
|
||||
export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [activeTooltip, setActiveTooltip] = useState<number | null>(null);
|
||||
const [activeTooltip, setActiveTooltip] = useState<ActiveTooltipState | null>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const healthData: ServiceHealthData = useMemo(() => {
|
||||
@@ -64,11 +82,74 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
|
||||
return () => document.removeEventListener('pointerdown', handler);
|
||||
}, [activeTooltip]);
|
||||
|
||||
const handlePointerEnter = useCallback((e: React.PointerEvent, idx: number) => {
|
||||
if (e.pointerType === 'mouse') {
|
||||
setActiveTooltip(idx);
|
||||
}
|
||||
}, []);
|
||||
const buildTooltipState = useCallback(
|
||||
(idx: number, anchorEl: HTMLDivElement): ActiveTooltipState => {
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
|
||||
let horizontal: TooltipHorizontalPosition = 'center';
|
||||
let left = centerX;
|
||||
|
||||
if (centerX <= TOOLTIP_SAFE_WIDTH / 2) {
|
||||
horizontal = 'left';
|
||||
left = rect.left;
|
||||
} else if (centerX >= window.innerWidth - TOOLTIP_SAFE_WIDTH / 2) {
|
||||
horizontal = 'right';
|
||||
left = rect.right;
|
||||
}
|
||||
|
||||
const vertical: TooltipVerticalPosition = rect.top <= TOOLTIP_SAFE_HEIGHT ? 'below' : 'above';
|
||||
const top = vertical === 'below' ? rect.bottom + TOOLTIP_OFFSET : rect.top - TOOLTIP_OFFSET;
|
||||
const translateX = horizontal === 'center' ? '-50%' : horizontal === 'right' ? '-100%' : '0';
|
||||
const translateY = vertical === 'below' ? '0' : '-100%';
|
||||
|
||||
return {
|
||||
idx,
|
||||
anchorEl,
|
||||
horizontal,
|
||||
vertical,
|
||||
left: Math.round(left),
|
||||
top: Math.round(top),
|
||||
transform: `translate(${translateX}, ${translateY})`,
|
||||
};
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTooltip) return;
|
||||
|
||||
const updateTooltipPosition = () => {
|
||||
if (!document.body.contains(activeTooltip.anchorEl)) {
|
||||
setActiveTooltip(null);
|
||||
return;
|
||||
}
|
||||
setActiveTooltip(buildTooltipState(activeTooltip.idx, activeTooltip.anchorEl));
|
||||
};
|
||||
|
||||
window.addEventListener('resize', updateTooltipPosition);
|
||||
window.addEventListener('scroll', updateTooltipPosition, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateTooltipPosition);
|
||||
window.removeEventListener('scroll', updateTooltipPosition, true);
|
||||
};
|
||||
}, [activeTooltip, buildTooltipState]);
|
||||
|
||||
const openTooltip = useCallback(
|
||||
(idx: number, anchorEl: HTMLDivElement) => {
|
||||
setActiveTooltip(buildTooltipState(idx, anchorEl));
|
||||
},
|
||||
[buildTooltipState]
|
||||
);
|
||||
|
||||
const handlePointerEnter = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>, idx: number) => {
|
||||
if (e.pointerType === 'mouse') {
|
||||
openTooltip(idx, e.currentTarget);
|
||||
}
|
||||
},
|
||||
[openTooltip]
|
||||
);
|
||||
|
||||
const handlePointerLeave = useCallback((e: React.PointerEvent) => {
|
||||
if (e.pointerType === 'mouse') {
|
||||
@@ -76,39 +157,49 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent, idx: number) => {
|
||||
if (e.pointerType === 'touch') {
|
||||
e.preventDefault();
|
||||
setActiveTooltip((prev) => (prev === idx ? null : idx));
|
||||
}
|
||||
}, []);
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>, idx: number) => {
|
||||
if (e.pointerType === 'touch') {
|
||||
e.preventDefault();
|
||||
setActiveTooltip((prev) =>
|
||||
prev?.idx === idx ? null : buildTooltipState(idx, e.currentTarget)
|
||||
);
|
||||
}
|
||||
},
|
||||
[buildTooltipState]
|
||||
);
|
||||
|
||||
const getTooltipPositionClass = (idx: number): string => {
|
||||
const col = Math.floor(idx / healthData.rows);
|
||||
if (col <= 2) return styles.healthTooltipLeft;
|
||||
if (col >= healthData.cols - 3) return styles.healthTooltipRight;
|
||||
return '';
|
||||
};
|
||||
|
||||
const getTooltipVerticalClass = (idx: number): string => {
|
||||
const row = idx % healthData.rows;
|
||||
if (row <= 1) return styles.healthTooltipBelow;
|
||||
return '';
|
||||
};
|
||||
|
||||
const renderTooltip = (detail: StatusBlockDetail, idx: number) => {
|
||||
const renderTooltip = (detail: StatusBlockDetail, tooltipState: ActiveTooltipState) => {
|
||||
const total = detail.success + detail.failure;
|
||||
const posClass = getTooltipPositionClass(idx);
|
||||
const vertClass = getTooltipVerticalClass(idx);
|
||||
const posClass =
|
||||
tooltipState.horizontal === 'left'
|
||||
? styles.healthTooltipLeft
|
||||
: tooltipState.horizontal === 'right'
|
||||
? styles.healthTooltipRight
|
||||
: '';
|
||||
const vertClass = tooltipState.vertical === 'below' ? styles.healthTooltipBelow : '';
|
||||
const timeRange = `${formatDateTime(detail.startTime)} – ${formatDateTime(detail.endTime)}`;
|
||||
|
||||
return (
|
||||
<div className={`${styles.healthTooltip} ${posClass} ${vertClass}`}>
|
||||
const tooltip = (
|
||||
<div
|
||||
className={`${styles.healthTooltip} ${posClass} ${vertClass}`}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${tooltipState.left}px`,
|
||||
top: `${tooltipState.top}px`,
|
||||
bottom: 'auto',
|
||||
right: 'auto',
|
||||
transform: tooltipState.transform,
|
||||
}}
|
||||
>
|
||||
<span className={styles.healthTooltipTime}>{timeRange}</span>
|
||||
{total > 0 ? (
|
||||
<span className={styles.healthTooltipStats}>
|
||||
<span className={styles.healthTooltipSuccess}>{t('status_bar.success_short')} {detail.success}</span>
|
||||
<span className={styles.healthTooltipFailure}>{t('status_bar.failure_short')} {detail.failure}</span>
|
||||
<span className={styles.healthTooltipSuccess}>
|
||||
{t('status_bar.success_short')} {detail.success}
|
||||
</span>
|
||||
<span className={styles.healthTooltipFailure}>
|
||||
{t('status_bar.failure_short')} {detail.failure}
|
||||
</span>
|
||||
<span className={styles.healthTooltipRate}>({(detail.rate * 100).toFixed(1)}%)</span>
|
||||
</span>
|
||||
) : (
|
||||
@@ -116,6 +207,8 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof document === 'undefined' ? tooltip : createPortal(tooltip, document.body);
|
||||
};
|
||||
|
||||
const rateClass = !hasData
|
||||
@@ -138,32 +231,29 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.healthGridScroller}>
|
||||
<div
|
||||
className={styles.healthGrid}
|
||||
ref={gridRef}
|
||||
>
|
||||
{healthData.blockDetails.map((detail, idx) => {
|
||||
const isIdle = detail.rate === -1;
|
||||
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
|
||||
const isActive = activeTooltip === idx;
|
||||
<div className={styles.healthGrid} ref={gridRef}>
|
||||
{healthData.blockDetails.map((detail, idx) => {
|
||||
const isIdle = detail.rate === -1;
|
||||
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
|
||||
const isActive = activeTooltip?.idx === idx;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`${styles.healthBlockWrapper} ${isActive ? styles.healthBlockActive : ''}`}
|
||||
onPointerEnter={(e) => handlePointerEnter(e, idx)}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
onPointerDown={(e) => handlePointerDown(e, idx)}
|
||||
>
|
||||
return (
|
||||
<div
|
||||
className={`${styles.healthBlock} ${isIdle ? styles.healthBlockIdle : ''}`}
|
||||
style={blockStyle}
|
||||
/>
|
||||
{isActive && renderTooltip(detail, idx)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
key={idx}
|
||||
className={`${styles.healthBlockWrapper} ${isActive ? styles.healthBlockActive : ''}`}
|
||||
onPointerEnter={(e) => handlePointerEnter(e, idx)}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
onPointerDown={(e) => handlePointerDown(e, idx)}
|
||||
>
|
||||
<div
|
||||
className={`${styles.healthBlock} ${isIdle ? styles.healthBlockIdle : ''}`}
|
||||
style={blockStyle}
|
||||
/>
|
||||
{isActive && activeTooltip && renderTooltip(detail, activeTooltip)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.healthLegend}>
|
||||
<span className={styles.healthLegendLabel}>{t('service_health.oldest')}</span>
|
||||
|
||||
@@ -117,6 +117,33 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
setSelectedFiles(new Set());
|
||||
}, []);
|
||||
|
||||
const applyDeletedFiles = useCallback((names: string[]) => {
|
||||
const deletedNames = Array.from(
|
||||
new Set(
|
||||
names
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
if (deletedNames.length === 0) return;
|
||||
|
||||
const deletedSet = new Set(deletedNames);
|
||||
setFiles((prev) => prev.filter((file) => !deletedSet.has(file.name)));
|
||||
setSelectedFiles((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (deletedSet.has(name)) {
|
||||
changed = true;
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFiles.size === 0) return;
|
||||
const existingNames = new Set(files.map((file) => file.name));
|
||||
@@ -190,36 +217,33 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
let successCount = 0;
|
||||
const failed: { name: string; message: string }[] = [];
|
||||
try {
|
||||
const result = await authFilesApi.uploadFiles(validFiles);
|
||||
const successCount = result.uploaded;
|
||||
|
||||
for (const file of validFiles) {
|
||||
try {
|
||||
await authFilesApi.upload(file);
|
||||
successCount++;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
failed.push({ name: file.name, message: errorMessage });
|
||||
if (successCount > 0) {
|
||||
const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : '';
|
||||
showNotification(
|
||||
`${t('auth_files.upload_success')}${suffix}`,
|
||||
result.failed.length ? 'warning' : 'success'
|
||||
);
|
||||
await loadFiles();
|
||||
await refreshKeyStats();
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : '';
|
||||
showNotification(
|
||||
`${t('auth_files.upload_success')}${suffix}`,
|
||||
failed.length ? 'warning' : 'success'
|
||||
);
|
||||
await loadFiles();
|
||||
await refreshKeyStats();
|
||||
if (result.failed.length > 0) {
|
||||
const details = result.failed
|
||||
.map((item) => `${item.name}: ${item.error}`)
|
||||
.join('; ');
|
||||
showNotification(`${t('notification.upload_failed')}: ${details}`, 'error');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
showNotification(`${t('notification.upload_failed')}: ${errorMessage}`, 'error');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
const details = failed.map((item) => `${item.name}: ${item.message}`).join('; ');
|
||||
showNotification(`${t('notification.upload_failed')}: ${details}`, 'error');
|
||||
}
|
||||
|
||||
setUploading(false);
|
||||
event.target.value = '';
|
||||
},
|
||||
[loadFiles, refreshKeyStats, showNotification, t]
|
||||
);
|
||||
@@ -234,15 +258,9 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
onConfirm: async () => {
|
||||
setDeleting(name);
|
||||
try {
|
||||
await authFilesApi.deleteFile(name);
|
||||
const result = await authFilesApi.deleteFile(name);
|
||||
showNotification(t('auth_files.delete_success'), 'success');
|
||||
setFiles((prev) => prev.filter((item) => item.name !== name));
|
||||
setSelectedFiles((prev) => {
|
||||
if (!prev.has(name)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(name);
|
||||
return next;
|
||||
});
|
||||
applyDeletedFiles(result.files.length > 0 ? result.files : [name]);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
|
||||
@@ -252,7 +270,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
},
|
||||
});
|
||||
},
|
||||
[showConfirmation, showNotification, t]
|
||||
[applyDeletedFiles, showConfirmation, showNotification, t]
|
||||
);
|
||||
|
||||
const handleDeleteAll = useCallback(
|
||||
@@ -301,35 +319,13 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
return;
|
||||
}
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
const deletedNames: string[] = [];
|
||||
const result = await authFilesApi.deleteFiles(
|
||||
filesToDelete.map((file) => file.name)
|
||||
);
|
||||
const success = result.deleted;
|
||||
const failed = result.failed.length;
|
||||
|
||||
for (const file of filesToDelete) {
|
||||
try {
|
||||
await authFilesApi.deleteFile(file.name);
|
||||
success++;
|
||||
deletedNames.push(file.name);
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
setFiles((prev) => prev.filter((f) => !deletedNames.includes(f.name)));
|
||||
setSelectedFiles((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const deletedSet = new Set(deletedNames);
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (deletedSet.has(name)) {
|
||||
changed = true;
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
applyDeletedFiles(result.files);
|
||||
|
||||
if (failed === 0 && isProblemOnly) {
|
||||
showNotification(
|
||||
@@ -380,7 +376,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
},
|
||||
});
|
||||
},
|
||||
[deselectAll, files, showConfirmation, showNotification, t]
|
||||
[applyDeletedFiles, deselectAll, files, showConfirmation, showNotification, t]
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
@@ -579,59 +575,33 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
variant: 'danger',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: async () => {
|
||||
const results = await Promise.allSettled(
|
||||
uniqueNames.map((name) => authFilesApi.deleteFile(name))
|
||||
);
|
||||
try {
|
||||
const result = await authFilesApi.deleteFiles(uniqueNames);
|
||||
applyDeletedFiles(result.files);
|
||||
|
||||
const deleted: string[] = [];
|
||||
let failCount = 0;
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
deleted.push(uniqueNames[index]);
|
||||
if (result.failed.length === 0) {
|
||||
showNotification(
|
||||
`${t('auth_files.delete_all_success')} (${result.deleted})`,
|
||||
'success'
|
||||
);
|
||||
} else {
|
||||
failCount++;
|
||||
showNotification(
|
||||
t('auth_files.delete_filtered_partial', {
|
||||
success: result.deleted,
|
||||
failed: result.failed.length,
|
||||
type: t('auth_files.filter_all'),
|
||||
}),
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (deleted.length > 0) {
|
||||
const deletedSet = new Set(deleted);
|
||||
setFiles((prev) => prev.filter((file) => !deletedSet.has(file.name)));
|
||||
}
|
||||
|
||||
setSelectedFiles((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const deletedSet = new Set(deleted);
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (deletedSet.has(name)) {
|
||||
changed = true;
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
|
||||
if (failCount === 0) {
|
||||
showNotification(
|
||||
`${t('auth_files.delete_all_success')} (${deleted.length})`,
|
||||
'success'
|
||||
);
|
||||
} else {
|
||||
showNotification(
|
||||
t('auth_files.delete_filtered_partial', {
|
||||
success: deleted.length,
|
||||
failed: failCount,
|
||||
type: t('auth_files.filter_all'),
|
||||
}),
|
||||
'warning'
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[showConfirmation, showNotification, t]
|
||||
[applyDeletedFiles, showConfirmation, showNotification, t]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -7,6 +7,7 @@ export type AuthFilesUiState = {
|
||||
problemOnly?: boolean;
|
||||
compactMode?: boolean;
|
||||
search?: string;
|
||||
regexSearchMode?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
regularPageSize?: number;
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"quota_update_required": "Please update the CPA version or check for updates",
|
||||
"quota_check_credential": "Please check the credential status",
|
||||
"copy": "Copy",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse",
|
||||
"status": "Status",
|
||||
"action": "Action",
|
||||
"custom_headers_label": "Custom Headers",
|
||||
@@ -496,10 +498,14 @@
|
||||
"pagination_info": "Page {{current}} / {{total}} · {{count}} files",
|
||||
"search_label": "Search configs",
|
||||
"search_placeholder": "Filter by name, type, or provider",
|
||||
"search_regex_placeholder": "Match name, type, or provider with a regex",
|
||||
"search_regex_invalid": "Enter a valid regex pattern (max {{max}} characters)",
|
||||
"search_regex_unsafe": "This regex may freeze the page and has been blocked (avoid nested quantifiers, alternation in repeated groups, or backreferences)",
|
||||
"problem_filter_label": "Problem Filter",
|
||||
"problem_filter_only": "Only show problematic credentials",
|
||||
"display_options_label": "Display options",
|
||||
"compact_mode_label": "Compact mode",
|
||||
"regex_search_mode_label": "Regex mode",
|
||||
"sort_label": "Sort",
|
||||
"sort_default": "Default",
|
||||
"sort_az": "A-Z Name",
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"quota_update_required": "Пожалуйста, обновите CPA или проверьте наличие обновлений",
|
||||
"quota_check_credential": "Пожалуйста, проверьте статус учётных данных",
|
||||
"copy": "Копировать",
|
||||
"expand": "Развернуть",
|
||||
"collapse": "Свернуть",
|
||||
"status": "Статус",
|
||||
"action": "Действие",
|
||||
"custom_headers_label": "Пользовательские заголовки",
|
||||
@@ -496,10 +498,14 @@
|
||||
"pagination_info": "Страница {{current}} / {{total}} · {{count}} файлов",
|
||||
"search_label": "Поиск конфигов",
|
||||
"search_placeholder": "Фильтр по имени, типу или провайдеру",
|
||||
"search_regex_placeholder": "Сопоставление имени, типа или провайдера по regex",
|
||||
"search_regex_invalid": "Введите корректный regex-шаблон (не более {{max}} символов)",
|
||||
"search_regex_unsafe": "Этот regex может вызвать зависание страницы и был заблокирован (избегайте вложенных квантификаторов, альтернативы в повторяющихся группах или обратных ссылок)",
|
||||
"problem_filter_label": "Фильтр проблем",
|
||||
"problem_filter_only": "Показывать только проблемные учётные данные",
|
||||
"display_options_label": "Параметры отображения",
|
||||
"compact_mode_label": "Компактный режим",
|
||||
"regex_search_mode_label": "Режим regex",
|
||||
"sort_label": "Сортировка",
|
||||
"sort_default": "По умолчанию",
|
||||
"sort_az": "A-Z Имя",
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"quota_update_required": "请更新 CPA 版本或检查更新",
|
||||
"quota_check_credential": "请检查凭证状态",
|
||||
"copy": "复制",
|
||||
"expand": "展开",
|
||||
"collapse": "收起",
|
||||
"status": "状态",
|
||||
"action": "操作",
|
||||
"custom_headers_label": "自定义请求头",
|
||||
@@ -496,10 +498,14 @@
|
||||
"pagination_info": "第 {{current}} / {{total}} 页 · 共 {{count}} 个文件",
|
||||
"search_label": "搜索配置文件",
|
||||
"search_placeholder": "输入名称、类型或提供方关键字",
|
||||
"search_regex_placeholder": "输入正则表达式匹配名称、类型或提供方",
|
||||
"search_regex_invalid": "请输入有效的正则表达式(最多 {{max}} 个字符)",
|
||||
"search_regex_unsafe": "该正则表达式可能导致页面卡顿,已阻止执行(避免嵌套量词、重复分组中的 | 或反向引用)",
|
||||
"problem_filter_label": "问题筛选",
|
||||
"problem_filter_only": "仅显示有问题凭证",
|
||||
"display_options_label": "显示选项",
|
||||
"compact_mode_label": "简略模式",
|
||||
"regex_search_mode_label": "正则模式",
|
||||
"sort_label": "排序",
|
||||
"sort_default": "默认",
|
||||
"sort_az": "A-Z 名称",
|
||||
|
||||
+204
-415
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ import { IconFilterAll } from '@/components/ui/icons';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { isLikelyUnsafeJsRegex } from '@/utils/regexSafety';
|
||||
import {
|
||||
MAX_CARD_PAGE_SIZE,
|
||||
MIN_CARD_PAGE_SIZE,
|
||||
@@ -67,6 +68,7 @@ const BATCH_BAR_BASE_TRANSFORM = 'translateX(-50%)';
|
||||
const BATCH_BAR_HIDDEN_TRANSFORM = 'translateX(-50%) translateY(56px)';
|
||||
const DEFAULT_REGULAR_PAGE_SIZE = 9;
|
||||
const DEFAULT_COMPACT_PAGE_SIZE = 12;
|
||||
const MAX_REGEX_SEARCH_PATTERN_LENGTH = 120;
|
||||
|
||||
export function AuthFilesPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -81,6 +83,7 @@ export function AuthFilesPage() {
|
||||
const [problemOnly, setProblemOnly] = useState(false);
|
||||
const [compactMode, setCompactMode] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [regexSearchMode, setRegexSearchMode] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSizeByMode, setPageSizeByMode] = useState({
|
||||
regular: DEFAULT_REGULAR_PAGE_SIZE,
|
||||
@@ -201,6 +204,9 @@ export function AuthFilesPage() {
|
||||
if (typeof persisted.search === 'string') {
|
||||
setSearch(persisted.search);
|
||||
}
|
||||
if (typeof persisted.regexSearchMode === 'boolean') {
|
||||
setRegexSearchMode(persisted.regexSearchMode);
|
||||
}
|
||||
if (typeof persisted.page === 'number' && Number.isFinite(persisted.page)) {
|
||||
setPage(Math.max(1, Math.round(persisted.page)));
|
||||
}
|
||||
@@ -236,6 +242,7 @@ export function AuthFilesPage() {
|
||||
problemOnly,
|
||||
compactMode,
|
||||
search,
|
||||
regexSearchMode,
|
||||
page,
|
||||
pageSize,
|
||||
regularPageSize: pageSizeByMode.regular,
|
||||
@@ -250,6 +257,7 @@ export function AuthFilesPage() {
|
||||
pageSize,
|
||||
pageSizeByMode,
|
||||
problemOnly,
|
||||
regexSearchMode,
|
||||
search,
|
||||
sortMode,
|
||||
uiStateHydrated,
|
||||
@@ -368,18 +376,63 @@ export function AuthFilesPage() {
|
||||
return counts;
|
||||
}, [filesMatchingProblemFilter]);
|
||||
|
||||
const normalizedSearch = search.trim();
|
||||
const { regexSearch, regexSearchErrorKey } = useMemo(() => {
|
||||
if (!regexSearchMode || !normalizedSearch) {
|
||||
return { regexSearch: null as RegExp | null, regexSearchErrorKey: undefined as string | undefined };
|
||||
}
|
||||
|
||||
if (normalizedSearch.length > MAX_REGEX_SEARCH_PATTERN_LENGTH) {
|
||||
return {
|
||||
regexSearch: null,
|
||||
regexSearchErrorKey: 'auth_files.search_regex_invalid',
|
||||
};
|
||||
}
|
||||
|
||||
if (isLikelyUnsafeJsRegex(normalizedSearch)) {
|
||||
return {
|
||||
regexSearch: null,
|
||||
regexSearchErrorKey: 'auth_files.search_regex_unsafe',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return { regexSearch: new RegExp(normalizedSearch, 'i'), regexSearchErrorKey: undefined };
|
||||
} catch {
|
||||
return {
|
||||
regexSearch: null,
|
||||
regexSearchErrorKey: 'auth_files.search_regex_invalid',
|
||||
};
|
||||
}
|
||||
}, [normalizedSearch, regexSearchMode]);
|
||||
|
||||
const searchError = regexSearchErrorKey
|
||||
? t(regexSearchErrorKey, { max: MAX_REGEX_SEARCH_PATTERN_LENGTH })
|
||||
: undefined;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return filesMatchingProblemFilter.filter((item) => {
|
||||
const matchType = filter === 'all' || item.type === filter;
|
||||
const term = search.trim().toLowerCase();
|
||||
const matchSearch =
|
||||
!term ||
|
||||
item.name.toLowerCase().includes(term) ||
|
||||
(item.type || '').toString().toLowerCase().includes(term) ||
|
||||
(item.provider || '').toString().toLowerCase().includes(term);
|
||||
const matchSearch = (() => {
|
||||
if (!normalizedSearch) return true;
|
||||
if (!regexSearchMode) {
|
||||
const term = normalizedSearch.toLowerCase();
|
||||
return (
|
||||
item.name.toLowerCase().includes(term) ||
|
||||
(item.type || '').toString().toLowerCase().includes(term) ||
|
||||
(item.provider || '').toString().toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
if (!regexSearch) return false;
|
||||
|
||||
return [item.name, item.type, item.provider].some((value) =>
|
||||
regexSearch.test((value || '').toString())
|
||||
);
|
||||
})();
|
||||
return matchType && matchSearch;
|
||||
});
|
||||
}, [filesMatchingProblemFilter, filter, search]);
|
||||
}, [filesMatchingProblemFilter, filter, normalizedSearch, regexSearch, regexSearchMode]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const copy = [...filtered];
|
||||
@@ -691,7 +744,12 @@ export function AuthFilesPage() {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder={t('auth_files.search_placeholder')}
|
||||
placeholder={
|
||||
regexSearchMode
|
||||
? t('auth_files.search_regex_placeholder')
|
||||
: t('auth_files.search_placeholder')
|
||||
}
|
||||
error={searchError}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterItem}>
|
||||
@@ -753,6 +811,21 @@ export function AuthFilesPage() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterToggleCard}>
|
||||
<ToggleSwitch
|
||||
checked={regexSearchMode}
|
||||
onChange={(value) => {
|
||||
setRegexSearchMode(value);
|
||||
setPage(1);
|
||||
}}
|
||||
ariaLabel={t('auth_files.regex_search_mode_label')}
|
||||
label={
|
||||
<span className={styles.filterToggleLabel}>
|
||||
{t('auth_files.regex_search_mode_label')}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1112,17 +1112,20 @@
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-secondary, #e5e7eb);
|
||||
background-color: var(--floating-surface, #ffffff);
|
||||
background-image: none;
|
||||
border: 1px solid var(--floating-border, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
box-shadow: var(--floating-shadow, 0 10px 24px rgba(0, 0, 0, 0.16));
|
||||
z-index: $z-dropdown;
|
||||
pointer-events: none;
|
||||
color: var(--text-primary);
|
||||
opacity: 1;
|
||||
background-clip: padding-box;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
@@ -1131,7 +1134,7 @@
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: var(--bg-primary, #fff);
|
||||
border-top-color: var(--floating-surface, #ffffff);
|
||||
}
|
||||
|
||||
&::before {
|
||||
@@ -1141,7 +1144,7 @@
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: var(--border-secondary, #e5e7eb);
|
||||
border-top-color: var(--floating-border, #e5e7eb);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1154,14 +1157,14 @@
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
border-top-color: transparent;
|
||||
border-bottom-color: var(--bg-primary, #fff);
|
||||
border-bottom-color: var(--floating-surface, #ffffff);
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
border-top-color: transparent;
|
||||
border-bottom-color: var(--border-secondary, #e5e7eb);
|
||||
border-bottom-color: var(--floating-border, #e5e7eb);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,31 @@ import type { OAuthModelAliasEntry } from '@/types';
|
||||
type StatusError = { status?: number };
|
||||
type AuthFileStatusResponse = { status: string; disabled: boolean };
|
||||
type AuthFileEntry = AuthFilesResponse['files'][number];
|
||||
type AuthFileBatchFailure = { name: string; error: string };
|
||||
type AuthFileBatchUploadResponse = {
|
||||
status?: string;
|
||||
uploaded?: number;
|
||||
files?: unknown;
|
||||
failed?: unknown;
|
||||
};
|
||||
type AuthFileBatchDeleteResponse = {
|
||||
status?: string;
|
||||
deleted?: number;
|
||||
files?: unknown;
|
||||
failed?: unknown;
|
||||
};
|
||||
type AuthFileBatchUploadResult = {
|
||||
status: string;
|
||||
uploaded: number;
|
||||
files: string[];
|
||||
failed: AuthFileBatchFailure[];
|
||||
};
|
||||
type AuthFileBatchDeleteResult = {
|
||||
status: string;
|
||||
deleted: number;
|
||||
files: string[];
|
||||
failed: AuthFileBatchFailure[];
|
||||
};
|
||||
|
||||
export const AUTH_FILE_INVALID_JSON_OBJECT_ERROR = 'AUTH_FILE_INVALID_JSON_OBJECT';
|
||||
|
||||
@@ -18,6 +43,129 @@ const getStatusCode = (err: unknown): number | undefined => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizeRequestedAuthFileNames = (names: string[]): string[] => {
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
|
||||
names.forEach((name) => {
|
||||
const trimmed = String(name ?? '').trim();
|
||||
if (!trimmed || seen.has(trimmed)) return;
|
||||
seen.add(trimmed);
|
||||
normalized.push(trimmed);
|
||||
});
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const normalizeBatchFileNames = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return normalizeRequestedAuthFileNames(value.map((item) => String(item ?? '')));
|
||||
};
|
||||
|
||||
const normalizeBatchFailures = (value: unknown): AuthFileBatchFailure[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
return value.reduce<AuthFileBatchFailure[]>((result, item) => {
|
||||
if (!item || typeof item !== 'object') return result;
|
||||
const entry = item as Record<string, unknown>;
|
||||
const name = String(entry.name ?? '').trim();
|
||||
const error =
|
||||
typeof entry.error === 'string'
|
||||
? entry.error.trim()
|
||||
: typeof entry.message === 'string'
|
||||
? entry.message.trim()
|
||||
: '';
|
||||
|
||||
if (!name && !error) return result;
|
||||
result.push({ name, error: error || 'Unknown error' });
|
||||
return result;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const deriveSuccessfulFileNames = (requestedNames: string[], failed: AuthFileBatchFailure[]): string[] => {
|
||||
const failedNames = new Set(
|
||||
failed
|
||||
.map((entry) => entry.name.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
if (failedNames.size === 0) {
|
||||
return [...requestedNames];
|
||||
}
|
||||
|
||||
return requestedNames.filter((name) => !failedNames.has(name));
|
||||
};
|
||||
|
||||
const normalizeBatchUploadResponse = (
|
||||
payload: AuthFileBatchUploadResponse | undefined,
|
||||
requestedNames: string[]
|
||||
): AuthFileBatchUploadResult => {
|
||||
const failed = normalizeBatchFailures(payload?.failed);
|
||||
const uploadedFilesFromPayload = normalizeBatchFileNames(payload?.files);
|
||||
const uploaded =
|
||||
typeof payload?.uploaded === 'number'
|
||||
? payload.uploaded
|
||||
: uploadedFilesFromPayload.length > 0
|
||||
? uploadedFilesFromPayload.length
|
||||
: requestedNames.length === 1 && failed.length === 0
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
let uploadedFiles = uploadedFilesFromPayload;
|
||||
if (uploadedFiles.length === 0 && uploaded > 0) {
|
||||
if (failed.length === 0 && uploaded === requestedNames.length) {
|
||||
uploadedFiles = [...requestedNames];
|
||||
} else {
|
||||
const derivedNames = deriveSuccessfulFileNames(requestedNames, failed);
|
||||
if (derivedNames.length === uploaded) {
|
||||
uploadedFiles = derivedNames;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: typeof payload?.status === 'string' ? payload.status : failed.length > 0 ? 'partial' : 'ok',
|
||||
uploaded,
|
||||
files: uploadedFiles,
|
||||
failed,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeBatchDeleteResponse = (
|
||||
payload: AuthFileBatchDeleteResponse | undefined,
|
||||
requestedNames: string[]
|
||||
): AuthFileBatchDeleteResult => {
|
||||
const failed = normalizeBatchFailures(payload?.failed);
|
||||
const deletedFilesFromPayload = normalizeBatchFileNames(payload?.files);
|
||||
const deleted =
|
||||
typeof payload?.deleted === 'number'
|
||||
? payload.deleted
|
||||
: deletedFilesFromPayload.length > 0
|
||||
? deletedFilesFromPayload.length
|
||||
: requestedNames.length === 1 && failed.length === 0
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
let deletedFiles = deletedFilesFromPayload;
|
||||
if (deletedFiles.length === 0 && deleted > 0) {
|
||||
if (failed.length === 0 && deleted === requestedNames.length) {
|
||||
deletedFiles = [...requestedNames];
|
||||
} else {
|
||||
const derivedNames = deriveSuccessfulFileNames(requestedNames, failed);
|
||||
if (derivedNames.length === deleted) {
|
||||
deletedFiles = derivedNames;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: typeof payload?.status === 'string' ? payload.status : failed.length > 0 ? 'partial' : 'ok',
|
||||
deleted,
|
||||
files: deletedFiles,
|
||||
failed,
|
||||
};
|
||||
};
|
||||
|
||||
const readTextField = (entry: AuthFileEntry, key: string): string => {
|
||||
const value = entry[key];
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
@@ -252,13 +400,35 @@ export const authFilesApi = {
|
||||
setStatus: (name: string, disabled: boolean) =>
|
||||
apiClient.patch<AuthFileStatusResponse>('/auth-files/status', { name, disabled }),
|
||||
|
||||
upload: (file: File) => {
|
||||
uploadFiles: async (files: File[]): Promise<AuthFileBatchUploadResult> => {
|
||||
const requestedNames = files.map((file) => file.name);
|
||||
if (requestedNames.length === 0) {
|
||||
return { status: 'ok', uploaded: 0, files: [], failed: [] };
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name);
|
||||
return apiClient.postForm('/auth-files', formData);
|
||||
files.forEach((file) => {
|
||||
formData.append('file', file, file.name);
|
||||
});
|
||||
const payload = await apiClient.postForm<AuthFileBatchUploadResponse>('/auth-files', formData);
|
||||
return normalizeBatchUploadResponse(payload, requestedNames);
|
||||
},
|
||||
|
||||
deleteFile: (name: string) => apiClient.delete(`/auth-files?name=${encodeURIComponent(name)}`),
|
||||
upload: (file: File) => authFilesApi.uploadFiles([file]),
|
||||
|
||||
deleteFiles: async (names: string[]): Promise<AuthFileBatchDeleteResult> => {
|
||||
const requestedNames = normalizeRequestedAuthFileNames(names);
|
||||
if (requestedNames.length === 0) {
|
||||
return { status: 'ok', deleted: 0, files: [], failed: [] };
|
||||
}
|
||||
|
||||
const payload = await apiClient.delete<AuthFileBatchDeleteResponse>('/auth-files', {
|
||||
data: { names: requestedNames },
|
||||
});
|
||||
return normalizeBatchDeleteResponse(payload, requestedNames);
|
||||
},
|
||||
|
||||
deleteFile: (name: string) => authFilesApi.deleteFiles([name]),
|
||||
|
||||
deleteAll: () => apiClient.delete('/auth-files', { params: { all: true } }),
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
--bg-hover: var(--bg-tertiary);
|
||||
--bg-quinary: #f6f4ee;
|
||||
--bg-error-light: rgba(198, 87, 70, 0.1);
|
||||
--floating-surface: #fffdf9;
|
||||
--floating-border: #d8d3ca;
|
||||
--floating-shadow: 0 12px 26px rgba(0, 0, 0, 0.14);
|
||||
|
||||
--text-primary: #2d2a26;
|
||||
--text-secondary: #6d6760;
|
||||
@@ -64,6 +67,9 @@
|
||||
--bg-hover: var(--bg-tertiary);
|
||||
--bg-quinary: #ffffff;
|
||||
--bg-error-light: rgba(198, 87, 70, 0.08);
|
||||
--floating-surface: #ffffff;
|
||||
--floating-border: #d9d9d9;
|
||||
--floating-shadow: 0 12px 26px rgba(0, 0, 0, 0.12);
|
||||
|
||||
--text-primary: #2d2a26;
|
||||
--text-secondary: #6d6760;
|
||||
@@ -118,6 +124,9 @@
|
||||
--bg-hover: #2e2a26;
|
||||
--bg-quinary: #191714;
|
||||
--bg-error-light: rgba(198, 87, 70, 0.18);
|
||||
--floating-surface: #2a2723;
|
||||
--floating-border: #4a443d;
|
||||
--floating-shadow: 0 14px 30px rgba(0, 0, 0, 0.4);
|
||||
|
||||
--text-primary: #f6f4f1;
|
||||
--text-secondary: #c9c3bb;
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
type GroupState = {
|
||||
hasInnerVariableQuantifier: boolean;
|
||||
hasAlternation: boolean;
|
||||
justOpened: boolean;
|
||||
};
|
||||
|
||||
type Quantifier = {
|
||||
length: number;
|
||||
min: number;
|
||||
max: number | null; // null means unbounded
|
||||
variable: boolean; // can match multiple lengths for the repeated token
|
||||
};
|
||||
|
||||
const OUTER_REPEAT_MAX_SAFE_UPPER_BOUND = 9;
|
||||
|
||||
const isDigit = (ch: string | undefined): ch is string => ch !== undefined && ch >= '0' && ch <= '9';
|
||||
|
||||
const readBraceQuantifier = (pattern: string, index: number): Quantifier | null => {
|
||||
if (pattern[index] !== '{') return null;
|
||||
|
||||
let i = index + 1;
|
||||
let minStr = '';
|
||||
while (isDigit(pattern[i])) {
|
||||
minStr += pattern[i];
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if (minStr.length === 0) return null;
|
||||
const min = Number(minStr);
|
||||
|
||||
let max: number | null = min;
|
||||
if (pattern[i] === ',') {
|
||||
i += 1;
|
||||
let maxStr = '';
|
||||
while (isDigit(pattern[i])) {
|
||||
maxStr += pattern[i];
|
||||
i += 1;
|
||||
}
|
||||
max = maxStr.length === 0 ? null : Number(maxStr);
|
||||
}
|
||||
|
||||
if (pattern[i] !== '}') return null;
|
||||
|
||||
const variable = max === null || max !== min;
|
||||
return { length: i - index + 1, min, max, variable };
|
||||
};
|
||||
|
||||
const readQuantifier = (pattern: string, index: number): Quantifier | null => {
|
||||
const ch = pattern[index];
|
||||
if (ch === '*') return { length: 1, min: 0, max: null, variable: true };
|
||||
if (ch === '+') return { length: 1, min: 1, max: null, variable: true };
|
||||
if (ch === '?') return { length: 1, min: 0, max: 1, variable: true };
|
||||
if (ch !== '{') return null;
|
||||
return readBraceQuantifier(pattern, index);
|
||||
};
|
||||
|
||||
/**
|
||||
* Heuristic safety check for user-supplied JS regex patterns.
|
||||
*
|
||||
* Goal: prevent patterns that are very likely to cause catastrophic backtracking
|
||||
* (e.g. `^(a+)+$`) from running on the main thread.
|
||||
*
|
||||
* Notes:
|
||||
* - This is intentionally conservative but tries to avoid blocking common safe patterns.
|
||||
* - We do not execute the regex here; only scan the pattern string.
|
||||
*/
|
||||
export function isLikelyUnsafeJsRegex(pattern: string): boolean {
|
||||
let inCharClass = false;
|
||||
const groupStack: GroupState[] = [
|
||||
{ hasInnerVariableQuantifier: false, hasAlternation: false, justOpened: false },
|
||||
];
|
||||
|
||||
const markInnerVariableQuantifier = () => {
|
||||
for (let i = 0; i < groupStack.length; i += 1) {
|
||||
groupStack[i].hasInnerVariableQuantifier = true;
|
||||
}
|
||||
};
|
||||
|
||||
const markAlternation = () => {
|
||||
for (let i = 0; i < groupStack.length; i += 1) {
|
||||
groupStack[i].hasAlternation = true;
|
||||
}
|
||||
};
|
||||
|
||||
const isOuterRepeatRisky = (q: Quantifier): boolean => {
|
||||
// If it cannot repeat more than once, it's not a "repeat group" in the sense that
|
||||
// triggers catastrophic backtracking (e.g. `(a+)?`).
|
||||
const max = q.max ?? Number.POSITIVE_INFINITY;
|
||||
if (max <= 1) return false;
|
||||
|
||||
// Unbounded repetition is the main hazard: `*`, `+`, `{m,}`.
|
||||
if (q.max === null) return true;
|
||||
|
||||
// Large fixed/variable upper bounds also explode combinatorially with an inner variable quantifier.
|
||||
return q.max > OUTER_REPEAT_MAX_SAFE_UPPER_BOUND;
|
||||
};
|
||||
|
||||
for (let i = 0; i < pattern.length; i += 1) {
|
||||
const ch = pattern[i];
|
||||
|
||||
// Reset "justOpened" once we move past the first token inside the group.
|
||||
const top = groupStack[groupStack.length - 1];
|
||||
if (top.justOpened) {
|
||||
top.justOpened = false;
|
||||
// `(?...)` group prefixes use `?` immediately after `(` and are not quantifiers.
|
||||
if (ch === '?') continue;
|
||||
}
|
||||
|
||||
if (ch === '\\') {
|
||||
const next = pattern[i + 1];
|
||||
// Backreferences often make backtracking far worse.
|
||||
if (next && next >= '1' && next <= '9') return true;
|
||||
// Named backreference: \k<name>
|
||||
if (next === 'k' && pattern[i + 2] === '<') return true;
|
||||
|
||||
// Skip escaped character.
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inCharClass) {
|
||||
if (ch === ']') inCharClass = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '[') {
|
||||
inCharClass = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '(') {
|
||||
groupStack.push({ hasInnerVariableQuantifier: false, hasAlternation: false, justOpened: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === ')') {
|
||||
const group = groupStack.pop();
|
||||
if (!group) return true; // unbalanced, treat as unsafe
|
||||
|
||||
const q = readQuantifier(pattern, i + 1);
|
||||
if (
|
||||
q &&
|
||||
isOuterRepeatRisky(q) &&
|
||||
(group.hasInnerVariableQuantifier || group.hasAlternation)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '|') {
|
||||
// Alternation inside a repeated group is frequently a backtracking hotspot.
|
||||
markAlternation();
|
||||
continue;
|
||||
}
|
||||
|
||||
const q = readQuantifier(pattern, i);
|
||||
if (q) {
|
||||
if (q.variable) markInnerVariableQuantifier();
|
||||
i += q.length - 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user