fix connection and release regressions

This commit is contained in:
cita
2026-03-18 18:00:59 +08:00
parent d928e067c1
commit 38e46e165b
16 changed files with 528 additions and 63 deletions
+57 -20
View File
@@ -46,14 +46,30 @@ jobs:
if-no-files-found: error if-no-files-found: error
build-packages: build-packages:
name: Build Desktop Package (${{ matrix.os }}) name: Build Desktop Package (${{ matrix.platform }})
needs: verify needs: verify
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.runner }}
timeout-minutes: 60 timeout-minutes: 60
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
os: [ubuntu-latest, windows-latest, macos-latest] include:
- platform: linux
runner: ubuntu-latest
artifactLabel: linux-x64
- platform: windows
runner: windows-latest
artifactLabel: windows-x64
- platform: mac-arm64
runner: macos-15
artifactLabel: mac-arm64
packageCommand: npm run package:desktop -- --mac --arm64
expectedMacArch: arm64
- platform: mac-x64
runner: macos-15-intel
artifactLabel: mac-x64
packageCommand: npm run package:desktop:mac:intel
expectedMacArch: x64
steps: steps:
- name: Checkout - name: Checkout
@@ -183,7 +199,7 @@ jobs:
done done
- name: Build desktop artifacts (macOS) - name: Build desktop artifacts (macOS)
if: runner.os == 'macOS' if: startsWith(matrix.platform, 'mac-')
shell: bash shell: bash
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -195,14 +211,15 @@ jobs:
APPLE_ID_INPUT: ${{ secrets.APPLE_ID }} APPLE_ID_INPUT: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD_INPUT: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_APP_SPECIFIC_PASSWORD_INPUT: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID_INPUT: ${{ secrets.APPLE_TEAM_ID }} APPLE_TEAM_ID_INPUT: ${{ secrets.APPLE_TEAM_ID }}
PACKAGE_COMMAND: ${{ matrix.packageCommand }}
run: | run: |
set -euo pipefail set -euo pipefail
if [ -n "${APPLE_ID_INPUT:-}" ]; then export APPLE_ID="$APPLE_ID_INPUT"; fi if [ -n "${APPLE_ID_INPUT:-}" ]; then export APPLE_ID="$APPLE_ID_INPUT"; fi
if [ -n "${APPLE_APP_SPECIFIC_PASSWORD_INPUT:-}" ]; then export APPLE_APP_SPECIFIC_PASSWORD="$APPLE_APP_SPECIFIC_PASSWORD_INPUT"; fi if [ -n "${APPLE_APP_SPECIFIC_PASSWORD_INPUT:-}" ]; then export APPLE_APP_SPECIFIC_PASSWORD="$APPLE_APP_SPECIFIC_PASSWORD_INPUT"; fi
if [ -n "${APPLE_TEAM_ID_INPUT:-}" ]; then export APPLE_TEAM_ID="$APPLE_TEAM_ID_INPUT"; fi if [ -n "${APPLE_TEAM_ID_INPUT:-}" ]; then export APPLE_TEAM_ID="$APPLE_TEAM_ID_INPUT"; fi
for attempt in 1 2; do for attempt in 1 2; do
echo "Desktop build (host arch) attempt ${attempt}/2" echo "Desktop build (${PACKAGE_COMMAND}) attempt ${attempt}/2"
if npm run package:desktop; then if eval "${PACKAGE_COMMAND}"; then
break break
fi fi
if [ "$attempt" -eq 2 ]; then if [ "$attempt" -eq 2 ]; then
@@ -210,25 +227,45 @@ jobs:
fi fi
sleep 90 sleep 90
done done
if [ "${RUNNER_ARCH:-}" != "X64" ]; then
for attempt in 1 2; do - name: Verify packaged macOS architecture
echo "Desktop build (mac intel x64) attempt ${attempt}/2" if: startsWith(matrix.platform, 'mac-')
if npm run package:desktop:mac:intel; then shell: bash
break env:
fi expectedMacArch: ${{ matrix.expectedMacArch }}
if [ "$attempt" -eq 2 ]; then run: |
exit 1 set -euo pipefail
fi mapfile -t app_binaries < <(find release -type f -path '*/Metapi.app/Contents/MacOS/Metapi')
sleep 90 if [ "${#app_binaries[@]}" -eq 0 ]; then
done echo "No packaged macOS app binary found under release/"
else exit 1
echo "Runner is already X64, skip extra Intel build"
fi fi
for binary in "${app_binaries[@]}"; do
archs="$(lipo -archs "$binary" 2>/dev/null | xargs)"
echo "$binary => ${archs:-unknown}"
if [ -z "${archs}" ]; then
echo "Unable to detect architecture via lipo"
exit 1
fi
if [ "${expectedMacArch}" = "x64" ]; then
[ "${archs}" = "x86_64" ] || {
echo "Expected x86_64-only binary but got: ${archs}"
exit 1
}
else
[ "${archs}" = "arm64" ] || {
echo "Expected arm64-only binary but got: ${archs}"
exit 1
}
fi
done
- name: Upload desktop package - name: Upload desktop package
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: metapi-desktop-${{ github.run_id }}-${{ runner.os }}-${{ runner.arch }} name: metapi-desktop-${{ github.run_id }}-${{ matrix.artifactLabel }}
path: | path: |
release/*.exe release/*.exe
release/*.dmg release/*.dmg
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('release workflow', () => {
it('builds macOS arm64 and x64 on dedicated runners and verifies packaged app architecture', () => {
const workflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8');
expect(workflow).toContain('runner: macos-15-intel');
expect(workflow).toContain('runner: macos-15');
expect(workflow).toContain('expectedMacArch: x64');
expect(workflow).toContain('expectedMacArch: arm64');
expect(workflow).toContain('Verify packaged macOS architecture');
expect(workflow).toContain('lipo -archs');
});
});
@@ -0,0 +1,178 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const verifyTokenMock = vi.fn();
const getApiTokensMock = vi.fn();
const refreshBalanceMock = vi.fn();
const refreshModelsForAccountMock = vi.fn();
const rebuildTokenRoutesFromAvailabilityMock = vi.fn();
const ensureDefaultTokenForAccountMock = vi.fn();
const syncTokensFromUpstreamMock = vi.fn();
vi.mock('../../services/platforms/index.js', () => ({
getAdapter: () => ({
verifyToken: (...args: unknown[]) => verifyTokenMock(...args),
getApiTokens: (...args: unknown[]) => getApiTokensMock(...args),
}),
}));
vi.mock('../../services/balanceService.js', () => ({
refreshBalance: (...args: unknown[]) => refreshBalanceMock(...args),
}));
vi.mock('../../services/modelService.js', () => ({
refreshModelsForAccount: (...args: unknown[]) => refreshModelsForAccountMock(...args),
rebuildTokenRoutesFromAvailability: (...args: unknown[]) => rebuildTokenRoutesFromAvailabilityMock(...args),
}));
vi.mock('../../services/accountTokenService.js', () => ({
ensureDefaultTokenForAccount: (...args: unknown[]) => ensureDefaultTokenForAccountMock(...args),
syncTokensFromUpstream: (...args: unknown[]) => syncTokensFromUpstreamMock(...args),
}));
type DbModule = typeof import('../../db/index.js');
describe('accounts background initialization', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
let resetBackgroundTasks: (() => void) | null = null;
let getBackgroundTask: ((taskId: string) => { status: string } | null) | null = null;
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-background-init-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./accounts.js');
const backgroundTaskModule = await import('../../services/backgroundTaskService.js');
db = dbModule.db;
schema = dbModule.schema;
resetBackgroundTasks = backgroundTaskModule.__resetBackgroundTasksForTests;
getBackgroundTask = backgroundTaskModule.getBackgroundTask;
app = Fastify();
await app.register(routesModule.accountsRoutes);
});
beforeEach(async () => {
verifyTokenMock.mockReset();
getApiTokensMock.mockReset();
refreshBalanceMock.mockReset();
refreshModelsForAccountMock.mockReset();
rebuildTokenRoutesFromAvailabilityMock.mockReset();
ensureDefaultTokenForAccountMock.mockReset();
syncTokensFromUpstreamMock.mockReset();
resetBackgroundTasks?.();
await db.delete(schema.proxyLogs).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.events).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
if (dataDir) {
try {
rmSync(dataDir, { recursive: true, force: true });
} catch {}
}
delete process.env.DATA_DIR;
});
it('returns immediately and queues background initialization when token sync is slow', async () => {
const site = await db.insert(schema.sites).values({
name: 'Session Site',
url: 'https://session.example.com',
platform: 'new-api',
}).returning().get();
verifyTokenMock.mockResolvedValue({
tokenType: 'session',
userInfo: { username: 'demo-user' },
apiToken: 'sk-demo',
});
ensureDefaultTokenForAccountMock.mockResolvedValue(undefined);
refreshBalanceMock.mockResolvedValue({ balance: 1, used: 0, quota: 1 });
refreshModelsForAccountMock.mockResolvedValue(undefined);
rebuildTokenRoutesFromAvailabilityMock.mockResolvedValue(undefined);
syncTokensFromUpstreamMock.mockResolvedValue(undefined);
let releaseTokens: ((value: Array<{ name: string; value: string }>) => void) | null = null;
const pendingTokens = new Promise<Array<{ name: string; value: string }>>((resolve) => {
releaseTokens = resolve;
});
getApiTokensMock.mockReturnValue(pendingTokens);
const responsePromise = app.inject({
method: 'POST',
url: '/api/accounts',
payload: {
siteId: site.id,
accessToken: 'session-token',
},
});
try {
const raceResult = await Promise.race([
responsePromise.then(() => 'response'),
new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 25)),
]);
expect(raceResult).toBe('response');
const response = await responsePromise;
expect(response.statusCode).toBe(200);
const body = response.json() as {
id: number;
queued?: boolean;
jobId?: string;
usernameDetected?: boolean;
apiTokenFound?: boolean;
};
expect(body).toMatchObject({
queued: true,
usernameDetected: true,
apiTokenFound: true,
});
expect(body.jobId).toBeTruthy();
const insertedAccounts = await db.select().from(schema.accounts).all();
expect(insertedAccounts).toHaveLength(1);
expect(getBackgroundTask?.(body.jobId!)).toMatchObject({
status: expect.stringMatching(/pending|running/),
});
releaseTokens?.([{ name: 'default', value: 'sk-demo' }]);
for (let attempt = 0; attempt < 20; attempt += 1) {
const task = getBackgroundTask?.(body.jobId!);
if (task?.status === 'succeeded') break;
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(syncTokensFromUpstreamMock).toHaveBeenCalledTimes(1);
expect(refreshBalanceMock).toHaveBeenCalledTimes(1);
expect(refreshModelsForAccountMock).toHaveBeenCalledTimes(1);
expect(rebuildTokenRoutesFromAvailabilityMock).toHaveBeenCalledTimes(1);
expect(getBackgroundTask?.(body.jobId!)).toMatchObject({ status: 'succeeded' });
} finally {
releaseTokens?.([]);
await responsePromise.catch(() => undefined);
}
});
});
+110 -26
View File
@@ -49,6 +49,17 @@ type AccountCapabilities = {
proxyOnly: boolean; proxyOnly: boolean;
}; };
type AccountInitializationParams = {
accountId: number;
site: typeof schema.sites.$inferSelect;
adapter: NonNullable<ReturnType<typeof getAdapter>>;
tokenType: 'session' | 'apikey' | 'unknown';
accessToken: string;
apiToken: string;
platformUserId?: number;
skipModelFetch?: boolean;
};
type VerifyFailureReason = 'needs-user-id' | 'invalid-user-id' | 'shield-blocked' | null; type VerifyFailureReason = 'needs-user-id' | 'invalid-user-id' | 'shield-blocked' | null;
const limitAccountLogin = createRateLimitGuard({ const limitAccountLogin = createRateLimitGuard({
@@ -123,6 +134,75 @@ function normalizePinnedFlag(input: unknown): boolean | null {
return null; return null;
} }
async function initializeAccountInBackground({
accountId,
site,
adapter,
tokenType,
accessToken,
apiToken,
platformUserId,
skipModelFetch,
}: AccountInitializationParams) {
const summary = {
accountId,
syncedTokenCount: 0,
refreshedBalance: false,
refreshedModels: false,
rebuiltRoutes: false,
};
if (tokenType === 'session' && apiToken) {
try {
await ensureDefaultTokenForAccount(accountId, apiToken, { name: 'default', source: 'manual' });
} catch {}
}
if (tokenType === 'session' && accessToken) {
try {
const syncedTokens = await adapter.getApiTokens(site.url, accessToken, platformUserId);
summary.syncedTokenCount = Array.isArray(syncedTokens) ? syncedTokens.length : 0;
if (summary.syncedTokenCount > 0) {
await syncTokensFromUpstream(accountId, syncedTokens);
}
} catch {}
}
if (tokenType === 'session') {
try {
await refreshBalance(accountId);
summary.refreshedBalance = true;
} catch {}
}
if (skipModelFetch !== true) {
try {
await refreshModelsForAccount(accountId);
summary.refreshedModels = true;
await rebuildTokenRoutesFromAvailability();
summary.rebuiltRoutes = true;
} catch {}
}
return summary;
}
function buildQueuedAccountInitializationMessage(
tokenType: 'session' | 'apikey' | 'unknown',
skipModelFetch?: boolean,
) {
if (tokenType === 'session' && skipModelFetch === true) {
return '账号已添加,后台正在同步令牌和余额信息。';
}
if (tokenType === 'session') {
return '账号已添加,后台正在同步令牌、余额和模型信息。';
}
if (skipModelFetch === true) {
return '已添加为 API Key 账号(可用于代理转发)。';
}
return '已添加为 API Key 账号,后台正在同步模型和路由信息。';
}
function normalizeSortOrder(input: unknown): number | null { function normalizeSortOrder(input: unknown): number | null {
if (input === undefined || input === null || input === '') return null; if (input === undefined || input === null || input === '') return null;
const parsed = Number.parseInt(String(input), 10); const parsed = Number.parseInt(String(input), 10);
@@ -1117,32 +1197,33 @@ export async function accountsRoutes(app: FastifyInstance) {
return reply.code(500).send({ success: false, message: '创建账号失败' }); return reply.code(500).send({ success: false, message: '创建账号失败' });
} }
if (tokenType === 'session' && apiToken) { const shouldQueueInitialization = tokenType === 'session' || body.skipModelFetch !== true;
try { let queuedTaskId: string | undefined;
await ensureDefaultTokenForAccount(result.id, apiToken, { name: 'default', source: 'manual' }); let queuedMessage: string | undefined;
} catch { } if (shouldQueueInitialization) {
} const taskTitle = `初始化连接 #${result.id}`;
const { task } = startBackgroundTask(
if (tokenType === 'session' && accessToken) { {
try { type: 'account-init',
const syncedTokens = await adapter.getApiTokens(site.url, accessToken, resolvedPlatformUserId); title: taskTitle,
if (syncedTokens.length > 0) { dedupeKey: `account-init-${result.id}`,
await syncTokensFromUpstream(result.id, syncedTokens); notifyOnFailure: true,
} successMessage: () => `${taskTitle}已完成`,
} catch { } failureMessage: (currentTask) => `${taskTitle}失败:${currentTask.error || 'unknown error'}`,
} },
async () => initializeAccountInBackground({
// Try to refresh balance accountId: result.id,
if (tokenType === 'session') { site,
try { await refreshBalance(result.id); } catch { } adapter,
} tokenType,
accessToken,
// Try to refresh models only if not skipping. apiToken,
if (body.skipModelFetch !== true) { platformUserId: resolvedPlatformUserId,
try { skipModelFetch: body.skipModelFetch,
await refreshModelsForAccount(result.id); }),
await rebuildTokenRoutesFromAvailability(); );
} catch { } queuedTaskId = task.id;
queuedMessage = buildQueuedAccountInitializationMessage(tokenType, body.skipModelFetch);
} }
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get(); const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
@@ -1158,6 +1239,9 @@ export async function accountsRoutes(app: FastifyInstance) {
modelCount: verifiedModels.length, modelCount: verifiedModels.length,
apiTokenFound: !!apiToken, apiTokenFound: !!apiToken,
usernameDetected: !!(!body.username && username), usernameDetected: !!(!body.username && username),
queued: !!queuedTaskId,
jobId: queuedTaskId,
message: queuedMessage,
}; };
}); });
+1 -1
View File
@@ -20,7 +20,7 @@ function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim(); const normalized = modelPattern.trim();
if (!normalized) return false; if (!normalized) return false;
if (normalized.toLowerCase().startsWith('re:')) return false; if (normalized.toLowerCase().startsWith('re:')) return false;
return !/[\*\?\[]/.test(normalized); return !/[\*\?]/.test(normalized);
} }
async function getDefaultTokenId(accountId: number): Promise<number | null> { async function getDefaultTokenId(accountId: number): Promise<number | null> {
+1 -1
View File
@@ -390,7 +390,7 @@ function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim(); const normalized = modelPattern.trim();
if (!normalized) return false; if (!normalized) return false;
if (normalized.toLowerCase().startsWith('re:')) return false; if (normalized.toLowerCase().startsWith('re:')) return false;
return !/[\*\?\[]/.test(normalized); return !/[\*\?]/.test(normalized);
} }
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> { async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
+22 -4
View File
@@ -10,6 +10,9 @@ type CenteredModalProps = {
footer?: React.ReactNode; footer?: React.ReactNode;
maxWidth?: number; maxWidth?: number;
bodyStyle?: React.CSSProperties; bodyStyle?: React.CSSProperties;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
showCloseButton?: boolean;
}; };
export default function CenteredModal({ export default function CenteredModal({
@@ -20,6 +23,9 @@ export default function CenteredModal({
footer, footer,
maxWidth = 860, maxWidth = 860,
bodyStyle, bodyStyle,
closeOnBackdrop = false,
closeOnEscape = false,
showCloseButton = true,
}: CenteredModalProps) { }: CenteredModalProps) {
const presence = useAnimatedVisibility(open, 220); const presence = useAnimatedVisibility(open, 220);
@@ -33,7 +39,7 @@ export default function CenteredModal({
}, [open]); }, [open]);
useEffect(() => { useEffect(() => {
if (!open || typeof document === 'undefined') return; if (!open || !closeOnEscape || typeof document === 'undefined') return;
const handleKeydown = (event: KeyboardEvent) => { const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose(); if (event.key === 'Escape') onClose();
}; };
@@ -41,21 +47,33 @@ export default function CenteredModal({
return () => { return () => {
document.removeEventListener('keydown', handleKeydown); document.removeEventListener('keydown', handleKeydown);
}; };
}, [open, onClose]); }, [closeOnEscape, open, onClose]);
if (!presence.shouldRender) return null; if (!presence.shouldRender) return null;
const modal = ( const modal = (
<div <div
className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()}
onClick={onClose} onClick={closeOnBackdrop ? onClose : undefined}
> >
<div <div
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ maxWidth }} style={{ maxWidth }}
onClick={(event) => event.stopPropagation()} 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}> <div className="modal-body" style={bodyStyle}>
{children} {children}
</div> </div>
+39 -1
View File
@@ -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 { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import CenteredModal from './CenteredModal.js';
describe('CenteredModal component', () => { 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', () => { it('uses the shared centered modal shell pattern', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/components/CenteredModal.tsx'), 'utf8'); const source = readFileSync(resolve(process.cwd(), 'src/web/components/CenteredModal.tsx'), 'utf8');
+36
View File
@@ -2598,11 +2598,47 @@ textarea {
.modal-header { .modal-header {
padding: 20px 24px 0; padding: 20px 24px 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: var(--color-text-primary); 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 { .modal-body {
padding: 16px 24px; padding: 16px 24px;
} }
+3 -1
View File
@@ -358,7 +358,9 @@ export default function Accounts() {
skipModelFetch: tokenForm.skipModelFetch, skipModelFetch: tokenForm.skipModelFetch,
}); });
closeAddPanel(); closeAddPanel();
if (result.tokenType === 'apikey') { if (result.queued) {
toast.info(result.message || '账号已添加,后台正在同步初始化信息。');
} else if (result.tokenType === 'apikey') {
toast.success('已添加为 API Key 账号(可用于代理转发)'); toast.success('已添加为 API Key 账号(可用于代理转发)');
} else { } else {
const parts: string[] = []; const parts: string[] = [];
+1 -1
View File
@@ -162,7 +162,7 @@ function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim(); const normalized = modelPattern.trim();
if (!normalized) return false; if (!normalized) return false;
if (normalized.toLowerCase().startsWith('re:')) return false; if (normalized.toLowerCase().startsWith('re:')) return false;
return !/[\*\?\[]/.test(normalized); return !/[\*\?]/.test(normalized);
} }
function routeTitle(route: RouteSelectorItem): string { function routeTitle(route: RouteSelectorItem): string {
+1 -1
View File
@@ -159,7 +159,7 @@ function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim(); const normalized = modelPattern.trim();
if (!normalized) return false; if (!normalized) return false;
if (isRegexModelPattern(normalized)) return false; if (isRegexModelPattern(normalized)) return false;
return !/[\*\?\[]/.test(normalized); return !/[\*\?]/.test(normalized);
} }
function routeTitle(route: RouteSelectorItem): string { function routeTitle(route: RouteSelectorItem): string {
+1 -1
View File
@@ -226,7 +226,7 @@ const isExactModelPattern = (modelPattern: string): boolean => {
const normalized = modelPattern.trim(); const normalized = modelPattern.trim();
if (!normalized) return false; if (!normalized) return false;
if (normalized.toLowerCase().startsWith('re:')) return false; if (normalized.toLowerCase().startsWith('re:')) return false;
return !/[\*\?\[]/.test(normalized); return !/[\*\?]/.test(normalized);
}; };
const splitCommaSeparated = (value: string): string[] => 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);
});
});
+1 -1
View File
@@ -45,7 +45,7 @@ export function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim(); const normalized = modelPattern.trim();
if (!normalized) return false; if (!normalized) return false;
if (isRegexModelPattern(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 } { export function parseRegexModelPattern(modelPattern: string): { regex: RegExp | null; error: string | null } {
@@ -79,6 +79,54 @@ describe('TokenRoutes grouped source models', () => {
vi.clearAllMocks(); 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 () => { it('collapses source-model groups by default for wildcard routes', async () => {
const channels = [ const channels = [
{ {