From bd659d493dd4bc470aea261c54cde05f273377f1 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 4 Jun 2026 10:07:40 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...202606040002_add_pages_project_root_dir.go | 43 +++++ openflare_server/model/goose/migrations.go | 1 + openflare_server/model/pages.go | 2 + openflare_server/service/pages.go | 19 +- openflare_server/service/pages_test.go | 34 +++- .../pages-deployment-upload-modal.tsx | 178 ++++++++++++++---- .../components/pages-project-editor-modal.tsx | 27 +++ .../components/pages-project-list-item.tsx | 8 +- .../web/features/pages/schemas.ts | 11 ++ openflare_server/web/features/pages/types.ts | 4 + 10 files changed, 273 insertions(+), 54 deletions(-) create mode 100644 openflare_server/model/goose/goose_202606040002_add_pages_project_root_dir.go diff --git a/openflare_server/model/goose/goose_202606040002_add_pages_project_root_dir.go b/openflare_server/model/goose/goose_202606040002_add_pages_project_root_dir.go new file mode 100644 index 00000000..dd63df3b --- /dev/null +++ b/openflare_server/model/goose/goose_202606040002_add_pages_project_root_dir.go @@ -0,0 +1,43 @@ +package goose + +import ( + "fmt" + + presslygoose "github.com/pressly/goose/v3" + "gorm.io/gorm" +) + +const versionPagesProjectRootDir int64 = 202606040002 + +// migration202606040002 adds RootDir and EntryFile fields to Pages projects. +func migration202606040002(backend string, ctx Context) *presslygoose.Migration { + return newGORMMigration( + versionPagesProjectRootDir, + "202606040002_add_pages_project_root_dir.go", + backend, + ctx, + migratePagesProjectRootDir, + ) +} + +func migratePagesProjectRootDir(ctx Context, db *gorm.DB, backend string) error { + if err := ctx.ApplyCurrentSchema(db, backend); err != nil { + return err + } + // Verify that the columns exist + if !db.Migrator().HasColumn("pages_projects", "root_dir") { + return fmt.Errorf("column pages_projects.root_dir is missing") + } + if !db.Migrator().HasColumn("pages_projects", "entry_file") { + return fmt.Errorf("column pages_projects.entry_file is missing") + } + // Backfill existing rows: if entry_file is empty, set to 'index.html' + type PagesProject struct { + ID uint `gorm:"primaryKey"` + EntryFile string `gorm:"size:512;not null;default:'index.html'"` + } + if err := db.Model(&PagesProject{}).Where("entry_file = '' OR entry_file IS NULL").Update("entry_file", "index.html").Error; err != nil { + return fmt.Errorf("failed to backfill pages_projects.entry_file: %w", err) + } + return nil +} diff --git a/openflare_server/model/goose/migrations.go b/openflare_server/model/goose/migrations.go index de78b289..a32e208a 100644 --- a/openflare_server/model/goose/migrations.go +++ b/openflare_server/model/goose/migrations.go @@ -46,6 +46,7 @@ func registeredMigrations(backend string, ctx Context) []*presslygoose.Migration migration202606030003(backend, ctx), migration202606030004(backend, ctx), migration202606040001(backend, ctx), + migration202606040002(backend, ctx), } } diff --git a/openflare_server/model/pages.go b/openflare_server/model/pages.go index 75fb2731..5b792a28 100644 --- a/openflare_server/model/pages.go +++ b/openflare_server/model/pages.go @@ -20,6 +20,8 @@ type PagesProject struct { APIProxyPass string `json:"api_proxy_pass" gorm:"size:2048;not null;default:''"` APIProxyRewrite string `json:"api_proxy_rewrite" gorm:"size:255;not null;default:''"` ActiveDeploymentID *uint `json:"active_deployment_id" gorm:"index"` + RootDir string `json:"root_dir" gorm:"size:512;not null;default:''"` + EntryFile string `json:"entry_file" gorm:"size:512;not null;default:'index.html'"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/openflare_server/service/pages.go b/openflare_server/service/pages.go index f5673e58..74ae1ac6 100644 --- a/openflare_server/service/pages.go +++ b/openflare_server/service/pages.go @@ -41,6 +41,8 @@ type PagesProjectInput struct { APIProxyPath string `json:"api_proxy_path"` APIProxyPass string `json:"api_proxy_pass"` APIProxyRewrite string `json:"api_proxy_rewrite"` + RootDir string `json:"root_dir"` + EntryFile string `json:"entry_file"` } type PagesProjectView struct { @@ -55,6 +57,8 @@ type PagesProjectView struct { APIProxyPath string `json:"api_proxy_path"` APIProxyPass string `json:"api_proxy_pass"` APIProxyRewrite string `json:"api_proxy_rewrite"` + RootDir string `json:"root_dir"` + EntryFile string `json:"entry_file"` ActiveDeploymentID *uint `json:"active_deployment_id"` ActiveDeployment *PagesDeploymentView `json:"active_deployment,omitempty"` DeploymentCount int64 `json:"deployment_count"` @@ -151,6 +155,8 @@ func UpdatePagesProject(id uint, input PagesProjectInput) (*PagesProjectView, er "api_proxy_path": project.APIProxyPath, "api_proxy_pass": project.APIProxyPass, "api_proxy_rewrite": project.APIProxyRewrite, + "root_dir": project.RootDir, + "entry_file": project.EntryFile, }).Error; err != nil { if model.IsUniqueConstraintError(err) { return nil, errors.New("Pages 项目标识已存在") @@ -241,11 +247,11 @@ func UploadPagesDeployment(projectID uint, fileHeader *multipart.FileHeader, roo if !strings.EqualFold(filepath.Ext(fileHeader.Filename), ".zip") { return nil, errors.New("Pages 部署包必须是 .zip 文件") } - rootDir, err = validateAndNormalizePagesRootDir(rootDir) + rootDir, err = validateAndNormalizePagesRootDir(project.RootDir) if err != nil { return nil, err } - entryFile = normalizePagesEntryFile(entryFile) + entryFile = normalizePagesEntryFile(project.EntryFile) tempPath, checksum, err := persistPagesUploadTemp(fileHeader) if err != nil { return nil, err @@ -485,6 +491,13 @@ func buildPagesProject(project *model.PagesProject, input PagesProjectInput) (*m project.APIProxyPass = apiProxyPass project.APIProxyRewrite = apiProxyRewrite + rootDir, err := validateAndNormalizePagesRootDir(input.RootDir) + if err != nil { + return nil, err + } + project.RootDir = rootDir + project.EntryFile = normalizePagesEntryFile(input.EntryFile) + return project, nil } @@ -504,6 +517,8 @@ func buildPagesProjectView(project *model.PagesProject) (*PagesProjectView, erro APIProxyPath: project.APIProxyPath, APIProxyPass: project.APIProxyPass, APIProxyRewrite: project.APIProxyRewrite, + RootDir: project.RootDir, + EntryFile: project.EntryFile, ActiveDeploymentID: project.ActiveDeploymentID, CreatedAt: project.CreatedAt, UpdatedAt: project.UpdatedAt, diff --git a/openflare_server/service/pages_test.go b/openflare_server/service/pages_test.go index d2b32fa4..9e79314f 100644 --- a/openflare_server/service/pages_test.go +++ b/openflare_server/service/pages_test.go @@ -331,15 +331,17 @@ func TestUploadPagesDeploymentWithRootDir(t *testing.T) { setupServiceTestDB(t) project, err := CreatePagesProject(PagesProjectInput{ - Name: "App Site", - Slug: "app-site", - Enabled: true, + Name: "App Site", + Slug: "app-site", + Enabled: true, + RootDir: "build", + EntryFile: "index.html", }) if err != nil { t.Fatalf("CreatePagesProject failed: %v", err) } - // 1. Upload a zip with files inside a subfolder, specifying "build" as rootDir. + // 1. Upload a zip with files inside a subfolder. uploadHeader := multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{ "build/index.html": "

App Root

", "build/static/bundle.js": "console.log('app')", @@ -359,12 +361,34 @@ func TestUploadPagesDeploymentWithRootDir(t *testing.T) { t.Fatalf("expected EntryFile to be 'index.html', got %q", deployment.EntryFile) } - // 2. Try uploading with wrong entry file relative to root directory, should fail + // 2. Update project configuration to a wrong entry file relative to root directory, upload should fail + project, err = UpdatePagesProject(project.ID, PagesProjectInput{ + Name: "App Site", + Slug: "app-site", + Enabled: true, + RootDir: "build", + EntryFile: "missing.html", + }) + if err != nil { + t.Fatalf("UpdatePagesProject failed: %v", err) + } _, err = UploadPagesDeployment(project.ID, uploadHeader, "build", "missing.html", "root") if err == nil || !strings.Contains(err.Error(), "缺少入口文件") { t.Fatalf("expected failure for missing entry file, got %v", err) } + // Revert to correct config for snapshot check + project, err = UpdatePagesProject(project.ID, PagesProjectInput{ + Name: "App Site", + Slug: "app-site", + Enabled: true, + RootDir: "build", + EntryFile: "index.html", + }) + if err != nil { + t.Fatalf("UpdatePagesProject failed: %v", err) + } + // 3. Test config snapshot LocalRoot path rendering project, err = ActivatePagesDeployment(project.ID, deployment.ID) if err != nil { diff --git a/openflare_server/web/features/pages/components/pages-deployment-upload-modal.tsx b/openflare_server/web/features/pages/components/pages-deployment-upload-modal.tsx index dc213379..1d647c75 100644 --- a/openflare_server/web/features/pages/components/pages-deployment-upload-modal.tsx +++ b/openflare_server/web/features/pages/components/pages-deployment-upload-modal.tsx @@ -1,7 +1,8 @@ 'use client'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useState } from 'react'; +import { useState, useRef } from 'react'; +import { UploadCloud, File as FileIcon, X } from 'lucide-react'; import { AppModal } from '@/components/ui/app-modal'; import { @@ -10,10 +11,9 @@ import { } from '@/features/pages/api/pages'; import { PrimaryButton, - ResourceField, - ResourceInput, SecondaryButton, } from '@/features/shared/components/resource-primitives'; +import { cn } from '@/lib/utils/cn'; import { deploymentsQueryKey, projectQueryKey, @@ -26,6 +26,15 @@ interface PagesDeploymentUploadModalProps { projectId: number; } +function formatBytes(bytes: number, decimals = 2) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +} + export function PagesDeploymentUploadModal({ isOpen, onClose, @@ -33,17 +42,19 @@ export function PagesDeploymentUploadModal({ }: PagesDeploymentUploadModalProps) { const queryClient = useQueryClient(); const [file, setFile] = useState(null); - const [rootDir, setRootDir] = useState(''); - const [entryFile, setEntryFile] = useState('index.html'); + const [isDragActive, setIsDragActive] = useState(false); const [uploadProgress, setUploadProgress] = useState(null); const [errorMessage, setErrorMessage] = useState(null); + const fileInputRef = useRef(null); const resetForm = () => { setFile(null); - setRootDir(''); - setEntryFile('index.html'); + setIsDragActive(false); setUploadProgress(null); setErrorMessage(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } }; const handleClose = () => { @@ -51,6 +62,31 @@ export function PagesDeploymentUploadModal({ onClose(); }; + const handleDrag = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.type === 'dragenter' || e.type === 'dragover') { + setIsDragActive(true); + } else if (e.type === 'dragleave') { + setIsDragActive(false); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragActive(false); + if (e.dataTransfer.files && e.dataTransfer.files[0]) { + const droppedFile = e.dataTransfer.files[0]; + if (droppedFile.name.toLowerCase().endsWith('.zip')) { + setFile(droppedFile); + setErrorMessage(null); + } else { + setErrorMessage('仅支持 zip 格式的文件'); + } + } + }; + const uploadMutation = useMutation({ mutationFn: async ({ shouldActivate }: { shouldActivate: boolean }) => { if (!file) { @@ -62,8 +98,8 @@ export function PagesDeploymentUploadModal({ const deployment = await uploadPagesDeployment( projectId, file, - rootDir, - entryFile, + '', + 'index.html', (percent) => { setUploadProgress(percent); }, @@ -106,7 +142,7 @@ export function PagesDeploymentUploadModal({ isOpen={isOpen} onClose={handleClose} title="上传部署包" - description="上传已构建的 zip 静态资源包,默认入口为 index.html。" + description="上传已构建的 zip 静态资源包。项目将使用配置的根目录与入口文件路径。" footer={
- - setFile(event.target.files?.[0] ?? null)} - /> - - - setRootDir(event.target.value)} - placeholder="例如: build" - /> - - - setEntryFile(event.target.value)} - /> - + {/* Hidden File Input */} + { + const selectedFile = event.target.files?.[0] ?? null; + if (selectedFile) { + if (selectedFile.name.toLowerCase().endsWith('.zip')) { + setFile(selectedFile); + setErrorMessage(null); + } else { + setErrorMessage('仅支持 zip 格式的文件'); + } + } + }} + className="hidden" + /> + +
+ + 部署包 + + +
fileInputRef.current?.click()} + className={cn( + 'group relative flex min-h-48 cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-[var(--border-default)] bg-[var(--surface-elevated)] p-6 text-center transition-all duration-200 hover:border-[var(--brand-primary)] hover:bg-[var(--surface-hover)]', + isDragActive && + 'scale-[0.99] border-[var(--brand-primary)] bg-[var(--accent-soft)]/20 shadow-inner', + file && 'border-solid border-[var(--brand-primary)]', + )} + > + {file ? ( +
e.stopPropagation()} + > +
+ +
+
+

+ {file.name} +

+

+ 大小: {formatBytes(file.size)} +

+
+
+ +
+
+ ) : ( +
+
+ +
+
+

+ 点击或拖拽 zip 部署包到此处 +

+

+ 仅支持 zip,Server 会校验文件数量、体积、路径逃逸和入口文件 +

+
+
+ )} +
+ {errorMessage && ( + + {errorMessage} + + )} +
+ {uploadProgress !== null && (
diff --git a/openflare_server/web/features/pages/components/pages-project-editor-modal.tsx b/openflare_server/web/features/pages/components/pages-project-editor-modal.tsx index a0d5853d..7e0258c7 100644 --- a/openflare_server/web/features/pages/components/pages-project-editor-modal.tsx +++ b/openflare_server/web/features/pages/components/pages-project-editor-modal.tsx @@ -40,6 +40,8 @@ function toFormValues(project?: PagesProject | null): PagesProjectFormValues { api_proxy_path: '', api_proxy_pass: '', api_proxy_rewrite: '', + root_dir: '', + entry_file: 'index.html', }; } return { @@ -52,6 +54,8 @@ function toFormValues(project?: PagesProject | null): PagesProjectFormValues { api_proxy_path: project.api_proxy_path || '', api_proxy_pass: project.api_proxy_pass || '', api_proxy_rewrite: project.api_proxy_rewrite || '', + root_dir: project.root_dir || '', + entry_file: project.entry_file || 'index.html', }; } @@ -97,6 +101,8 @@ export function PagesProjectEditorModal({ api_proxy_rewrite: values.api_proxy_enabled ? values.api_proxy_rewrite.trim() : '', + root_dir: values.root_dir?.trim() || '', + entry_file: values.entry_file?.trim() || 'index.html', }; return project ? updatePagesProject(project.id, payload) @@ -185,6 +191,27 @@ export function PagesProjectEditorModal({ {...form.register('description')} /> + + + + + + -
-
-

部署数

-

- {project.deployment_count} -

-
+

当前激活 diff --git a/openflare_server/web/features/pages/schemas.ts b/openflare_server/web/features/pages/schemas.ts index 4738a7ea..54c36d0e 100644 --- a/openflare_server/web/features/pages/schemas.ts +++ b/openflare_server/web/features/pages/schemas.ts @@ -25,6 +25,17 @@ export const pagesProjectSchema = z api_proxy_path: z.string().trim(), api_proxy_pass: z.string().trim(), api_proxy_rewrite: z.string().trim(), + root_dir: z + .string() + .trim() + .max(512, '根目录不能超过 512 个字符') + .optional() + .or(z.literal('')), + entry_file: z + .string() + .trim() + .min(1, '请输入入口文件') + .max(512, '入口文件不能超过 512 个字符'), }) .superRefine((data, ctx) => { if (data.spa_fallback_enabled) { diff --git a/openflare_server/web/features/pages/types.ts b/openflare_server/web/features/pages/types.ts index 1f366ef8..d6616ea9 100644 --- a/openflare_server/web/features/pages/types.ts +++ b/openflare_server/web/features/pages/types.ts @@ -25,6 +25,8 @@ export interface PagesProject { api_proxy_path: string; api_proxy_pass: string; api_proxy_rewrite: string; + root_dir?: string; + entry_file: string; active_deployment_id?: number | null; active_deployment?: PagesDeployment | null; deployment_count: number; @@ -43,4 +45,6 @@ export interface PagesProjectPayload { api_proxy_path: string; api_proxy_pass: string; api_proxy_rewrite: string; + root_dir?: string; + entry_file: string; }