feat(panel): implement admin submission approval and reject queue
This commit is contained in:
12 files changed
+769
-4
No files matched your search
@@ -11,6 +11,7 @@ import type {
|
||||
Phase,
|
||||
ServerInfo,
|
||||
WhitelistImage,
|
||||
Submission,
|
||||
} from "../src/lib/types";
|
||||
|
||||
const ACCOUNT_IDS = ["owner", "user", "linked", "setup"] as const;
|
||||
@@ -53,6 +54,7 @@ interface MockState {
|
||||
backups: BackupView[];
|
||||
builds: Build[];
|
||||
passkeys: Record<AccountID, { id: string; name: string; created_at: string }[]>;
|
||||
submissions: Submission[];
|
||||
}
|
||||
|
||||
// PLAYER_NAME mirrors the backend's mcNameRe (handlers_access.go) so the mock
|
||||
@@ -267,6 +269,39 @@ function initialState(): MockState {
|
||||
user: [],
|
||||
setup: [],
|
||||
},
|
||||
submissions: [
|
||||
{
|
||||
id: "sub-1",
|
||||
submitted_by: "[email protected]",
|
||||
display_name: "Pixelmon Modpack V2",
|
||||
context_ref: "minio/contexts/sub-1/context.tar.gz",
|
||||
status: "pending_review",
|
||||
created_at: new Date(Date.now() - 3600000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "sub-2",
|
||||
submitted_by: "[email protected]",
|
||||
display_name: "Create: Astral pack",
|
||||
context_ref: "minio/contexts/sub-2/context.tar.gz",
|
||||
status: "approved",
|
||||
image_ref: "registry.felis.svc:5000/user-uploads/sub-2:latest",
|
||||
build_id: "bld-1",
|
||||
reviewed_by: "[email protected]",
|
||||
created_at: new Date(Date.now() - 7200000).toISOString(),
|
||||
reviewed_at: new Date(Date.now() - 7100000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "sub-3",
|
||||
submitted_by: "[email protected]",
|
||||
display_name: "Dangerous Modpack (Exploitative)",
|
||||
context_ref: "minio/contexts/sub-3/context.tar.gz",
|
||||
status: "rejected",
|
||||
reviewed_by: "[email protected]",
|
||||
reject_reason: "Contains malicious code in scripts/run.sh that tries to download remote malware.",
|
||||
created_at: new Date(Date.now() - 10800000).toISOString(),
|
||||
reviewed_at: new Date(Date.now() - 10700000).toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -650,6 +685,7 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
if (await handleImageRoute(ctx)) return true;
|
||||
if (await handleSubmissionRoute(ctx)) return true;
|
||||
return await handleServerRoute(ctx);
|
||||
}
|
||||
}
|
||||
@@ -816,6 +852,106 @@ async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function handleSubmissionRoute(ctx: SessionContext): Promise<boolean> {
|
||||
if (ctx.parts[2] !== "submissions") return false;
|
||||
|
||||
// GET /api/v1/submissions
|
||||
if (is("GET", ctx) && ctx.parts.length === 3) {
|
||||
if (ctx.account.role !== "admin") {
|
||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||
return true;
|
||||
}
|
||||
sendJSON(ctx.res, 200, { submissions: ctx.state.submissions });
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/v1/submissions/{id}/approve
|
||||
if (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;
|
||||
}
|
||||
const id = ctx.parts[3];
|
||||
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.status !== "pending_review") {
|
||||
sendError(ctx.res, 409, "already_reviewed", "submission already reviewed");
|
||||
return true;
|
||||
}
|
||||
|
||||
sub.status = "approved";
|
||||
sub.reviewed_by = ctx.account.email;
|
||||
sub.reviewed_at = new Date().toISOString();
|
||||
sub.image_ref = `registry.felis.svc:5000/user-uploads/${id}:latest`;
|
||||
|
||||
// Trigger mock build
|
||||
const newBuild: Build = {
|
||||
id: `bld-${Date.now()}`,
|
||||
image_ref: sub.image_ref,
|
||||
status: "building",
|
||||
dockerfile: `# felis user-modpack submission ${id}\n# The executed Dockerfile is provided by the uploaded build context:\n# ${sub.context_ref}\n`,
|
||||
context_ref: sub.context_ref,
|
||||
requested_by: ctx.account.email,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
ctx.state.builds.unshift(newBuild);
|
||||
sub.build_id = newBuild.id;
|
||||
|
||||
// Succeeded after 15 seconds
|
||||
setTimeout(() => {
|
||||
const b = ctx.state.builds.find((x) => x.id === newBuild.id);
|
||||
if (b && b.status === "building") {
|
||||
b.status = "succeeded";
|
||||
b.finished_at = new Date().toISOString();
|
||||
if (!ctx.state.images.some((i) => i.image_ref === b.image_ref)) {
|
||||
ctx.state.images.unshift({ image_ref: b.image_ref, enabled: true, source: "built" });
|
||||
}
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
sendJSON(ctx.res, 200, sub);
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/v1/submissions/{id}/reject
|
||||
if (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;
|
||||
}
|
||||
const id = ctx.parts[3];
|
||||
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.status !== "pending_review") {
|
||||
sendError(ctx.res, 409, "already_reviewed", "submission already reviewed");
|
||||
return true;
|
||||
}
|
||||
|
||||
const body = await readJSON<{ reason?: string }>(ctx.req);
|
||||
const reason = body.reason?.trim();
|
||||
if (!reason) {
|
||||
sendError(ctx.res, 400, "bad_request", "reason is required");
|
||||
return true;
|
||||
}
|
||||
|
||||
sub.status = "rejected";
|
||||
sub.reviewed_by = ctx.account.email;
|
||||
sub.reviewed_at = new Date().toISOString();
|
||||
sub.reject_reason = reason;
|
||||
|
||||
sendJSON(ctx.res, 200, sub);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function streamBuildLogs(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ServerBackups } from "@/pages/ServerBackups";
|
||||
import { Account } from "@/pages/Account";
|
||||
import { ImageAdmin } from "@/pages/admin/ImageAdmin";
|
||||
import { ImageBuildPage } from "@/pages/admin/ImageBuildPage";
|
||||
import { SubmissionsPage } from "@/pages/admin/SubmissionsPage";
|
||||
|
||||
// Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES):
|
||||
// / User-Side — app-tier, every authenticated principal
|
||||
@@ -53,6 +54,7 @@ export default function App() {
|
||||
<Route index element={<Navigate to="/admin/images" replace />} />
|
||||
<Route path="images" element={<ImageAdmin />} />
|
||||
<Route path="builds" element={<ImageBuildPage />} />
|
||||
<Route path="submissions" element={<SubmissionsPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -15,6 +15,7 @@ const buttonVariants = cva(
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline bg-transparent",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
|
||||
@@ -52,5 +52,24 @@
|
||||
"filter_all": "All",
|
||||
"filter_enabled": "Enabled",
|
||||
"filter_disabled": "Disabled",
|
||||
"image_ref_label": "Image Reference"
|
||||
"image_ref_label": "Image Reference",
|
||||
"submissions_title": "Submissions",
|
||||
"submissions_subtitle": "Review queue for user-uploaded modpacks. Approved submissions will automatically trigger safe Kaniko builds with Trivy scans.",
|
||||
"table_submitted_by": "Submitted By",
|
||||
"table_display_name": "Name",
|
||||
"approve_btn": "Approve",
|
||||
"reject_btn": "Reject",
|
||||
"reject_reason_label": "Rejection Reason",
|
||||
"reject_reason_placeholder": "Please provide a reason (max 1000 characters)",
|
||||
"reject_dialog_title": "Reject Submission",
|
||||
"reject_dialog_desc": "Please provide a detailed reason for rejecting this modpack submission. This reason will be shown to the user.",
|
||||
"reject_dialog_submit": "Confirm Reject",
|
||||
"status_pending_review": "Pending Review",
|
||||
"status_approved": "Approved",
|
||||
"status_rejected": "Rejected",
|
||||
"no_submissions_title": "No Submissions Found",
|
||||
"no_submissions_hint": "There are currently no pending or reviewed modpack submissions.",
|
||||
"reviewer": "Reviewer",
|
||||
"reviewed_at": "Reviewed At",
|
||||
"reject_reason": "Rejection Reason"
|
||||
}
|
||||
@@ -4,5 +4,6 @@
|
||||
"account": "Account",
|
||||
"admin_section": "Admin",
|
||||
"admin_images": "Images",
|
||||
"admin_builds": "Build Pipeline"
|
||||
"admin_builds": "Build Pipeline",
|
||||
"admin_submissions": "Submissions"
|
||||
}
|
||||
@@ -52,5 +52,24 @@
|
||||
"filter_all": "全部",
|
||||
"filter_enabled": "已启用",
|
||||
"filter_disabled": "已禁用",
|
||||
"image_ref_label": "镜像引用"
|
||||
"image_ref_label": "镜像引用",
|
||||
"submissions_title": "审核提交",
|
||||
"submissions_subtitle": "用户模组包上传的审核队列。通过审核的任务将自动启动 Kaniko 构建并运行 Trivy 安全扫描。",
|
||||
"table_submitted_by": "提交人",
|
||||
"table_display_name": "名称",
|
||||
"approve_btn": "通过",
|
||||
"reject_btn": "驳回",
|
||||
"reject_reason_label": "驳回原因",
|
||||
"reject_reason_placeholder": "请填写驳回原因(限 1000 字符)",
|
||||
"reject_dialog_title": "驳回申请",
|
||||
"reject_dialog_desc": "请提供驳回该模组包提交的详细原因,提交后该原因将展示给用户。",
|
||||
"reject_dialog_submit": "确定驳回",
|
||||
"status_pending_review": "待审核",
|
||||
"status_approved": "已通过",
|
||||
"status_rejected": "已驳回",
|
||||
"no_submissions_title": "暂无审核任务",
|
||||
"no_submissions_hint": "当前没有待审核或已审核的模组包提交。",
|
||||
"reviewer": "审核人",
|
||||
"reviewed_at": "审核时间",
|
||||
"reject_reason": "驳回理由"
|
||||
}
|
||||
@@ -4,5 +4,6 @@
|
||||
"account": "账户",
|
||||
"admin_section": "管理",
|
||||
"admin_images": "镜像",
|
||||
"admin_builds": "构建流水线"
|
||||
"admin_builds": "构建流水线",
|
||||
"admin_submissions": "审核提交"
|
||||
}
|
||||
@@ -383,4 +383,40 @@ describe("image whitelist and builds wire shapes", () => {
|
||||
expect(String(url)).toBe("/images/build/bld-1/cancel");
|
||||
expect((opts as RequestInit).method).toBe("POST");
|
||||
});
|
||||
|
||||
describe("submissions", () => {
|
||||
it("listSubmissions GETs from /submissions", async () => {
|
||||
const submissions = [{ id: "sub-1", display_name: "test", status: "pending_review" }];
|
||||
const fetchSpy = fakeFetch({ submissions });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.listSubmissions();
|
||||
expect(res).toEqual(submissions);
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(String(url)).toBe("/submissions");
|
||||
expect((opts as RequestInit).method).toBe("GET");
|
||||
});
|
||||
|
||||
it("approveSubmission POSTs to /submissions/{id}/approve", async () => {
|
||||
const sub = { id: "sub-1", status: "approved" };
|
||||
const fetchSpy = fakeFetch(sub);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.approveSubmission("sub-1");
|
||||
expect(res).toEqual(sub);
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(String(url)).toBe("/submissions/sub-1/approve");
|
||||
expect((opts as RequestInit).method).toBe("POST");
|
||||
});
|
||||
|
||||
it("rejectSubmission POSTs {reason} to /submissions/{id}/reject", async () => {
|
||||
const sub = { id: "sub-1", status: "rejected", reject_reason: "bad" };
|
||||
const fetchSpy = fakeFetch(sub);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.rejectSubmission("sub-1", "bad");
|
||||
expect(res).toEqual(sub);
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(String(url)).toBe("/submissions/sub-1/reject");
|
||||
expect((opts as RequestInit).method).toBe("POST");
|
||||
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({ reason: "bad" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
ServerInfo,
|
||||
WhitelistImage,
|
||||
WhitelistResult,
|
||||
Submission,
|
||||
} from "./types";
|
||||
import { loadConfig } from "./config";
|
||||
import i18next from "i18next";
|
||||
@@ -248,6 +249,15 @@ export const api = {
|
||||
|
||||
passkeyDelete: (id: string) =>
|
||||
request<void>("DELETE", `/account/passkey/credentials/${id}`),
|
||||
|
||||
listSubmissions: () =>
|
||||
request<{ submissions: Submission[] }>("GET", "/submissions").then((r) => r.submissions ?? []),
|
||||
|
||||
approveSubmission: (id: string) =>
|
||||
request<Submission>("POST", `/submissions/${id}/approve`),
|
||||
|
||||
rejectSubmission: (id: string, reason: string) =>
|
||||
request<Submission>("POST", `/submissions/${id}/reject`, { reason }),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
UserRound,
|
||||
Boxes,
|
||||
Cpu,
|
||||
ClipboardCheck,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -53,6 +54,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
items: [
|
||||
{ to: "/admin/images", key: "admin_images", icon: Boxes },
|
||||
{ to: "/admin/builds", key: "admin_builds", icon: Cpu },
|
||||
{ to: "/admin/submissions", key: "admin_submissions", icon: ClipboardCheck },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -245,3 +245,20 @@ export interface Build {
|
||||
created_at: string;
|
||||
finished_at?: string;
|
||||
}
|
||||
|
||||
export type SubmissionStatus = "pending_review" | "approved" | "rejected";
|
||||
|
||||
/** Submission mirrors an image_submissions row (spec §6, migration 0002). */
|
||||
export interface Submission {
|
||||
id: string;
|
||||
submitted_by: string;
|
||||
display_name: string;
|
||||
context_ref: string;
|
||||
status: SubmissionStatus;
|
||||
image_ref?: string;
|
||||
build_id?: string;
|
||||
reviewed_by?: string;
|
||||
reject_reason?: string;
|
||||
created_at: string;
|
||||
reviewed_at?: string;
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { ClipboardCheck, CheckCircle2, CircleSlash, Search, ChevronDown, ChevronUp, Check, X, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
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 StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
accentClass,
|
||||
}: {
|
||||
icon: any;
|
||||
label: string;
|
||||
value: number;
|
||||
accentClass?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className={cn("rounded-md p-2 bg-muted/30", accentClass || "text-muted-foreground")}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold font-mono leading-none">{value}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground font-medium">{label}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubmissionsPage() {
|
||||
const { t, i18n } = useTranslation("admin");
|
||||
const locale = i18n.language;
|
||||
const now = Date.now();
|
||||
|
||||
const { data, error, loading, reload } = useAsync(() => api.listSubmissions(), []);
|
||||
const submissions: Submission[] = data ?? [];
|
||||
|
||||
// Dialog State
|
||||
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
|
||||
const [rejectingId, setRejectingId] = useState<string | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState("");
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
// Pending actions (for button spinners)
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [busyType, setBusyType] = useState<"approve" | "reject" | null>(null);
|
||||
|
||||
// Search & Filtering State
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | SubmissionStatus>("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Filtered & Paginated Submissions
|
||||
const filteredSubmissions = useMemo(() => {
|
||||
let list = [...submissions];
|
||||
|
||||
// 1. Search Filter
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
list = list.filter(
|
||||
(s) =>
|
||||
s.display_name.toLowerCase().includes(q) ||
|
||||
s.submitted_by.toLowerCase().includes(q) ||
|
||||
s.id.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Status Filter
|
||||
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]);
|
||||
|
||||
async function handleApprove(id: string) {
|
||||
if (busyId) return;
|
||||
setBusyId(id);
|
||||
setBusyType("approve");
|
||||
setActionError(null);
|
||||
try {
|
||||
await api.approveSubmission(id);
|
||||
reload();
|
||||
} catch (err) {
|
||||
setActionError(humanizeError(err));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
setBusyType(null);
|
||||
}
|
||||
}
|
||||
|
||||
function openRejectDialog(id: string) {
|
||||
setRejectingId(id);
|
||||
setRejectReason("");
|
||||
setActionError(null);
|
||||
setRejectDialogOpen(true);
|
||||
}
|
||||
|
||||
async function handleRejectSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!rejectingId || !rejectReason.trim()) return;
|
||||
setBusyId(rejectingId);
|
||||
setBusyType("reject");
|
||||
setActionError(null);
|
||||
try {
|
||||
await api.rejectSubmission(rejectingId, rejectReason.trim());
|
||||
setRejectDialogOpen(false);
|
||||
reload();
|
||||
} catch (err) {
|
||||
setActionError(humanizeError(err));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
setBusyType(null);
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_BADGE_STYLE: Record<SubmissionStatus, string> = {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<ClipboardCheck className="h-6 w-6 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t("submissions_title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("submissions_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Error Alert */}
|
||||
{actionError && (
|
||||
<p className="text-xs text-destructive font-medium bg-destructive/10 p-2.5 rounded-md">
|
||||
{actionError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Stats Cards Row */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<StatCard icon={ClipboardCheck} label={t("filter_all")} value={stats.total} />
|
||||
<StatCard icon={Loader2} label={t("status_pending_review")} value={stats.pending} accentClass={cn(stats.pending > 0 && "text-amber-500 bg-amber-500/10")} />
|
||||
<StatCard icon={CheckCircle2} label={t("status_approved")} value={stats.approved} accentClass="text-emerald-500 bg-emerald-500/10" />
|
||||
<StatCard icon={CircleSlash} label={t("status_rejected")} value={stats.rejected} accentClass="text-rose-500 bg-rose-500/10" />
|
||||
</div>
|
||||
|
||||
{/* Main Table Card */}
|
||||
<Card className="w-full">
|
||||
<CardContent className="p-0">
|
||||
{/* Filters Bar */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b">
|
||||
<div className="relative flex-1 w-full">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索模组包名称或提交人..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground shrink-0 select-none border border-border/40">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStatusFilter("all");
|
||||
setPage(1);
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
statusFilter === "all"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-background/30 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t("filter_all")} ({stats.total})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStatusFilter("pending_review");
|
||||
setPage(1);
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
statusFilter === "pending_review"
|
||||
? "bg-background text-amber-500 shadow-sm"
|
||||
: "text-muted-foreground hover:bg-background/30 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t("status_pending_review")} ({stats.pending})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStatusFilter("approved");
|
||||
setPage(1);
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
statusFilter === "approved"
|
||||
? "bg-background text-emerald-500 shadow-sm"
|
||||
: "text-muted-foreground hover:bg-background/30 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t("status_approved")} ({stats.approved})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStatusFilter("rejected");
|
||||
setPage(1);
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
statusFilter === "rejected"
|
||||
? "bg-background text-rose-500 shadow-sm"
|
||||
: "text-muted-foreground hover:bg-background/30 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t("status_rejected")} ({stats.rejected})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List Content */}
|
||||
{loading && !data ? (
|
||||
<div className="py-12"><Loading /></div>
|
||||
) : error ? (
|
||||
<div className="py-12"><ErrorState error={error} onRetry={reload} /></div>
|
||||
) : filteredSubmissions.length === 0 ? (
|
||||
<div className="p-4 border-b-0">
|
||||
<EmptyState
|
||||
title={search.trim() || statusFilter !== "all" ? "无匹配结果" : t("no_submissions_title")}
|
||||
hint={search.trim() || statusFilter !== "all" ? "尝试更换搜索词或筛选条件" : t("no_submissions_hint")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
{/* Table Header */}
|
||||
<div className="hidden md:grid grid-cols-12 gap-3 px-4 py-2.5 text-[11px] font-semibold text-muted-foreground border-b select-none">
|
||||
<div className="col-span-4">{t("table_display_name")}</div>
|
||||
<div className="col-span-3">{t("table_submitted_by")}</div>
|
||||
<div className="col-span-2 text-center">{t("table_status")}</div>
|
||||
<div className="col-span-2">{t("table_created_at")}</div>
|
||||
<div className="col-span-1 text-right">{t("table_action")}</div>
|
||||
</div>
|
||||
|
||||
{/* Table Body */}
|
||||
<div className="divide-y divide-border select-text">
|
||||
{paginatedSubmissions.map((sub) => {
|
||||
const isExpanded = expandedId === sub.id;
|
||||
const isBusyApprove = busyId === sub.id && busyType === "approve";
|
||||
const isBusyReject = busyId === sub.id && busyType === "reject";
|
||||
return (
|
||||
<div key={sub.id} className="flex flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 md:grid-cols-12 gap-3 items-center px-4 py-3 text-xs transition-colors hover:bg-accent/25 cursor-pointer",
|
||||
isExpanded && "bg-muted/10",
|
||||
)}
|
||||
onClick={() => setExpandedId(isExpanded ? null : sub.id)}
|
||||
>
|
||||
{/* Name */}
|
||||
<div className="col-span-4 flex items-center gap-2 min-w-0">
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 shrink-0 text-muted-foreground/60" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground/60" />
|
||||
)}
|
||||
<span className="font-semibold truncate">{sub.display_name}</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono bg-muted/40 px-1 py-0.2 rounded shrink-0">
|
||||
{sub.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Submitter */}
|
||||
<div className="col-span-3 truncate text-muted-foreground" title={sub.submitted_by}>
|
||||
{sub.submitted_by}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="col-span-2 md:text-center py-0.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-[10px] px-2 py-0.5 font-semibold capitalize", STATUS_BADGE_STYLE[sub.status])}
|
||||
>
|
||||
{sub.status === "pending_review" && t("status_pending_review")}
|
||||
{sub.status === "approved" && t("status_approved")}
|
||||
{sub.status === "rejected" && t("status_rejected")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Created At */}
|
||||
<div
|
||||
className="col-span-2 text-muted-foreground"
|
||||
title={formatAbsolute(sub.created_at, locale)}
|
||||
>
|
||||
{formatRelative(sub.created_at, now, locale)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons (only in table row if NOT pending, else show triggers) */}
|
||||
<div className="col-span-1 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
{sub.status === "pending_review" ? (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-7 w-7 text-emerald-500 hover:text-emerald-600 border-emerald-500/20 hover:bg-emerald-500/10 focus-visible:ring-emerald-500"
|
||||
onClick={() => handleApprove(sub.id)}
|
||||
disabled={!!busyId}
|
||||
title={t("approve_btn")}
|
||||
>
|
||||
{isBusyApprove ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-7 w-7 text-rose-500 hover:text-rose-600 border-rose-500/20 hover:bg-rose-500/10 focus-visible:ring-rose-500"
|
||||
onClick={() => openRejectDialog(sub.id)}
|
||||
disabled={!!busyId}
|
||||
title={t("reject_btn")}
|
||||
>
|
||||
{isBusyReject ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground/60 font-medium">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable details block */}
|
||||
{isExpanded && (
|
||||
<div className="px-10 py-3 bg-muted/20 border-t border-b border-border/40 text-xs text-muted-foreground space-y-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="font-semibold text-foreground mb-1">构建上下文引用 (Context Ref)</p>
|
||||
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all">{sub.context_ref}</pre>
|
||||
</div>
|
||||
{sub.image_ref && (
|
||||
<div>
|
||||
<p className="font-semibold text-foreground mb-1">目标镜像引用 (Image Ref)</p>
|
||||
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all">{sub.image_ref}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sub.build_id && (
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">关联构建任务 (Build ID)</p>
|
||||
<code className="font-mono bg-background border rounded px-1.5 py-0.5">{sub.build_id}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sub.status !== "pending_review" && (
|
||||
<div className="pt-1 border-t border-border/20 mt-2 flex flex-col gap-1.5">
|
||||
<div>
|
||||
<span className="font-semibold text-foreground">{t("reviewer")}:</span>{" "}
|
||||
<span>{sub.reviewed_by}</span>
|
||||
</div>
|
||||
{sub.reviewed_at && (
|
||||
<div>
|
||||
<span className="font-semibold text-foreground">{t("reviewed_at")}:</span>{" "}
|
||||
<span>{formatAbsolute(sub.reviewed_at, locale)} ({formatRelative(sub.reviewed_at, now, locale)})</span>
|
||||
</div>
|
||||
)}
|
||||
{sub.status === "rejected" && sub.reject_reason && (
|
||||
<div className="bg-rose-500/5 border border-rose-500/10 rounded-md p-2 mt-1">
|
||||
<span className="font-semibold text-rose-500 block mb-1">{t("reject_reason")}:</span>
|
||||
<p className="text-foreground whitespace-pre-wrap leading-relaxed">{sub.reject_reason}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{filteredSubmissions.length > PAGE_SIZE && (
|
||||
<div className="p-4 border-t">
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={filteredSubmissions.length}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Reject Modal Dialog */}
|
||||
<Dialog open={rejectDialogOpen} onOpenChange={(o) => {
|
||||
setRejectDialogOpen(o);
|
||||
if (!o) {
|
||||
setRejectingId(null);
|
||||
setRejectReason("");
|
||||
setActionError(null);
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("reject_dialog_title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("reject_dialog_desc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleRejectSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="rejectReasonInput" className="text-xs font-medium text-muted-foreground">
|
||||
{t("reject_reason_label")} *
|
||||
</Label>
|
||||
<textarea
|
||||
id="rejectReasonInput"
|
||||
placeholder={t("reject_reason_placeholder")}
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
maxLength={1000}
|
||||
disabled={!!busyId}
|
||||
required
|
||||
className="flex min-h-[100px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setRejectDialogOpen(false)}
|
||||
disabled={!!busyId}
|
||||
className="text-xs"
|
||||
>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={!!busyId || !rejectReason.trim()}
|
||||
className="text-xs gap-1.5"
|
||||
>
|
||||
{busyId && busyType === "reject" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<X className="h-4 w-4" />
|
||||
)}
|
||||
{t("reject_dialog_submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user