forked from boos/BoosAPI
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:
@@ -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
@@ -11,6 +11,7 @@ import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||
import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js';
|
||||
import { useIsMobile } from '../components/useIsMobile.js';
|
||||
import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
|
||||
import SiteCreatedModal from '../components/SiteCreatedModal.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js';
|
||||
import { tr } from '../i18n.js';
|
||||
@@ -206,6 +207,11 @@ export default function Sites() {
|
||||
const [pinningSiteId, setPinningSiteId] = useState<number | null>(null);
|
||||
const [selectedSiteIds, setSelectedSiteIds] = useState<number[]>([]);
|
||||
const [expandedSiteIds, setExpandedSiteIds] = useState<number[]>([]);
|
||||
const [createdSiteForChoice, setCreatedSiteForChoice] = useState<{
|
||||
id: number;
|
||||
name: string;
|
||||
platform?: string | null;
|
||||
} | null>(null);
|
||||
const isMobile = useIsMobile();
|
||||
const [showMobileTools, setShowMobileTools] = useState(false);
|
||||
const [batchActionLoading, setBatchActionLoading] = useState(false);
|
||||
@@ -460,23 +466,15 @@ export default function Sites() {
|
||||
toast.success(`站点 "${payload.name}" 已添加`);
|
||||
const createdSiteId = Number(created?.id) || 0;
|
||||
if (createdSiteId > 0) {
|
||||
// 所有平台都弹出选择Modal,让用户决定下一步
|
||||
const createdPlatform = typeof created?.platform === 'string' && created.platform.trim()
|
||||
? created.platform.trim()
|
||||
: payload.platform;
|
||||
const params = new URLSearchParams({
|
||||
create: '1',
|
||||
siteId: String(createdSiteId),
|
||||
setCreatedSiteForChoice({
|
||||
id: 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 {
|
||||
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 () => {
|
||||
if (!form.url.trim()) {
|
||||
toast.error('请先输入 URL');
|
||||
@@ -800,6 +829,19 @@ export default function Sites() {
|
||||
: <>确定要删除选中的 <strong>{deleteConfirm?.count || 0}</strong> 个站点吗?</>}
|
||||
/>
|
||||
|
||||
{createdSiteForChoice && (
|
||||
<SiteCreatedModal
|
||||
siteName={createdSiteForChoice.name}
|
||||
platform={createdSiteForChoice.platform}
|
||||
onChoice={handleSiteCreatedChoice}
|
||||
onClose={() => {
|
||||
setCreatedSiteForChoice(null);
|
||||
closeEditor();
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeEditor && (
|
||||
<CenteredModal
|
||||
open={Boolean(editor)}
|
||||
|
||||
@@ -38,7 +38,7 @@ function LocationProbe() {
|
||||
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.addSite.mockResolvedValue(createdSite);
|
||||
|
||||
@@ -96,6 +96,27 @@ async function createSiteAndCollectLocation(createdSite: { id: number; platform?
|
||||
});
|
||||
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());
|
||||
} finally {
|
||||
root?.unmount();
|
||||
@@ -111,15 +132,15 @@ describe('Sites create redirect', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('redirects a session-capable site to the session connection create flow', async () => {
|
||||
const rendered = await createSiteAndCollectLocation({ id: 21, platform: 'new-api' });
|
||||
it('shows modal after creating a site and navigates to session account when user chooses it', async () => {
|
||||
const rendered = await createSiteAndClickModalChoice({ id: 21, name: 'Demo Site', platform: 'new-api' }, 'session');
|
||||
|
||||
expect(rendered).toContain('/accounts?create=1&siteId=21');
|
||||
expect(rendered).not.toContain('segment=apikey');
|
||||
});
|
||||
|
||||
it('redirects an official API-key-only site to the apikey connection create flow', async () => {
|
||||
const rendered = await createSiteAndCollectLocation({ id: 22, platform: 'openai' });
|
||||
it('shows modal after creating a site and navigates to API key when user chooses it', async () => {
|
||||
const rendered = await createSiteAndClickModalChoice({ id: 22, name: 'Demo Site', platform: 'openai' }, 'apikey');
|
||||
|
||||
expect(rendered).toContain('/accounts?');
|
||||
expect(rendered).toContain('segment=apikey');
|
||||
@@ -127,12 +148,84 @@ describe('Sites create redirect', () => {
|
||||
expect(rendered).toContain('siteId=22');
|
||||
});
|
||||
|
||||
it('redirects a codex site to oauth management instead of legacy account import', async () => {
|
||||
const rendered = await createSiteAndCollectLocation({ id: 23, platform: 'codex' });
|
||||
it('shows modal after creating a codex site and allows choosing later', async () => {
|
||||
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('provider=codex');
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user