'use client'; import { useEffect, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Eye, EyeOff } from 'lucide-react'; import { toast } from 'sonner'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Field, FieldDescription, FieldGroup, FieldLabel, FieldTitle, } from '@/components/ui/field'; import { Input } from '@/components/ui/input'; import { Spinner } from '@/components/ui/spinner'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { type PagesRemoteNetworkPolicy, type PagesSource, type PagesSourceActionReceipt, type PagesSourceUpdatePayload, PagesService, } from '@/lib/services/openflare'; import { deploymentsQueryKey, projectQueryKey, projectsQueryKey, sourceQueryKey, } from '../../components/pages-utils'; import { type PagesGitHubSourceFormErrors, type PagesGitHubSourceFormValue, PagesSourceGitHubFields, } from './pages-source-github-fields'; import { validGitHubAssetName, validGitHubReleaseTag, validGitHubRepositoryURL, } from './pages-source-validation'; export type PagesSourceMode = 'manual' | 'remote_url' | 'github_release'; type Confirmation = 'trusted_internal' | 'manual' | null; interface PagesSourceDialogProps { open: boolean; onOpenChange: (open: boolean) => void; projectId: number; source: PagesSource; initialMode?: PagesSourceMode; onActionDispatched?: (receipt: PagesSourceActionReceipt) => void; } const DEFAULT_GITHUB_ASSET = 'dist.zip'; const DEFAULT_GITHUB_CHECK_INTERVAL = 60; const EMPTY_GITHUB_ERRORS: PagesGitHubSourceFormErrors = { repository: '', releaseTag: '', assetName: '', }; function githubRepositoryURL(repository: string) { const value = repository.trim(); return value ? `https://github.com/${value}` : ''; } export function PagesSourceDialog({ open, onOpenChange, projectId, source, initialMode, onActionDispatched, }: PagesSourceDialogProps) { const queryClient = useQueryClient(); const [mode, setMode] = useState('manual'); const [networkPolicy, setNetworkPolicy] = useState('public'); const [replaceURL, setReplaceURL] = useState(false); const [remoteURL, setRemoteURL] = useState(''); const [revealRemoteURL, setRevealRemoteURL] = useState(false); const [urlError, setURLError] = useState(''); const [githubForm, setGitHubForm] = useState({ repositoryURL: '', releaseSelector: 'latest', releaseTag: '', assetName: DEFAULT_GITHUB_ASSET, }); const [githubErrors, setGitHubErrors] = useState(EMPTY_GITHUB_ERRORS); const [confirmation, setConfirmation] = useState(null); useEffect(() => { if (!open) { setRemoteURL(''); setRevealRemoteURL(false); return; } const nextMode = initialMode ?? source.source_type; setMode(nextMode); setNetworkPolicy( source.source_type === 'remote_url' ? source.remote_network_policy : 'public', ); setReplaceURL(source.source_type !== 'remote_url'); setRemoteURL(''); setRevealRemoteURL(false); setURLError(''); setGitHubForm({ repositoryURL: source.source_type === 'github_release' ? githubRepositoryURL(source.github_repository) : '', releaseSelector: source.source_type === 'github_release' ? source.release_selector : 'latest', releaseTag: source.source_type === 'github_release' ? (source.release_tag ?? '') : '', assetName: source.source_type === 'github_release' ? source.asset_name : DEFAULT_GITHUB_ASSET, }); setGitHubErrors(EMPTY_GITHUB_ERRORS); setConfirmation(null); }, [initialMode, open, source]); const invalidateSourceState = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: sourceQueryKey(projectId) }), queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) }), queryClient.invalidateQueries({ queryKey: deploymentsQueryKey(projectId), }), queryClient.invalidateQueries({ queryKey: projectsQueryKey }), ]); }; const updateMutation = useMutation({ mutationFn: (payload: PagesSourceUpdatePayload) => PagesService.updateSource(projectId, payload), onSuccess: async (result) => { queryClient.setQueryData(sourceQueryKey(projectId), result.source); if (result.check_task) onActionDispatched?.(result.check_task); await invalidateSourceState(); toast.success('部署源已更新'); if (result.warning) toast.warning(result.warning); setConfirmation(null); setRemoteURL(''); setRevealRemoteURL(false); onOpenChange(false); }, onError: (error) => { toast.error(error instanceof Error ? error.message : '部署源更新失败'); }, }); const deleteMutation = useMutation({ mutationFn: () => PagesService.deleteSource(projectId), onSuccess: async (manualSource) => { queryClient.setQueryData(sourceQueryKey(projectId), manualSource); await invalidateSourceState(); toast.success('已切换回手动部署'); setConfirmation(null); setRemoteURL(''); setRevealRemoteURL(false); onOpenChange(false); }, onError: (error) => { toast.error(error instanceof Error ? error.message : '切换失败'); }, }); const isPending = updateMutation.isPending || deleteMutation.isPending; const remotePayload = (): PagesSourceUpdatePayload => ({ source_type: 'remote_url', remote_url_set: replaceURL, remote_url: replaceURL ? remoteURL.trim() : '', remote_network_policy: networkPolicy, }); const submitRemote = () => { if (replaceURL) { const value = remoteURL.trim(); if (!value) { setURLError('请输入 Remote URL'); return; } try { const parsed = new URL(value); if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error(); } catch { setURLError('请输入有效的 HTTP(S) URL'); return; } } setURLError(''); if (networkPolicy === 'trusted_internal') { setConfirmation('trusted_internal'); return; } updateMutation.mutate(remotePayload()); }; const submitGitHub = () => { const normalizedRepositoryURL = githubForm.repositoryURL.trim(); const nextRepositoryError = validGitHubRepositoryURL( normalizedRepositoryURL, ) ? '' : '请输入 https://github.com/{owner}/{repo} 格式的公开仓库地址'; const nextReleaseTagError = githubForm.releaseSelector === 'tag' && !validGitHubReleaseTag(githubForm.releaseTag) ? 'Release tag 须为有效 Git ref(1–255 字节,可使用 /、#、&、=)' : ''; const nextAssetNameError = validGitHubAssetName(githubForm.assetName) ? '' : 'Asset 文件名须为 1–255 字节,且不能是路径或包含控制、换行、双向文本字符'; setGitHubErrors({ repository: nextRepositoryError, releaseTag: nextReleaseTagError, assetName: nextAssetNameError, }); if (nextRepositoryError || nextReleaseTagError || nextAssetNameError) { return; } const payload: PagesSourceUpdatePayload = githubForm.releaseSelector === 'latest' ? { source_type: 'github_release', repository_url: normalizedRepositoryURL, release_selector: 'latest', release_tag: '', asset_name: githubForm.assetName, auto_update_enabled: false, check_interval_minutes: source.source_type === 'github_release' && source.release_selector === 'latest' && source.check_interval_minutes ? source.check_interval_minutes : DEFAULT_GITHUB_CHECK_INTERVAL, } : { source_type: 'github_release', repository_url: normalizedRepositoryURL, release_selector: 'tag', release_tag: githubForm.releaseTag, asset_name: githubForm.assetName, auto_update_enabled: false, check_interval_minutes: 0, }; updateMutation.mutate(payload); }; const handleSubmit = () => { switch (mode) { case 'manual': if (source.source_type === 'manual') { onOpenChange(false); } else { setConfirmation('manual'); } return; case 'remote_url': submitRemote(); return; case 'github_release': submitGitHub(); } }; const submitLabel = mode === 'manual' ? '使用手动部署' : mode === 'remote_url' ? '保存 Remote 来源' : '保存 GitHub 来源'; return ( <> { if (!isPending) onOpenChange(nextOpen); }} > 部署源设置 手动部署、Remote URL 与 GitHub Release 使用独立配置。 来源类型 { if ( value === 'manual' || value === 'remote_url' || value === 'github_release' ) { setMode(value); if (value !== 'remote_url') { setRemoteURL(''); setRevealRemoteURL(false); setURLError(''); } } }} > 手动部署 Remote URL GitHub Release 远端来源只负责发现内容,发布结果始终保留为不可变部署。 仓库源码构建将在后续作为独立来源类型提供。 {mode === 'manual' ? ( 手动部署
保留现有部署与当前生产版本,后续通过“上传部署包”创建新部署。
) : mode === 'remote_url' ? ( <> Remote URL {source.source_type === 'remote_url' && !replaceURL ? (
{source.display_url}
) : (
{ setRemoteURL(event.target.value); setURLError(''); }} />
)} {urlError || (replaceURL ? '保存后不会回显原始地址或 query token。' : '界面只显示脱敏地址;留空表示保留当前地址。')} {source.source_type === 'remote_url' && replaceURL ? ( ) : null}
网络策略 { if (value === 'public' || value === 'trusted_internal') { setNetworkPolicy(value); } }} > 公网安全模式 受信内网模式 {networkPolicy === 'public' ? '阻止内网地址、代理与不安全 TLS。' : '允许访问内网地址与自签名证书,仅用于可信来源。'} ) : ( )}
{ if (!nextOpen && !isPending) setConfirmation(null); }} > {confirmation === 'manual' ? '切换回手动部署' : '启用受信内网模式'} {confirmation === 'manual' ? '当前来源配置将被删除,但已有部署与当前生产版本会保留。' : '该模式允许访问私有网络并接受自签名证书。请确认此地址属于可信内部来源,且不会被非可信用户控制。'} 取消 { event.preventDefault(); if (confirmation === 'manual') { deleteMutation.mutate(); } else if (confirmation === 'trusted_internal') { updateMutation.mutate(remotePayload()); } }} > {isPending ? : null} 确认 ); }