diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index 0dc8af1..fcc0656 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -853,10 +853,65 @@ async function handleImageRoute(ctx: SessionContext): Promise { } async function handleSubmissionRoute(ctx: SessionContext): Promise { - if (ctx.parts[2] !== "submissions") return false; + const isAdminSubmissions = ctx.parts[2] === "submissions"; + const isMeSubmissions = ctx.parts[2] === "me" && ctx.parts[3] === "submissions"; + if (!isAdminSubmissions && !isMeSubmissions) return false; + + // GET /api/v1/me/submissions + if (isMeSubmissions && is("GET", ctx) && ctx.parts.length === 4) { + const userSubs = ctx.state.submissions.filter((s) => s.submitted_by === ctx.account.email); + sendJSON(ctx.res, 200, { submissions: userSubs }); + return true; + } + + // POST /api/v1/me/submissions + if (isMeSubmissions && is("POST", ctx) && ctx.parts.length === 4) { + const body = await readJSON<{ display_name?: string }>(ctx.req); + const displayName = body.display_name?.trim(); + if (!displayName) { + sendError(ctx.res, 400, "bad_request", "display_name is required"); + return true; + } + const id = `sub-${Date.now()}`; + const newSub: Submission = { + id, + submitted_by: ctx.account.email, + display_name: displayName, + context_ref: `minio/contexts/${id}/context.tar.gz`, + status: "pending_review", + created_at: new Date().toISOString(), + }; + ctx.state.submissions.unshift(newSub); + sendJSON(ctx.res, 201, newSub); + return true; + } + + // POST /api/v1/me/submissions/{id}/context + if (isMeSubmissions && is("POST", ctx) && ctx.parts[5] === "context" && ctx.parts.length === 6) { + const id = ctx.parts[4]; + const sub = ctx.state.submissions.find((s) => s.id === id); + if (!sub) { + sendError(ctx.res, 404, "not_found", "submission not found"); + return true; + } + if (sub.submitted_by !== ctx.account.email) { + sendError(ctx.res, 404, "not_found", "submission not found"); + return true; + } + if (sub.status !== "pending_review") { + sendError(ctx.res, 409, "already_reviewed", "submission has already been reviewed"); + return true; + } + + // Read the body stream to end so the socket is clean + for await (const _ of ctx.req) { /* discard */ } + + sendJSON(ctx.res, 200, sub); + return true; + } // GET /api/v1/submissions - if (is("GET", ctx) && ctx.parts.length === 3) { + if (isAdminSubmissions && is("GET", ctx) && ctx.parts.length === 3) { if (ctx.account.role !== "admin") { sendError(ctx.res, 403, "forbidden", "admin account required"); return true; @@ -866,7 +921,7 @@ async function handleSubmissionRoute(ctx: SessionContext): Promise { } // POST /api/v1/submissions/{id}/approve - if (is("POST", ctx) && ctx.parts[4] === "approve" && ctx.parts.length === 5) { + if (isAdminSubmissions && is("POST", ctx) && ctx.parts[4] === "approve" && ctx.parts.length === 5) { if (ctx.account.role !== "admin") { sendError(ctx.res, 403, "forbidden", "admin account required"); return true; @@ -917,7 +972,7 @@ async function handleSubmissionRoute(ctx: SessionContext): Promise { } // POST /api/v1/submissions/{id}/reject - if (is("POST", ctx) && ctx.parts[4] === "reject" && ctx.parts.length === 5) { + if (isAdminSubmissions && is("POST", ctx) && ctx.parts[4] === "reject" && ctx.parts.length === 5) { if (ctx.account.role !== "admin") { sendError(ctx.res, 403, "forbidden", "admin account required"); return true; diff --git a/panel/src/App.tsx b/panel/src/App.tsx index 9810853..9ffd790 100644 --- a/panel/src/App.tsx +++ b/panel/src/App.tsx @@ -15,6 +15,7 @@ import { Account } from "@/pages/Account"; import { ImageAdmin } from "@/pages/admin/ImageAdmin"; import { ImageBuildPage } from "@/pages/admin/ImageBuildPage"; import { SubmissionsPage } from "@/pages/admin/SubmissionsPage"; +import { MySubmissionsPage } from "@/pages/MySubmissionsPage"; // Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES): // / User-Side — app-tier, every authenticated principal @@ -45,6 +46,7 @@ export default function App() { } /> } /> } /> + } /> } /> {/* Admin-Side — admin-tier (server & content ops). diff --git a/panel/src/i18n/index.ts b/panel/src/i18n/index.ts index b396ef4..d8f576e 100644 --- a/panel/src/i18n/index.ts +++ b/panel/src/i18n/index.ts @@ -11,6 +11,7 @@ import enOps from "./resources/en-US/ops.json"; import enErrors from "./resources/en-US/errors.json"; import enNavigation from "./resources/en-US/navigation.json"; import enBackups from "./resources/en-US/backups.json"; +import enSubmissions from "./resources/en-US/submissions.json"; import zhCommon from "./resources/zh-CN/common.json"; import zhAuth from "./resources/zh-CN/auth.json"; import zhDashboard from "./resources/zh-CN/dashboard.json"; @@ -21,6 +22,7 @@ import zhOps from "./resources/zh-CN/ops.json"; import zhErrors from "./resources/zh-CN/errors.json"; import zhNavigation from "./resources/zh-CN/navigation.json"; import zhBackups from "./resources/zh-CN/backups.json"; +import zhSubmissions from "./resources/zh-CN/submissions.json"; i18next .use(LanguageDetector) @@ -38,6 +40,7 @@ i18next errors: enErrors, navigation: enNavigation, backups: enBackups, + submissions: enSubmissions, }, "zh-CN": { common: zhCommon, @@ -50,6 +53,7 @@ i18next errors: zhErrors, navigation: zhNavigation, backups: zhBackups, + submissions: zhSubmissions, }, }, fallbackLng: "en-US", diff --git a/panel/src/i18n/resources/en-US/navigation.json b/panel/src/i18n/resources/en-US/navigation.json index c365e76..c2600df 100644 --- a/panel/src/i18n/resources/en-US/navigation.json +++ b/panel/src/i18n/resources/en-US/navigation.json @@ -1,6 +1,7 @@ { "dashboard": "Dashboard", "my_servers": "Servers", + "my_submissions": "My Submissions", "account": "Account", "admin_section": "Admin", "admin_images": "Images", diff --git a/panel/src/i18n/resources/en-US/submissions.json b/panel/src/i18n/resources/en-US/submissions.json new file mode 100644 index 0000000..900a985 --- /dev/null +++ b/panel/src/i18n/resources/en-US/submissions.json @@ -0,0 +1,33 @@ +{ + "title": "My Submissions", + "subtitle": "Submit your custom modpacks for review and track their build status. Approved submissions will automatically trigger safe builds.", + "submit_card_title": "Submit New Modpack", + "submit_card_desc": "Provide a name and upload your build context gzip tarball (.tar.gz).", + "display_name_label": "Display Name", + "display_name_placeholder": "e.g., Pixelmon Adventure Pack", + "file_label": "Build Context (.tar.gz)", + "file_drag_hint": "Drag and drop a .tar.gz file here, or click to browse", + "file_selected": "Selected file: {{name}} ({{size}})", + "submit_btn": "Submit", + "submitting_create": "Creating submission...", + "submitting_upload": "Uploading build context...", + "submit_success": "Modpack submitted successfully!", + "list_card_title": "Submission History", + "filter_all": "All Statuses", + "status_pending_review": "Pending Review", + "status_approved": "Approved", + "status_rejected": "Rejected", + "table_display_name": "Modpack Name", + "table_status": "Status", + "table_created_at": "Submitted At", + "table_reviewed_by": "Reviewed By", + "table_image_ref": "Image Reference", + "table_reject_reason": "Reject Reason", + "no_submissions_title": "No Submissions Found", + "no_submissions_hint": "You haven't submitted any modpacks yet.", + "search_placeholder": "Search submissions...", + "error_file_type": "Please upload a valid .tar.gz file.", + "error_file_size": "File exceeds the allowed size limit.", + "error_name_required": "Display name is required.", + "error_file_required": "Build context file is required." +} diff --git a/panel/src/i18n/resources/zh-CN/navigation.json b/panel/src/i18n/resources/zh-CN/navigation.json index 52dd370..b54798c 100644 --- a/panel/src/i18n/resources/zh-CN/navigation.json +++ b/panel/src/i18n/resources/zh-CN/navigation.json @@ -1,6 +1,7 @@ { "dashboard": "仪表盘", "my_servers": "服务器", + "my_submissions": "我的提交", "account": "账户", "admin_section": "管理", "admin_images": "镜像", diff --git a/panel/src/i18n/resources/zh-CN/submissions.json b/panel/src/i18n/resources/zh-CN/submissions.json new file mode 100644 index 0000000..1885039 --- /dev/null +++ b/panel/src/i18n/resources/zh-CN/submissions.json @@ -0,0 +1,33 @@ +{ + "title": "内容提交", + "subtitle": "提交您的自定义模组包进行审核并跟踪其构建状态。通过审核的提交将自动触发安全构建。", + "submit_card_title": "提交新模组包", + "submit_card_desc": "提供显示名称并上传您的构建上下文 gzip 压缩包 (.tar.gz)。", + "display_name_label": "显示名称", + "display_name_placeholder": "例如:Pixelmon 冒险包", + "file_label": "构建上下文 (.tar.gz)", + "file_drag_hint": "拖拽 .tar.gz 文件到此处,或点击浏览文件", + "file_selected": "已选择文件: {{name}} ({{size}})", + "submit_btn": "提交", + "submitting_create": "正在创建提交...", + "submitting_upload": "正在上传构建上下文...", + "submit_success": "模组包提交成功!", + "list_card_title": "提交历史", + "filter_all": "全部状态", + "status_pending_review": "等待审核", + "status_approved": "审核通过", + "status_rejected": "已拒绝", + "table_display_name": "模组包名称", + "table_status": "状态", + "table_created_at": "提交时间", + "table_reviewed_by": "审核人", + "table_image_ref": "镜像引用", + "table_reject_reason": "拒绝原因", + "no_submissions_title": "暂无提交记录", + "no_submissions_hint": "您还没有提交过任何模组包。", + "search_placeholder": "搜索提交记录...", + "error_file_type": "请上传有效的 .tar.gz 压缩文件。", + "error_file_size": "文件超过了允许的大小限制。", + "error_name_required": "必须填写显示名称。", + "error_file_required": "必须上传构建上下文文件。" +} diff --git a/panel/src/lib/api.test.ts b/panel/src/lib/api.test.ts index 68b1738..703d06e 100644 --- a/panel/src/lib/api.test.ts +++ b/panel/src/lib/api.test.ts @@ -418,5 +418,42 @@ describe("image whitelist and builds wire shapes", () => { expect((opts as RequestInit).method).toBe("POST"); expect(JSON.parse((opts as RequestInit).body as string)).toEqual({ reason: "bad" }); }); + + it("listMySubmissions GETs from /me/submissions", async () => { + const submissions = [{ id: "sub-2", display_name: "my test", status: "pending_review" }]; + const fetchSpy = fakeFetch({ submissions }); + vi.stubGlobal("fetch", fetchSpy); + const res = await api.listMySubmissions(); + expect(res).toEqual(submissions); + const [url, opts] = (fetchSpy as unknown as ReturnType).mock.calls[0]; + expect(String(url)).toBe("/me/submissions"); + expect((opts as RequestInit).method).toBe("GET"); + }); + + it("createSubmission POSTs {display_name} to /me/submissions", async () => { + const sub = { id: "sub-3", display_name: "new submission", status: "pending_review" }; + const fetchSpy = fakeFetch(sub); + vi.stubGlobal("fetch", fetchSpy); + const res = await api.createSubmission("new submission"); + expect(res).toEqual(sub); + const [url, opts] = (fetchSpy as unknown as ReturnType).mock.calls[0]; + expect(String(url)).toBe("/me/submissions"); + expect((opts as RequestInit).method).toBe("POST"); + expect(JSON.parse((opts as RequestInit).body as string)).toEqual({ display_name: "new submission" }); + }); + + it("uploadSubmissionContext POSTs Blob to /me/submissions/{id}/context", async () => { + const sub = { id: "sub-3", display_name: "new submission", status: "pending_review" }; + const fetchSpy = fakeFetch(sub); + vi.stubGlobal("fetch", fetchSpy); + const blob = new Blob(["test"], { type: "application/x-gzip" }); + const res = await api.uploadSubmissionContext("sub-3", blob); + expect(res).toEqual(sub); + const [url, opts] = (fetchSpy as unknown as ReturnType).mock.calls[0]; + expect(String(url)).toBe("/me/submissions/sub-3/context"); + expect((opts as RequestInit).method).toBe("POST"); + expect((opts as RequestInit).body).toBe(blob); + expect((opts as RequestInit).headers).toEqual({ "Content-Type": "application/x-gzip" }); + }); }); }); diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 940d604..17ddcd4 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -57,6 +57,34 @@ async function request(method: string, path: string, body?: unknown): Promise return parsed as T; } +async function requestRaw( + method: string, + path: string, + body: Blob, + headers?: Record, +): Promise { + const { apiBase } = await loadConfig(); + const res = await fetch(`${apiBase}${path}`, { + method, + credentials: "include", + headers, + body, + }); + + const text = await res.text(); + const parsed: unknown = text ? JSON.parse(text) : null; + + if (!res.ok) { + const err: ApiError = { + status: res.status, + code: isApiError(parsed) ? parsed.error.code : "error", + message: isApiError(parsed) ? parsed.error.message : res.statusText, + }; + throw err; + } + return parsed as T; +} + export const api = { // Local-password auth (spec §B1). login sets an HttpOnly session cookie as a // side effect — the panel never sees it — and returns only what to route on next @@ -258,6 +286,17 @@ export const api = { rejectSubmission: (id: string, reason: string) => request("POST", `/submissions/${id}/reject`, { reason }), + + listMySubmissions: () => + request<{ submissions: Submission[] }>("GET", "/me/submissions").then((r) => r.submissions ?? []), + + createSubmission: (displayName: string) => + request("POST", "/me/submissions", { display_name: displayName }), + + uploadSubmissionContext: (id: string, file: Blob) => + requestRaw("POST", `/me/submissions/${id}/context`, file, { + "Content-Type": "application/x-gzip", + }), }; /** diff --git a/panel/src/lib/nav.ts b/panel/src/lib/nav.ts index 9be9f57..8fd7f94 100644 --- a/panel/src/lib/nav.ts +++ b/panel/src/lib/nav.ts @@ -5,6 +5,7 @@ import { Boxes, Cpu, ClipboardCheck, + Upload, type LucideIcon, } from "lucide-react"; @@ -44,6 +45,7 @@ export const NAV_SECTIONS: NavSection[] = [ items: [ { to: "/", key: "dashboard", icon: LayoutDashboard, end: true }, { to: "/servers", key: "my_servers", icon: Server }, + { to: "/submissions", key: "my_submissions", icon: Upload }, { to: "/account", key: "account", icon: UserRound }, ], }, diff --git a/panel/src/pages/MySubmissionsPage.tsx b/panel/src/pages/MySubmissionsPage.tsx new file mode 100644 index 0000000..12bee34 --- /dev/null +++ b/panel/src/pages/MySubmissionsPage.tsx @@ -0,0 +1,613 @@ +import { useState, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { + Upload, + Search, + Loader2, + AlertCircle, + FileText, + X, + ChevronDown, + ChevronUp, + Plus, + CheckCircle, + CircleSlash, + ClipboardCheck, + type LucideIcon, +} from "lucide-react"; +import { + Card, + CardContent, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { Loading, ErrorState, EmptyState } from "@/components/States"; +import { Pagination } from "@/components/Pagination"; +import { api, humanizeError } from "@/lib/api"; +import { useAsync } from "@/lib/hooks"; +import { formatRelative, formatAbsolute } from "@/lib/format"; +import type { Submission, SubmissionStatus } from "@/lib/types"; + +const PAGE_SIZE = 10; + +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", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; +} + +function StatCard({ + icon: Icon, + label, + value, + accentClass, +}: { + icon: LucideIcon; + label: string; + value: number; + accentClass?: string; +}) { + return ( + + +
+ +
+
+
{value}
+
{label}
+
+
+
+ ); +} + +export function MySubmissionsPage() { + const { t, i18n } = useTranslation("submissions"); + const locale = i18n.language; + const now = Date.now(); + + // Async API hook + const { data, error: fetchError, loading, reload } = useAsync(() => api.listMySubmissions(), []); + const submissions: Submission[] = data ?? []; + + // Dialog State + const [dialogOpen, setDialogOpen] = useState(false); + + // Form State + const [displayName, setDisplayName] = useState(""); + const [file, setFile] = useState(null); + const [dragActive, setDragActive] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitStep, setSubmitStep] = useState<"create" | "upload" | null>(null); + const [error, setError] = useState(null); + + const fileInputRef = useRef(null); + + // Search & Filtering State + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState<"all" | SubmissionStatus>("all"); + const [page, setPage] = useState(1); + const [expandedId, setExpandedId] = useState(null); + + // Filtered & Paginated Submissions + const filteredSubmissions = useMemo(() => { + let list = [...submissions]; + + if (search.trim()) { + const q = search.toLowerCase(); + list = list.filter( + (s) => + s.display_name.toLowerCase().includes(q) || + s.id.toLowerCase().includes(q), + ); + } + + if (statusFilter !== "all") { + list = list.filter((s) => s.status === statusFilter); + } + + return list; + }, [submissions, search, statusFilter]); + + // Reset page when filter changes + const lastFilterKey = `${search}-${statusFilter}`; + const [prevFilterKey, setPrevFilterKey] = useState(lastFilterKey); + if (prevFilterKey !== lastFilterKey) { + setPage(1); + setPrevFilterKey(lastFilterKey); + } + + const paginatedSubmissions = useMemo(() => { + const start = (page - 1) * PAGE_SIZE; + return filteredSubmissions.slice(start, start + PAGE_SIZE); + }, [filteredSubmissions, page]); + + // Stats + const stats = useMemo(() => { + const total = submissions.length; + const pending = submissions.filter((s) => s.status === "pending_review").length; + const approved = submissions.filter((s) => s.status === "approved").length; + const rejected = submissions.filter((s) => s.status === "rejected").length; + return { total, pending, approved, rejected }; + }, [submissions]); + + // Drag and drop event handlers + const handleDrag = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.type === "dragenter" || e.type === "dragover") { + setDragActive(true); + } else if (e.type === "dragleave") { + setDragActive(false); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + if (e.dataTransfer.files && e.dataTransfer.files[0]) { + validateAndSetFile(e.dataTransfer.files[0]); + } + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const selectedFile = e.target.files?.[0] || null; + validateAndSetFile(selectedFile); + }; + + const validateAndSetFile = (selectedFile: File | null) => { + setError(null); + if (!selectedFile) { + setFile(null); + return; + } + if (!selectedFile.name.endsWith(".tar.gz")) { + setError(t("error_file_type")); + setFile(null); + return; + } + const maxBytes = 1024 * 1024 * 1024; // 1 GiB limit + if (selectedFile.size > maxBytes) { + setError(t("error_file_size")); + setFile(null); + return; + } + setFile(selectedFile); + }; + + const handleFormSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + const trimmedName = displayName.trim(); + if (!trimmedName) { + setError(t("error_name_required")); + return; + } + if (!file) { + setError(t("error_file_required")); + return; + } + + setIsSubmitting(true); + setSubmitStep("create"); + + try { + // 1. Create the submission metadata + const sub = await api.createSubmission(trimmedName); + + // 2. Upload context file + setSubmitStep("upload"); + await api.uploadSubmissionContext(sub.id, file); + + setDisplayName(""); + setFile(null); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + setDialogOpen(false); + reload(); + } catch (err) { + setError(humanizeError(err)); + } finally { + setIsSubmitting(false); + setSubmitStep(null); + } + }; + + const STATUS_BADGE_STYLE: Record = { + pending_review: "bg-amber-500/10 text-amber-500 border-amber-500/20", + approved: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20", + rejected: "bg-rose-500/10 text-rose-500 border-rose-500/20", + }; + + return ( +
+ {/* Header */} +
+
+ +
+

{t("title")}

+

{t("subtitle")}

+
+
+ + {/* Submit Button to Open Dialog */} + +
+ + {/* Stats Cards Row */} +
+ + 0 && "text-amber-500 bg-amber-500/10")} /> + + +
+ + {/* Submission History Card */} + + + {/* Filters Bar */} +
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+
+ + + + +
+
+ + {/* List Content */} + {loading && !data ? ( +
+ ) : fetchError ? ( +
+ ) : filteredSubmissions.length === 0 ? ( +
+ +
+ ) : ( +
+ {/* Table Header */} +
+
{t("table_display_name")}
+
{t("table_status")}
+
{t("table_created_at")}
+
+ + {/* Table Rows */} +
+ {paginatedSubmissions.map((sub) => { + const isExpanded = expandedId === sub.id; + return ( +
+ {/* Row Header */} +
setExpandedId(isExpanded ? null : sub.id)} + className={cn( + "grid grid-cols-1 md:grid-cols-12 gap-3 px-4 py-3.5 items-center cursor-pointer transition-colors text-sm hover:bg-muted/30", + isExpanded && "bg-muted/20", + )} + > + {/* Display Name */} +
+ {isExpanded ? ( + + ) : ( + + )} +
+ + {sub.display_name} + + + ID: {sub.id} + +
+
+ + {/* Status */} +
+ + {sub.status === "pending_review" && t("status_pending_review")} + {sub.status === "approved" && t("status_approved")} + {sub.status === "rejected" && t("status_rejected")} + +
+ + {/* Created At */} +
+ {formatRelative(sub.created_at, now, locale)} +
+
+ + {/* Expandable details block */} + {isExpanded && ( +
+
+
+

构建上下文引用 (Context Ref)

+
{sub.context_ref}
+
+ {sub.image_ref && ( +
+

目标镜像引用 (Image Ref)

+
{sub.image_ref}
+
+ )} +
+ + {sub.status !== "pending_review" && ( +
+ {sub.reviewed_by && ( +
+ {t("table_reviewed_by")}: + {sub.reviewed_by} + {sub.reviewed_at && ( + + ({formatAbsolute(sub.reviewed_at, locale)}) + + )} +
+ )} + {sub.status === "rejected" && sub.reject_reason && ( +
+ {t("table_reject_reason")}: + {sub.reject_reason} +
+ )} +
+ )} +
+ )} +
+ ); + })} +
+ + {/* Pagination Footer */} + {filteredSubmissions.length > PAGE_SIZE && ( +
+ +
+ )} +
+ )} +
+
+ + {/* Submit Modpack Dialog */} + !isSubmitting && setDialogOpen(open)}> + + + {t("submit_card_title")} +

+ {t("submit_card_desc")} +

+
+ +
+ {/* Error Banner */} + {error && ( +
+ + {error} +
+ )} + + {/* Display Name Input */} +
+ + setDisplayName(e.target.value)} + disabled={isSubmitting} + required + /> +
+ + {/* File Dropzone */} +
+ + + + +
!isSubmitting && fileInputRef.current?.click()} + className={cn( + "flex flex-col items-center justify-center border border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors min-h-[140px]", + dragActive ? "border-primary bg-primary/5" : "border-border hover:bg-muted/30", + isSubmitting && "opacity-50 cursor-not-allowed", + )} + > + {file ? ( +
+ +

+ {file.name} +

+

{formatBytes(file.size)}

+ {!isSubmitting && ( + + )} +
+ ) : ( + <> + +

{t("file_drag_hint")}

+

支持 .tar.gz 格式 (最大 1GB)

+ + )} +
+
+ + + + + +
+
+
+
+ ); +}