From c39a3edcc35d4b040380dd808683ac01abb78da1 Mon Sep 17 00:00:00 2001
From: deqiying
Date: Sun, 19 Jul 2026 18:31:42 +0800
Subject: [PATCH] =?UTF-8?q?feat(pages):=20=E6=94=AF=E6=8C=81=20GitHub=20Re?=
=?UTF-8?q?lease=20=E9=83=A8=E7=BD=B2=E6=BA=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
增加 latest/tag 手动检查与同步、ETag 与限流退避、资源替换确认,以及对应的前端来源管理和部署来源展示。
---
docs/changelog/index.md | 1 +
docs/docs.go | 4 +-
docs/plan/20260719-pages-source-sync-v2.md | 9 +-
docs/swagger.json | 4 +-
docs/swagger.yaml | 4 +-
.../detail/components/deployment-history.tsx | 4 +-
.../detail/components/pages-source-card.tsx | 282 ++++--
.../components/pages-source-details.tsx | 137 +++
.../detail/components/pages-source-dialog.tsx | 273 +++++-
.../components/pages-source-github-fields.tsx | 130 +++
.../components/pages-source-validation.ts | 68 ++
frontend/components/ui/alert.tsx | 66 ++
frontend/lib/services/openflare/index.ts | 5 +
.../lib/services/openflare/pages.service.ts | 4 +-
frontend/lib/services/openflare/types.ts | 41 +-
.../tests/openflare/pages-service.test.ts | 68 ++
.../tests/openflare/pages-source-ui.test.tsx | 467 ++++++++-
.../openflare/pages-source-validation.test.ts | 100 ++
internal/apps/openflare/pages/errs.go | 106 ++-
.../apps/openflare/pages/github_source.go | 343 +++++++
.../openflare/pages/github_source_action.go | 724 ++++++++++++++
.../pages/github_source_identity_test.go | 111 +++
.../openflare/pages/github_source_test.go | 888 ++++++++++++++++++
internal/apps/openflare/pages/logics.go | 8 +-
internal/apps/openflare/pages/routers.go | 21 +-
internal/apps/openflare/pages/source.go | 141 ++-
.../apps/openflare/pages/source_runtime.go | 24 +
internal/apps/openflare/pages/source_sync.go | 38 +-
.../apps/openflare/pages/source_sync_test.go | 12 +-
internal/apps/openflare/pages/source_tasks.go | 130 ++-
internal/apps/openflare/pages/source_test.go | 31 +-
internal/integration/githubrelease/client.go | 725 ++++++++++++++
.../integration/githubrelease/client_test.go | 748 +++++++++++++++
.../integration/githubrelease/transport.go | 312 ++++++
34 files changed, 5751 insertions(+), 278 deletions(-)
create mode 100644 frontend/app/(main)/pages/detail/components/pages-source-details.tsx
create mode 100644 frontend/app/(main)/pages/detail/components/pages-source-github-fields.tsx
create mode 100644 frontend/app/(main)/pages/detail/components/pages-source-validation.ts
create mode 100644 frontend/components/ui/alert.tsx
create mode 100644 frontend/tests/openflare/pages-source-validation.test.ts
create mode 100644 internal/apps/openflare/pages/github_source.go
create mode 100644 internal/apps/openflare/pages/github_source_action.go
create mode 100644 internal/apps/openflare/pages/github_source_identity_test.go
create mode 100644 internal/apps/openflare/pages/github_source_test.go
create mode 100644 internal/integration/githubrelease/client.go
create mode 100644 internal/integration/githubrelease/client_test.go
create mode 100644 internal/integration/githubrelease/transport.go
diff --git a/docs/changelog/index.md b/docs/changelog/index.md
index db529121..9dc94e9d 100644
--- a/docs/changelog/index.md
+++ b/docs/changelog/index.md
@@ -23,6 +23,7 @@ sidebar: false
### 新增
+- Pages 项目新增持久部署源,可配置 Remote URL 或公开 GitHub Release,并支持手动检查、同步发布、来源状态查看与同一 Release 资源替换确认;部署历史会保留安全的来源快照。
- WAF 规则编排新增「UA 检查」节点:可要求携带 User-Agent、按浏览器/操作系统白名单(且/或)匹配,并优先屏蔽常见爬虫、非正常 UA(不含爬虫)与自定义正则 UA。
### 改进
diff --git a/docs/docs.go b/docs/docs.go
index 6947b49a..2f5ca21a 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -8879,7 +8879,7 @@ const docTemplate = `{
"SessionCookie": []
}
],
- "description": "Remote URL 来源不支持检查更新;该端点为后续 GitHub Release 来源保留统一动作契约",
+ "description": "异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新",
"produces": [
"application/json"
],
@@ -9112,7 +9112,7 @@ const docTemplate = `{
"SessionCookie": []
}
],
- "description": "Phase 1 支持 Remote URL 来源;完整地址仅写入,不会在响应中返回",
+ "description": "支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回",
"consumes": [
"application/json"
],
diff --git a/docs/plan/20260719-pages-source-sync-v2.md b/docs/plan/20260719-pages-source-sync-v2.md
index 47859802..03978901 100644
--- a/docs/plan/20260719-pages-source-sync-v2.md
+++ b/docs/plan/20260719-pages-source-sync-v2.md
@@ -211,9 +211,16 @@ of_pages_projects
GitHub:
```text
-SHA-256("github|" + owner_repo + "|" + selector + "|" + tag + "|" + asset_name)
+LP(value) = uint64be(byte_length(UTF8(value))) || UTF8(value)
+
+SHA-256(
+ "openflare:pages:github-release:v2" ||
+ LP(owner_repo) || LP(selector) || LP(tag) || LP(asset_name)
+)
```
+GitHub identity 对每个 UTF-8 字段使用无歧义的长度前缀编码,不能使用分隔符直接拼接;自动更新开关和检查间隔不参与 identity。
+
Remote:
```text
diff --git a/docs/swagger.json b/docs/swagger.json
index 5c81cc5e..cc0f02c5 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -8872,7 +8872,7 @@
"SessionCookie": []
}
],
- "description": "Remote URL 来源不支持检查更新;该端点为后续 GitHub Release 来源保留统一动作契约",
+ "description": "异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新",
"produces": [
"application/json"
],
@@ -9105,7 +9105,7 @@
"SessionCookie": []
}
],
- "description": "Phase 1 支持 Remote URL 来源;完整地址仅写入,不会在响应中返回",
+ "description": "支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回",
"consumes": [
"application/json"
],
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index b0e074d9..edce34e0 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -9468,7 +9468,7 @@ paths:
- openflare-pages
/api/v1/d/pages/{id}/source/check:
post:
- description: Remote URL 来源不支持检查更新;该端点为后续 GitHub Release 来源保留统一动作契约
+ description: 异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新
parameters:
- description: 项目 ID
in: path
@@ -9611,7 +9611,7 @@ paths:
post:
consumes:
- application/json
- description: Phase 1 支持 Remote URL 来源;完整地址仅写入,不会在响应中返回
+ description: 支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回
parameters:
- description: 项目 ID
in: path
diff --git a/frontend/app/(main)/pages/detail/components/deployment-history.tsx b/frontend/app/(main)/pages/detail/components/deployment-history.tsx
index bd046500..7fe01105 100644
--- a/frontend/app/(main)/pages/detail/components/deployment-history.tsx
+++ b/frontend/app/(main)/pages/detail/components/deployment-history.tsx
@@ -45,7 +45,7 @@ const SOURCE_LABELS: Record = {
manual_upload: '本地上传',
manual_url: 'URL 导入',
remote_url: 'Remote URL',
- github_release: 'GitHub Release',
+ github_release: 'GitHub',
};
const TRIGGER_LABELS: Record = {
@@ -172,7 +172,7 @@ export function DeploymentHistory({
) : deployments.length === 0 ? (
) : (
deployments.map((deployment) => {
diff --git a/frontend/app/(main)/pages/detail/components/pages-source-card.tsx b/frontend/app/(main)/pages/detail/components/pages-source-card.tsx
index eced9522..9e101436 100644
--- a/frontend/app/(main)/pages/detail/components/pages-source-card.tsx
+++ b/frontend/app/(main)/pages/detail/components/pages-source-card.tsx
@@ -1,11 +1,28 @@
'use client';
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { Download, Pencil, RefreshCw, RotateCcw } from 'lucide-react';
+import {
+ Download,
+ Github,
+ Pencil,
+ RefreshCw,
+ RotateCcw,
+ Search,
+} from 'lucide-react';
import { toast } from 'sonner';
import { ErrorInline } from '@/components/layout/error';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -21,12 +38,11 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Spinner } from '@/components/ui/spinner';
import { AdminTaskService } from '@/lib/services/admin';
import {
- type PagesSource,
+ type PagesSourceActionPayload,
type PagesSourceActionReceipt,
type PagesSourceStatus,
PagesService,
} from '@/lib/services/openflare';
-import { formatDateTime } from '@/lib/utils';
import {
deploymentsQueryKey,
@@ -34,7 +50,11 @@ import {
projectsQueryKey,
sourceQueryKey,
} from '../../components/pages-utils';
-import { PagesSourceDialog } from './pages-source-dialog';
+import { type PagesSourceMode, PagesSourceDialog } from './pages-source-dialog';
+import {
+ GitHubSourceDetails,
+ RemoteSourceDetails,
+} from './pages-source-details';
const ACTION_POLL_INTERVAL = 2_000;
const ACTION_MAX_WAIT = 16 * 60 * 1_000;
@@ -59,10 +79,8 @@ interface ActiveAction {
startedAt: number;
}
-function revisionSummary(source: PagesSource) {
- if (source.source_type === 'manual' || !source.last_applied)
- return '尚未应用';
- return `${source.last_applied.label} · ${source.last_applied.revision.slice(0, 12)}`;
+function sourceActionLabel(action: PagesSourceActionReceipt['action']) {
+ return action === 'check' ? '检查' : '同步并发布';
}
export function PagesSourceCard({ projectId }: { projectId: number }) {
@@ -70,11 +88,10 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
const handledExecutionID = useRef(null);
const sourcePollingStartedAt = useRef(null);
const [dialogOpen, setDialogOpen] = useState(false);
- const [dialogMode, setDialogMode] = useState<'manual' | 'remote_url'>(
- 'manual',
- );
+ const [dialogMode, setDialogMode] = useState('manual');
const [activeAction, setActiveAction] = useState(null);
const [actionTimedOut, setActionTimedOut] = useState(false);
+ const [attentionDialogOpen, setAttentionDialogOpen] = useState(false);
const sourceQuery = useQuery({
queryKey: sourceQueryKey(projectId),
@@ -102,8 +119,11 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
'task-execution',
activeAction?.receipt.execution_id ?? '',
],
- queryFn: () =>
- AdminTaskService.getTaskExecution(activeAction!.receipt.execution_id),
+ queryFn: () => {
+ const executionID = activeAction?.receipt.execution_id;
+ if (!executionID) throw new Error('缺少任务执行 ID');
+ return AdminTaskService.getTaskExecution(executionID);
+ },
enabled: Boolean(activeAction) && !actionTimedOut,
refetchInterval: (query) => {
if (actionTimedOut) return false;
@@ -114,6 +134,15 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
},
});
+ const beginActionPolling = useCallback(
+ (receipt: PagesSourceActionReceipt) => {
+ handledExecutionID.current = null;
+ setActiveAction({ receipt, startedAt: Date.now() });
+ setActionTimedOut(false);
+ },
+ [],
+ );
+
useEffect(() => {
if (!activeAction || actionTimedOut) return;
const elapsed = Date.now() - activeAction.startedAt;
@@ -124,7 +153,11 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
useEffect(() => {
const execution = executionQuery.data;
- if (!execution || !['succeeded', 'failed'].includes(execution.status)) {
+ if (
+ !activeAction ||
+ !execution ||
+ !['succeeded', 'failed'].includes(execution.status)
+ ) {
return;
}
if (handledExecutionID.current === execution.id) return;
@@ -142,21 +175,36 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
]);
+ const actionLabel = sourceActionLabel(activeAction.receipt.action);
if (execution.status === 'succeeded') {
- toast.success('部署源同步并发布完成');
+ toast.success(`部署源${actionLabel}完成`);
} else {
- toast.error(execution.error_message || '部署源同步失败');
+ toast.error(execution.error_message || `部署源${actionLabel}失败`);
}
setActiveAction(null);
setActionTimedOut(false);
- }, [executionQuery.data, projectId, queryClient]);
+ }, [activeAction, executionQuery.data, projectId, queryClient]);
+
+ const checkMutation = useMutation({
+ mutationFn: () => PagesService.checkSource(projectId),
+ onSuccess: async (receipt) => {
+ beginActionPolling(receipt);
+ await queryClient.invalidateQueries({
+ queryKey: sourceQueryKey(projectId),
+ });
+ toast.success('检查任务已提交');
+ },
+ onError: (error) => {
+ toast.error(error instanceof Error ? error.message : '检查任务提交失败');
+ },
+ });
const syncMutation = useMutation({
- mutationFn: () => PagesService.syncSource(projectId, {}),
+ mutationFn: (payload: PagesSourceActionPayload) =>
+ PagesService.syncSource(projectId, payload),
onSuccess: async (receipt) => {
- handledExecutionID.current = null;
- setActiveAction({ receipt, startedAt: Date.now() });
- setActionTimedOut(false);
+ setAttentionDialogOpen(false);
+ beginActionPolling(receipt);
await queryClient.invalidateQueries({
queryKey: sourceQueryKey(projectId),
});
@@ -176,13 +224,38 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
const sourceBusy =
source?.source_type !== 'manual' &&
(source?.sync_status === 'checking' || source?.sync_status === 'syncing');
- const actionsDisabled = syncMutation.isPending || executionBusy || sourceBusy;
+ const actionsDisabled =
+ checkMutation.isPending ||
+ syncMutation.isPending ||
+ executionBusy ||
+ sourceBusy;
+ const checkBusy =
+ checkMutation.isPending ||
+ (executionBusy && activeAction?.receipt.action === 'check') ||
+ (source?.source_type !== 'manual' && source?.sync_status === 'checking');
+ const syncBusy =
+ syncMutation.isPending ||
+ (executionBusy && activeAction?.receipt.action === 'sync') ||
+ (source?.source_type !== 'manual' && source?.sync_status === 'syncing');
+ const dispatchError = checkMutation.error ?? syncMutation.error;
- const openSourceDialog = (mode: 'manual' | 'remote_url') => {
+ const openSourceDialog = (mode: PagesSourceMode) => {
setDialogMode(mode);
setDialogOpen(true);
};
+ const dispatchSync = () => {
+ checkMutation.reset();
+ if (
+ source?.source_type === 'github_release' &&
+ source.sync_status === 'attention'
+ ) {
+ setAttentionDialogOpen(true);
+ return;
+ }
+ syncMutation.mutate({});
+ };
+
if (sourceQuery.isLoading) {
return (
@@ -219,10 +292,22 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
);
}
+ const effectiveSourceStatus = executionBusy
+ ? activeAction?.receipt.action === 'check'
+ ? 'checking'
+ : 'syncing'
+ : source.source_type === 'manual'
+ ? undefined
+ : (source.sync_status ?? 'idle');
const status =
source.source_type === 'manual'
? null
- : SOURCE_STATUS[source.sync_status ?? 'idle'];
+ : SOURCE_STATUS[effectiveSourceStatus ?? 'idle'];
+ const attentionRevision =
+ source.source_type === 'github_release' &&
+ source.sync_status === 'attention'
+ ? source.last_seen
+ : undefined;
return (
<>
@@ -250,47 +335,20 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
) : source.source_type === 'remote_url' ? (
-
-
- 脱敏地址
- {source.display_url}
-
-
- 网络策略
-
- {source.remote_network_policy === 'trusted_internal'
- ? '受信内网模式'
- : '公网安全模式'}
-
-
-
- 最近同步
-
- {source.last_synced_at
- ? formatDateTime(source.last_synced_at)
- : '尚未同步'}
-
-
-
-
- 已应用 revision
-
-
- {revisionSummary(source)}
-
-
- {source.last_error ? (
-
-
-
- ) : null}
-
+
) : (
-
- 当前版本暂不提供该来源类型的编辑界面。
-
+
)}
+ {dispatchError ? (
+
+ ) : null}
{executionQuery.isError ? (
{source.source_type === 'manual' ? (
-
- ) : source.source_type === 'remote_url' ? (
+ <>
+
+
+ >
+ ) : (
<>
+ {source.source_type === 'github_release' ? (
+
+ ) : null}
>
- ) : null}
+ )}
+
+ {
+ if (!syncMutation.isPending) setAttentionDialogOpen(open);
+ }}
+ >
+
+
+ 确认发布当前 GitHub revision
+
+ 这将发布卡片当前显示的精确 revision:{' '}
+
+ {attentionRevision?.revision ??
+ '当前 revision 已不可用,请刷新后重试'}
+
+
+
+
+
+ 取消
+
+ {
+ event.preventDefault();
+ if (!attentionRevision) return;
+ syncMutation.mutate({
+ confirmed_revision: attentionRevision.revision,
+ });
+ }}
+ >
+ {syncMutation.isPending ? (
+
+ ) : null}
+ 确认并发布
+
+
+
+
>
);
}
diff --git a/frontend/app/(main)/pages/detail/components/pages-source-details.tsx b/frontend/app/(main)/pages/detail/components/pages-source-details.tsx
new file mode 100644
index 00000000..100db35b
--- /dev/null
+++ b/frontend/app/(main)/pages/detail/components/pages-source-details.tsx
@@ -0,0 +1,137 @@
+import { TriangleAlert } from 'lucide-react';
+
+import { ErrorInline } from '@/components/layout/error';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import {
+ type PagesGitHubReleaseSource,
+ type PagesRemoteURLSource,
+ type PagesSourceRevision,
+} from '@/lib/services/openflare';
+import { formatDateTime } from '@/lib/utils';
+
+function revisionSummary(revision?: PagesSourceRevision) {
+ if (!revision) return '尚无记录';
+ return `${revision.label} · ${revision.revision.slice(0, 12)}`;
+}
+
+export function RemoteSourceDetails({
+ source,
+}: {
+ source: PagesRemoteURLSource;
+}) {
+ return (
+
+
+ 脱敏地址
+ {source.display_url}
+
+
+ 网络策略
+
+ {source.remote_network_policy === 'trusted_internal'
+ ? '受信内网模式'
+ : '公网安全模式'}
+
+
+
+ 最近同步
+
+ {source.last_synced_at
+ ? formatDateTime(source.last_synced_at)
+ : '尚未同步'}
+
+
+
+ 已应用 revision
+
+ {revisionSummary(source.last_applied)}
+
+
+ {source.last_error ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+export function GitHubSourceDetails({
+ source,
+}: {
+ source: PagesGitHubReleaseSource;
+}) {
+ const attentionRevision =
+ source.sync_status === 'attention' ? source.last_seen : undefined;
+
+ return (
+
+ {attentionRevision ? (
+
+
+ Release Asset 发生变化,需要显式确认
+
+
+ 当前远端 revision
+ 与已发布内容不一致。请核对版本和资源后,再确认发布这一精确
+ revision。
+
+ {attentionRevision.revision}
+
+
+ ) : null}
+
+
+
+ GitHub 仓库
+ {source.github_repository}
+
+
+ Release 选择
+
+ {source.release_selector === 'latest'
+ ? '最新 Release'
+ : `固定 Tag · ${source.release_tag ?? '未提供'}`}
+
+
+
+ Release Asset
+ {source.asset_name}
+
+
+ 远端已发现
+
+ {revisionSummary(source.last_seen)}
+
+
+
+ 当前已应用
+
+ {revisionSummary(source.last_applied)}
+
+
+
+ 最近检查
+
+ {source.last_checked_at
+ ? formatDateTime(source.last_checked_at)
+ : '尚未检查'}
+
+
+
+ 最近同步
+
+ {source.last_synced_at
+ ? formatDateTime(source.last_synced_at)
+ : '尚未同步'}
+
+
+ {source.last_error ? (
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/frontend/app/(main)/pages/detail/components/pages-source-dialog.tsx b/frontend/app/(main)/pages/detail/components/pages-source-dialog.tsx
index c3bdb0a1..3915bcdf 100644
--- a/frontend/app/(main)/pages/detail/components/pages-source-dialog.tsx
+++ b/frontend/app/(main)/pages/detail/components/pages-source-dialog.tsx
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { Eye, EyeOff } from 'lucide-react';
import { toast } from 'sonner';
import {
@@ -36,6 +37,8 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
type PagesRemoteNetworkPolicy,
type PagesSource,
+ type PagesSourceActionReceipt,
+ type PagesSourceUpdatePayload,
PagesService,
} from '@/lib/services/openflare';
@@ -45,8 +48,19 @@ import {
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 SourceMode = 'manual' | 'remote_url';
type Confirmation = 'trusted_internal' | 'manual' | null;
interface PagesSourceDialogProps {
@@ -54,7 +68,21 @@ interface PagesSourceDialogProps {
onOpenChange: (open: boolean) => void;
projectId: number;
source: PagesSource;
- initialMode?: SourceMode;
+ 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({
@@ -63,21 +91,33 @@ export function PagesSourceDialog({
projectId,
source,
initialMode,
+ onActionDispatched,
}: PagesSourceDialogProps) {
const queryClient = useQueryClient();
- const [mode, setMode] = useState('manual');
+ 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) return;
- const nextMode =
- initialMode ??
- (source.source_type === 'remote_url' ? 'remote_url' : 'manual');
+ if (!open) {
+ setRemoteURL('');
+ setRevealRemoteURL(false);
+ return;
+ }
+ const nextMode = initialMode ?? source.source_type;
setMode(nextMode);
setNetworkPolicy(
source.source_type === 'remote_url'
@@ -86,7 +126,27 @@ export function PagesSourceDialog({
);
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]);
@@ -102,19 +162,17 @@ export function PagesSourceDialog({
};
const updateMutation = useMutation({
- mutationFn: () =>
- PagesService.updateSource(projectId, {
- source_type: 'remote_url',
- remote_url_set: replaceURL,
- remote_url: replaceURL ? remoteURL.trim() : '',
- remote_network_policy: networkPolicy,
- }),
+ 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) => {
@@ -129,6 +187,8 @@ export function PagesSourceDialog({
await invalidateSourceState();
toast.success('已切换回手动部署');
setConfirmation(null);
+ setRemoteURL('');
+ setRevealRemoteURL(false);
onOpenChange(false);
},
onError: (error) => {
@@ -138,6 +198,13 @@ export function PagesSourceDialog({
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();
@@ -158,21 +225,86 @@ export function PagesSourceDialog({
setConfirmation('trusted_internal');
return;
}
- updateMutation.mutate();
+ 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 = () => {
- if (mode === 'manual') {
- if (source.source_type === 'manual') {
- onOpenChange(false);
- } else {
- setConfirmation('manual');
- }
- return;
+ switch (mode) {
+ case 'manual':
+ if (source.source_type === 'manual') {
+ onOpenChange(false);
+ } else {
+ setConfirmation('manual');
+ }
+ return;
+ case 'remote_url':
+ submitRemote();
+ return;
+ case 'github_release':
+ submitGitHub();
}
- submitRemote();
};
+ const submitLabel =
+ mode === 'manual'
+ ? '使用手动部署'
+ : mode === 'remote_url'
+ ? '保存 Remote 来源'
+ : '保存 GitHub 来源';
+
return (
<>