[优化] 操作优化

This commit is contained in:
ryan
2026-06-04 10:07:40 +08:00
parent 6a2a6028c3
commit bd659d493d
10 changed files with 273 additions and 54 deletions
@@ -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
}
@@ -46,6 +46,7 @@ func registeredMigrations(backend string, ctx Context) []*presslygoose.Migration
migration202606030003(backend, ctx), migration202606030003(backend, ctx),
migration202606030004(backend, ctx), migration202606030004(backend, ctx),
migration202606040001(backend, ctx), migration202606040001(backend, ctx),
migration202606040002(backend, ctx),
} }
} }
+2
View File
@@ -20,6 +20,8 @@ type PagesProject struct {
APIProxyPass string `json:"api_proxy_pass" gorm:"size:2048;not null;default:''"` APIProxyPass string `json:"api_proxy_pass" gorm:"size:2048;not null;default:''"`
APIProxyRewrite string `json:"api_proxy_rewrite" gorm:"size:255;not null;default:''"` APIProxyRewrite string `json:"api_proxy_rewrite" gorm:"size:255;not null;default:''"`
ActiveDeploymentID *uint `json:"active_deployment_id" gorm:"index"` 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"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
+17 -2
View File
@@ -41,6 +41,8 @@ type PagesProjectInput struct {
APIProxyPath string `json:"api_proxy_path"` APIProxyPath string `json:"api_proxy_path"`
APIProxyPass string `json:"api_proxy_pass"` APIProxyPass string `json:"api_proxy_pass"`
APIProxyRewrite string `json:"api_proxy_rewrite"` APIProxyRewrite string `json:"api_proxy_rewrite"`
RootDir string `json:"root_dir"`
EntryFile string `json:"entry_file"`
} }
type PagesProjectView struct { type PagesProjectView struct {
@@ -55,6 +57,8 @@ type PagesProjectView struct {
APIProxyPath string `json:"api_proxy_path"` APIProxyPath string `json:"api_proxy_path"`
APIProxyPass string `json:"api_proxy_pass"` APIProxyPass string `json:"api_proxy_pass"`
APIProxyRewrite string `json:"api_proxy_rewrite"` APIProxyRewrite string `json:"api_proxy_rewrite"`
RootDir string `json:"root_dir"`
EntryFile string `json:"entry_file"`
ActiveDeploymentID *uint `json:"active_deployment_id"` ActiveDeploymentID *uint `json:"active_deployment_id"`
ActiveDeployment *PagesDeploymentView `json:"active_deployment,omitempty"` ActiveDeployment *PagesDeploymentView `json:"active_deployment,omitempty"`
DeploymentCount int64 `json:"deployment_count"` DeploymentCount int64 `json:"deployment_count"`
@@ -151,6 +155,8 @@ func UpdatePagesProject(id uint, input PagesProjectInput) (*PagesProjectView, er
"api_proxy_path": project.APIProxyPath, "api_proxy_path": project.APIProxyPath,
"api_proxy_pass": project.APIProxyPass, "api_proxy_pass": project.APIProxyPass,
"api_proxy_rewrite": project.APIProxyRewrite, "api_proxy_rewrite": project.APIProxyRewrite,
"root_dir": project.RootDir,
"entry_file": project.EntryFile,
}).Error; err != nil { }).Error; err != nil {
if model.IsUniqueConstraintError(err) { if model.IsUniqueConstraintError(err) {
return nil, errors.New("Pages 项目标识已存在") 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") { if !strings.EqualFold(filepath.Ext(fileHeader.Filename), ".zip") {
return nil, errors.New("Pages 部署包必须是 .zip 文件") return nil, errors.New("Pages 部署包必须是 .zip 文件")
} }
rootDir, err = validateAndNormalizePagesRootDir(rootDir) rootDir, err = validateAndNormalizePagesRootDir(project.RootDir)
if err != nil { if err != nil {
return nil, err return nil, err
} }
entryFile = normalizePagesEntryFile(entryFile) entryFile = normalizePagesEntryFile(project.EntryFile)
tempPath, checksum, err := persistPagesUploadTemp(fileHeader) tempPath, checksum, err := persistPagesUploadTemp(fileHeader)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -485,6 +491,13 @@ func buildPagesProject(project *model.PagesProject, input PagesProjectInput) (*m
project.APIProxyPass = apiProxyPass project.APIProxyPass = apiProxyPass
project.APIProxyRewrite = apiProxyRewrite 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 return project, nil
} }
@@ -504,6 +517,8 @@ func buildPagesProjectView(project *model.PagesProject) (*PagesProjectView, erro
APIProxyPath: project.APIProxyPath, APIProxyPath: project.APIProxyPath,
APIProxyPass: project.APIProxyPass, APIProxyPass: project.APIProxyPass,
APIProxyRewrite: project.APIProxyRewrite, APIProxyRewrite: project.APIProxyRewrite,
RootDir: project.RootDir,
EntryFile: project.EntryFile,
ActiveDeploymentID: project.ActiveDeploymentID, ActiveDeploymentID: project.ActiveDeploymentID,
CreatedAt: project.CreatedAt, CreatedAt: project.CreatedAt,
UpdatedAt: project.UpdatedAt, UpdatedAt: project.UpdatedAt,
+29 -5
View File
@@ -331,15 +331,17 @@ func TestUploadPagesDeploymentWithRootDir(t *testing.T) {
setupServiceTestDB(t) setupServiceTestDB(t)
project, err := CreatePagesProject(PagesProjectInput{ project, err := CreatePagesProject(PagesProjectInput{
Name: "App Site", Name: "App Site",
Slug: "app-site", Slug: "app-site",
Enabled: true, Enabled: true,
RootDir: "build",
EntryFile: "index.html",
}) })
if err != nil { if err != nil {
t.Fatalf("CreatePagesProject failed: %v", err) 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{ uploadHeader := multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{
"build/index.html": "<h1>App Root</h1>", "build/index.html": "<h1>App Root</h1>",
"build/static/bundle.js": "console.log('app')", "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) 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") _, err = UploadPagesDeployment(project.ID, uploadHeader, "build", "missing.html", "root")
if err == nil || !strings.Contains(err.Error(), "缺少入口文件") { if err == nil || !strings.Contains(err.Error(), "缺少入口文件") {
t.Fatalf("expected failure for missing entry file, got %v", err) 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 // 3. Test config snapshot LocalRoot path rendering
project, err = ActivatePagesDeployment(project.ID, deployment.ID) project, err = ActivatePagesDeployment(project.ID, deployment.ID)
if err != nil { if err != nil {
@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query'; 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 { AppModal } from '@/components/ui/app-modal';
import { import {
@@ -10,10 +11,9 @@ import {
} from '@/features/pages/api/pages'; } from '@/features/pages/api/pages';
import { import {
PrimaryButton, PrimaryButton,
ResourceField,
ResourceInput,
SecondaryButton, SecondaryButton,
} from '@/features/shared/components/resource-primitives'; } from '@/features/shared/components/resource-primitives';
import { cn } from '@/lib/utils/cn';
import { import {
deploymentsQueryKey, deploymentsQueryKey,
projectQueryKey, projectQueryKey,
@@ -26,6 +26,15 @@ interface PagesDeploymentUploadModalProps {
projectId: number; 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({ export function PagesDeploymentUploadModal({
isOpen, isOpen,
onClose, onClose,
@@ -33,17 +42,19 @@ export function PagesDeploymentUploadModal({
}: PagesDeploymentUploadModalProps) { }: PagesDeploymentUploadModalProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [rootDir, setRootDir] = useState(''); const [isDragActive, setIsDragActive] = useState(false);
const [entryFile, setEntryFile] = useState('index.html');
const [uploadProgress, setUploadProgress] = useState<number | null>(null); const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const resetForm = () => { const resetForm = () => {
setFile(null); setFile(null);
setRootDir(''); setIsDragActive(false);
setEntryFile('index.html');
setUploadProgress(null); setUploadProgress(null);
setErrorMessage(null); setErrorMessage(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}; };
const handleClose = () => { const handleClose = () => {
@@ -51,6 +62,31 @@ export function PagesDeploymentUploadModal({
onClose(); 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({ const uploadMutation = useMutation({
mutationFn: async ({ shouldActivate }: { shouldActivate: boolean }) => { mutationFn: async ({ shouldActivate }: { shouldActivate: boolean }) => {
if (!file) { if (!file) {
@@ -62,8 +98,8 @@ export function PagesDeploymentUploadModal({
const deployment = await uploadPagesDeployment( const deployment = await uploadPagesDeployment(
projectId, projectId,
file, file,
rootDir, '',
entryFile, 'index.html',
(percent) => { (percent) => {
setUploadProgress(percent); setUploadProgress(percent);
}, },
@@ -106,7 +142,7 @@ export function PagesDeploymentUploadModal({
isOpen={isOpen} isOpen={isOpen}
onClose={handleClose} onClose={handleClose}
title="上传部署包" title="上传部署包"
description="上传已构建的 zip 静态资源包,默认入口为 index.html。" description="上传已构建的 zip 静态资源包。项目将使用配置的根目录与入口文件路径。"
footer={ footer={
<div className="flex flex-wrap justify-end gap-3"> <div className="flex flex-wrap justify-end gap-3">
<SecondaryButton <SecondaryButton
@@ -140,36 +176,98 @@ export function PagesDeploymentUploadModal({
} }
> >
<div className="space-y-4"> <div className="space-y-4">
<ResourceField {/* Hidden File Input */}
label="部署包" <input
hint="仅支持 zip,Server 会校验文件数量、体积、路径逃逸和入口文件。" ref={fileInputRef}
error={errorMessage ?? undefined} type="file"
> accept=".zip,application/zip"
<ResourceInput onChange={(event) => {
type="file" const selectedFile = event.target.files?.[0] ?? null;
accept=".zip,application/zip" if (selectedFile) {
onChange={(event) => setFile(event.target.files?.[0] ?? null)} if (selectedFile.name.toLowerCase().endsWith('.zip')) {
/> setFile(selectedFile);
</ResourceField> setErrorMessage(null);
<ResourceField } else {
label="根目录 (可选)" setErrorMessage('仅支持 zip 格式的文件');
hint="静态资源的根文件夹路径 (例如 build 或 dist),若为空则默认为 zip 包根目录。" }
> }
<ResourceInput }}
value={rootDir} className="hidden"
onChange={(event) => setRootDir(event.target.value)} />
placeholder="例如: build"
/> <div className="block space-y-2">
</ResourceField> <span className="flex items-center gap-2 text-sm font-medium text-[var(--foreground-primary)]">
<ResourceField <span>部署包</span>
label="入口文件" </span>
hint="基于根目录下的入口文件路径。"
> <div
<ResourceInput onDragEnter={handleDrag}
value={entryFile} onDragOver={handleDrag}
onChange={(event) => setEntryFile(event.target.value)} onDragLeave={handleDrag}
/> onDrop={handleDrop}
</ResourceField> onClick={() => 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 ? (
<div
className="flex flex-col items-center space-y-3"
onClick={(e) => e.stopPropagation()}
>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[var(--brand-primary)] transition duration-200 group-hover:scale-110">
<FileIcon className="h-6 w-6" />
</div>
<div className="space-y-1">
<p className="max-w-[300px] truncate text-sm font-medium text-[var(--foreground-primary)]">
{file.name}
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
大小: {formatBytes(file.size)}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-[var(--border-default)] bg-[var(--control-background)] px-3 text-xs font-medium text-[var(--foreground-primary)] transition hover:border-[var(--status-danger-border)] hover:bg-[var(--status-danger-soft)] hover:text-[var(--status-danger-foreground)]"
>
<X className="h-3 w-3" />
清除文件
</button>
</div>
</div>
) : (
<div className="pointer-events-none flex flex-col items-center space-y-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--surface-muted)] text-[var(--foreground-muted)] transition duration-200 group-hover:scale-110 group-hover:bg-[var(--accent-soft)] group-hover:text-[var(--brand-primary)]">
<UploadCloud className="h-6 w-6" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
点击或拖拽 zip 部署包到此处
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
仅支持 zip,Server 会校验文件数量、体积、路径逃逸和入口文件
</p>
</div>
</div>
)}
</div>
{errorMessage && (
<span className="mt-1 block text-xs text-[var(--status-danger-foreground)]">
{errorMessage}
</span>
)}
</div>
{uploadProgress !== null && ( {uploadProgress !== null && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between text-xs font-medium text-[var(--foreground-secondary)]"> <div className="flex items-center justify-between text-xs font-medium text-[var(--foreground-secondary)]">
@@ -40,6 +40,8 @@ function toFormValues(project?: PagesProject | null): PagesProjectFormValues {
api_proxy_path: '', api_proxy_path: '',
api_proxy_pass: '', api_proxy_pass: '',
api_proxy_rewrite: '', api_proxy_rewrite: '',
root_dir: '',
entry_file: 'index.html',
}; };
} }
return { return {
@@ -52,6 +54,8 @@ function toFormValues(project?: PagesProject | null): PagesProjectFormValues {
api_proxy_path: project.api_proxy_path || '', api_proxy_path: project.api_proxy_path || '',
api_proxy_pass: project.api_proxy_pass || '', api_proxy_pass: project.api_proxy_pass || '',
api_proxy_rewrite: project.api_proxy_rewrite || '', 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 api_proxy_rewrite: values.api_proxy_enabled
? values.api_proxy_rewrite.trim() ? values.api_proxy_rewrite.trim()
: '', : '',
root_dir: values.root_dir?.trim() || '',
entry_file: values.entry_file?.trim() || 'index.html',
}; };
return project return project
? updatePagesProject(project.id, payload) ? updatePagesProject(project.id, payload)
@@ -185,6 +191,27 @@ export function PagesProjectEditorModal({
{...form.register('description')} {...form.register('description')}
/> />
</ResourceField> </ResourceField>
<ResourceField
label="根目录 (可选)"
hint="静态资源的根文件夹路径 (例如 build 或 dist),若为空则默认为 zip 包根目录。"
error={form.formState.errors.root_dir?.message}
>
<ResourceInput
placeholder="例如: build"
{...form.register('root_dir')}
/>
</ResourceField>
<ResourceField
label="入口文件"
hint="基于根目录下的入口文件路径。"
error={form.formState.errors.entry_file?.message}
>
<ResourceInput
placeholder="index.html"
{...form.register('entry_file')}
required
/>
</ResourceField>
<ToggleField <ToggleField
label="启用 SPA fallback" label="启用 SPA fallback"
description="开启后未命中的路径会回退到指定文件,适合 React/Vue history 路由。" description="开启后未命中的路径会回退到指定文件,适合 React/Vue history 路由。"
@@ -42,13 +42,7 @@ export function PagesProjectListItem({ project }: { project: PagesProject }) {
) : null} ) : null}
</div> </div>
<div className="grid shrink-0 grid-cols-2 gap-3 text-sm md:min-w-80"> <div className="grid shrink-0 grid-cols-1 gap-3 text-sm md:min-w-40">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-3">
<p className="text-xs text-[var(--foreground-secondary)]">部署数</p>
<p className="mt-1 font-semibold text-[var(--foreground-primary)]">
{project.deployment_count}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-3"> <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-3">
<p className="text-xs text-[var(--foreground-secondary)]"> <p className="text-xs text-[var(--foreground-secondary)]">
当前激活 当前激活
@@ -25,6 +25,17 @@ export const pagesProjectSchema = z
api_proxy_path: z.string().trim(), api_proxy_path: z.string().trim(),
api_proxy_pass: z.string().trim(), api_proxy_pass: z.string().trim(),
api_proxy_rewrite: 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) => { .superRefine((data, ctx) => {
if (data.spa_fallback_enabled) { if (data.spa_fallback_enabled) {
@@ -25,6 +25,8 @@ export interface PagesProject {
api_proxy_path: string; api_proxy_path: string;
api_proxy_pass: string; api_proxy_pass: string;
api_proxy_rewrite: string; api_proxy_rewrite: string;
root_dir?: string;
entry_file: string;
active_deployment_id?: number | null; active_deployment_id?: number | null;
active_deployment?: PagesDeployment | null; active_deployment?: PagesDeployment | null;
deployment_count: number; deployment_count: number;
@@ -43,4 +45,6 @@ export interface PagesProjectPayload {
api_proxy_path: string; api_proxy_path: string;
api_proxy_pass: string; api_proxy_pass: string;
api_proxy_rewrite: string; api_proxy_rewrite: string;
root_dir?: string;
entry_file: string;
} }