feat(web): 用户工作台 - 产品化壳层、内容库布局与积分入口
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
f52702b70f
commit
969e2831b2
46 files changed
+5355
-944
No files matched your search
@@ -1,3 +1,5 @@
|
||||
import { CollectionToolbar } from "@/components/layout/collection-toolbar";
|
||||
import { DeleteButton } from "@/components/ui/base/buttons/delete-button";
|
||||
import { AlertTriangle, AudioLines, Box, CheckCheck, Clapperboard, Copy, Download, FileText, FileUp, FolderOpen, FolderPlus, Image as ImageIcon, Images, LayoutGrid, Link2, Maximize2, MoreHorizontal, PencilLine, Play, Plus, RotateCcw, Search, Trash2, Upload, ZoomIn, ZoomOut, type LucideIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -5,7 +7,7 @@ import { App, Button, Drawer, Dropdown, Form, Input, Modal, Popconfirm, Progress
|
||||
import type { MenuProps } from "antd";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import { CollectionGrid, ListToolbar, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page";
|
||||
import { CollectionGrid, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page";
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { AssetMediaPreview } from "@/components/asset-media-preview";
|
||||
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
|
||||
@@ -170,7 +172,6 @@ export default function AssetsPage() {
|
||||
);
|
||||
const visibleAssetIds = useMemo(() => visibleAssets.map((asset) => asset.id), [visibleAssets]);
|
||||
const allFilteredSelected = visibleAssetIds.length > 0 && visibleAssetIds.every((id) => selectedIds.includes(id));
|
||||
const canCreateAsset = page === 1 && viewMode === "library" && !keyword.trim() && kindFilter === "all" && categoryFilter === "all" && folderFilter === "all";
|
||||
const totalAssets = assetPageQuery.data?.total ?? filteredAssets.length;
|
||||
const kindCounts = useMemo(() => assetCountMap(kindOptions, assetPageQuery.data?.kindCounts, viewMode === "trash" ? trashAssets : activeAssets, (asset) => asset.kind), [activeAssets, assetPageQuery.data?.kindCounts, trashAssets, viewMode]);
|
||||
const categoryCounts = useMemo(() => assetCountMap(categoryOptions, assetPageQuery.data?.categoryCounts, viewMode === "trash" ? trashAssets : activeAssets, (asset) => asset.category || "other"), [activeAssets, assetPageQuery.data?.categoryCounts, trashAssets, viewMode]);
|
||||
@@ -233,6 +234,7 @@ export default function AssetsPage() {
|
||||
message.success(`已删除分类「${folder.name}」,其中素材已移至未分类`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "素材分类删除失败");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -525,7 +527,7 @@ export default function AssetsPage() {
|
||||
<PageHeader
|
||||
title={viewMode === "trash" ? "素材库 / 回收站" : "素材库"}
|
||||
description={viewMode === "trash" ? "已删除画布或手动归档的临时素材,可随时还原或彻底清理。" : "管理文本、图片、视频、音频和 3D 模型素材。"}
|
||||
meta={<span className="app-projects-header-meta assets-header-meta">{validAssets.length} 个素材</span>}
|
||||
meta={<span className="app-projects-header-meta assets-header-meta">{totalAssets} 个素材</span>}
|
||||
actions={
|
||||
<div className="assets-header-actions">
|
||||
<div className="assets-header-action-buttons">
|
||||
@@ -558,26 +560,15 @@ export default function AssetsPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button icon={<Plus className="size-3.5" />} onClick={openCreate}>
|
||||
新增素材
|
||||
</Button>
|
||||
<Button icon={<Images className="size-3.5" />} onClick={() => setBatchUploadOpen(true)}>
|
||||
批量上传图片
|
||||
</Button>
|
||||
<Button icon={<FolderOpen className="size-3.5" />} onClick={() => navigate("/plugins/eagle")}>
|
||||
Eagle 素材库
|
||||
</Button>
|
||||
<Button aria-label="导出全部素材" title="导出全部素材" icon={<Download className="size-3.5" />} onClick={() => void exportAllAssets()} />
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: "package", icon: <FileUp className="size-4" />, label: "导入素材包", onClick: () => assetInputRef.current?.click() },
|
||||
{ key: "model", icon: <Upload className="size-4" />, label: "上传 3D 模型", onClick: () => modelInputRef.current?.click() },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button aria-label="导入素材" title="导入素材" icon={<FileUp className="size-3.5" />} />
|
||||
<Button type="primary" icon={<Plus />} onClick={openCreate}>新增素材</Button>
|
||||
<Button icon={<Images />} onClick={() => setBatchUploadOpen(true)}>上传图片</Button>
|
||||
<Dropdown trigger={["click"]} menu={{ items: [
|
||||
{ key: "eagle", icon: <FolderOpen />, label: "Eagle 素材库", onClick: () => navigate("/plugins/eagle") },
|
||||
{ key: "package", icon: <FileUp />, label: "导入素材包", onClick: () => assetInputRef.current?.click() },
|
||||
{ key: "model", icon: <Upload />, label: "上传 3D 模型", onClick: () => modelInputRef.current?.click() },
|
||||
{ key: "export", icon: <Download />, label: "导出全部素材", onClick: () => void exportAllAssets() },
|
||||
] }}>
|
||||
<Button type="text" aria-label="更多素材操作" icon={<MoreHorizontal />} />
|
||||
</Dropdown>
|
||||
</>
|
||||
)}
|
||||
@@ -586,9 +577,8 @@ export default function AssetsPage() {
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ListToolbar
|
||||
className="library-toolbar"
|
||||
active={Boolean(keyword || kindFilter !== "all" || categoryFilter !== "all")}
|
||||
<CollectionToolbar
|
||||
active={Boolean(keyword || kindFilter !== "all" || categoryFilter !== "all" || folderFilter !== "all")}
|
||||
onReset={() => {
|
||||
setKeyword("");
|
||||
setKindFilter("all");
|
||||
@@ -608,28 +598,20 @@ export default function AssetsPage() {
|
||||
setKeyword(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
value={folderFilter}
|
||||
className="w-full sm:w-44"
|
||||
options={[{ label: "全部自定义分类", value: "all" }, { label: "未分类", value: "uncategorized" }, ...folders.map((folder) => ({ label: folder.name, value: folder.id }))]}
|
||||
onChange={(value) => {
|
||||
setFolderFilter(value as AssetFolderFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
value={gridDensity}
|
||||
className="w-full sm:w-32"
|
||||
suffixIcon={<LayoutGrid className="size-3.5" />}
|
||||
options={[{ label: "舒适 · 6 列", value: 6 }, { label: "标准 · 8 列", value: 8 }, { label: "紧凑 · 10 列", value: 10 }]}
|
||||
options={[{ label: "舒适", value: 6 }, { label: "标准", value: 8 }, { label: "紧凑", value: 10 }]}
|
||||
onChange={(value) => setGridDensity(value as AssetGridDensity)}
|
||||
/>
|
||||
</ListToolbar>
|
||||
</CollectionToolbar>
|
||||
</div>
|
||||
|
||||
<div className="canvas-library-frame assets-library-frame">
|
||||
<div className="grid min-h-0 gap-4 lg:grid-cols-[176px_minmax(0,1fr)]">
|
||||
<aside className="thin-scrollbar flex gap-2 overflow-x-auto py-3 lg:sticky lg:top-0 lg:block lg:max-h-[calc(100vh-150px)] lg:overflow-y-auto lg:pr-3">
|
||||
<div className="collection-content assets-collection-content">
|
||||
<div className="assets-collection-layout">
|
||||
<aside className="assets-collection-filters" aria-label="素材分类">
|
||||
<div className="assets-collection-filter-scroll">
|
||||
<AssetFilterGroup
|
||||
title="素材类型"
|
||||
options={kindOptions}
|
||||
@@ -651,15 +633,17 @@ export default function AssetsPage() {
|
||||
setCategoryFilter(value as AssetCategory | "all");
|
||||
setPage(1);
|
||||
}}
|
||||
className="lg:mt-5"
|
||||
/>
|
||||
<div className="mt-5">
|
||||
<div className="mb-1.5 flex items-center justify-between px-1 text-[var(--fs-tiny)] font-semibold uppercase tracking-[0.08em] text-foreground/38">
|
||||
<span>我的分类</span>
|
||||
<section className="collection-filter-group assets-folder-filter">
|
||||
<div className="collection-folder-heading">
|
||||
<span className="collection-filter-label">我的分类</span>
|
||||
<button type="button" className="assets-folder-add" title="新建分类" aria-label="新建分类" onClick={() => { setFolderName(""); setFolderEditor("new"); }}><FolderPlus className="size-3.5" /></button>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<button type="button" aria-pressed={folderFilter === "uncategorized"} className={`assets-filter-item w-full ${folderFilter === "uncategorized" ? "is-active" : ""}`} onClick={() => { setFolderFilter("uncategorized"); setPage(1); }}>
|
||||
<div className="collection-folder-list">
|
||||
<button type="button" aria-pressed={folderFilter === "all"} className={`assets-filter-item ${folderFilter === "all" ? "is-active" : ""}`} onClick={() => { setFolderFilter("all"); setPage(1); }}>
|
||||
<span className="assets-filter-item-label">全部</span><span className="assets-filter-count">{activeAssets.length}</span>
|
||||
</button>
|
||||
<button type="button" aria-pressed={folderFilter === "uncategorized"} className={`assets-filter-item ${folderFilter === "uncategorized" ? "is-active" : ""}`} onClick={() => { setFolderFilter("uncategorized"); setPage(1); }}>
|
||||
<span className="assets-filter-item-label">未分类</span><span className="assets-filter-count">{folderCounts[""] ?? activeAssets.filter((asset) => !asset.folderId).length}</span>
|
||||
</button>
|
||||
{folders.map((folder) => (
|
||||
@@ -667,15 +651,14 @@ export default function AssetsPage() {
|
||||
<button type="button" aria-pressed={folderFilter === folder.id} className={`assets-filter-item min-w-0 flex-1 ${folderFilter === folder.id ? "is-active" : ""}`} onClick={() => { setFolderFilter(folder.id); setPage(1); }}>
|
||||
<span className="assets-filter-item-label min-w-0 truncate">{folder.name}</span><span className="assets-filter-count">{folderCounts[folder.id] ?? activeAssets.filter((asset) => asset.folderId === folder.id).length}</span>
|
||||
</button>
|
||||
<Dropdown trigger={["click"]} menu={{ items: [{ key: "rename", icon: <PencilLine className="size-3.5" />, label: "重命名", onClick: () => { setFolderName(folder.name); setFolderEditor(folder); } }, { key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除分类", onClick: () => void removeFolder(folder) }] }}>
|
||||
<button type="button" className="assets-folder-more" aria-label={`管理分类 ${folder.name}`} title="管理分类"><MoreHorizontal className="size-3.5" /></button>
|
||||
</Dropdown>
|
||||
<button type="button" className="product-icon-button" aria-label={`重命名分类 ${folder.name}`} onClick={() => { setFolderName(folder.name); setFolderEditor(folder); }}><PencilLine /></button>
|
||||
<DeleteButton label={`删除分类 ${folder.name}`} description="分类删除后,其中的素材会移至未分类,素材文件会保留。" onConfirm={() => removeFolder(folder)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="mt-6 border-t border-border/40 pt-3">
|
||||
<div className="mb-1.5 px-1 text-[var(--fs-tiny)] font-semibold uppercase tracking-[0.08em] text-foreground/38">垃圾箱与归档</div>
|
||||
<div className="collection-trash-entry">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={viewMode === "trash"}
|
||||
@@ -737,15 +720,6 @@ export default function AssetsPage() {
|
||||
<WorkspaceState icon="assets" compact title="没有匹配的素材" description="调整关键词或左侧分类后再试。" />
|
||||
) : (
|
||||
<CollectionGrid className="library-grid assets-library-grid" style={{ "--assets-grid-columns": gridDensity } as React.CSSProperties}>
|
||||
{canCreateAsset ? (
|
||||
<button type="button" className="library-create-card" onClick={openCreate}>
|
||||
<span className="library-create-cover">
|
||||
<Plus className="size-8" />
|
||||
</span>
|
||||
<span className="library-create-title">新增素材</span>
|
||||
<span className="library-create-meta">文本、图片、音视频或模型</span>
|
||||
</button>
|
||||
) : null}
|
||||
{visibleAssets.map((asset) => (
|
||||
<AssetCard
|
||||
key={asset.id}
|
||||
@@ -1086,12 +1060,12 @@ function AssetCard({
|
||||
return (
|
||||
<AssetLibraryCard selected={selected}>
|
||||
<AssetCover asset={asset} selected={selected} isTrash={isTrash} onSelect={onSelect} onOpen={onOpen} menuItems={menuItems} />
|
||||
<button type="button" className="block w-full px-2.5 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--workspace-accent)]" onClick={onOpen}>
|
||||
<button type="button" className="asset-collection-body block w-full px-2.5 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--workspace-accent)]" onClick={onOpen}>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<h2 className="truncate text-[var(--fs-body)] font-semibold text-foreground" title={asset.title}>
|
||||
{asset.title}
|
||||
</h2>
|
||||
<span className="shrink-0 text-[var(--fs-tiny)] tabular-nums text-foreground/38">{formatAssetTime(asset.updatedAt)}</span>
|
||||
<span className="asset-collection-date shrink-0 tabular-nums">{formatAssetTime(asset.updatedAt)}</span>
|
||||
</div>
|
||||
{isTrash ? (
|
||||
<div className="mt-1 flex items-center gap-1 text-[var(--fs-tiny)] font-medium text-amber-600 dark:text-amber-400" title={formatExpirationDate(asset.updatedAt, retentionDays)}>
|
||||
@@ -1099,11 +1073,11 @@ function AssetCard({
|
||||
<span>{formatExpirationHint(asset.updatedAt, retentionDays)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 truncate text-[var(--fs-label)] text-foreground/52" title={summary}>
|
||||
<div className="asset-collection-summary mt-1 truncate" title={summary}>
|
||||
{summary}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/38">
|
||||
<div className="asset-collection-source mt-1 flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate">{asset.source || "未标注来源"}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="truncate">{assetProjectLabel(asset)}</span>
|
||||
@@ -1323,9 +1297,9 @@ function AssetFilterGroup({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="mb-1.5 px-1 text-[var(--fs-tiny)] font-semibold uppercase tracking-[0.08em] text-foreground/38">{title}</div>
|
||||
<div className="flex gap-1.5 lg:block lg:space-y-0.5">
|
||||
<div className={`collection-filter-group ${className}`}>
|
||||
<span className="collection-filter-label">{title}</span>
|
||||
<div className="collection-filter-options">
|
||||
{options.map((option) => {
|
||||
const active = value === option.value;
|
||||
return (
|
||||
|
||||
Reference in new issue
Block a user