Files
ttstd-infinite-canvas/web/src/pages/projects/index.tsx

390 lines
29 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.
import { CollectionToolbar } from "@/components/layout/collection-toolbar";
import { DeleteButton } from "@/components/ui/base/buttons/delete-button";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { MediaPlaceholder } from "@/components/ui/product/media-placeholder";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { App, Button, Form, Input, Modal, Select } from "antd";
import { ArrowRight, BookOpenText, FileText, FolderKanban, Images, LayoutGrid, Palette, Plus, Search, Sparkles } from "lucide-react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { CollectionGrid, PageHeader, WorkspacePage } from "@/components/layout/workspace-page";
import { WorkspaceErrorState, WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state";
import { CanvasStylePickerModal, resolveCanvasStylePreset, resolveProjectCanvasStyle, type CanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
import { resourceFileUrl } from "@/services/api/resources";
import { ModelPicker } from "@/components/model-picker";
import { createStyleProfileSnapshot, parseStyleProfile, serializeStyleProfile } from "@/lib/canvas/style-profile";
import { projectSummaryCompletion, projectSummaryStage } from "@/lib/project-workbench";
import { settingsPath } from "@/lib/settings-navigation";
import { PromptTemplateOperation, parseGeneratedStory, promptTemplateTaskPlaceholder, shortDramaOutlineVariables } from "@/lib/prompts";
import { runBackendGenerationTask } from "@/services/api/generation-task";
import { createProject, deleteProject, importProjectUnits, listProjects, type ProjectSummary } from "@/services/api/projects";
import { modelDisplayName, useEffectiveConfig } from "@/stores/use-config-store";
import { sourceTypeLabel } from "./detail/shared";
type ProjectForm = { name: string; aspectRatio: string; sourceType: string };
export default function ProjectsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { message, modal } = App.useApp();
const effectiveConfig = useEffectiveConfig();
const [createForm] = Form.useForm<ProjectForm>();
const [searchParams, setSearchParams] = useSearchParams();
const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState<"all" | "active" | "archived">("all");
const [sort, setSort] = useState<"updated" | "progress" | "name">("updated");
const [storyDraft, setStoryDraft] = useState("");
const [createSource, setCreateSource] = useState<"blank" | "novel" | "text">("blank");
const [selectedStyle, setSelectedStyle] = useState<CanvasStylePreset | null>(null);
const [stylePickerOpen, setStylePickerOpen] = useState(false);
const [generateModel, setGenerateModel] = useState("");
const [generateChapterCount, setGenerateChapterCount] = useState("5");
const [generateStructure, setGenerateStructure] = useState("单线推进");
const [generateChapterLength, setGenerateChapterLength] = useState("中");
const [generateWordCount, setGenerateWordCount] = useState("800");
const [generatePerspective, setGeneratePerspective] = useState("第三人称");
const [generateTone, setGenerateTone] = useState("平稳叙事");
const [generateCharacterScale, setGenerateCharacterScale] = useState("3-4 个");
const [generating, setGenerating] = useState(false);
const [generationStatus, setGenerationStatus] = useState("");
const [generationPreview, setGenerationPreview] = useState("");
const createOpen = searchParams.get("create") === "1";
const setCreateOpen = (open: boolean) => {
const next = new URLSearchParams(searchParams);
if (open) next.set("create", "1");
else next.delete("create");
setSearchParams(next, { replace: true });
};
const openCreate = (source: "blank" | "novel" | "text") => {
setCreateSource(source);
setCreateOpen(true);
};
useEffect(() => {
if (!createOpen) return;
createForm.setFieldsValue({
name: storyDraft.trim().slice(0, 24) || "",
sourceType: createSource,
aspectRatio: "9:16",
});
}, [createForm, createOpen, createSource, storyDraft]);
const generateStory = async () => {
const story = storyDraft.trim();
if (!story || generating) return;
const textModel = generateModel || effectiveConfig.textModel;
if (!textModel || !effectiveConfig.textModels.includes(textModel)) {
if (!textModel) {
modal.warning({
title: "需要先选择文本模型",
content: "请在上方“AI 模型”中选择一个已配置的文本模型,或先到设置中完成模型渠道配置。",
okText: "去设置",
cancelText: "取消",
onOk: () => navigate(settingsPath("models")),
});
} else {
message.error(`模型 ${textModel} 未在文本模型列表中,请重新选择`);
}
return;
}
setGenerating(true);
setGenerationStatus("正在创建项目…");
setGenerationPreview("");
try {
const project = await createUniqueProjectName(story, selectedStyle);
setGenerationStatus("AI 正在生成故事大纲与章节…");
const result = await runBackendGenerationTask({
projectId: project.project.id,
mode: "text",
prompt: promptTemplateTaskPlaceholder("短剧大纲"),
config: { ...effectiveConfig, model: textModel, imageModel: textModel, videoModel: textModel, textModel },
metadata: {
source: "project-story-generator",
projectId: project.project.id,
promptTemplateOperation: PromptTemplateOperation.ShortDramaOutline,
promptTemplateVariables: shortDramaOutlineVariables({
story,
chapterCount: generateChapterCount,
structure: generateStructure,
wordCount: generateWordCount,
perspective: generatePerspective,
tone: generateTone,
characterScale: generateCharacterScale,
chapterLength: generateChapterLength,
}),
},
onTextDelta: setGenerationPreview,
});
const answer = result.text || "";
const parsed = parseGeneratedStory(answer);
if (!parsed.chapters.length) throw new Error("AI 没有返回有效的章节内容,请重试");
setGenerationStatus(`正在导入 ${parsed.chapters.length} 个章节…`);
await importProjectUnits(project.project.id, parsed.chapters.map((chapter: { title: string; content: string }) => ({ kind: "chapter", title: chapter.title, sourceText: chapter.content })));
await queryClient.invalidateQueries({ queryKey: ["projects"] });
navigate(`/projects/${project.project.id}/overview`);
} catch (error) {
message.error(error instanceof Error ? error.message : "AI 生成失败,请重试");
} finally {
setGenerating(false);
setGenerationStatus("");
setGenerationPreview("");
}
};
const loadMoreRef = useRef<HTMLDivElement>(null);
const query = useInfiniteQuery({
// 分页查询和画布页的全量项目查询不能共用缓存形状,否则两个页面会互相覆盖缓存数据。
queryKey: ["projects", "paged"],
queryFn: ({ pageParam }) => listProjects({ page: pageParam, pageSize: 50 }),
initialPageParam: 1,
getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.page + 1 : undefined),
});
const mutation = useMutation({
mutationFn: createProject,
onSuccess: ({ project }) => {
setCreateOpen(false);
void queryClient.invalidateQueries({ queryKey: ["projects"] });
navigate(`/projects/${project.id}/overview`);
},
onError: (error) => message.error(error instanceof Error ? error.message : "项目创建失败"),
});
const deleteMutation = useMutation({
mutationFn: deleteProject,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["projects"] });
message.success("项目已删除");
},
onError: (error) => message.error(error instanceof Error ? error.message : "项目删除失败"),
});
const allProjects = useMemo(() => query.data?.pages.flatMap((page) => page.projects) || [], [query.data]);
const rows = useMemo(() => {
const normalizedKeyword = keyword.trim().toLowerCase();
return [...allProjects]
.filter(({ project }) => status === "all" || project.status === status)
.filter(({ project }) => !normalizedKeyword || `${project.name} ${project.description} ${project.stylePresetId} ${parseStyleProfile(project.styleProfileJson)?.title || resolveCanvasStylePreset(project.stylePresetId)?.title || ""}`.toLowerCase().includes(normalizedKeyword))
.sort((left, right) => {
if (sort === "name") return left.project.name.localeCompare(right.project.name, "zh-CN");
if (sort === "progress") return projectSummaryCompletion(right) - projectSummaryCompletion(left);
return right.project.updatedAt.localeCompare(left.project.updatedAt);
});
}, [allProjects, keyword, sort, status]);
const totalProjectCount = query.data?.pages[0]?.total ?? allProjects.length;
useEffect(() => {
const node = loadMoreRef.current;
if (!node || !query.hasNextPage || query.isError) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting && !query.isFetchingNextPage) void query.fetchNextPage();
},
{ rootMargin: "600px" },
);
observer.observe(node);
return () => observer.disconnect();
}, [query.fetchNextPage, query.hasNextPage, query.isError, query.isFetchingNextPage]);
const hasInitialError = query.isError && !query.data;
return (
<WorkspacePage className="library-page project-library-page" grid>
<PageHeader title="短剧 Agent" description="你的故事、章节与镜头,都在这里。" meta={<span className="app-projects-header-meta">{totalProjectCount} 个项目</span>} />
<details className="story-launcher-panel" aria-label="开始一部新短剧">
<summary className="story-launcher-head">
<div className="story-launcher-title">
<span className="story-launcher-mark"><Sparkles className="size-4" /></span>
<div>
<h2>开始一部新短剧</h2>
<p>一句话生成章节,也可以导入小说或从空白开始</p>
</div>
</div>
<span className="story-launcher-expand"><Plus className="size-4" /><span>展开创作</span></span>
</summary>
<div className="story-launcher-main">
<Input.TextArea
className="story-launcher-input"
value={storyDraft}
onChange={(event) => setStoryDraft(event.target.value)}
placeholder="例如:一个失忆的快递员,每天收到十年前寄出的信件……"
autoSize={{ minRows: 2, maxRows: 5 }}
aria-label="一句话故事"
/>
{selectedStyle ? <button type="button" className="story-launcher-style-chip" onClick={() => setStylePickerOpen(true)} title={selectedStyle.title}>
<img src={selectedStyle.imageUrl} alt="" />
<span>{selectedStyle.title}</span>
</button> : null}
</div>
<div className="story-launcher-actions">
<Button icon={<FolderKanban />} onClick={() => openCreate("blank")}>空白项目</Button>
<Button icon={<FileText />} onClick={() => openCreate("novel")}>导入小说</Button>
<Button icon={<Palette />} onClick={() => setStylePickerOpen(true)}>{selectedStyle ? "更换画风" : "选画风"}</Button>
<ModelPicker
config={effectiveConfig}
value={generateModel || effectiveConfig.textModel}
onChange={setGenerateModel}
capability="text"
variant="creation"
placeholder="选择文本模型"
showSelectedPrice={false}
showOptionPrices
popoverClassName="agent-model-picker-popover"
/>
<Button type="default" icon={<Sparkles className="size-3.5" />} disabled={!storyDraft.trim() || generating} loading={generating} onClick={() => void generateStory()}>AI 生成章节</Button>
<Button type="primary" icon={<Plus className="size-3.5" />} onClick={() => openCreate(createSource)}>开始创作</Button>
</div>
<details className="story-launcher-options">
<summary>故事设置 · {generateChapterCount} 章 · {generatePerspective} · {generateTone}</summary>
<div className="story-launcher-controls">
<label><span>章节数量</span><Select size="small" className="min-w-28" value={generateChapterCount} onChange={setGenerateChapterCount} options={[{ label: "3 章", value: "3" }, { label: "5 章", value: "5" }, { label: "8 章", value: "8" }, { label: "10 章", value: "10" }]} /></label>
<label><span>叙事结构</span><Select size="small" className="min-w-32" value={generateStructure} onChange={setGenerateStructure} options={[{ label: "单线推进", value: "单线推进" }, { label: "双线并行", value: "双线并行" }, { label: "群像多线", value: "群像多线" }, { label: "反转嵌套", value: "反转嵌套" }]} /></label>
<label><span>章节篇幅</span><Select size="small" className="min-w-28" value={generateChapterLength} onChange={setGenerateChapterLength} options={[{ label: "精炼", value: "短" }, { label: "均衡", value: "中" }, { label: "丰满", value: "长" }]} /></label>
<label><span>单章字数</span><Select size="small" className="min-w-28" value={generateWordCount} onChange={setGenerateWordCount} options={[{ label: "500 字", value: "500" }, { label: "800 字", value: "800" }, { label: "1200 字", value: "1200" }, { label: "2000 字", value: "2000" }]} /></label>
<label><span>叙述视角</span><Select size="small" className="min-w-28" value={generatePerspective} onChange={setGeneratePerspective} options={[{ label: "第三人称", value: "第三人称" }, { label: "第一人称", value: "第一人称" }, { label: "多视角", value: "多视角" }]} /></label>
<label><span>故事基调</span><Select size="small" className="min-w-32" value={generateTone} onChange={setGenerateTone} options={[{ label: "平稳叙事", value: "平稳叙事" }, { label: "轻松喜剧", value: "轻松喜剧" }, { label: "紧张悬疑", value: "紧张悬疑" }, { label: "热血成长", value: "热血成长" }, { label: "甜宠治愈", value: "甜宠治愈" }]} /></label>
<label><span>角色规模</span><Select size="small" className="min-w-28" value={generateCharacterScale} onChange={setGenerateCharacterScale} options={[{ label: "2 个", value: "2 个" }, { label: "3-4 个", value: "3-4 个" }, { label: "5-6 个", value: "5-6 个" }]} /></label>
</div>
</details>
</details>
<CollectionToolbar active={Boolean(keyword || status !== "all" || sort !== "updated")} onReset={() => { setKeyword(""); setStatus("all"); setSort("updated"); }}>
<Input allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索项目、简介或画风" onChange={(event) => setKeyword(event.target.value)} />
<Select className="w-32" value={status} onChange={setStatus} options={[{ label: "全部状态", value: "all" }, { label: "进行中", value: "active" }, { label: "已归档", value: "archived" }]} />
<Select className="w-32" value={sort} onChange={setSort} options={[{ label: "最近更新", value: "updated" }, { label: "章节进度", value: "progress" }, { label: "项目名称", value: "name" }]} />
</CollectionToolbar>
{hasInitialError ? <WorkspaceErrorState description={query.error instanceof Error ? query.error.message : "项目列表加载失败"} onRetry={() => void query.refetch()} /> : null}
{query.isLoading ? <WorkspaceLoadingState label="正在整理项目" detail="读取章节、画布与资产进度" /> : null}
{!query.isLoading && !hasInitialError && rows.length ? (
<CollectionGrid className="library-grid project-library-grid">
{rows.map((row) => <ProjectRow key={row.project.id} row={row} onDelete={() => deleteMutation.mutateAsync(row.project.id)} />)}
</CollectionGrid>
) : null}
{!query.isLoading && !hasInitialError ? <div ref={loadMoreRef} className="library-load-more" aria-live="polite">
{query.isFetchingNextPage ? "正在加载更多项目…" : query.isError ? <button type="button" onClick={() => void query.fetchNextPage()}>加载更多失败,点击重试</button> : query.hasNextPage ? "继续下滑加载更多(每页 50 条)" : allProjects.length ? `已加载全部 ${totalProjectCount} 个项目` : null}
</div> : null}
{!query.isLoading && !rows.length && !hasInitialError && (keyword || status !== "all") ? (
<WorkspaceState
icon="projects"
title={keyword || status !== "all" ? "没有匹配的项目" : "创建第一个故事项目"}
description={keyword || status !== "all" ? "调整搜索词或状态筛选后再试。" : "项目会集中保存章节、项目画布、角色场景和制作进度。自由试图可从画布开始。"}
action={!keyword && status === "all" ? <Button type="primary" icon={<Plus className="size-3.5" />} onClick={() => setCreateOpen(true)}>创建项目</Button> : undefined}
/>
) : null}
<Modal className="library-modal" title="创建短剧项目" open={createOpen} footer={null} destroyOnHidden onCancel={() => setCreateOpen(false)} width={560} styles={{ body: { paddingTop: 12 } }}>
<Form<ProjectForm> form={createForm} layout="vertical" initialValues={{ aspectRatio: "9:16", sourceType: "blank" }} onFinish={(values) => mutation.mutate({ ...values, type: "short-drama", ...(selectedStyle ? { stylePresetId: selectedStyle.id, styleProfileJson: serializeStyleProfile(selectedStyle.profile || createStyleProfileSnapshot(selectedStyle)) } : {}) })}>
<div className="mb-4 grid grid-cols-3 gap-2">
<button type="button" className={createSource === "blank" ? "app-story-source is-active" : "app-story-source"} onClick={() => { setCreateSource("blank"); createForm.setFieldValue("sourceType", "blank"); }}><FolderKanban className="size-4" /><span>空白开始</span></button>
<button type="button" className={createSource === "novel" ? "app-story-source is-active" : "app-story-source"} onClick={() => { setCreateSource("novel"); createForm.setFieldValue("sourceType", "novel"); }}><FileText className="size-4" /><span>导入小说</span></button>
<button type="button" className={createSource === "text" ? "app-story-source is-active" : "app-story-source"} onClick={() => { setCreateSource("text"); createForm.setFieldValue("sourceType", "text"); }}><BookOpenText className="size-4" /><span>粘贴文本</span></button>
</div>
<Form.Item name="name" label="项目名称" rules={[{ required: true, whitespace: true, message: "请输入项目名称" }]}><Input autoFocus placeholder="例如:长安夜行" /></Form.Item>
<div className="grid grid-cols-2 gap-3">
<Form.Item name="aspectRatio" label="默认画幅"><Select options={[{ label: "9:16 竖屏", value: "9:16" }, { label: "16:9 横屏", value: "16:9" }, { label: "1:1 方形", value: "1:1" }]} /></Form.Item>
<Form.Item name="sourceType" label="内容来源"><Select options={[{ label: "空白开始", value: "blank" }, { label: "导入小说", value: "novel" }, { label: "粘贴文本", value: "text" }]} /></Form.Item>
</div>
<Form.Item label="项目画风"><button type="button" className="app-story-modal-style" onClick={() => setStylePickerOpen(true)}>{selectedStyle ? <><img src={selectedStyle.imageUrl} alt="" /><span>{selectedStyle.title}</span><em>更换</em></> : <><Palette className="size-4" /><span>选择项目画风(可选)</span></>}</button></Form.Item>
<p className="-mt-1 mb-5 text-xs leading-5 text-foreground/48">创建后先进入项目概览。章节、画风和参考资产可以逐步补充。</p>
<div className="flex justify-end gap-2"><Button onClick={() => setCreateOpen(false)}>取消</Button><Button type="primary" htmlType="submit" loading={mutation.isPending}>创建项目</Button></div>
</Form>
</Modal>
<CanvasStylePickerModal
open={stylePickerOpen}
value={selectedStyle?.id}
onClose={() => setStylePickerOpen(false)}
onSelect={(preset) => { setSelectedStyle(preset); setStylePickerOpen(false); }}
/>
<Modal className="library-modal" title="AI 生成章节" open={generating} footer={null} closable={false} mask={{ closable: false }} keyboard={false} width={760}>
<div className="app-story-generating">
<div className="app-story-generating-head">
<span className="app-story-generating-mark"><Sparkles className="size-4" /></span>
<div className="min-w-0">
<p>AI 正在创作</p>
<span className="block text-[var(--fs-tiny)] text-foreground/45">正在生成剧名、简介与章节</span>
</div>
{generateModel || effectiveConfig.textModel ? <span className="app-story-generating-model">{modelDisplayName(effectiveConfig, generateModel || effectiveConfig.textModel)}</span> : null}
</div>
<div className="app-story-generating-progress" aria-hidden="true" />
<div className="app-story-generating-grid">
<div className="app-story-generating-story">
<span className="app-story-generating-caption">故事起点</span>
<p>{storyDraft.trim() || "等待故事输入"}</p>
<span className="app-story-generating-meta">{generateChapterCount} 章 · 每章约 {generateWordCount} 字 · {generateStructure} · {generatePerspective}</span>
{selectedStyle ? <span className="app-story-generating-style"><img src={selectedStyle.imageUrl} alt="" /><span>{selectedStyle.title}</span></span> : null}
</div>
<ol className="app-story-generating-steps">
{generationSteps.map((step) => {
const state = generationStatus.startsWith(step.label) ? "is-active" : generationStepDone(step.label, generationStatus) ? "is-done" : "";
return <li key={step.label} className={state}><span className="app-story-generating-step-dot" /><span>{step.label}</span><em>{state === "is-active" ? "进行中" : state === "is-done" ? "完成" : "等待"}</em></li>;
})}
</ol>
</div>
<div className="app-story-generating-preview">
<div className="app-story-generating-preview-head"><span>实时草稿</span><span className="app-story-generating-live" /><em>{generationPreview ? "正在输出" : "等待模型输出"}</em></div>
<pre>{generationPreview}</pre>
</div>
</div>
</Modal>
</WorkspacePage>
);
}
async function createUniqueProjectName(story: string, selectedStyle: CanvasStylePreset | null) {
const base = story.trim().slice(0, 24);
const buildInput = (name: string) => ({
name,
type: "short-drama" as const,
aspectRatio: "9:16",
sourceType: "blank",
description: story.trim(),
...(selectedStyle ? { stylePresetId: selectedStyle.id, styleProfileJson: serializeStyleProfile(selectedStyle.profile || createStyleProfileSnapshot(selectedStyle)) } : {}),
});
let attempt = 0;
for (;;) {
try {
return await createProject(buildInput(attempt === 0 ? base : `${base}(${attempt + 1})`));
} catch (error) {
const message = error instanceof Error ? error.message : "";
const uniqueConflict = message.includes("UNIQUE") || message.includes("projects.user_id") || message.includes("projects.name");
if (!uniqueConflict || attempt >= 5) throw error;
attempt += 1;
}
}
}
const generationSteps = [
{ label: "正在创建项目" },
{ label: "AI 正在生成故事大纲与章节" },
{ label: "正在导入章节" },
];
function generationStepDone(label: string, status: string) {
if (label === "正在创建项目") return status.startsWith("AI 正在生成") || status.startsWith("正在导入");
if (label === "AI 正在生成故事大纲与章节") return status.startsWith("正在导入");
return false;
}
function ProjectRow({ row, onDelete }: { row: ProjectSummary; onDelete: () => Promise<unknown> }) {
const completion = projectSummaryCompletion(row);
const stage = projectSummaryStage(row);
const projectStyle = resolveProjectCanvasStyle(row.project.stylePresetId, row.project.styleProfileJson);
const styleTitle = projectStyle?.title || parseStyleProfile(row.project.styleProfileJson)?.title || resolveCanvasStylePreset(row.project.stylePresetId)?.title || (row.project.stylePresetId ? "自定义画风" : "未设置画风");
const coverUrl = row.project.coverResourceId ? resourceFileUrl(row.project.coverResourceId) : projectStyle?.imageUrl;
return (
<Link to={`/projects/${row.project.id}/overview`} className="product-collection-card library-card project-library-card group">
<span className="project-library-cover">
{coverUrl ? <CachedResourceImage className="project-library-cover-art" src={coverUrl} alt="" fallback={<MediaPlaceholder failed />} /> : <MediaPlaceholder label="故事由此开始" />}
<span className="project-library-cover-scrim" />
<span className="project-library-cover-ratio">{row.project.aspectRatio}</span>
<span className="project-library-cover-stage">{stage.label}</span>
<span className="project-collection-delete"><DeleteButton label={`删除项目 ${row.project.name}`} description="项目章节、画布关联和素材归属将一并移除;独立画布与素材库原始素材会保留。此操作不可撤销。" onConfirm={onDelete} /></span>
</span>
<span className="project-library-body">
<span className="project-library-heading"><strong title={row.project.name}>{row.project.name}</strong>{row.project.status === "archived" ? <em>已归档</em> : null}<ArrowRight className="project-library-arrow size-4" /></span>
<span className="project-library-subtitle">{styleTitle} · {sourceTypeLabel(row.project.sourceType)}</span>
<span className="project-library-progress"><span><span>{row.completedUnitCount}/{row.unitCount} 章</span><span>{completion}%</span></span><i><b style={{ width: `${completion}%` }} /></i></span>
<span className="project-library-stats"><ProjectCount icon={<BookOpenText className="size-3.5" />} label="章节" value={row.unitCount} /><ProjectCount icon={<LayoutGrid className="size-3.5" />} label="画布" value={row.canvasCount} /><ProjectCount icon={<Images className="size-3.5" />} label="资产" value={row.assetCount} /></span>
</span>
</Link>
);
}
function ProjectCount({ icon, label, value }: { icon: ReactNode; label: string; value: number }) {
return <span className="inline-flex items-center gap-1.5" title={`${value} ${label}`}><span className="text-foreground/32">{icon}</span><strong className="font-medium tabular-nums text-foreground/65">{value}</strong><span>{label}</span></span>;
}