fix(platform): 用户工作台 - 统一资源卡片并修正项目列表与预览交互

This commit is contained in:
ddCat
2026-08-06 23:01:08 +08:00
parent 61f0dfb9c6
commit 7d4384fa1f
12 changed files with 529 additions and 202 deletions

View File

@@ -1,10 +1,11 @@
import { useMemo, useState } from "react";
import { useMutation, useMutationState, useQuery, useQueryClient } from "@tanstack/react-query";
import { App, Button, Dropdown, Form, Input, Modal, Popconfirm, Select, Tabs, type FormInstance } from "antd";
import { Box, Check, ChevronDown, FileText, Image as ImageIcon, Link2, Music2, Plus, RefreshCw, Sparkles, Trash2, UserRound, Video, VolumeX } from "lucide-react";
import { Box, Check, ChevronDown, Download, FileText, Image as ImageIcon, Link2, Music2, Plus, RefreshCw, Sparkles, Trash2, UserRound, Video, VolumeX } from "lucide-react";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { AssetMediaPreview } from "@/components/asset-media-preview";
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon";
import { resolveCanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
@@ -27,10 +28,11 @@ import {
import { saveRemoteUserDataNow } from "@/services/user-data-sync";
import { useAssetStore, type Asset, type AssetCategory, type AssetStatus, type EntityAsset, type ImageAsset } from "@/stores/use-asset-store";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { saveAs } from "file-saver";
import { ProjectCharacterCard } from "./project-character-card";
import { generateCharacterTurnaround } from "./project-character-media";
import { categoryLabels, categoryLabel, mediaLabel, StatusPill, formatTime, type ProjectDetailViewProps } from "./shared";
import { categoryLabels, categoryLabel, mediaLabel, StatusPill, formatTime, textValue, type ProjectDetailViewProps } from "./shared";
const categories = ["all", "character", "environment", "wardrobe", "prop", "weapon", "style", "other"];
const characterFields = [
@@ -56,6 +58,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
const [editorAsset, setEditorAsset] = useState<ProjectAsset | "new" | null>(null);
const [imageAsset, setImageAsset] = useState<ProjectAsset | null>(null);
const [voiceAsset, setVoiceAsset] = useState<ProjectAsset | null>(null);
const [previewAsset, setPreviewAsset] = useState<ProjectAsset | null>(null);
const [imageSelection, setImageSelection] = useState("");
const [voiceProfileId, setVoiceProfileId] = useState("");
const [voiceInstructions, setVoiceInstructions] = useState("");
@@ -149,6 +152,18 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
};
const openImages = (asset: ProjectAsset) => { setImageAsset(asset); setImageSelection(""); };
const openVoice = (asset: ProjectAsset) => { setVoiceAsset(asset); setVoiceProfileId(asset.character?.voice?.profile.id || ""); setVoiceInstructions(asset.character?.voice?.instructions || ""); };
const downloadPreviewAsset = (asset: ProjectAsset) => {
const personal = personalAssets.find((item) => item.id === asset.id);
if (personal && (personal.kind === "image" || personal.kind === "video" || personal.kind === "audio" || personal.kind === "model")) {
const url = personal.kind === "image" ? personal.data.dataUrl : personal.data.url;
const extension = personal.kind === "model" ? personal.data.fileName.split(".").pop() || "glb" : personal.data.mimeType.split("/")[1] || "bin";
saveAs(url, `${asset.title || "asset"}.${extension}`);
return;
}
const cover = asset.character?.representations.find((item) => item.role === "turnaround_sheet") || asset.character?.representations.find((item) => item.role === "primary") || asset.character?.representations[0];
if (cover) saveAs(resourceFileUrl(cover.resourceId), `${asset.title || "character"}.png`);
else message.warning("当前资产没有可下载的媒体文件");
};
return (
<div>
<header className="flex min-h-[72px] flex-col gap-4 border-b border-border/70 pb-4 sm:flex-row sm:items-center sm:justify-between">
@@ -201,11 +216,12 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
</section>
) : null}
<div className="mb-2 flex items-center justify-between text-xs text-foreground/45"><span>{category === "all" ? "全部资产" : categoryLabel(category)}</span><span>{visibleAssets.length} 项已确认</span></div>
{visibleAssets.length ? <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">{visibleAssets.map((asset) => asset.category === "character" ? <ProjectCharacterCard key={asset.id} asset={asset} generating={generatingAssetIds.has(asset.id)} removing={unlinkMutation.isPending && unlinkMutation.variables === asset.id} onEdit={() => openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onRemove={() => unlinkMutation.mutate(asset.id)} /> : <MediaAssetCard key={asset.id} asset={asset} personalAsset={personalAssets.find((item) => item.id === asset.id)} onCategoryChange={(next) => categoryMutation.mutate({ id: asset.id, next })} onVersion={() => versionMutation.mutate(asset.id)} onRemove={() => unlinkMutation.mutate(asset.id)} loading={(categoryMutation.isPending && categoryMutation.variables?.id === asset.id) || (versionMutation.isPending && versionMutation.variables === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} />)}</div> : (category === "all" || category === "character") && pendingCandidates.length ? null : <WorkspaceState icon="assets" compact title="这个分类还没有资产" description={category === "character" ? "新建角色,或先从剧情章节提取角色信息。" : "可从个人素材库引用,或切换到其他分类查看。"} />}
{visibleAssets.length ? <div className="project-assets-grid assets-library-grid">{visibleAssets.map((asset) => asset.category === "character" ? <ProjectCharacterCard key={asset.id} asset={asset} generating={generatingAssetIds.has(asset.id)} removing={unlinkMutation.isPending && unlinkMutation.variables === asset.id} onOpen={() => setPreviewAsset(asset)} onEdit={() => openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onRemove={() => unlinkMutation.mutate(asset.id)} /> : <MediaAssetCard key={asset.id} asset={asset} personalAsset={personalAssets.find((item) => item.id === asset.id)} onOpen={() => setPreviewAsset(asset)} onCategoryChange={(next) => categoryMutation.mutate({ id: asset.id, next })} onVersion={() => versionMutation.mutate(asset.id)} onRemove={() => unlinkMutation.mutate(asset.id)} loading={(categoryMutation.isPending && categoryMutation.variables?.id === asset.id) || (versionMutation.isPending && versionMutation.variables === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} />)}</div> : (category === "all" || category === "character") && pendingCandidates.length ? null : <WorkspaceState icon="assets" compact title="这个分类还没有资产" description={category === "character" ? "新建角色,或先从剧情章节提取角色信息。" : "可从个人素材库引用,或切换到其他分类查看。"} />}
</div>
</div>
<Modal title="引用个人素材" open={addOpen} okText="加入项目" cancelText="取消" okButtonProps={{ disabled: !assetId, loading: addMutation.isPending }} onCancel={() => setAddOpen(false)} onOk={() => addMutation.mutate()} width={480}><div className="grid gap-3"><Select showSearch optionFilterProp="label" value={assetId || undefined} placeholder="选择未加入项目的素材" options={availableAssets.map((asset) => ({ label: `${asset.title} · ${mediaLabel(asset.kind)}`, value: asset.id }))} onChange={(value) => { setAssetId(value); const next = personalAssets.find((asset) => asset.id === value); setAssetCategory(next?.kind === "entity" ? "character" : next?.category || "other"); }} /><Select value={assetCategory} disabled={selectedPersonalAsset?.kind === "entity"} options={selectedPersonalAsset?.kind === "entity" ? [{ value: "character", label: "角色" }] : Object.entries(categoryLabels).filter(([value]) => value !== "character").map(([value, label]) => ({ value, label }))} onChange={setAssetCategory} /></div></Modal>
<ProjectAssetPreviewModal asset={previewAsset} personalAsset={previewAsset ? personalAssets.find((item) => item.id === previewAsset.id) : undefined} onClose={() => setPreviewAsset(null)} onDownload={() => previewAsset && downloadPreviewAsset(previewAsset)} onReplaceImage={() => { if (!previewAsset || previewAsset.category !== "character") return; setPreviewAsset(null); openImages(previewAsset); }} />
<CharacterEditorModal open={Boolean(editorAsset)} editing={editorAsset !== "new"} form={form} loading={saveCharacter.isPending} onClose={() => setEditorAsset(null)} onSave={() => form.validateFields().then((values) => saveCharacter.mutate(values))} />
<Modal title={`绑定单张三视图 · ${imageAsset?.title || ""}`} open={Boolean(imageAsset)} okText="绑定并生成新版本" cancelText="取消" width={720} okButtonProps={{ loading: bindImagesMutation.isPending, disabled: !imageSelection }} onCancel={() => setImageAsset(null)} onOk={() => bindImagesMutation.mutate()}>{imageAssets.length ? <div className="grid max-h-[420px] gap-3 overflow-y-auto sm:grid-cols-3">{imageAssets.map((asset) => <button type="button" key={asset.id} onClick={() => setImageSelection(asset.id)} className={`overflow-hidden rounded-lg border text-left transition ${imageSelection === asset.id ? "border-[var(--workspace-accent)] ring-2 ring-[var(--workspace-accent)]/20" : "border-border/70 hover:border-foreground/35"}`}><div className="relative aspect-[4/3] bg-foreground/[.045]"><img src={asset.data.dataUrl} alt={asset.title} loading="lazy" decoding="async" className="h-full w-full object-cover" />{imageSelection === asset.id ? <span className="absolute right-2 top-2 grid size-6 place-items-center rounded-full bg-[var(--workspace-accent)] text-white"><Check className="size-4" /></span> : null}</div><div className="truncate px-2 py-2 text-[var(--fs-label)] font-medium">{asset.title}</div></button>)}</div> : <WorkspaceState icon="assets" compact title="个人图片素材为空" description="需要一张包含正面、侧面、背面的角色设定图。" />}</Modal>
<Modal title={`选择声音素材 · ${voiceAsset?.title || ""}`} open={Boolean(voiceAsset)} okText="绑定并生成新版本" cancelText="取消" okButtonProps={{ loading: bindVoiceMutation.isPending, disabled: !voiceProfileId }} onCancel={() => setVoiceAsset(null)} onOk={() => bindVoiceMutation.mutate()}><div className="grid gap-3"><Select loading={voices.isLoading} showSearch optionFilterProp="label" value={voiceProfileId || undefined} placeholder="选择声音" options={(voices.data?.profiles || []).map((voice) => ({ label: `${voice.name} · ${voice.language}`, value: voice.id }))} onChange={setVoiceProfileId} /><Input.TextArea rows={3} value={voiceInstructions} placeholder="表演指令,例如:克制、温暖、语速稍慢" onChange={(event) => setVoiceInstructions(event.target.value)} />{voiceAsset?.character && voiceAsset.character.voiceStatus !== "missing" ? <div className="flex items-center justify-between border-t border-border/70 pt-2"><span className="text-[var(--fs-label)] text-foreground/45">当前绑定:{voiceAsset.character.voice?.profile.name || "声音素材不可用"}</span><Popconfirm title="解除当前声音绑定?" description="该操作会保留历史版本,并创建一个未绑定声音的新版本。" okText="解除" cancelText="取消" onConfirm={() => unbindVoiceMutation.mutate()}><Button type="text" danger size="small" loading={unbindVoiceMutation.isPending} icon={<VolumeX className="size-3.5" />}>解除声音</Button></Popconfirm></div> : null}</div></Modal>
@@ -225,8 +241,29 @@ function CharacterEditorModal({ open, editing, form, loading, onClose, onSave }:
type CharacterFormKey = (typeof characterFields)[number][0];
function MediaAssetCard({ asset, personalAsset, onCategoryChange, onVersion, onRemove, loading }: { asset: ProjectAsset; personalAsset?: Asset; onCategoryChange: (category: string) => void; onVersion: () => void; onRemove: () => void; loading: boolean }) {
return <article className="overflow-hidden rounded-lg border border-border/80 bg-background"><div className="relative aspect-[4/3] overflow-hidden bg-foreground/[.05]"><AssetMediaPreview asset={personalAsset} alt={asset.title} className="h-full w-full bg-black object-cover" fallback={<div className="grid h-full place-items-center text-foreground/25"><MediaIcon kind={asset.mediaType} /></div>} /><div className="absolute inset-x-2 top-2 flex items-center justify-between"><StatusPill status={asset.status} /><span className="rounded bg-black/50 px-1.5 py-0.5 text-[var(--fs-micro)] text-white">{mediaLabel(asset.mediaType)}</span></div></div><div className="p-2.5"><div className="flex items-start justify-between gap-2"><div className="min-w-0"><h3 className="truncate text-xs font-medium">{asset.title}</h3><div className="mt-1 flex items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/42"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.category], items: Object.entries(categoryLabels).filter(([value]) => value !== "character").map(([value, label]) => ({ key: value, label })), onClick: ({ key }) => onCategoryChange(key) }}><button type="button" disabled={loading} className="inline-flex h-6 items-center gap-1 rounded px-1.5 text-[var(--fs-tiny)] text-foreground/50 hover:bg-foreground/[.055]"><span>{categoryLabel(asset.category)}</span><ChevronDown className="size-3" /></button></Dropdown><span>·</span><span>v{Math.max(1, asset.versionCount)}</span></div></div><Link2 className="mt-0.5 size-3.5 shrink-0 text-[var(--workspace-accent)]" /></div><div className="mt-2 flex items-center justify-between gap-2 border-t border-border/60 pt-1.5"><span className="text-[var(--fs-micro)] text-foreground/38">{formatTime(asset.updatedAt)}</span><div className="flex items-center"><Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={onVersion} /><Popconfirm title="移出项目资产?" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} loading={loading} /></Popconfirm></div></div></div></article>;
function MediaAssetCard({ asset, personalAsset, onOpen, onCategoryChange, onVersion, onRemove, loading }: { asset: ProjectAsset; personalAsset?: Asset; onOpen: () => void; onCategoryChange: (category: string) => void; onVersion: () => void; onRemove: () => void; loading: boolean }) {
return <AssetLibraryCard className="project-asset-library-card"><AssetLibraryCardMedia className="relative aspect-[4/3] overflow-hidden bg-foreground/[.05]"><button type="button" className="project-asset-media-button" onClick={onOpen} aria-label={`查看资产:${asset.title}`}><AssetMediaPreview asset={personalAsset} alt={asset.title} className="h-full w-full bg-black object-cover" fallback={<div className="grid h-full place-items-center text-foreground/25"><MediaIcon kind={asset.mediaType} /></div>} /><div className="absolute inset-x-2 top-2 flex items-center justify-between"><StatusPill status={asset.status} /><span className="rounded bg-black/50 px-1.5 py-0.5 text-[var(--fs-micro)] text-white">{mediaLabel(asset.mediaType)}</span></div></button></AssetLibraryCardMedia><div className="p-2.5"><button type="button" className="project-asset-title-button" onClick={onOpen}><span className="min-w-0 truncate text-xs font-medium">{asset.title}</span><span className="shrink-0 text-[var(--fs-micro)] text-foreground/38">{formatTime(asset.updatedAt)}</span></button><div className="mt-1 flex items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/42"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.category], items: Object.entries(categoryLabels).filter(([value]) => value !== "character").map(([value, label]) => ({ key: value, label })), onClick: ({ key }) => onCategoryChange(key) }}><button type="button" disabled={loading} className="inline-flex h-6 items-center gap-1 rounded px-1.5 text-[var(--fs-tiny)] text-foreground/50 hover:bg-foreground/[.055]"><span>{categoryLabel(asset.category)}</span><ChevronDown className="size-3" /></button></Dropdown><span>·</span><span>v{Math.max(1, asset.versionCount)}</span><Link2 className="ml-auto size-3.5 shrink-0 text-[var(--workspace-accent)]" /></div><div className="mt-2 flex items-center justify-between gap-2 border-t border-border/60 pt-1.5"><span className="text-[var(--fs-micro)] text-foreground/38">{mediaLabel(asset.mediaType)}</span><div className="flex items-center"><Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={onVersion} /><Popconfirm title="移出项目资产?" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} loading={loading} /></Popconfirm></div></div></div></AssetLibraryCard>;
}
function ProjectAssetPreviewModal({ asset, personalAsset, onClose, onDownload, onReplaceImage }: { asset: ProjectAsset | null; personalAsset?: Asset; onClose: () => void; onDownload: () => void; onReplaceImage: () => void }) {
const characterCover = asset?.character?.representations.find((item) => item.role === "turnaround_sheet") || asset?.character?.representations.find((item) => item.role === "primary") || asset?.character?.representations[0];
const canDownload = Boolean(personalAsset && ["image", "video", "audio", "model"].includes(personalAsset.kind)) || Boolean(characterCover);
const previewClass = asset?.category === "character" ? "is-character" : personalAsset?.kind === "video" ? "is-video" : personalAsset?.kind === "audio" ? "is-audio" : personalAsset?.kind === "text" ? "is-text" : "is-image";
return (
<Modal className="library-modal project-asset-preview-modal" title={asset?.category === "character" ? "角色卡预览" : "资产预览"} open={Boolean(asset)} width={840} onCancel={onClose} footer={<div className="flex justify-end gap-2"><Button onClick={onClose}>关闭</Button>{asset?.category === "character" ? <Button onClick={onReplaceImage}>替换图片</Button> : null}{canDownload ? <Button type="primary" icon={<Download className="size-3.5" />} onClick={onDownload}>下载</Button> : null}</div>}>
{asset ? <div className="project-asset-preview-layout">
<div className={`project-asset-preview-stage ${previewClass}`}>
{asset.category === "character" ? characterCover ? <img src={resourceFileUrl(characterCover.resourceId)} alt={asset.title} className="project-asset-preview-media" /> : <div className="grid min-h-48 place-items-center text-foreground/35"><UserRound className="size-12" /></div> : personalAsset?.kind === "video" ? <video src={personalAsset.data.url} controls className="project-asset-preview-media" /> : personalAsset?.kind === "audio" ? <audio src={personalAsset.data.url} controls className="project-asset-preview-audio" /> : personalAsset?.kind === "image" ? <img src={personalAsset.data.dataUrl || personalAsset.coverUrl} alt={asset.title} className="project-asset-preview-media" /> : personalAsset?.kind === "text" ? <p className="project-asset-preview-text">{personalAsset.data.content}</p> : <div className="grid min-h-48 place-items-center text-foreground/35"><MediaIcon kind={asset.mediaType} /></div>}
</div>
<aside className="project-asset-preview-details">
<div className="project-asset-preview-eyebrow">{asset.category === "character" ? "角色卡" : mediaLabel(asset.mediaType)}</div>
<h3 className="project-asset-preview-title">{asset.title}</h3>
<p className="project-asset-preview-meta">更新于 {formatTime(asset.updatedAt)}</p>
{asset.character ? <div className="project-asset-preview-sections"><section><span>剧情定位</span><p>{textValue(asset.character.definition.role) || "未填写"}</p></section><section><span>外观设定</span><p>{textValue(asset.character.definition.appearance) || textValue(asset.character.definition.consistencyPrompt) || "未填写"}</p></section><div className="project-asset-preview-status">形象:{asset.character.visualStatus === "ready" ? "已绑定" : "待完善"} · 声音:{asset.character.voiceStatus === "ready" ? "已绑定" : "未绑定"}</div></div> : <div className="project-asset-preview-facts"><span>版本 <strong>v{Math.max(1, asset.versionCount)}</strong></span><span>{asset.usages.length} 处引用</span></div>}
</aside>
</div> : null}
</Modal>
);
}
function characterDefinition(values: CharacterForm) {

View File

@@ -1,14 +1,14 @@
import { useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { App, Button, Popconfirm, Select, Tooltip } from "antd";
import { ArrowUpRight, Film, Link2, Unlink, X } from "lucide-react";
import { Link } from "react-router";
import { Link2, Unlink, X } from "lucide-react";
import { CanvasProjectCard } from "@/components/canvas/canvas-project-card";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { linkCanvasUnit, unlinkCanvasProject, unlinkCanvasUnit } from "@/services/api/projects";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
import { formatTime, type ProjectDetailViewProps } from "./shared";
import { type ProjectDetailViewProps } from "./shared";
export default function ProjectCanvasesView({ detail, refreshProject }: ProjectDetailViewProps) {
const { message } = App.useApp();
@@ -44,32 +44,35 @@ export default function ProjectCanvasesView({ detail, refreshProject }: ProjectD
return (
<div>
{canvases.length ? (
<div className="grid grid-cols-[repeat(auto-fill,minmax(280px,360px))] justify-start gap-3">
{canvases.map((canvas, index) => {
<div className="project-library-grid library-grid">
{canvases.map((canvas) => {
const links = linksByCanvas[canvas.id] || [];
const linkedUnits = links.map((link) => detail.units.find((unit) => unit.id === link.unitId)).filter(Boolean);
const unlinkedUnits = detail.units.filter((unit) => !links.some((link) => link.unitId === unit.id));
const project = toCanvasProject(canvas, detail.project.id, localCanvases.find((item) => item.id === canvas.id));
return (
<article key={canvas.id} className="min-w-0 overflow-hidden rounded-lg border border-border/80 bg-background">
<Link to={`/canvas/${canvas.id}`} className="group relative block h-24 overflow-hidden border-b border-border/70 bg-[#111827] p-3 text-white">
<div className="absolute inset-0 opacity-40" style={{ backgroundImage: "linear-gradient(rgba(255,255,255,.08) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.08) 1px, transparent 1px)", backgroundSize: "20px 20px" }} />
<div className="relative flex h-full flex-col justify-between"><div className="flex items-center justify-between"><span className="flex items-center gap-1.5 text-[var(--fs-tiny)] text-white/55"><Film className="size-3" />画布 {String(index + 1).padStart(2, "0")}</span><ArrowUpRight className="size-3.5 text-white/45 group-hover:text-[var(--workspace-accent)]" /></div><div><div className="truncate text-[var(--fs-body)] font-semibold">{canvas.title}</div><div className="mt-0.5 text-[var(--fs-micro)] text-white/48">更新于 {formatTime(canvas.updatedAt)}</div></div></div>
</Link>
<div className="p-2.5">
<div className="flex items-center justify-between"><span className="text-[var(--fs-tiny)] font-medium text-foreground/48">关联章节</span><span className="text-[var(--fs-micro)] tabular-nums text-foreground/38">{linkedUnits.length} 个</span></div>
<div className="mt-1.5 flex min-h-6 max-h-12 flex-wrap gap-1 overflow-y-auto">
{linkedUnits.length ? linkedUnits.map((unit) => (
<span key={unit!.id} className="inline-flex h-5 max-w-full items-center gap-1 rounded bg-[var(--workspace-accent-soft)] pl-1.5 pr-0.5 text-[var(--fs-micro)] text-[var(--workspace-accent)]"><span className="truncate">{String(unit!.position + 1).padStart(2, "0")} · {unit!.title}</span><Tooltip title="解除章节关联"><button type="button" className="grid size-4 shrink-0 place-items-center rounded hover:bg-foreground/[.07]" aria-label={`解除${unit!.title}关联`} onClick={() => unlinkUnitMutation.mutate({ canvasId: canvas.id, unitId: unit!.id })}><X className="size-3" /></button></Tooltip></span>
)) : <span className="py-0.5 text-[var(--fs-tiny)] text-foreground/38">尚未关联章节</span>}
<CanvasProjectCard
key={canvas.id}
project={project}
projectName={detail.project.name}
readOnly
footer={
<div className="border-t border-border/60 pt-2.5">
<div className="flex items-center justify-between"><span className="text-[var(--fs-tiny)] font-medium text-foreground/48">关联章节</span><span className="text-[var(--fs-micro)] tabular-nums text-foreground/38">{linkedUnits.length} 个</span></div>
<div className="mt-1.5 flex min-h-6 max-h-12 flex-wrap gap-1 overflow-y-auto">
{linkedUnits.length ? linkedUnits.map((unit) => (
<span key={unit!.id} className="inline-flex h-5 max-w-full items-center gap-1 rounded bg-[var(--workspace-accent-soft)] pl-1.5 pr-0.5 text-[var(--fs-micro)] text-[var(--workspace-accent)]"><span className="truncate">{String(unit!.position + 1).padStart(2, "0")} · {unit!.title}</span><Tooltip title="解除章节关联"><button type="button" className="grid size-4 shrink-0 place-items-center rounded hover:bg-foreground/[.07]" aria-label={`解除${unit!.title}关联`} onClick={() => unlinkUnitMutation.mutate({ canvasId: canvas.id, unitId: unit!.id })}><X className="size-3" /></button></Tooltip></span>
)) : <span className="py-0.5 text-[var(--fs-tiny)] text-foreground/38">尚未关联章节</span>}
</div>
<div className="mt-1.5 flex items-center gap-1.5">
<Select size="small" className="min-w-0 flex-1" placeholder={unlinkedUnits.length ? "关联更多章节" : "全部章节已关联"} disabled={!unlinkedUnits.length} options={unlinkedUnits.map((unit) => ({ label: `${String(unit.position + 1).padStart(2, "0")} · ${unit.title}`, value: unit.id }))} onChange={(unitId) => { setLinkingCanvasId(canvas.id); linkMutation.mutate({ canvasId: canvas.id, unitId }); }} loading={linkMutation.isPending && linkingCanvasId === canvas.id} suffixIcon={<Link2 className="size-3.5" />} />
<Popconfirm title="解除画布与项目的关系?" description="画布文档不会删除,之后仍可在“画布”中打开。" okText="解除关系" cancelText="取消" okButtonProps={{ danger: true, loading: unlinkProjectMutation.isPending }} onConfirm={() => unlinkProjectMutation.mutate(canvas.id)}>
<Tooltip title="解除项目关系"><Button size="small" type="text" danger icon={<Unlink className="size-3.5" />} aria-label="解除项目关系" /></Tooltip>
</Popconfirm>
</div>
</div>
<div className="mt-1.5 flex items-center gap-1.5 border-t border-border/60 pt-2">
<Select size="small" className="min-w-0 flex-1" placeholder={unlinkedUnits.length ? "关联更多章节" : "全部章节已关联"} disabled={!unlinkedUnits.length} options={unlinkedUnits.map((unit) => ({ label: `${String(unit.position + 1).padStart(2, "0")} · ${unit.title}`, value: unit.id }))} onChange={(unitId) => { setLinkingCanvasId(canvas.id); linkMutation.mutate({ canvasId: canvas.id, unitId }); }} loading={linkMutation.isPending && linkingCanvasId === canvas.id} suffixIcon={<Link2 className="size-3.5" />} />
<Popconfirm title="解除画布与项目的关系?" description="画布文档不会删除,之后仍可在“画布”中打开。" okText="解除关系" cancelText="取消" okButtonProps={{ danger: true, loading: unlinkProjectMutation.isPending }} onConfirm={() => unlinkProjectMutation.mutate(canvas.id)}>
<Tooltip title="解除项目关系"><Button size="small" type="text" danger icon={<Unlink className="size-3.5" />} aria-label="解除项目关系" /></Tooltip>
</Popconfirm>
</div>
</div>
</article>
}
/>
);
})}
</div>
@@ -77,3 +80,22 @@ export default function ProjectCanvasesView({ detail, refreshProject }: ProjectD
</div>
);
}
function toCanvasProject(canvas: { id: string; title: string; createdAt: string; updatedAt: string }, projectId: string, local?: CanvasProject): CanvasProject {
if (local) return { ...local, title: canvas.title || local.title, updatedAt: canvas.updatedAt || local.updatedAt };
return {
id: canvas.id,
projectId,
title: canvas.title,
createdAt: canvas.createdAt,
updatedAt: canvas.updatedAt,
nodes: [],
connections: [],
chatSessions: [],
activeChatId: null,
backgroundMode: "dots",
showImageInfo: true,
viewport: { x: 0, y: 0, k: 1 },
directorScenes: [],
};
}

View File

@@ -19,42 +19,47 @@ export default function ProjectOverviewView({ detail }: ProjectDetailViewProps)
const unitStages = projectUnitStages(detail);
return (
<div className="space-y-7">
<section className="project-overview-focus overflow-hidden rounded-lg border border-border/75 bg-background/80 shadow-sm">
<div className="grid lg:grid-cols-[minmax(0,1fr)_300px]">
<div className="min-w-0 p-5 sm:p-6 lg:p-7">
<div className="flex flex-wrap items-center gap-2 text-[var(--fs-label)]">
<span className="font-medium text-[var(--workspace-accent)]">当前任务</span>
<span className="text-foreground/20" aria-hidden>/</span>
<span className="text-foreground/45">{stage.label}</span>
{attentionCount ? <span className="rounded bg-foreground/[.07] px-1.5 py-0.5 text-[var(--fs-tiny)] font-medium text-foreground/70">{attentionCount} 项待处理</span> : null}
<div className="space-y-8">
<section className="project-overview-focus">
<div className="grid lg:grid-cols-[minmax(0,1fr)_308px]">
<div className="project-overview-primary">
<div className="project-overview-eyebrow">
<span>当前任务</span>
<span className="project-overview-eyebrow-divider" aria-hidden>/</span>
<span className="project-overview-eyebrow-stage">{stage.label}</span>
{attentionCount ? <span className="project-overview-eyebrow-badge">{attentionCount} 项待处理</span> : null}
</div>
<h2 className="mt-3 max-w-[680px] text-2xl font-semibold leading-8 text-balance">{primaryAction.title}</h2>
<p className="mt-2 max-w-[680px] text-sm leading-6 text-foreground/52 text-pretty">{primaryAction.description}</p>
<div className="mt-5 flex flex-wrap items-center gap-3">
<Link to={primaryAction.href} className="inline-flex h-10 max-w-full items-center gap-2 rounded-md bg-[var(--workspace-accent)] px-4 text-sm font-medium text-white transition-[filter] hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<h2 className="project-overview-title">{primaryAction.title}</h2>
<p className="project-overview-description">{primaryAction.description}</p>
<div className="project-overview-cta">
{/* 主按钮走 --btn-solid-* 配对色:原先是 bg-[--workspace-accent] + text-white,
而暗色下该 accent 是 #f5f5f5,等于白底白字。 */}
<Link to={primaryAction.href} className="project-overview-cta-primary">
<span className="truncate">{primaryAction.actionLabel}</span><ArrowRight className="size-4 shrink-0" />
</Link>
{continueTarget.href !== primaryAction.href ? <Link to={continueTarget.href} className="inline-flex h-10 items-center gap-2 px-1 text-xs font-medium text-foreground/48 hover:text-foreground">继续最近工作<ArrowRight className="size-3.5" /></Link> : null}
{continueTarget.href !== primaryAction.href ? <Link to={continueTarget.href} className="project-overview-cta-secondary">继续最近工作<ArrowRight className="size-3.5" /></Link> : null}
</div>
</div>
<aside className="project-overview-status border-t border-border/70 bg-foreground/[.018] p-5 lg:border-l lg:border-t-0 lg:p-6" aria-label="项目进度">
<div className="flex items-end justify-between gap-3">
<div><div className="text-[var(--fs-tiny)] font-medium text-foreground/38">章节进度</div><div className="mt-1 text-lg font-semibold tabular-nums">{completedUnits}<span className="mx-1 text-sm font-normal text-foreground/28">/</span>{units.length}</div></div>
<span className="text-xs font-medium tabular-nums text-foreground/42">{completion}%</span>
<aside className="project-overview-status" aria-label="项目进度">
<div className="project-overview-progress">
<div className="project-overview-progress-head">
<span className="project-overview-status-label">章节进度</span>
<span className="project-overview-progress-percent">{completion}%</span>
</div>
<div className="project-overview-progress-count">{completedUnits}<span>/ {units.length}</span></div>
<div className="project-overview-progress-track" aria-label={`章节完成度 ${completion}%`}><div style={{ width: `${completion}%` }} /></div>
</div>
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-foreground/[.08]" aria-label={`章节完成度 ${completion}%`}><div className="h-full rounded-full bg-[var(--workspace-accent)] transition-[width]" style={{ width: `${completion}%` }} /></div>
<dl className="mt-5 grid grid-cols-2 gap-x-5 gap-y-3 text-xs">
<dl className="project-overview-facts">
<ProjectFact label="当前阶段" value={stage.label} />
<ProjectFact label="分镜镜头" value={`${shots.length} 个`} />
<ProjectFact label="项目画布" value={`${canvases.length} 张`} />
<ProjectFact label="需要处理" value={`${attentionCount} 项`} attention={attentionCount > 0} />
</dl>
{secondaryActions.length ? (
<div className="mt-5 border-t border-border/65 pt-4">
<div className="text-[var(--fs-tiny)] font-medium text-foreground/38">随后处理</div>
<div className="mt-2 space-y-1">{secondaryActions.map((action) => <SecondaryAction key={action.id} action={action} />)}</div>
<div className="project-overview-next">
<span className="project-overview-status-label">随后处理</span>
<div className="mt-2 space-y-0.5">{secondaryActions.map((action) => <SecondaryAction key={action.id} action={action} />)}</div>
</div>
) : null}
</aside>
@@ -62,61 +67,57 @@ export default function ProjectOverviewView({ detail }: ProjectDetailViewProps)
</section>
<section>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="text-[var(--fs-tiny)] font-medium text-foreground/38">制作流水线</div>
<h2 className="mt-1 text-lg font-semibold">章节进度</h2>
<p className="mt-1 text-xs leading-5 text-foreground/46">从内容确认到项目画布,每章只显示当前真实状态。</p>
<div className="project-pipeline-head">
<div className="min-w-0">
<h2 className="project-pipeline-title">章节进度</h2>
<p className="project-pipeline-hint">从内容确认到项目画布,每章只显示当前真实状态。</p>
</div>
<Link to={`/projects/${project.id}/chapters`} className="inline-flex h-8 items-center gap-1.5 text-xs font-medium text-foreground/48 hover:text-foreground">查看全部章节<ArrowRight className="size-3.5" /></Link>
<Link to={`/projects/${project.id}/chapters`} className="project-pipeline-more">查看全部章节<ArrowRight className="size-3.5" /></Link>
</div>
{unitStages.length ? (
<div className="project-pipeline-surface mt-4 overflow-hidden rounded-lg border border-border/75 bg-background/70">
<div className="divide-y divide-border/65">
{unitStages.map((item) => (
<Link key={item.unit.id} to={`/projects/${project.id}/chapters/${item.unit.id}`} className="group grid gap-4 p-4 transition-colors hover:bg-foreground/[.025] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-foreground/20 sm:p-5 lg:grid-cols-[minmax(220px,.75fr)_minmax(480px,1.35fr)_24px] lg:items-center">
<span className="flex min-w-0 items-center gap-3">
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-foreground/[.055] text-[var(--fs-tiny)] font-semibold tabular-nums text-foreground/45">{String(item.unit.position + 1).padStart(2, "0")}</span>
<span className="min-w-0"><span className="block truncate text-sm font-medium">{item.unit.title}</span><span className="mt-1 block text-[var(--fs-tiny)] text-foreground/36">更新于 {formatTime(item.unit.updatedAt)}</span></span>
</span>
<StagePipeline content={item.content} assets={item.assets} storyboard={item.storyboard} canvas={item.canvas} />
<ArrowRight className="hidden size-4 text-foreground/22 transition group-hover:translate-x-0.5 group-hover:text-foreground/55 lg:block" />
</Link>
))}
</div>
<div className="project-pipeline-surface">
{unitStages.map((item) => (
<Link key={item.unit.id} to={`/projects/${project.id}/chapters/${item.unit.id}`} className="project-pipeline-row group">
<span className="project-pipeline-chapter">
<span className="project-pipeline-index">{String(item.unit.position + 1).padStart(2, "0")}</span>
<span className="min-w-0"><span className="project-pipeline-chapter-title">{item.unit.title}</span><span className="project-pipeline-chapter-time">更新于 {formatTime(item.unit.updatedAt)}</span></span>
</span>
<StagePipeline content={item.content} assets={item.assets} storyboard={item.storyboard} canvas={item.canvas} />
<ArrowRight className="project-pipeline-arrow size-4" />
</Link>
))}
</div>
) : <div className="mt-4 overflow-hidden rounded-lg border border-border/75 bg-background/70 p-2"><WorkspaceState icon="projects" compact title="还没有剧情章节" description="添加章节后,这里会显示内容、资产、分镜和画布的制作进度。" /></div>}
) : <div className="project-pipeline-surface p-2"><WorkspaceState icon="projects" compact title="还没有剧情章节" description="添加章节后,这里会显示内容、资产、分镜和画布的制作进度。" /></div>}
</section>
</div>
);
}
function ProjectFact({ label, value, attention = false }: { label: string; value: string; attention?: boolean }) {
return <div className="min-w-0"><dt className="text-[var(--fs-tiny)] text-foreground/36">{label}</dt><dd className={`mt-1 truncate font-medium ${attention ? "text-foreground/85" : "text-foreground/72"}`}>{value}</dd></div>;
return <div className="min-w-0"><dt>{label}</dt><dd className={attention ? "is-attention" : ""}>{value}</dd></div>;
}
function SecondaryAction({ action }: { action: ProjectWorkbenchAction }) {
const Icon = action.tone === "danger" ? CircleAlert : action.tone === "attention" ? Clock3 : CheckCircle2;
return <Link to={action.href} className="group flex min-w-0 items-center gap-2 rounded px-1 py-1.5 text-[var(--fs-label)] text-foreground/52 hover:bg-foreground/[.04] hover:text-foreground"><Icon className={`size-3.5 shrink-0 ${action.tone === "danger" ? "text-foreground/80" : action.tone === "attention" ? "text-foreground/60" : "text-foreground/30"}`} /><span className="min-w-0 flex-1 truncate">{action.title}</span><ArrowRight className="size-3 shrink-0 text-foreground/25 transition group-hover:text-foreground/55" /></Link>;
return <Link to={action.href} className="project-overview-next-item group"><Icon className={`size-3.5 shrink-0 ${action.tone === "danger" ? "text-foreground/80" : action.tone === "attention" ? "text-foreground/60" : "text-foreground/30"}`} /><span className="min-w-0 flex-1 truncate">{action.title}</span><ArrowRight className="size-3 shrink-0 text-foreground/25 transition group-hover:text-foreground/55" /></Link>;
}
function StagePipeline({ content, assets, storyboard, canvas }: { content: ProjectStageCell; assets: ProjectStageCell; storyboard: ProjectStageCell; canvas: ProjectStageCell }) {
const stages = [{ label: "内容", cell: content }, { label: "资产", cell: assets }, { label: "分镜", cell: storyboard }, { label: "画布", cell: canvas }];
return (
<span className="grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-4">
<span className="project-pipeline-stages">
{stages.map(({ label, cell }) => <StageStep key={label} label={label} cell={cell} />)}
</span>
);
}
function StageStep({ label, cell }: { label: string; cell: ProjectStageCell }) {
const bar = cell.state === "completed" ? "bg-foreground/65" : cell.state === "attention" ? "bg-foreground/40" : cell.state === "active" ? "bg-[var(--workspace-accent)]" : "bg-foreground/10";
return (
<span className="min-w-0">
<span className="block text-[var(--fs-tiny)] font-medium text-foreground/34">{label}</span>
<span className={`mt-2 block h-1 rounded-full ${bar}`} />
<span className="mt-1.5 block truncate text-[var(--fs-tiny)] text-foreground/48">{cell.label}</span>
<span className={`project-pipeline-stage is-${cell.state}`}>
<span className="project-pipeline-stage-label">{label}</span>
<span className="project-pipeline-stage-track" />
<span className="project-pipeline-stage-value">{cell.label}</span>
</span>
);
}

View File

@@ -4,11 +4,15 @@ import type { ReactNode } from "react";
import { resourceFileUrl } from "@/services/api/resources";
import type { ProjectAsset } from "@/services/api/projects";
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
export function ProjectCharacterCard({ asset, generating, removing, onEdit, onGenerate, onBindImages, onBindVoice, onRemove }: {
import { textValue } from "./shared";
export function ProjectCharacterCard({ asset, generating, removing, onOpen, onEdit, onGenerate, onBindImages, onBindVoice, onRemove }: {
asset: ProjectAsset;
generating: boolean;
removing: boolean;
onOpen: () => void;
onEdit: () => void;
onGenerate: () => void;
onBindImages: () => void;
@@ -23,16 +27,18 @@ export function ProjectCharacterCard({ asset, generating, removing, onEdit, onGe
const voiceStatus = character?.voiceStatus === "ready" ? `声音已绑定 · ${character.voice?.profile.name}` : character?.voiceStatus === "unavailable" ? "声音需处理" : "声音未绑定";
const readinessLabel = character?.visualStatus === "ready" ? character.voiceStatus === "ready" ? "可直接用于生成" : "形象已就绪" : "设定进行中";
return (
<article className="group overflow-hidden rounded-lg border border-border/80 bg-background shadow-[0_10px_30px_rgba(0,0,0,.04)]">
<div className="relative aspect-[3/2] overflow-hidden bg-foreground/[.045]">
{cover ? <img src={resourceFileUrl(cover.resourceId)} alt={asset.title} loading="lazy" decoding="async" className="h-full w-full object-contain p-1" /> : <div className="grid h-full place-items-center"><span className="grid size-14 place-items-center rounded-lg border border-border/70 bg-background/75 text-foreground/24"><UserRound className="size-7" /></span></div>}
<AssetLibraryCard className="project-character-card">
<AssetLibraryCardMedia className="relative aspect-[3/2] overflow-hidden bg-foreground/[.045]">
<button type="button" className="project-asset-media-button" onClick={onOpen} aria-label={`查看角色卡:${asset.title}`}>
{cover ? <img src={resourceFileUrl(cover.resourceId)} alt={asset.title} loading="lazy" decoding="async" className="h-full w-full object-contain p-1" /> : <div className="grid h-full place-items-center"><span className="grid size-14 place-items-center rounded-lg border border-border/70 bg-background/75 text-foreground/24"><UserRound className="size-7" /></span></div>}
</button>
<div className="absolute inset-x-2 top-2 flex items-center justify-between gap-2">
<span className="rounded bg-black/60 px-1.5 py-0.5 text-[var(--fs-micro)] font-medium text-white">角色卡 · v{character?.version || 1}</span>
<span className="rounded bg-black/60 px-1.5 py-0.5 text-[var(--fs-micro)] text-white">{readinessLabel}</span>
</div>
</div>
</AssetLibraryCardMedia>
<div className="p-3">
<div className="flex items-start justify-between gap-3"><div className="min-w-0"><h3 className="truncate text-sm font-semibold">{asset.title}</h3><p className="mt-0.5 truncate text-[var(--fs-label)] text-foreground/48">{role}</p></div><Button type="text" size="small" className="!h-7 !px-1.5" icon={<Pencil className="size-3.5" />} onClick={onEdit} aria-label={`编辑 ${asset.title}`} /></div>
<div className="flex items-start justify-between gap-3"><button type="button" className="project-character-title-button" onClick={onOpen}><h3 className="truncate text-sm font-semibold">{asset.title}</h3><p className="mt-0.5 truncate text-[var(--fs-label)] text-foreground/48">{role}</p></button><Button type="text" size="small" className="!h-7 !px-1.5" icon={<Pencil className="size-3.5" />} onClick={onEdit} aria-label={`编辑 ${asset.title}`} /></div>
<p className="mt-2 line-clamp-2 min-h-9 text-[var(--fs-label)] leading-[18px] text-foreground/55">{appearance}</p>
<div className="mt-2 grid gap-1.5">
<StatusLine icon={<ImageIcon className="size-3.5" />} ready={character?.visualStatus === "ready"} label={imageStatus} action={character?.visualStatus === "ready" ? "更换" : "初始化"} onClick={character?.visualStatus === "ready" ? onBindImages : onGenerate} />
@@ -42,14 +48,10 @@ export function ProjectCharacterCard({ asset, generating, removing, onEdit, onGe
<Button size="small" className="min-w-0 flex-1" icon={<Sparkles className="size-3.5" />} loading={generating} disabled={removing} onClick={onGenerate}>{character?.visualStatus === "missing" ? "初始化三视图" : "重新生成三视图"}</Button><Popconfirm title="移出项目角色?" description="已有画布或镜头引用时将无法移出。" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" loading={removing} disabled={generating} icon={<Trash2 className="size-3.5" />} aria-label={`移出 ${asset.title}`} /></Popconfirm>
</div>
</div>
</article>
</AssetLibraryCard>
);
}
function StatusLine({ icon, ready, label, action, onClick }: { icon: ReactNode; ready: boolean; label: string; action: string; onClick: () => void }) {
return <div className={`flex min-w-0 items-center gap-2 rounded-md border px-2 py-1.5 text-[var(--fs-tiny)] ${ready ? "border-emerald-500/20 bg-emerald-500/[.06] text-emerald-700 dark:text-emerald-300" : "border-border/70 bg-foreground/[.025] text-foreground/48"}`}><span className="shrink-0">{icon}</span><span className="min-w-0 flex-1 truncate">{label}</span><button type="button" className="shrink-0 font-medium text-[var(--workspace-accent)] hover:underline" onClick={onClick}>{action}</button></div>;
}
function textValue(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}

View File

@@ -119,3 +119,7 @@ export function formatTime(value?: string) {
export function formatCount(value: number) {
return new Intl.NumberFormat("zh-CN").format(value);
}
export function textValue(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}

View File

@@ -121,5 +121,5 @@ function ProjectRow({ row }: { row: ProjectSummary }) {
}
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 className="hidden 2xl:inline">{label}</span></span>;
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>;
}