[优化] 操作优化

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),
migration202606030004(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:''"`
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"`
}
+17 -2
View File
@@ -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,
+29 -5
View File
@@ -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": "<h1>App Root</h1>",
"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 {
@@ -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<File | null>(null);
const [rootDir, setRootDir] = useState('');
const [entryFile, setEntryFile] = useState('index.html');
const [isDragActive, setIsDragActive] = useState(false);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
@@ -140,36 +176,98 @@ export function PagesDeploymentUploadModal({
}
>
<div className="space-y-4">
<ResourceField
label="部署包"
hint="仅支持 zip,Server 会校验文件数量、体积、路径逃逸和入口文件。"
error={errorMessage ?? undefined}
>
<ResourceInput
type="file"
accept=".zip,application/zip"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>
</ResourceField>
<ResourceField
label="根目录 (可选)"
hint="静态资源的根文件夹路径 (例如 build 或 dist),若为空则默认为 zip 包根目录。"
>
<ResourceInput
value={rootDir}
onChange={(event) => setRootDir(event.target.value)}
placeholder="例如: build"
/>
</ResourceField>
<ResourceField
label="入口文件"
hint="基于根目录下的入口文件路径。"
>
<ResourceInput
value={entryFile}
onChange={(event) => setEntryFile(event.target.value)}
/>
</ResourceField>
{/* Hidden File Input */}
<input
ref={fileInputRef}
type="file"
accept=".zip,application/zip"
onChange={(event) => {
const selectedFile = event.target.files?.[0] ?? null;
if (selectedFile) {
if (selectedFile.name.toLowerCase().endsWith('.zip')) {
setFile(selectedFile);
setErrorMessage(null);
} else {
setErrorMessage('仅支持 zip 格式的文件');
}
}
}}
className="hidden"
/>
<div className="block space-y-2">
<span className="flex items-center gap-2 text-sm font-medium text-[var(--foreground-primary)]">
<span>部署包</span>
</span>
<div
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
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 && (
<div className="space-y-2">
<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_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')}
/>
</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
label="启用 SPA fallback"
description="开启后未命中的路径会回退到指定文件,适合 React/Vue history 路由。"
@@ -42,13 +42,7 @@ export function PagesProjectListItem({ project }: { project: PagesProject }) {
) : null}
</div>
<div className="grid shrink-0 grid-cols-2 gap-3 text-sm md:min-w-80">
<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="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)]">
当前激活
@@ -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) {
@@ -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;
}