feat(panel): implement admin submission approval and reject queue

This commit is contained in:
Lemon-miaow committed 2026-07-02 23:38:46 +08:00
1 parent c7315e44b6
commit 191640c3f5
12 files changed
+769 -4

No files matched your search

+136
View File
@@ -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,
+2
View File
@@ -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 />} />
+1
View File
@@ -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",
+20 -1
View File
@@ -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"
}
+20 -1
View File
@@ -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": "审核提交"
}
+36
View File
@@ -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" });
});
});
});
+10
View File
@@ -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 }),
};
/**
+2
View File
@@ -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 },
],
},
];
+17
View File
@@ -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;
}
+521
View File
@@ -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>
);
}