From 9212b765cd6ed9152b5c7cb6752c502ff6c2d242 Mon Sep 17 00:00:00 2001 From: Delicious233 <2991573049@qq.com> Date: Mon, 30 Mar 2026 18:51:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E7=AB=99=E7=82=B9=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E5=90=8E=E6=98=BE=E7=A4=BA=E9=80=89=E6=8B=A9=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E6=A1=86=20(#302)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * feat: 站点创建后显示选择对话框 新站点创建后不再根据平台类型自动跳转,而是显示一个选择对话框让用户决定下一步操作: - 添加账号(用户名密码登录) - 添加 API Key - 稍后配置 对于 codex 平台,选择"添加账号"时会跳转到 OAuth 流程,其他平台则跳转到账号管理页面。 这样可以避免用户被强制跳转,提供更灵活的配置选择。 * fix: add getModelTokenCandidates mock to Settings tests * fix: add getModelTokenCandidates mock to proxy-transport test --------- Co-authored-by: Ding Co-authored-by: Claude Sonnet 4.6 --- src/web/components/SiteCreatedModal.tsx | 83 ++++++++++++++ src/web/pages/Sites.tsx | 68 +++++++++--- src/web/pages/sites.create-redirect.test.tsx | 109 +++++++++++++++++-- 3 files changed, 239 insertions(+), 21 deletions(-) create mode 100644 src/web/components/SiteCreatedModal.tsx diff --git a/src/web/components/SiteCreatedModal.tsx b/src/web/components/SiteCreatedModal.tsx new file mode 100644 index 0000000..d060468 --- /dev/null +++ b/src/web/components/SiteCreatedModal.tsx @@ -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(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 ( + { + if (e.target === dialogRef.current) { + onChoice('later'); + } + }} + > +
+

+ 站点创建成功 +

+

+ 站点 "{siteName}" 已添加成功。接下来您想做什么? +

+ +
+ + + +
+ +

+ 提示:您可以随时在"站点管理"页面配置账号信息 +

+
+
+ ); +} \ No newline at end of file diff --git a/src/web/pages/Sites.tsx b/src/web/pages/Sites.tsx index 1bdd0c8..c30b08c 100644 --- a/src/web/pages/Sites.tsx +++ b/src/web/pages/Sites.tsx @@ -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(null); const [selectedSiteIds, setSelectedSiteIds] = useState([]); const [expandedSiteIds, setExpandedSiteIds] = useState([]); + 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() { : <>确定要删除选中的 {deleteConfirm?.count || 0} 个站点吗?} /> + {createdSiteForChoice && ( + { + setCreatedSiteForChoice(null); + closeEditor(); + load(); + }} + /> + )} + {activeEditor && ( {`${location.pathname}${location.search}`}; } -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( + + + + } /> + + + , + ); + }); + 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(); }); });