feat: connect resource library workflows
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user