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(); }); });