Files
auto-virtual-tryon-frontend/src/features/libraries/library-page.tsx
2026-03-28 13:42:22 +08:00

415 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { CardEyebrow } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { LibraryEditModal } from "@/features/libraries/components/library-edit-modal";
import { LibraryUploadModal } from "@/features/libraries/components/library-upload-modal";
import { archiveLibraryResource } from "@/features/libraries/manage-resource";
import type { LibraryItemVM, LibraryType } from "@/lib/types/view-models";
type LibraryPageProps = {
isLoading?: boolean;
items: LibraryItemVM[];
libraryType: LibraryType;
message?: string;
};
type LibraryEnvelope = {
data?: {
items?: LibraryItemVM[];
};
message?: string;
};
type LibraryMeta = {
description: string;
singularLabel: string;
tabLabel: string;
};
const LIBRARY_META: Record<LibraryType, LibraryMeta> = {
models: {
description: "按模特资源管理上传、封面和提单联动素材。",
singularLabel: "模特",
tabLabel: "模特",
},
scenes: {
description: "按场景资源管理上传、封面和提单联动素材。",
singularLabel: "场景",
tabLabel: "场景",
},
garments: {
description: "按服装资源管理上传、封面和提单联动素材。",
singularLabel: "服装",
tabLabel: "服装",
},
};
const LIBRARY_SECTIONS: Array<{
href: string;
label: string;
libraryType: LibraryType;
}> = [
{ href: "/libraries/models", label: "模特", libraryType: "models" },
{ href: "/libraries/scenes", label: "场景", libraryType: "scenes" },
{ href: "/libraries/garments", label: "服装", libraryType: "garments" },
];
const STATE_TITLE = "资源库状态";
const DEFAULT_MESSAGE = "资源库当前显示真实后端数据。";
function joinClasses(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(" ");
}
function getResourceRequestId(item: LibraryItemVM) {
return typeof item.backendId === "number" ? String(item.backendId) : item.id;
}
export function LibraryPage({
isLoading = false,
items,
libraryType,
message = DEFAULT_MESSAGE,
}: LibraryPageProps) {
const meta = LIBRARY_META[libraryType];
const [archivingItem, setArchivingItem] = useState<LibraryItemVM | null>(null);
const [editingItem, setEditingItem] = useState<LibraryItemVM | null>(null);
const [isArchiving, setIsArchiving] = useState(false);
const [isUploadOpen, setIsUploadOpen] = useState(false);
const [visibleItems, setVisibleItems] = useState(items);
const [statusMessage, setStatusMessage] = useState(message);
useEffect(() => {
setVisibleItems(items);
}, [items]);
useEffect(() => {
setStatusMessage(message);
}, [message]);
function handleUploaded(item: LibraryItemVM) {
setVisibleItems((current) => [
item,
...current.filter((candidate) => candidate.id !== item.id),
]);
setStatusMessage("资源上传成功,已写入正式资源库。");
setIsUploadOpen(false);
}
function handleSaved(item: LibraryItemVM) {
setVisibleItems((current) =>
current.map((candidate) => (candidate.id === item.id ? item : candidate)),
);
setStatusMessage("资源更新成功,封面与元数据已同步。");
setEditingItem(null);
}
async function handleArchive(item: LibraryItemVM) {
setIsArchiving(true);
try {
const archivedId = await archiveLibraryResource({
libraryType,
resourceId: getResourceRequestId(item),
});
setVisibleItems((current) =>
current.filter((candidate) => candidate.id !== archivedId),
);
setStatusMessage(`资源「${item.name}」已移入归档。`);
setArchivingItem(null);
} catch (archiveError) {
setStatusMessage(
archiveError instanceof Error
? archiveError.message
: "资源删除失败,请稍后重试。",
);
} finally {
setIsArchiving(false);
}
}
return (
<section className="space-y-8">
<PageHeader
eyebrow="Resource library"
title="资源库"
description={meta.description}
meta={`${meta.tabLabel}管理视图`}
/>
<nav
aria-label="Library sections"
className="flex flex-wrap gap-3 rounded-[28px] border border-[var(--border-soft)] bg-[rgba(255,250,242,0.88)] p-2 shadow-[var(--shadow-card)]"
>
{LIBRARY_SECTIONS.map((section) => {
const isCurrent = section.libraryType === libraryType;
return (
<Link
key={section.libraryType}
href={section.href}
aria-current={isCurrent ? "page" : undefined}
className={joinClasses(
"inline-flex min-h-11 items-center rounded-[20px] px-4 py-2 text-sm font-medium transition",
isCurrent
? "bg-[var(--accent-primary)] text-[var(--accent-ink)] shadow-[0_12px_30px_rgba(110,127,82,0.22)]"
: "text-[var(--ink-muted)] hover:bg-[var(--surface-muted)] hover:text-[var(--ink-strong)]",
)}
>
{section.label}
</Link>
);
})}
</nav>
<div className="rounded-[28px] border border-[rgba(145,104,46,0.2)] bg-[rgba(202,164,97,0.12)] px-6 py-5">
<p className="text-lg font-semibold tracking-[-0.02em] text-[#7a5323]">
{STATE_TITLE}
</p>
<p className="mt-2 text-sm leading-7 text-[#7a5323]">{statusMessage}</p>
</div>
<div
data-testid="library-masonry"
className="columns-1 gap-5 md:columns-2 xl:columns-3 2xl:columns-4"
>
<div className="mb-5 break-inside-avoid">
<div className="rounded-[28px] border border-dashed border-[var(--border-strong)] bg-[linear-gradient(180deg,rgba(255,250,242,0.96),rgba(246,237,224,0.92))] p-5 shadow-[var(--shadow-card)]">
<div className="space-y-4">
<div className="space-y-2">
<CardEyebrow>{meta.tabLabel} upload</CardEyebrow>
<div className="space-y-2">
<h2 className="text-lg font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
{meta.singularLabel}
</h2>
<p className="text-sm leading-6 text-[var(--ink-muted)]">
</p>
</div>
</div>
<Button
className="min-h-11 w-full justify-center"
size="lg"
onClick={() => setIsUploadOpen(true)}
>
</Button>
</div>
</div>
</div>
{isLoading ? (
<div className="mb-5 break-inside-avoid rounded-[28px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-5 py-6 text-sm text-[var(--ink-muted)]">
</div>
) : null}
{!isLoading && !visibleItems.length ? (
<div className="mb-5 break-inside-avoid rounded-[28px] border border-[var(--border-soft)] bg-[var(--surface)] p-5 shadow-[var(--shadow-card)]">
<EmptyState
eyebrow="Library empty"
title={`暂无${meta.singularLabel}资源`}
description="当前库还没有资源条目,先从首卡入口上传第一批素材。"
/>
</div>
) : null}
{!isLoading
? visibleItems.map((item) => (
<article
key={item.id}
className="mb-5 break-inside-avoid rounded-[28px] border border-[var(--border-soft)] bg-[var(--surface)] p-4 shadow-[var(--shadow-card)]"
>
<div className="space-y-4">
<div className="rounded-[22px] border border-[rgba(74,64,53,0.08)] bg-[linear-gradient(160deg,rgba(255,249,240,0.95),rgba(230,217,199,0.82))] p-4">
<div className="relative aspect-[4/5] overflow-hidden rounded-[18px] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.88),rgba(218,197,170,0.48))]">
{item.previewUri ? (
// previewUri may come from mock:// fixtures during tests, so keep a plain img here.
// eslint-disable-next-line @next/next/no-img-element
<img
alt={`${item.name} 预览图`}
className="h-full w-full object-cover"
src={item.previewUri}
/>
) : null}
{/* <div className="absolute inset-x-0 bottom-0 flex items-end p-4">
<div className="rounded-[18px] bg-[rgba(74,64,53,0.72)] px-3 py-2 text-sm font-medium text-[#fffaf5]">
{meta.singularLabel}预览
</div>
</div> */}
</div>
</div>
<div className="space-y-3">
<div className="space-y-1">
<p className="text-base font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
{item.name}
</p>
<p className="text-sm leading-6 text-[var(--ink-muted)]">
{item.description}
</p>
</div>
{item.tags.length ? (
<div className="flex flex-wrap gap-2">
{item.tags.map((tag) => (
<span
key={tag}
className="rounded-full bg-[rgba(110,98,84,0.08)] px-2.5 py-1 font-[var(--font-mono)] text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)]"
>
{tag}
</span>
))}
</div>
) : null}
{/* <code className="block rounded-[18px] bg-[rgba(74,64,53,0.08)] px-3 py-3 text-xs leading-6 text-[var(--ink-muted)]">
{item.previewUri}
</code> */}
<div className="grid gap-2 sm:grid-cols-2">
<Button
size="sm"
variant="secondary"
onClick={() => setEditingItem(item)}
>
</Button>
<Button
size="sm"
variant="danger"
onClick={() => {
setArchivingItem(item);
}}
>
</Button>
</div>
</div>
</div>
</article>
))
: null}
</div>
<LibraryUploadModal
libraryType={libraryType}
open={isUploadOpen}
onClose={() => setIsUploadOpen(false)}
onUploaded={handleUploaded}
/>
<LibraryEditModal
item={editingItem}
libraryType={libraryType}
open={editingItem !== null}
onClose={() => setEditingItem(null)}
onSaved={handleSaved}
/>
<AlertDialog
open={archivingItem !== null}
onOpenChange={(open) => {
if (!open && !isArchiving) {
setArchivingItem(null);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{`确认删除${meta.singularLabel}资源`}</AlertDialogTitle>
<AlertDialogDescription>
{archivingItem
? `删除后,这条资源会被移入归档,不再出现在当前列表中。当前资源:${archivingItem.name}`
: "删除后,这条资源会被移入归档,不再出现在当前列表中。"}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isArchiving}></AlertDialogCancel>
<AlertDialogAction
disabled={!archivingItem || isArchiving}
onClick={(event) => {
event.preventDefault();
if (!archivingItem || isArchiving) {
return;
}
void handleArchive(archivingItem);
}}
>
{isArchiving ? "删除中..." : "确认删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
export function LibraryPageScreen({ libraryType }: { libraryType: LibraryType }) {
const [items, setItems] = useState<LibraryItemVM[]>([]);
const [message, setMessage] = useState(DEFAULT_MESSAGE);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let active = true;
async function loadLibrary() {
setIsLoading(true);
try {
const response = await fetch(`/api/libraries/${libraryType}`);
const payload = (await response.json()) as LibraryEnvelope;
if (!active) {
return;
}
setItems(payload.data?.items ?? []);
setMessage(payload.message ?? DEFAULT_MESSAGE);
} catch {
if (!active) {
return;
}
setItems([]);
setMessage("资源库数据加载失败,请稍后重试。");
} finally {
if (active) {
setIsLoading(false);
}
}
}
void loadLibrary();
return () => {
active = false;
};
}, [libraryType]);
return (
<LibraryPage
isLoading={isLoading}
items={items}
libraryType={libraryType}
message={message}
/>
);
}