mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
[新增] 界面优化
This commit is contained in:
@@ -176,3 +176,14 @@ button {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
@@ -221,19 +221,17 @@ function SidebarContent({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-2">
|
||||
<div className="flex max-h-full min-h-0 flex-col gap-2 overflow-y-auto pr-1">
|
||||
{dashboardNavigation.map((item) => (
|
||||
<SidebarNavItem
|
||||
key={item.href}
|
||||
item={item}
|
||||
currentPath={currentPath}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
forceExpanded={forceExpanded}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<nav className="min-h-0 flex-1 overflow-y-auto no-scrollbar pr-1 flex flex-col gap-2">
|
||||
{dashboardNavigation.map((item) => (
|
||||
<SidebarNavItem
|
||||
key={item.href}
|
||||
item={item}
|
||||
currentPath={currentPath}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
forceExpanded={forceExpanded}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
import { apiRequest, getApiUrl, ApiError } from '@/lib/api/client';
|
||||
import type { ApiEnvelope } from '@/types/api';
|
||||
|
||||
import type {
|
||||
PagesDeployment,
|
||||
@@ -42,13 +43,58 @@ export function uploadPagesDeployment(
|
||||
projectId: number,
|
||||
file: File,
|
||||
entryFile = 'index.html',
|
||||
onProgress?: (percent: number) => void,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
formData.append('package', file);
|
||||
formData.append('entry_file', entryFile);
|
||||
return apiRequest<PagesDeployment>(`/pages/${projectId}/deployments/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
if (!onProgress) {
|
||||
const formData = new FormData();
|
||||
formData.append('package', file);
|
||||
formData.append('entry_file', entryFile);
|
||||
return apiRequest<PagesDeployment>(`/pages/${projectId}/deployments/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise<PagesDeployment>((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append('package', file);
|
||||
formData.append('entry_file', entryFile);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', getApiUrl(`/pages/${projectId}/deployments/upload`));
|
||||
xhr.withCredentials = true;
|
||||
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const percent = Math.round((event.loaded / event.total) * 100);
|
||||
onProgress(percent);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.onload = () => {
|
||||
let payload: ApiEnvelope<PagesDeployment> | null = null;
|
||||
try {
|
||||
payload = JSON.parse(xhr.responseText) as ApiEnvelope<PagesDeployment>;
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
if (payload && payload.success) {
|
||||
resolve(payload.data);
|
||||
} else {
|
||||
reject(new ApiError(payload?.message || '请求失败', xhr.status));
|
||||
}
|
||||
} else {
|
||||
reject(new ApiError(payload?.message || `请求失败(${xhr.status})`, xhr.status));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
reject(new ApiError('网络请求失败', 0));
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
getPagesProject,
|
||||
getPagesDeployments,
|
||||
getPagesProjects,
|
||||
updatePagesProject,
|
||||
uploadPagesDeployment,
|
||||
} from '@/features/pages/api/pages';
|
||||
import type { PagesProject } from '@/features/pages/types';
|
||||
@@ -135,6 +136,8 @@ export function PagesProjectDetailPage({ projectId }: { projectId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [entryFile, setEntryFile] = useState('index.html');
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||
const [isEditModalOpen, setEditModalOpen] = useState(false);
|
||||
|
||||
const parsedProjectId = Number(projectId);
|
||||
const projectQuery = useQuery({
|
||||
@@ -153,16 +156,23 @@ export function PagesProjectDetailPage({ projectId }: { projectId: string }) {
|
||||
if (!file) {
|
||||
throw new Error('请选择 zip 文件');
|
||||
}
|
||||
return uploadPagesDeployment(parsedProjectId, file, entryFile);
|
||||
setUploadProgress(0);
|
||||
return uploadPagesDeployment(parsedProjectId, file, entryFile, (percent) => {
|
||||
setUploadProgress(percent);
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
setFile(null);
|
||||
setUploadProgress(null);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKey(parsedProjectId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
},
|
||||
onError: () => {
|
||||
setUploadProgress(null);
|
||||
},
|
||||
});
|
||||
const activateMutation = useMutation({
|
||||
mutationFn: (deploymentId: number) =>
|
||||
@@ -246,6 +256,12 @@ export function PagesProjectDetailPage({ projectId }: { projectId: string }) {
|
||||
>
|
||||
返回列表
|
||||
</Link>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setEditModalOpen(true)}
|
||||
>
|
||||
编辑项目
|
||||
</SecondaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
disabled={deleteProjectMutation.isPending}
|
||||
@@ -280,12 +296,28 @@ export function PagesProjectDetailPage({ projectId }: { projectId: string }) {
|
||||
onChange={(event) => setEntryFile(event.target.value)}
|
||||
/>
|
||||
</ResourceField>
|
||||
{uploadProgress !== null && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs font-medium text-[var(--foreground-secondary)]">
|
||||
<span>上传进度</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--surface-muted)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--brand-primary)] transition-all duration-300 ease-out"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
disabled={!file || uploadMutation.isPending}
|
||||
onClick={() => uploadMutation.mutate()}
|
||||
>
|
||||
{uploadMutation.isPending ? '上传中...' : '上传部署'}
|
||||
{uploadMutation.isPending
|
||||
? `上传中 (${uploadProgress ?? 0}%)...`
|
||||
: '上传部署'}
|
||||
</PrimaryButton>
|
||||
{uploadMutation.error ? (
|
||||
<p className="text-sm text-[var(--status-danger-foreground)]">
|
||||
@@ -374,10 +406,137 @@ export function PagesProjectDetailPage({ projectId }: { projectId: string }) {
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<PagesProjectEditModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={() => setEditModalOpen(false)}
|
||||
project={project}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PagesProjectEditModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
project,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
project: PagesProject;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState(project.name);
|
||||
const [slug, setSlug] = useState(project.slug);
|
||||
const [description, setDescription] = useState(project.description || '');
|
||||
const [spaFallbackEnabled, setSpaFallbackEnabled] = useState(project.spa_fallback_enabled);
|
||||
const [spaFallbackPath, setSpaFallbackPath] = useState(project.spa_fallback_path);
|
||||
|
||||
useEffect(() => {
|
||||
setName(project.name);
|
||||
setSlug(project.slug);
|
||||
setDescription(project.description || '');
|
||||
setSpaFallbackEnabled(project.spa_fallback_enabled);
|
||||
setSpaFallbackPath(project.spa_fallback_path);
|
||||
}, [project]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updatePagesProject(project.id, {
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
enabled: project.enabled,
|
||||
spa_fallback_enabled: spaFallbackEnabled,
|
||||
spa_fallback_path: spaFallbackPath,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
queryClient.invalidateQueries({ queryKey: projectQueryKey(project.id) });
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
},
|
||||
});
|
||||
|
||||
function handleUpdate(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
updateMutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="编辑 Pages 项目"
|
||||
description="修改静态站点项目的基础配置。"
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
form="pages-project-edit-form"
|
||||
disabled={updateMutation.isPending || name.trim() === ''}
|
||||
>
|
||||
{updateMutation.isPending ? '保存中...' : '保存修改'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="pages-project-edit-form"
|
||||
className="grid gap-4 md:grid-cols-2"
|
||||
onSubmit={handleUpdate}
|
||||
>
|
||||
<ResourceField label="项目名称">
|
||||
<ResourceInput
|
||||
value={name}
|
||||
placeholder="Marketing Site"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="项目标识" hint="留空时会按名称自动生成。">
|
||||
<ResourceInput
|
||||
value={slug}
|
||||
placeholder="marketing-site"
|
||||
onChange={(event) => setSlug(event.target.value)}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="描述" className="md:col-span-2">
|
||||
<ResourceInput
|
||||
value={description}
|
||||
placeholder="这个项目托管的静态站点用途"
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ToggleField
|
||||
label="启用 SPA fallback"
|
||||
description="开启后未命中的路径会回退到指定文件,适合 React/Vue history 路由。"
|
||||
checked={spaFallbackEnabled}
|
||||
onChange={setSpaFallbackEnabled}
|
||||
/>
|
||||
<ResourceField
|
||||
label="SPA 回退路径"
|
||||
hint="以 / 开头,例如 /index.html 或 /app.html。关闭 fallback 时不会生效。"
|
||||
>
|
||||
<ResourceInput
|
||||
value={spaFallbackPath}
|
||||
placeholder="/index.html"
|
||||
disabled={!spaFallbackEnabled}
|
||||
onChange={(event) => setSpaFallbackPath(event.target.value)}
|
||||
/>
|
||||
</ResourceField>
|
||||
{updateMutation.error ? (
|
||||
<p className="text-sm text-[var(--status-danger-foreground)] md:col-span-2">
|
||||
{updateMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
function PagesProjectCreateModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
|
||||
Reference in New Issue
Block a user