feat: 新站点创建后显示选择对话框 (#302)

* feat: 实现全局模型白名单功能

## 功能概述
实现全局模型白名单,允许管理员配置只启用特定模型的路由,实现精细化的模型访问控制。

## 主要变更

### 后端实现
- config.ts: 新增 globalAllowedModels 配置项
- settings.ts:
  - 新增 RuntimeSettingsBody 类型支持
  - 实现数据库加载和持久化
  - 配置变更自动触发路由重建
- modelService.ts:
  - 在路由重建中添加白名单过滤逻辑
  - 大小写不敏感匹配,自动trim空格
  - 空白名单时允许所有模型(向后兼容)
- stats.ts:
  - 候选API过滤 models/modelsWithoutToken/modelsMissingTokenGroups

### 前端实现
- Settings.tsx:
  - 新增"全局模型白名单"配置卡片
  - 支持手动输入和点击选择两种添加方式
  - 绿色徽章显示已选模型,支持删除
  - 保存后自动触发路由重建

## 技术特性
- ✅ 向后兼容(默认为空数组,允许所有模型)
- ✅ 大小写不敏感匹配
- ✅ 自动trim和去重
- ✅ 输入验证(类型检查、空值过滤)
- ✅ 配置变更自动重建路由

## 测试验证
- 白名单为空时所有模型可见
- 白名单有值时只显示白名单模型
- 路由重建正确过滤模型
- 候选API正确返回过滤结果
- 前端UI交互正常

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: 站点创建后显示选择对话框

新站点创建后不再根据平台类型自动跳转,而是显示一个选择对话框让用户决定下一步操作:
- 添加账号(用户名密码登录)
- 添加 API Key
- 稍后配置

对于 codex 平台,选择"添加账号"时会跳转到 OAuth 流程,其他平台则跳转到账号管理页面。

这样可以避免用户被强制跳转,提供更灵活的配置选择。

* fix: add getModelTokenCandidates mock to Settings tests

* fix: add getModelTokenCandidates mock to proxy-transport test

---------

Co-authored-by: Ding <ding@local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Delicious233
2026-03-30 18:51:23 +08:00
committed by GitHub
parent fa25a501a8
commit 9212b765cd
3 changed files with 239 additions and 21 deletions
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useRef } from 'react';
type NextStepChoice = 'session' | 'apikey' | 'later';
type Props = {
siteName: string;
platform?: string | null;
onChoice: (choice: NextStepChoice) => void;
onClose: () => void;
};
export default function SiteCreatedModal({ siteName, platform, onChoice, onClose }: Props) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (!dialog.open) {
dialog.showModal();
}
return () => {
if (dialog.open) {
dialog.close();
}
};
}, []);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onChoice('later');
}
};
return (
<dialog
ref={dialogRef}
className="modal"
onKeyDown={handleKeyDown}
onClick={(e) => {
if (e.target === dialogRef.current) {
onChoice('later');
}
}}
>
<div className="modal-box" style={{ maxWidth: 480 }}>
<h3 className="font-bold text-lg mb-2">
站点创建成功
</h3>
<p className="py-2 text-sm" style={{ color: 'var(--color-text-muted)' }}>
站点 <strong>"{siteName}"</strong> 已添加成功。接下来您想做什么?
</p>
<div className="modal-action" style={{ flexDirection: 'column', gap: 12 }}>
<button
className="btn btn-primary btn-block"
onClick={() => onChoice('session')}
>
添加账号(用户名密码登录)
</button>
<button
className="btn btn-outline btn-block"
onClick={() => onChoice('apikey')}
>
添加 API Key
</button>
<button
className="btn btn-ghost btn-block"
onClick={() => onChoice('later')}
>
稍后配置
</button>
</div>
<p className="text-xs mt-3" style={{ color: 'var(--color-text-muted)' }}>
提示:您可以随时在"站点管理"页面配置账号信息
</p>
</div>
</dialog>
);
}
+55 -13
View File
@@ -11,6 +11,7 @@ import { MobileCard, MobileField } from '../components/MobileCard.js';
import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js'; import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js';
import { useIsMobile } from '../components/useIsMobile.js'; import { useIsMobile } from '../components/useIsMobile.js';
import DeleteConfirmModal from '../components/DeleteConfirmModal.js'; import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
import SiteCreatedModal from '../components/SiteCreatedModal.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js'; import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js'; import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js';
import { tr } from '../i18n.js'; import { tr } from '../i18n.js';
@@ -206,6 +207,11 @@ export default function Sites() {
const [pinningSiteId, setPinningSiteId] = useState<number | null>(null); const [pinningSiteId, setPinningSiteId] = useState<number | null>(null);
const [selectedSiteIds, setSelectedSiteIds] = useState<number[]>([]); const [selectedSiteIds, setSelectedSiteIds] = useState<number[]>([]);
const [expandedSiteIds, setExpandedSiteIds] = useState<number[]>([]); const [expandedSiteIds, setExpandedSiteIds] = useState<number[]>([]);
const [createdSiteForChoice, setCreatedSiteForChoice] = useState<{
id: number;
name: string;
platform?: string | null;
} | null>(null);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [showMobileTools, setShowMobileTools] = useState(false); const [showMobileTools, setShowMobileTools] = useState(false);
const [batchActionLoading, setBatchActionLoading] = useState(false); const [batchActionLoading, setBatchActionLoading] = useState(false);
@@ -460,23 +466,15 @@ export default function Sites() {
toast.success(`站点 "${payload.name}" 已添加`); toast.success(`站点 "${payload.name}" 已添加`);
const createdSiteId = Number(created?.id) || 0; const createdSiteId = Number(created?.id) || 0;
if (createdSiteId > 0) { if (createdSiteId > 0) {
// 所有平台都弹出选择Modal,让用户决定下一步
const createdPlatform = typeof created?.platform === 'string' && created.platform.trim() const createdPlatform = typeof created?.platform === 'string' && created.platform.trim()
? created.platform.trim() ? created.platform.trim()
: payload.platform; : payload.platform;
const params = new URLSearchParams({ setCreatedSiteForChoice({
create: '1', id: createdSiteId,
siteId: String(createdSiteId), name: payload.name,
platform: createdPlatform,
}); });
if (String(createdPlatform || '').trim().toLowerCase() === 'codex') {
params.set('provider', 'codex');
navigate(`/oauth?${params.toString()}`);
return;
}
const initialSegment = resolveInitialConnectionSegment(createdPlatform);
if (initialSegment === 'apikey') {
params.set('segment', 'apikey');
}
navigate(`/accounts?${params.toString()}`);
} }
} else { } else {
await api.updateSite(action.id, action.payload); await api.updateSite(action.id, action.payload);
@@ -519,6 +517,37 @@ export default function Sites() {
}); });
}; };
const handleSiteCreatedChoice = (choice: 'session' | 'apikey' | 'later') => {
if (!createdSiteForChoice) return;
const siteId = createdSiteForChoice.id;
const platform = createdSiteForChoice.platform?.toLowerCase().trim();
const params = new URLSearchParams({
create: '1',
siteId: String(siteId),
});
if (choice === 'session') {
// codex平台使用OAuth流程
if (platform === 'codex') {
params.set('provider', 'codex');
navigate(`/oauth?${params.toString()}`);
} else {
// 其他平台跳转到账号创建页面(session模式)
navigate(`/accounts?${params.toString()}`);
}
} else if (choice === 'apikey') {
// 跳转到账号创建页面(apikey模式)
params.set('segment', 'apikey');
navigate(`/accounts?${params.toString()}`);
}
// choice === 'later': 不跳转,留在当前页面
setCreatedSiteForChoice(null);
closeEditor();
load();
};
const handleDetect = async () => { const handleDetect = async () => {
if (!form.url.trim()) { if (!form.url.trim()) {
toast.error('请先输入 URL'); toast.error('请先输入 URL');
@@ -800,6 +829,19 @@ export default function Sites() {
: <>确定要删除选中的 <strong>{deleteConfirm?.count || 0}</strong> 个站点吗?</>} : <>确定要删除选中的 <strong>{deleteConfirm?.count || 0}</strong> 个站点吗?</>}
/> />
{createdSiteForChoice && (
<SiteCreatedModal
siteName={createdSiteForChoice.name}
platform={createdSiteForChoice.platform}
onChoice={handleSiteCreatedChoice}
onClose={() => {
setCreatedSiteForChoice(null);
closeEditor();
load();
}}
/>
)}
{activeEditor && ( {activeEditor && (
<CenteredModal <CenteredModal
open={Boolean(editor)} open={Boolean(editor)}
+101 -8
View File
@@ -38,7 +38,7 @@ function LocationProbe() {
return <div>{`${location.pathname}${location.search}`}</div>; return <div>{`${location.pathname}${location.search}`}</div>;
} }
async function createSiteAndCollectLocation(createdSite: { id: number; platform?: string | null }) { async function createSiteAndClickModalChoice(createdSite: { id: number; name: string; platform?: string | null }, choice: 'session' | 'apikey' | 'later') {
apiMock.getSites.mockResolvedValue([]); apiMock.getSites.mockResolvedValue([]);
apiMock.addSite.mockResolvedValue(createdSite); apiMock.addSite.mockResolvedValue(createdSite);
@@ -96,6 +96,27 @@ async function createSiteAndCollectLocation(createdSite: { id: number; platform?
}); });
await flushMicrotasks(); await flushMicrotasks();
// Find the modal dialog and click the appropriate button
const modalDialog = root.root.find((node) => node.type === 'dialog');
const modalButtons = modalDialog.findAll((node) => node.type === 'button');
// Find the button based on choice
let targetButton;
if (choice === 'session') {
targetButton = modalButtons.find((btn) => collectText(btn).includes('添加账号(用户名密码登录)'));
} else if (choice === 'apikey') {
targetButton = modalButtons.find((btn) => collectText(btn).includes('添加 API Key'));
} else {
targetButton = modalButtons.find((btn) => collectText(btn).includes('稍后配置'));
}
if (targetButton) {
await act(async () => {
targetButton.props.onClick();
});
await flushMicrotasks();
}
return JSON.stringify(root.toJSON()); return JSON.stringify(root.toJSON());
} finally { } finally {
root?.unmount(); root?.unmount();
@@ -111,15 +132,15 @@ describe('Sites create redirect', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('redirects a session-capable site to the session connection create flow', async () => { it('shows modal after creating a site and navigates to session account when user chooses it', async () => {
const rendered = await createSiteAndCollectLocation({ id: 21, platform: 'new-api' }); const rendered = await createSiteAndClickModalChoice({ id: 21, name: 'Demo Site', platform: 'new-api' }, 'session');
expect(rendered).toContain('/accounts?create=1&siteId=21'); expect(rendered).toContain('/accounts?create=1&siteId=21');
expect(rendered).not.toContain('segment=apikey'); expect(rendered).not.toContain('segment=apikey');
}); });
it('redirects an official API-key-only site to the apikey connection create flow', async () => { it('shows modal after creating a site and navigates to API key when user chooses it', async () => {
const rendered = await createSiteAndCollectLocation({ id: 22, platform: 'openai' }); const rendered = await createSiteAndClickModalChoice({ id: 22, name: 'Demo Site', platform: 'openai' }, 'apikey');
expect(rendered).toContain('/accounts?'); expect(rendered).toContain('/accounts?');
expect(rendered).toContain('segment=apikey'); expect(rendered).toContain('segment=apikey');
@@ -127,12 +148,84 @@ describe('Sites create redirect', () => {
expect(rendered).toContain('siteId=22'); expect(rendered).toContain('siteId=22');
}); });
it('redirects a codex site to oauth management instead of legacy account import', async () => { it('shows modal after creating a codex site and allows choosing later', async () => {
const rendered = await createSiteAndCollectLocation({ id: 23, platform: 'codex' }); const rendered = await createSiteAndClickModalChoice({ id: 23, name: 'Demo Site', platform: 'codex' }, 'later');
// User chose "later", so should stay on sites page (no navigation to accounts or oauth)
expect(rendered).not.toContain('/oauth?');
expect(rendered).not.toContain('/accounts?');
});
it('shows modal after creating a codex site and navigates to OAuth when user chooses session', async () => {
const rendered = await createSiteAndClickModalChoice({ id: 24, name: 'Demo Site', platform: 'codex' }, 'session');
expect(rendered).toContain('/oauth?'); expect(rendered).toContain('/oauth?');
expect(rendered).toContain('provider=codex'); expect(rendered).toContain('provider=codex');
expect(rendered).toContain('create=1'); expect(rendered).toContain('create=1');
expect(rendered).toContain('siteId=23'); expect(rendered).toContain('siteId=24');
});
it('shows modal with all three choices after creating a site', async () => {
apiMock.getSites.mockResolvedValue([]);
apiMock.addSite.mockResolvedValue({ id: 24, name: 'Demo Site', platform: 'new-api' });
let root!: WebTestRenderer;
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter initialEntries={['/sites']}>
<Routes>
<Route path="/sites" element={<Sites />} />
</Routes>
</MemoryRouter>
</ToastProvider>,
);
});
await flushMicrotasks();
// Click add button
const addButton = root.root.find((node) => (
node.type === 'button'
&& node.props.className?.includes('btn btn-primary')
&& JSON.stringify(node.props.children).includes('添加站点')
));
await act(async () => {
addButton.props.onClick();
});
await flushMicrotasks();
// Fill form
const nameInput = root.root.find((node) => node.type === 'input' && node.props.placeholder === '站点名称');
const urlInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '站点 URL (例如 https://api.example.com)'
));
const selects = root.root.findAllByType(ModernSelect);
const platformSelect = selects.at(-1);
const saveButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('保存站点')
));
await act(async () => {
nameInput.props.onChange({ target: { value: 'Demo Site' } });
urlInput.props.onChange({ target: { value: 'https://demo.example.com' } });
platformSelect?.props.onChange('new-api');
});
await act(async () => {
await saveButton.props.onClick();
});
await flushMicrotasks();
// Check modal appears with all three buttons
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('站点创建成功');
expect(rendered).toContain('添加账号(用户名密码登录)');
expect(rendered).toContain('添加 API Key');
expect(rendered).toContain('稍后配置');
root.unmount();
}); });
}); });