fix connection and release regressions
This commit is contained in:
@@ -10,6 +10,9 @@ type CenteredModalProps = {
|
||||
footer?: React.ReactNode;
|
||||
maxWidth?: number;
|
||||
bodyStyle?: React.CSSProperties;
|
||||
closeOnBackdrop?: boolean;
|
||||
closeOnEscape?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
};
|
||||
|
||||
export default function CenteredModal({
|
||||
@@ -20,6 +23,9 @@ export default function CenteredModal({
|
||||
footer,
|
||||
maxWidth = 860,
|
||||
bodyStyle,
|
||||
closeOnBackdrop = false,
|
||||
closeOnEscape = false,
|
||||
showCloseButton = true,
|
||||
}: CenteredModalProps) {
|
||||
const presence = useAnimatedVisibility(open, 220);
|
||||
|
||||
@@ -33,7 +39,7 @@ export default function CenteredModal({
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || typeof document === 'undefined') return;
|
||||
if (!open || !closeOnEscape || typeof document === 'undefined') return;
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
@@ -41,21 +47,33 @@ export default function CenteredModal({
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
}, [closeOnEscape, open, onClose]);
|
||||
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
const modal = (
|
||||
<div
|
||||
className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
onClick={onClose}
|
||||
onClick={closeOnBackdrop ? onClose : undefined}
|
||||
>
|
||||
<div
|
||||
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
style={{ maxWidth }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header">{title}</div>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">{title}</div>
|
||||
{showCloseButton ? (
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close-button"
|
||||
onClick={onClose}
|
||||
aria-label="关闭弹框"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="modal-body" style={bodyStyle}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import CenteredModal from './CenteredModal.js';
|
||||
|
||||
describe('CenteredModal component', () => {
|
||||
it('does not close on backdrop click by default and exposes an explicit close button', async () => {
|
||||
const onClose = vi.fn();
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<CenteredModal open onClose={onClose} title="测试弹框">
|
||||
<div>content</div>
|
||||
</CenteredModal>,
|
||||
);
|
||||
});
|
||||
|
||||
const closeButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& node.props['aria-label'] === '关闭弹框'
|
||||
));
|
||||
|
||||
const backdrop = root.root.find((node) => (
|
||||
typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modal-backdrop')
|
||||
));
|
||||
|
||||
expect(backdrop.props.onClick).toBeUndefined();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
closeButton.props.onClick();
|
||||
});
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the shared centered modal shell pattern', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/components/CenteredModal.tsx'), 'utf8');
|
||||
|
||||
|
||||
@@ -2598,11 +2598,47 @@ textarea {
|
||||
|
||||
.modal-header {
|
||||
padding: 20px 24px 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-close-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-close-button:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.modal-close-button:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
@@ -358,7 +358,9 @@ export default function Accounts() {
|
||||
skipModelFetch: tokenForm.skipModelFetch,
|
||||
});
|
||||
closeAddPanel();
|
||||
if (result.tokenType === 'apikey') {
|
||||
if (result.queued) {
|
||||
toast.info(result.message || '账号已添加,后台正在同步初始化信息。');
|
||||
} else if (result.tokenType === 'apikey') {
|
||||
toast.success('已添加为 API Key 账号(可用于代理转发)');
|
||||
} else {
|
||||
const parts: string[] = [];
|
||||
|
||||
@@ -162,7 +162,7 @@ function isExactModelPattern(modelPattern: string): boolean {
|
||||
const normalized = modelPattern.trim();
|
||||
if (!normalized) return false;
|
||||
if (normalized.toLowerCase().startsWith('re:')) return false;
|
||||
return !/[\*\?\[]/.test(normalized);
|
||||
return !/[\*\?]/.test(normalized);
|
||||
}
|
||||
|
||||
function routeTitle(route: RouteSelectorItem): string {
|
||||
|
||||
@@ -159,7 +159,7 @@ function isExactModelPattern(modelPattern: string): boolean {
|
||||
const normalized = modelPattern.trim();
|
||||
if (!normalized) return false;
|
||||
if (isRegexModelPattern(normalized)) return false;
|
||||
return !/[\*\?\[]/.test(normalized);
|
||||
return !/[\*\?]/.test(normalized);
|
||||
}
|
||||
|
||||
function routeTitle(route: RouteSelectorItem): string {
|
||||
|
||||
@@ -226,7 +226,7 @@ const isExactModelPattern = (modelPattern: string): boolean => {
|
||||
const normalized = modelPattern.trim();
|
||||
if (!normalized) return false;
|
||||
if (normalized.toLowerCase().startsWith('re:')) return false;
|
||||
return !/[\*\?\[]/.test(normalized);
|
||||
return !/[\*\?]/.test(normalized);
|
||||
};
|
||||
|
||||
const splitCommaSeparated = (value: string): string[] =>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isExactModelPattern } from './utils.js';
|
||||
|
||||
describe('isExactModelPattern', () => {
|
||||
it('treats bracket-prefixed literal model names as exact patterns', () => {
|
||||
expect(isExactModelPattern('[NV]deepseek-v3.1-terminus')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -45,7 +45,7 @@ export function isExactModelPattern(modelPattern: string): boolean {
|
||||
const normalized = modelPattern.trim();
|
||||
if (!normalized) return false;
|
||||
if (isRegexModelPattern(normalized)) return false;
|
||||
return !/[\*\?\[]/.test(normalized);
|
||||
return !/[\*\?]/.test(normalized);
|
||||
}
|
||||
|
||||
export function parseRegexModelPattern(modelPattern: string): { regex: RegExp | null; error: string | null } {
|
||||
|
||||
@@ -79,6 +79,54 @@ describe('TokenRoutes grouped source models', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('does not treat bracket-prefixed exact model routes as group filters', async () => {
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
id: 4386, modelPattern: '[NV]deepseek-v3.1-terminus', displayName: null,
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
channelCount: 1, enabledChannelCount: 1, siteNames: ['test'],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
{
|
||||
id: 3383, modelPattern: 're:^claude-(opus|sonnet)-4-5$', displayName: 'claude-opus-4-6',
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
channelCount: 4, enabledChannelCount: 4, siteNames: ['site-a', 'site-b'],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
]);
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const filterToggle = findButtonByText(root.root, '筛选');
|
||||
await act(async () => {
|
||||
filterToggle.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('全部群组1');
|
||||
|
||||
const bracketGroupButtons = root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& collectText(node).includes('[NV]deepseek-v3.1-terminus')
|
||||
));
|
||||
expect(bracketGroupButtons).toHaveLength(0);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('collapses source-model groups by default for wildcard routes', async () => {
|
||||
const channels = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user