415 lines
14 KiB
TypeScript
415 lines
14 KiB
TypeScript
"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}
|
||
/>
|
||
);
|
||
}
|