feat: connect resource library workflows

This commit is contained in:
afei A
2026-03-28 13:42:22 +08:00
parent c604e6ace1
commit 162d3e12d2
42 changed files with 4709 additions and 305 deletions

View File

@@ -1,10 +1,25 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { Card, CardContent, CardDescription, CardEyebrow, CardHeader, CardTitle } from "@/components/ui/card";
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 = {
@@ -21,29 +36,50 @@ type LibraryEnvelope = {
message?: string;
};
const LIBRARY_META: Record<
LibraryType,
{ title: string; description: string; eyebrow: string }
> = {
type LibraryMeta = {
description: string;
singularLabel: string;
tabLabel: string;
};
const LIBRARY_META: Record<LibraryType, LibraryMeta> = {
models: {
title: "模特",
description: "继续复用提单页所需的 mock 模特数据,同时保留正式资源库的结构和标签体系。",
eyebrow: "Model library",
description: "模特资源管理上传、封面和提单联动素材。",
singularLabel: "模特",
tabLabel: "模特",
},
scenes: {
title: "场景",
description: "场景库先用 mock 占位数据承接,等真实后台资源列表可用后再切换数据源。",
eyebrow: "Scene library",
description: "场景资源管理上传、封面和提单联动素材。",
singularLabel: "场景",
tabLabel: "场景",
},
garments: {
title: "服装",
description: "服装资源暂时继续走 mock 资产,但页面本身已经按正式资源库组织。",
eyebrow: "Garment library",
description: "服装资源管理上传、封面和提单联动素材。",
singularLabel: "服装",
tabLabel: "服装",
},
};
const TITLE_MESSAGE = "当前资源库仍使用 mock 数据";
const DEFAULT_MESSAGE = "当前只保留页面结构和 mock 资源项,真实资源查询能力将在后端支持后接入。";
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,
@@ -52,95 +88,282 @@ export function LibraryPage({
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={meta.eyebrow}
title={meta.title}
eyebrow="Resource library"
title="资源库"
description={meta.description}
meta="正式占位模块"
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]">
{TITLE_MESSAGE}
{STATE_TITLE}
</p>
<p className="mt-2 text-sm leading-7 text-[#7a5323]">{message}</p>
<p className="mt-2 text-sm leading-7 text-[#7a5323]">{statusMessage}</p>
</div>
<Card>
<CardHeader>
<div className="space-y-2">
<CardEyebrow>Library inventory</CardEyebrow>
<div className="space-y-1">
<CardTitle>{meta.title}</CardTitle>
<CardDescription>
BFF
</CardDescription>
<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>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<div className="rounded-[24px] border border-dashed border-[var(--border-strong)] bg-[var(--surface-muted)] px-5 py-6 text-sm text-[var(--ink-muted)]">
</div>
) : null}
</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>
{!isLoading && items.length ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => (
<div
key={item.id}
className="rounded-[24px] border border-[var(--border-soft)] bg-[var(--surface-muted)] px-4 py-4"
>
<div className="space-y-3">
<div>
<p className="text-sm font-semibold text-[var(--ink-strong)]">
<div className="space-y-1">
<p className="text-base font-semibold tracking-[-0.02em] text-[var(--ink-strong)]">
{item.name}
</p>
<p className="mt-1 text-sm leading-6 text-[var(--ink-muted)]">
<p className="text-sm leading-6 text-[var(--ink-muted)]">
{item.description}
</p>
</div>
<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.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="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>
))}
</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>
))}
</div>
) : null}
</article>
))
: null}
</div>
{!isLoading && !items.length ? (
<EmptyState
eyebrow="Library empty"
title="暂无资源条目"
description="当前库还没有占位数据,等后端或运营资源列表准备好后再补齐。"
/>
) : null}
</CardContent>
</Card>
<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(
"当前资源库仍使用 mock 数据,真实资源查询能力将在后端支持后接入。",
);
const [message, setMessage] = useState(DEFAULT_MESSAGE);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
@@ -158,10 +381,7 @@ export function LibraryPageScreen({ libraryType }: { libraryType: LibraryType })
}
setItems(payload.data?.items ?? []);
setMessage(
payload.message ??
"当前资源库仍使用 mock 数据,真实资源查询能力将在后端支持后接入。",
);
setMessage(payload.message ?? DEFAULT_MESSAGE);
} catch {
if (!active) {
return;