fix(canvas): 创作界面 - 统一素材选择并修复画布顶栏与暗色交互

This commit is contained in:
ddCat
2026-08-14 18:35:43 +08:00
parent 920cd92c16
commit acbbbe1078
18 changed files with 673 additions and 448 deletions

View File

@@ -0,0 +1,230 @@
import { Modal } from "antd";
import { Check, FileText, FolderOpen, Image as ImageIcon, LoaderCircle, Music2, Search, Upload, UserRound, Video } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { AssetMediaPreview } from "@/components/asset-media-preview";
import { cn } from "@/lib/utils";
import type { Asset } from "@/stores/use-asset-store";
export type AssetLibraryPickerItem = {
id: string;
title: string;
category: string;
kindLabel: string;
asset?: Asset;
imageUrl?: string;
imageFit?: "cover" | "contain";
description?: string;
searchText?: string;
disabledReason?: string;
};
type Props = {
open: boolean;
items: AssetLibraryPickerItem[];
categoryLabels: Record<string, string>;
initialCategory?: string;
initialSelectedIds?: Iterable<string>;
multiple?: boolean;
title?: string;
eyebrow?: string;
confirmLabel?: (count: number) => string;
emptyTitle?: string;
emptyDescription?: string;
footerNote?: string;
upload?: {
accept: string;
description: string;
onUpload: (files: FileList) => Promise<string[]>;
};
onClose: () => void;
onConfirm: (ids: string[]) => Promise<void> | void;
};
export function AssetLibraryPickerModal({
open,
items,
categoryLabels,
initialCategory = "all",
initialSelectedIds,
multiple = true,
title = "素材库",
eyebrow = "参考内容",
confirmLabel = (count) => `使用已选素材${count ? `(${count})` : ""}`,
emptyTitle = "这个分类还没有素材",
emptyDescription = "换个分类后再试。",
footerNote,
upload,
onClose,
onConfirm,
}: Props) {
const [category, setCategory] = useState(initialCategory);
const [keyword, setKeyword] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [working, setWorking] = useState(false);
const [uploadingCount, setUploadingCount] = useState(0);
const [error, setError] = useState("");
const uploadInputRef = useRef<HTMLInputElement>(null);
const initialSelectedIdsRef = useRef(initialSelectedIds);
const itemsRef = useRef(items);
initialSelectedIdsRef.current = initialSelectedIds;
itemsRef.current = items;
const categories = useMemo(() => ["all", ...Array.from(new Set(items.map((item) => item.category || "other")))], [items]);
const visibleItems = useMemo(() => {
const query = keyword.trim().toLowerCase();
return items.filter((item) => {
if (category !== "all" && item.category !== category) return false;
return !query || [item.title, item.searchText || "", item.description || ""].join(" ").toLowerCase().includes(query);
});
}, [category, items, keyword]);
const selectedIds = useMemo(
() => items.filter((item) => !item.disabledReason && selected.has(item.id)).map((item) => item.id),
[items, selected],
);
useEffect(() => {
if (!open) return;
setCategory(initialCategory);
setKeyword("");
const selectableIds = new Set(itemsRef.current.filter((item) => !item.disabledReason).map((item) => item.id));
setSelected(new Set(Array.from(initialSelectedIdsRef.current || []).filter((id) => selectableIds.has(id))));
setWorking(false);
setUploadingCount(0);
setError("");
}, [initialCategory, open]);
useEffect(() => {
if (category === "all" || categories.includes(category)) return;
setCategory("all");
}, [categories, category]);
const toggle = (item: AssetLibraryPickerItem) => {
if (item.disabledReason || working) return;
setError("");
setSelected((current) => {
if (!multiple) return current.has(item.id) ? new Set() : new Set([item.id]);
const next = new Set(current);
if (next.has(item.id)) next.delete(item.id);
else next.add(item.id);
return next;
});
};
const confirm = async () => {
if (!selectedIds.length || working) return;
setWorking(true);
setError("");
try {
await onConfirm(selectedIds);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "素材操作失败,请重试");
} finally {
setWorking(false);
}
};
const handleUpload = async (files: FileList | null) => {
if (!upload || !files?.length || working) return;
setWorking(true);
setError("");
setUploadingCount(files.length);
try {
const ids = await upload.onUpload(files);
if (ids.length) setSelected((current) => new Set(multiple ? [...current, ...ids] : ids.slice(-1)));
} catch (reason) {
setError(reason instanceof Error ? reason.message : "素材上传失败,请重试");
} finally {
if (uploadInputRef.current) uploadInputRef.current.value = "";
setWorking(false);
setUploadingCount(0);
}
};
const countFor = (value: string) => value === "all" ? items.length : items.filter((item) => item.category === value).length;
const uploading = uploadingCount > 0;
return (
<Modal
open={open}
footer={null}
title={null}
destroyOnHidden
closable={!working}
maskClosable={!working}
keyboard={!working}
onCancel={() => {
if (!working) onClose();
}}
width="min(1040px, calc(100vw - 24px))"
className="asset-library-picker-modal"
styles={{ container: { padding: 0 }, body: { padding: 0 } }}
>
<div className="asset-picker-shell">
<header className="asset-picker-toolbar">
<div className="asset-picker-heading"><span>{eyebrow}</span><strong>{title}</strong></div>
<label className="asset-picker-search"><Search aria-hidden /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索素材名称或标签" aria-label="搜索素材" /></label>
<span className="asset-picker-count">已选 {selectedIds.length} · {visibleItems.length} 个素材</span>
</header>
<div className="asset-picker-body">
<nav className="asset-picker-categories" aria-label="素材分类">
{categories.map((value) => (
<button key={value} type="button" className={category === value ? "is-active" : ""} onClick={() => setCategory(value)}>
<span>{categoryLabels[value] || "其他"}</span><em>{countFor(value)}</em>
</button>
))}
</nav>
<div className="asset-picker-grid-wrap">
<div className="asset-picker-grid">
{visibleItems.length ? visibleItems.map((item) => (
<PickerCard key={item.id} item={item} selected={selected.has(item.id)} onToggle={() => toggle(item)} />
)) : (
<div className="asset-picker-empty"><FolderOpen /><strong>{emptyTitle}</strong><span>{upload ? "换个分类,或从底部上传一份新素材。" : emptyDescription}</span></div>
)}
</div>
</div>
</div>
<footer className={cn("asset-picker-footer", !upload && "is-compact")}>
{upload ? (
<>
<input ref={uploadInputRef} type="file" hidden accept={upload.accept} multiple={multiple} onChange={(event) => void handleUpload(event.target.files)} />
<button type="button" className="asset-picker-upload" onClick={() => uploadInputRef.current?.click()} disabled={working} aria-busy={uploading}>
{uploading ? <LoaderCircle className="animate-spin" /> : <Upload />}
<span><strong>{uploading ? `正在上传 ${uploadingCount} 个素材` : "上传新素材"}</strong><small>{uploading ? "保存完成后会自动选中" : upload.description}</small></span>
</button>
</>
) : footerNote ? <span className="asset-picker-footer-note">{footerNote}</span> : <span />}
{error ? <span className="asset-picker-footer-error" role="alert">{error}</span> : null}
<div className="asset-picker-actions">
<button type="button" onClick={onClose} disabled={working}>取消</button>
<button type="button" className="is-primary" disabled={working || !selectedIds.length} onClick={() => void confirm()}>
{working && !uploading ? <LoaderCircle className="animate-spin" /> : <Check />}{confirmLabel(selectedIds.length)}
</button>
</div>
</footer>
</div>
</Modal>
);
}
function PickerCard({ item, selected, onToggle }: { item: AssetLibraryPickerItem; selected: boolean; onToggle: () => void }) {
const disabled = Boolean(item.disabledReason);
return (
<button type="button" className={cn("asset-picker-card", selected && "is-selected", disabled && "is-disabled")} onClick={onToggle} disabled={disabled} aria-pressed={selected} title={item.disabledReason || item.title}>
<div className="asset-picker-card-media">
{item.imageUrl ? <img src={item.imageUrl} alt={item.title} loading="lazy" decoding="async" className={item.imageFit === "contain" ? "is-contain" : undefined} /> : <AssetMediaPreview asset={item.asset} alt={item.title} fallback={<div className="asset-picker-card-fallback">{kindIcon(item.kindLabel)}</div>} />}
<span className="asset-picker-card-check"><Check /></span>
<span className="asset-picker-card-kind">{item.kindLabel}</span>
{item.disabledReason ? <span className="asset-picker-card-lock">{item.disabledReason}</span> : null}
</div>
<div className="asset-picker-card-copy"><strong>{item.title || "未命名素材"}</strong>{item.description ? <span>{item.description}</span> : null}</div>
</button>
);
}
function kindIcon(label: string): ReactNode {
if (label.includes("角色")) return <UserRound />;
if (label.includes("视频")) return <Video />;
if (label.includes("音频")) return <Music2 />;
if (label.includes("文本")) return <FileText />;
return <ImageIcon />;
}

View File

@@ -1,10 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { Input, Modal, Pagination, Tag } from "antd";
import { Search } from "lucide-react";
import { useMemo } from "react";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { AssetMediaPreview } from "@/components/asset-media-preview";
import { cn } from "@/lib/utils";
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
type InsertableAsset = Extract<Asset, { kind: "text" | "image" | "video" | "audio" }>;
@@ -18,129 +14,44 @@ export type InsertAssetPayload =
type Props = {
open: boolean;
defaultTab?: string;
onInsert: (payload: InsertAssetPayload) => void;
onClose: () => void;
};
const categoryLabels: Record<string, string> = { all: "全部素材", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" };
export function AssetPickerModal({ open, onInsert, onClose }: Props) {
return (
<Modal title="选择素材" open={open} onCancel={onClose} footer={null} width={860} destroyOnHidden styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}>
<MyAssetsTab onInsert={onInsert} />
</Modal>
);
}
const PAGE_SIZE = 8;
const kindOptions = [
{ label: "全部", value: "all" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
{ label: "视频", value: "video" },
{ label: "音频", value: "audio" },
];
function PickerCard({ asset, onClick }: { asset: InsertableAsset; onClick: () => void }) {
const { title, kind } = asset;
return (
<button
type="button"
className="group relative cursor-pointer overflow-hidden rounded-lg border border-stone-200 bg-white text-left transition hover:border-stone-400 hover:shadow-md dark:border-stone-700 dark:bg-stone-900 dark:hover:border-stone-500"
onClick={onClick}
>
<AssetMediaPreview asset={asset} alt={title} className="aspect-[4/3] w-full bg-black object-cover" fallback={<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-3 text-center text-xs leading-5 text-stone-500 dark:bg-stone-800 dark:text-stone-400">{title}</div>} />
<div className="p-2.5">
<div className="flex items-center justify-between gap-2">
<span className="line-clamp-1 text-xs font-medium text-stone-800 dark:text-stone-200">{title}</span>
<Tag className="m-0 shrink-0 text-[var(--fs-tiny)]">{kind === "image" ? "图片" : kind === "video" ? "视频" : kind === "audio" ? "音频" : "文本"}</Tag>
</div>
</div>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-stone-950/0 text-sm font-medium text-white opacity-0 transition group-hover:bg-stone-950/55 group-hover:opacity-100">插入</div>
</button>
);
}
function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) {
const assets = useAssetStore((state) => state.assets);
const [keyword, setKeyword] = useState("");
const [kindFilter, setKindFilter] = useState("all");
const [page, setPage] = useState(1);
const insertableAssets = useMemo(() => assets.filter((asset): asset is InsertableAsset => asset.kind === "text" || asset.kind === "image" || asset.kind === "video" || asset.kind === "audio"), [assets]);
const items = useMemo<AssetLibraryPickerItem[]>(() => insertableAssets.map((asset) => ({
id: asset.id,
title: asset.title,
category: asset.category || "other",
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本",
asset,
searchText: asset.tags.join(" "),
})), [insertableAssets]);
const filtered = useMemo(() => {
const query = keyword.trim().toLowerCase();
return assets
.filter((asset): asset is InsertableAsset => asset.kind === "text" || asset.kind === "image" || asset.kind === "video" || asset.kind === "audio")
.filter((a) => kindFilter === "all" || a.kind === kindFilter)
.filter((a) => !query || [a.title, ...(a.tags || [])].join(" ").toLowerCase().includes(query));
}, [assets, keyword, kindFilter]);
const visible = useMemo(() => filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [filtered, page]);
useEffect(() => {
const maxPage = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
setPage((v) => Math.min(v, maxPage));
}, [filtered.length]);
const handleInsert = (asset: InsertableAsset) => {
if (asset.kind === "text") {
onInsert({ kind: "text", content: asset.data.content, title: asset.title, assetId: asset.id });
} else if (asset.kind === "audio") {
onInsert({ kind: "audio", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id });
} else if (asset.kind === "video") {
onInsert({ kind: "video", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id });
} else if (asset.kind === "image") {
onInsert({ kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title, assetId: asset.id });
}
const insert = (id: string) => {
const asset = insertableAssets.find((item) => item.id === id);
if (!asset) throw new Error("所选素材已不存在,请重新选择");
if (asset.kind === "text") onInsert({ kind: "text", content: asset.data.content, title: asset.title, assetId: asset.id });
else if (asset.kind === "audio") onInsert({ kind: "audio", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id });
else if (asset.kind === "video") onInsert({ kind: "video", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id });
else onInsert({ kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title, assetId: asset.id });
onClose();
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Input
className="w-56"
size="small"
prefix={<Search className="size-3.5 text-stone-400" />}
placeholder="搜索素材"
value={keyword}
allowClear
onChange={(e) => {
setPage(1);
setKeyword(e.target.value);
}}
/>
<div className="flex gap-1.5">
{kindOptions.map((opt) => (
<Tag.CheckableTag
key={opt.value}
checked={kindFilter === opt.value}
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
onChange={() => {
setPage(1);
setKindFilter(opt.value);
}}
>
{opt.label}
</Tag.CheckableTag>
))}
</div>
</div>
{visible.length ? (
<div className="grid grid-cols-4 gap-3">
{visible.map((asset) => (
<PickerCard key={asset.id} asset={asset} onClick={() => handleInsert(asset)} />
))}
</div>
) : (
<WorkspaceState icon="assets" compact title="没有可用素材" description={keyword || kindFilter !== "all" ? "调整关键词或素材类型后再试。" : "先在素材库中添加图片、视频、音频或文本。"} />
)}
{filtered.length > PAGE_SIZE && (
<div className="flex justify-center">
<Pagination size="small" current={page} pageSize={PAGE_SIZE} total={filtered.length} onChange={setPage} showSizeChanger={false} />
</div>
)}
</div>
<AssetLibraryPickerModal
open={open}
items={items}
categoryLabels={categoryLabels}
multiple={false}
confirmLabel={() => "插入所选素材"}
emptyDescription="先在素材库中添加图片、视频、音频或文本。"
onClose={onClose}
onConfirm={(ids) => insert(ids[0])}
/>
);
}

View File

@@ -118,7 +118,7 @@ export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true,
{
id: "asset-tray-toggle",
label: open ? "收起素材空间" : `打开素材空间,共 ${(showLibrary ? assetImages.length : 0) + canvasImages.length} 项`,
icon: <span className="relative"><Images /><span className="absolute -right-1.5 -top-1.5 min-w-3 rounded-full px-0.5 text-center text-[var(--fs-nano)] font-bold leading-3" style={{ background: theme.accent.primary, color: "#ffffff" }}>{(showLibrary ? assetImages.length : 0) + canvasImages.length}</span></span>,
icon: <span className="relative"><Images /><span className="absolute -right-1.5 -top-1.5 min-w-3 rounded-full px-0.5 text-center text-[var(--fs-nano)] font-bold leading-3" style={{ background: theme.accent.primary, color: theme.accent.onPrimary }}>{(showLibrary ? assetImages.length : 0) + canvasImages.length}</span></span>,
active: open,
onClick: () => setOpen((value) => !value),
},

View File

@@ -305,8 +305,8 @@ function FaceSelectionOverlay({
>
{selected ? (
<span
className="absolute -top-2.5 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-full px-1.5 py-0.5 text-[var(--fs-micro)] font-semibold text-white"
style={{ background: active ? theme.accent.primary : "rgba(20,20,22,.82)" }}
className="absolute -top-2.5 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-full px-1.5 py-0.5 text-[var(--fs-micro)] font-semibold"
style={{ background: active ? theme.accent.primary : "rgba(20,20,22,.82)", color: active ? theme.accent.onPrimary : "#ffffff" }}
>
{selected.name}
</span>

View File

@@ -74,7 +74,7 @@ export function ImageToolSettingsModal({ open, tools, selectedIds, showLabels, o
{tools.map((tool) => (
<label key={tool.id} className="flex h-8 min-w-0 cursor-pointer items-center gap-1 rounded-[var(--r-md)] border px-1.5 transition-colors" style={{ background: selected.has(tool.id) ? theme.accent.primarySoft : "transparent", borderColor: selected.has(tool.id) ? theme.accent.primary : theme.toolbar.border, color: selected.has(tool.id) ? theme.accent.primary : theme.node.text }}>
<Checkbox className="canvas-image-tool-checkbox shrink-0" style={{ "--tool-accent": theme.accent.primary } as CSSProperties} value={tool.id} disabled={!selected.has(tool.id) && selectedTools.length >= maxSelected} />
<span className="grid size-5 shrink-0 place-items-center rounded-[var(--r-sm)] [&_svg]:size-3" style={{ background: selected.has(tool.id) ? theme.accent.primary : theme.toolbar.itemHover, color: selected.has(tool.id) ? "#ffffff" : theme.node.muted }}>{tool.icon}</span>
<span className="grid size-5 shrink-0 place-items-center rounded-[var(--r-sm)] [&_svg]:size-3" style={{ background: selected.has(tool.id) ? theme.accent.primary : theme.toolbar.itemHover, color: selected.has(tool.id) ? theme.accent.onPrimary : theme.node.muted }}>{tool.icon}</span>
<span className="min-w-0 truncate text-[var(--fs-tiny)] font-medium leading-none">{tool.label}</span>
</label>
))}

View File

@@ -473,10 +473,10 @@ export function CanvasNodeInfoModal({ node, open, onClose, onMetadataChange, rea
};
const title = (
<div className="flex items-center justify-between gap-4 pr-10">
<div className="canvas-node-inspector-title">
<div className="min-w-0">
<div className="text-[var(--fs-heading-lg)] font-semibold tracking-[-0.02em]">节点信息</div>
{node ? <div className="mt-0.5 truncate text-xs opacity-45">{node.id}</div> : null}
<div className="text-[var(--fs-heading-lg)] font-semibold">节点信息</div>
{node ? <div className="canvas-node-inspector-id">{node.id}</div> : null}
</div>
<Segmented
size="small"
@@ -497,70 +497,66 @@ export function CanvasNodeInfoModal({ node, open, onClose, onMetadataChange, rea
open={open && Boolean(node)}
centered
footer={null}
width={720}
width={760}
onCancel={onClose}
styles={{ body: { paddingTop: 8 } }}
styles={{ body: { paddingTop: 4 } }}
>
{node ? (
<div className="h-[min(68vh,640px)] min-h-[420px] text-sm" style={{ color: theme.node.text }}>
<div className="canvas-node-inspector" style={{ color: theme.node.text }}>
{view === "info" ? (
<div className="thin-scrollbar h-full space-y-4 overflow-auto pr-1">
<div className="grid gap-2 rounded-2xl border p-3" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
<div className="grid grid-cols-2 gap-2 max-sm:grid-cols-1">
<div className="thin-scrollbar canvas-node-inspector-scroll">
<section className="canvas-node-inspector-section">
<div className="canvas-node-inspector-section-heading"><span>基础信息</span><em>{node.metadata?.status || "idle"}</em></div>
<div className="canvas-node-inspector-facts">
<InfoRow label="类型" value={nodeTypeLabel} />
<InfoRow label="状态" value={node.metadata?.status || "idle"} />
<InfoRow label="尺寸" value={`${Math.round(node.width)} x ${Math.round(node.height)}`} />
<InfoRow label="位置" value={`${Math.round(node.position.x)}, ${Math.round(node.position.y)}`} />
{batchCount > 1 ? <InfoRow label="图片组" value={`${batchCount} 张`} /> : null}
{imageBytes ? <InfoRow label="图片大小" value={formatBytes(imageBytes)} /> : null}
</div>
{node.type === CanvasNodeType.Image ? (
<div className="border-t pt-3" style={{ borderColor: theme.toolbar.border }}>
<div className="mb-2 text-xs font-medium opacity-45">项目资产分类</div>
<div className="flex flex-wrap gap-1.5">
{assetCategoryOptions.map((option) => {
const active = assetCategory === option.value;
return <button key={option.value} type="button" disabled={readOnly} onClick={() => saveAssetCategory(option.value)} className="h-7 rounded-md border px-2 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60" style={{ borderColor: active ? theme.accent.primary : theme.toolbar.border, background: active ? theme.accent.primarySoft : theme.toolbar.panel, color: active ? theme.accent.primary : theme.node.muted }}>{option.label}</button>;
})}
</div>
<div className="mt-2 text-[var(--fs-label)] leading-5 opacity-45">生成后会按此分类进入项目资产;角色、场景和画风工作流会自动预填。</div>
</div>
) : null}
{node.metadata?.prompt ? (
<div className="rounded-xl border px-3 py-2" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.panel }}>
<div className="mb-1 text-xs font-medium opacity-45">提示词</div>
<div className="whitespace-pre-wrap break-words leading-6">{node.metadata.prompt}</div>
</div>
) : null}
{node.type === CanvasNodeType.Skill && node.metadata?.skillSnapshot ? (
<div className="rounded-xl border px-3 py-2" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.panel }}>
<div className="mb-1 text-xs font-medium opacity-45">技能模板</div>
<div className="whitespace-pre-wrap break-words leading-6">{node.metadata.skillSnapshot.template}</div>
{node.metadata.skillSnapshot.outputContract ? (
<>
<div className="mb-1 mt-3 text-xs font-medium opacity-45">输出约束</div>
<div className="whitespace-pre-wrap break-words leading-6">{node.metadata.skillSnapshot.outputContract}</div>
</>
) : null}
</div>
) : null}
</div>
</section>
{node.type === CanvasNodeType.Image ? (
<div className="rounded-2xl border p-3" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}>
<div className="mb-2 flex items-center justify-between gap-3">
<section className="canvas-node-inspector-section">
<div className="canvas-node-inspector-section-heading"><span>项目资产分类</span></div>
<div className="canvas-node-inspector-options">
{assetCategoryOptions.map((option) => {
const active = assetCategory === option.value;
return <button key={option.value} type="button" disabled={readOnly} onClick={() => saveAssetCategory(option.value)} className={active ? "is-active" : ""} style={{ "--inspector-accent": theme.accent.primary, "--inspector-accent-soft": theme.accent.primarySoft } as CSSProperties}>{option.label}</button>;
})}
</div>
<p className="canvas-node-inspector-help">生成后会按此分类进入项目资产;角色、场景和画风工作流会自动预填。</p>
</section>
) : null}
{node.metadata?.prompt ? (
<section className="canvas-node-inspector-section">
<div className="canvas-node-inspector-section-heading"><span>提示词</span></div>
<div className="canvas-node-inspector-copy">{node.metadata.prompt}</div>
</section>
) : null}
{node.type === CanvasNodeType.Skill && node.metadata?.skillSnapshot ? (
<section className="canvas-node-inspector-section">
<div className="canvas-node-inspector-section-heading"><span>技能模板</span></div>
<div className="canvas-node-inspector-copy">{node.metadata.skillSnapshot.template}</div>
{node.metadata.skillSnapshot.outputContract ? <><div className="canvas-node-inspector-subheading">输出约束</div><div className="canvas-node-inspector-copy">{node.metadata.skillSnapshot.outputContract}</div></> : null}
</section>
) : null}
{node.type === CanvasNodeType.Image ? (
<section className="canvas-node-inspector-section">
<div className="canvas-node-inspector-section-heading">
<div>
<div className="text-sm font-semibold">资产标签</div>
<div className="mt-0.5 text-xs opacity-45">一条标签描述一个角色、环境、道具或镜头用途。</div>
<span>资产标签</span>
<p>一条标签描述一个角色、环境、道具或镜头用途。</p>
</div>
<span className="shrink-0 text-xs opacity-45">{assetTags.length} 条</span>
<em>{assetTags.length} 条</em>
</div>
{readOnly ? (
<div className="mb-2 rounded-lg border px-3 py-2 text-xs opacity-55" style={{ borderColor: theme.toolbar.border }}>
分享画布为只读,标签无法编辑。
</div>
<div className="canvas-node-inspector-notice">分享画布为只读,标签无法编辑。</div>
) : (
<div className="flex gap-2">
<div className="canvas-node-inspector-tag-editor">
<Input
value={assetTagInput}
placeholder="例如:角色: 张三"
@@ -572,7 +568,7 @@ export function CanvasNodeInfoModal({ node, open, onClose, onMetadataChange, rea
</Button>
</div>
)}
<div className="mt-3 flex min-h-10 flex-wrap gap-2 rounded-xl border px-2 py-2" style={{ borderColor: theme.toolbar.border, background: theme.node.fill }}>
<div className="canvas-node-inspector-tags">
{assetTags.length ? (
assetTags.map((tag) => (
<Tag key={tag} closable={!readOnly} onClose={() => (readOnly ? onUnauthorized?.() : removeAssetTag(tag))} className="!m-0 !rounded-lg !px-2 !py-1 !text-sm">
@@ -580,20 +576,20 @@ export function CanvasNodeInfoModal({ node, open, onClose, onMetadataChange, rea
</Tag>
))
) : (
<span className="px-1 py-1 text-xs opacity-40">{readOnly ? "暂无标签" : "还没有标签,输入后点击“加入”或按 Enter。"}</span>
<span className="canvas-node-inspector-empty-label">{readOnly ? "暂无标签" : "还没有标签,输入后点击“加入”或按 Enter。"}</span>
)}
</div>
</div>
</section>
) : null}
{node.metadata?.errorDetails ? (
<div className="rounded-2xl border p-3 text-red-500" style={{ borderColor: theme.node.stroke }}>
<section className="canvas-node-inspector-error">
{generationErrorMessage(node.metadata.errorDetails)}
</div>
</section>
) : null}
</div>
) : (
<pre className="thin-scrollbar h-full overflow-auto rounded-2xl border p-3 text-xs leading-5" style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}>
<pre className="thin-scrollbar canvas-node-inspector-json" style={{ color: theme.node.text }}>
{json}
</pre>
)}
@@ -605,9 +601,9 @@ export function CanvasNodeInfoModal({ node, open, onClose, onMetadataChange, rea
function InfoRow({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="rounded-xl border px-3 py-2" style={{ borderColor: "rgba(148,163,184,.22)" }}>
<div className="mb-1 text-xs font-medium opacity-45">{label}</div>
<div className="min-w-0 whitespace-pre-wrap break-words text-sm font-medium leading-5">{value}</div>
<div className="canvas-node-inspector-fact">
<div>{label}</div>
<strong>{value}</strong>
</div>
);
}

View File

@@ -791,7 +791,7 @@ function skillOutputModeLabel(mode?: string) {
function ResourceLabelBadge({ reference, theme }: { reference: CanvasResourceReference; theme: CanvasTheme }) {
return (
<span className="pointer-events-none min-w-0 max-w-28 truncate rounded-md px-1.5 py-1 text-[var(--fs-tiny)] font-medium leading-none text-white shadow-sm" style={{ background: reference.active ? theme.accent.primary : "rgba(0,0,0,.35)", opacity: reference.active ? 1 : 0.75 }} title={reference.title || reference.label}>
<span className="pointer-events-none min-w-0 max-w-28 truncate rounded-md px-1.5 py-1 text-[var(--fs-tiny)] font-medium leading-none shadow-sm" style={{ background: reference.active ? theme.accent.primary : "rgba(0,0,0,.35)", color: reference.active ? theme.accent.onPrimary : "#ffffff", opacity: reference.active ? 1 : 0.75 }} title={reference.title || reference.label}>
{reference.label}
</span>
);
@@ -801,7 +801,7 @@ function ResourceStorageBadge({ storageKey, active, theme }: { storageKey?: stri
const location = resourceStorageLocation(storageKey);
const background = active ? (location === "local" ? "rgba(245,158,11,.9)" : theme.accent.primary) : "rgba(0,0,0,.35)";
return (
<span className="pointer-events-auto shrink-0 rounded-md px-1.5 py-1 text-[var(--fs-tiny)] font-medium leading-none text-white shadow-sm" style={{ background, opacity: active ? 1 : 0.75 }} title={resourceStorageTitle(storageKey)}>
<span className="pointer-events-auto shrink-0 rounded-md px-1.5 py-1 text-[var(--fs-tiny)] font-medium leading-none shadow-sm" style={{ background, color: active && location !== "local" ? theme.accent.onPrimary : "#ffffff", opacity: active ? 1 : 0.75 }} title={resourceStorageTitle(storageKey)}>
{resourceStorageLabel(storageKey)}
</span>
);

View File

@@ -1,10 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { Button, Modal } from "antd";
import { Check, FileText, Image as ImageIcon, Music2, UserRound, Video } from "lucide-react";
import { useMemo } from "react";
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import type { InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
import { AssetMediaPreview } from "@/components/asset-media-preview";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { compileCharacterReferencePrompt } from "@/lib/canvas/canvas-character-reference";
import { resourceFileUrl } from "@/services/api/resources";
import type { ProjectAsset, ProjectDetail } from "@/services/api/projects";
@@ -15,9 +12,6 @@ type ProjectPickerItem = { id: string; category: string; character?: ProjectAsse
export function CanvasProjectAssetModal({ open, detail, initialCategory = "all", onClose, onInsert }: { open: boolean; detail?: ProjectDetail; initialCategory?: string; onClose: () => void; onInsert: (payloads: InsertAssetPayload[]) => Promise<void> | void }) {
const mediaAssets = useAssetStore((state) => state.assets);
const [category, setCategory] = useState("all");
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [inserting, setInserting] = useState(false);
const items = useMemo(() => {
const mediaById = new Map(mediaAssets.map((asset) => [asset.id, asset]));
const projectItems = (detail?.assets || []).flatMap((asset): ProjectPickerItem[] => {
@@ -31,43 +25,43 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
.filter((asset) => asset.kind !== "model" && asset.kind !== "entity")
.map((media) => ({ id: media.id, category: media.category || "other", media }));
}, [detail?.assets, mediaAssets]);
const categories = useMemo(() => ["all", ...Array.from(new Set(items.map((item) => item.category)))], [items]);
const visible = category === "all" ? items : items.filter((item) => item.category === category);
const pickerItems = useMemo<AssetLibraryPickerItem[]>(() => items.map((item) => {
const character = item.character;
const media = item.media;
const coverRepresentation = character?.character?.representations.find((representation) => representation.role === "turnaround_sheet") || character?.character?.representations.find((representation) => representation.role === "primary") || character?.character?.representations.find((representation) => representation.role === "front");
return {
id: item.id,
title: character?.title || media?.title || "未命名资产",
category: item.category,
kindLabel: character ? "角色卡" : media?.kind === "video" ? "视频" : media?.kind === "audio" ? "音频" : media?.kind === "text" ? "文本" : "图片",
asset: media,
imageUrl: coverRepresentation ? resourceFileUrl(coverRepresentation.resourceId) : undefined,
imageFit: character ? "contain" : "cover",
description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : undefined,
searchText: media?.tags.join(" "),
};
}), [items]);
useEffect(() => {
setSelectedIds(new Set());
setInserting(false);
setCategory(open ? initialCategory : "all");
}, [initialCategory, open]);
const toggle = (id: string) => setSelectedIds((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; });
const insert = async () => {
const payloads = items.filter((item) => selectedIds.has(item.id)).map(toInsertPayload);
if (!payloads.length) return;
setInserting(true);
try { await onInsert(payloads); onClose(); } finally { setInserting(false); }
};
return <Modal open={open} title={null} footer={null} destroyOnHidden onCancel={onClose} width="min(920px, calc(100vw - 24px))" styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0 } }}>
<div className="flex h-[min(620px,calc(100vh-80px))] min-h-[440px] flex-col overflow-hidden">
<header className="flex h-12 shrink-0 items-center justify-between border-b border-border py-0 pl-4 pr-12"><h2 className="text-sm font-semibold">引用项目角色与资产</h2><span className="text-[var(--fs-label)] text-foreground/42">已选 {selectedIds.size} 项</span></header>
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] sm:grid-cols-[150px_minmax(0,1fr)] sm:grid-rows-1">
<nav className="thin-scrollbar flex min-w-0 gap-1 overflow-x-auto border-b border-border p-2 sm:block sm:overflow-y-auto sm:border-b-0 sm:border-r" aria-label="项目资产分类">{categories.map((item) => <button key={item} type="button" onClick={() => setCategory(item)} className={`flex h-11 min-w-[104px] shrink-0 items-center justify-between rounded-md px-2 text-xs sm:w-full sm:min-w-0 ${category === item ? "bg-surface-active font-medium" : "text-foreground/55 hover:bg-surface-hover"}`}><span>{categoryLabels[item] || "其他"}</span><span className="min-w-5 rounded bg-foreground/[.05] px-1 text-center text-[var(--fs-tiny)] tabular-nums">{item === "all" ? items.length : items.filter((asset) => asset.category === item).length}</span></button>)}</nav>
<div className="thin-scrollbar min-h-0 overflow-y-auto p-3">{visible.length ? <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">{visible.map((item) => <ProjectAssetCard key={item.id} item={item} selected={selectedIds.has(item.id)} onToggle={() => toggle(item.id)} />)}</div> : <WorkspaceState icon="assets" compact className="h-full" title="此分类没有可引用资产" description={detail ? "先在项目角色与资产中完成角色确认或素材关联。" : "当前为自由画布,下方展示本地素材库;也可先上传素材后再引用。"} />}</div>
</div>
<footer className="flex h-12 shrink-0 items-center justify-between border-t border-border px-3"><span className="text-[var(--fs-tiny)] text-foreground/42">角色引用会在生成时解析当前角色版本</span><div className="flex gap-2"><Button size="small" onClick={onClose}>取消</Button><Button size="small" type="primary" disabled={!selectedIds.size} loading={inserting} onClick={() => void insert()}>引入 {selectedIds.size || ""} 项</Button></div></footer>
</div>
</Modal>;
}
function ProjectAssetCard({ item, selected, onToggle }: { item: ProjectPickerItem; selected: boolean; onToggle: () => void }) {
const character = item.character;
const media = item.media;
const coverRepresentation = character?.character?.representations.find((representation) => representation.role === "turnaround_sheet") || character?.character?.representations.find((representation) => representation.role === "primary") || character?.character?.representations.find((representation) => representation.role === "front");
const cover = coverRepresentation ? resourceFileUrl(coverRepresentation.resourceId) : "";
const Icon = character ? UserRound : media?.kind === "video" ? Video : media?.kind === "audio" ? Music2 : media?.kind === "text" ? FileText : ImageIcon;
const label = character ? "角色卡" : media?.kind === "video" ? "视频" : media?.kind === "audio" ? "音频" : media?.kind === "text" ? "文本" : "图片";
const title = character?.title || media?.title || "未命名资产";
return <button type="button" onClick={onToggle} className={`relative min-w-0 overflow-hidden rounded-md border text-left transition-colors ${selected ? "border-[var(--workspace-accent)] bg-[var(--workspace-accent-soft)]" : "border-border/80 hover:border-foreground/30"}`}><div className="relative aspect-[4/3] overflow-hidden bg-foreground/[.04]">{cover ? <img src={cover} alt={title} loading="lazy" decoding="async" className="h-full w-full object-contain p-1" /> : <AssetMediaPreview asset={media} alt={title} className="h-full w-full bg-black object-cover" fallback={<div className="grid h-full place-items-center text-foreground/25"><Icon className="size-7" /></div>} />}<span className={`absolute right-1.5 top-1.5 grid size-5 place-items-center rounded border ${selected ? "border-[var(--workspace-accent)] bg-[var(--workspace-accent)] text-white" : "border-white/60 bg-black/25 text-transparent backdrop-blur"}`}><Check className="size-3" /></span><span className="absolute bottom-1.5 left-1.5 rounded bg-black/55 px-1.5 py-0.5 text-[var(--fs-micro)] text-white">{label}</span></div><div className="px-2 py-1.5"><div className="truncate text-[var(--fs-label)] font-medium">{title}</div>{character ? <div className="mt-0.5 truncate text-[var(--fs-micro)] text-foreground/42">{character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · {character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}</div> : null}</div></button>;
return (
<AssetLibraryPickerModal
open={open}
items={pickerItems}
categoryLabels={categoryLabels}
initialCategory={initialCategory}
title="项目资产"
confirmLabel={(count) => `引入已选资产${count ? `(${count})` : ""}`}
emptyTitle="此分类没有可引用资产"
emptyDescription={detail ? "先在项目角色与资产中完成角色确认或素材关联。" : "当前为自由画布,可先在素材库添加内容。"}
footerNote="角色引用会在生成时解析当前角色版本"
onClose={onClose}
onConfirm={async (ids) => {
const payloads = items.filter((item) => ids.includes(item.id)).map(toInsertPayload);
if (!payloads.length) return;
await onInsert(payloads);
onClose();
}}
/>
);
}
function toInsertPayload(item: ProjectPickerItem): InsertAssetPayload {

View File

@@ -155,7 +155,7 @@ export function CanvasShortDramaGuide({ progress, collapsed, onToggle, onSkip, o
height: step.status === "current" ? "var(--flow-step-node-current)" : "var(--flow-step-node)",
background: step.status === "pending" ? "transparent" : theme.accent.primary,
border: step.status === "pending" ? "var(--stroke-2) solid var(--cn-stroke)" : "none",
color: step.status === "pending" ? theme.node.muted : "#fff",
color: step.status === "pending" ? theme.node.muted : theme.accent.onPrimary,
boxShadow: step.status === "current" ? "var(--flow-step-current-glow)" : undefined,
fontSize: step.status === "current" ? "var(--fs-body)" : "var(--fs-caption)",
}}

View File

@@ -591,7 +591,7 @@ export function CanvasTimelineDialog({
width,
background: isSelected ? theme.accent.primary : clipFill,
borderColor: isSelected ? theme.timeline.clipSelectedBorder : theme.timeline.trackBorder,
color: isSelected ? "#fff" : theme.node.text,
color: isSelected ? theme.accent.onPrimary : theme.node.text,
}}
onPointerDown={(event) => handleClipPointerDown(event, clip.id, "move")}
onPointerMove={handleClipPointerMove}

View File

@@ -94,7 +94,7 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName,
</div>
)}
>
<button type="button" className={cn("flex min-h-10 w-full min-w-0 items-center overflow-hidden rounded-md text-left transition-colors hover:bg-surface-hover", accountClassName)} title={creditsEnabled ? `${user.displayName || user.username} · ${balance} 积分` : user.displayName || user.username}>
<button type="button" className={cn("app-workspace-account-button flex min-h-10 w-full min-w-0 items-center overflow-hidden rounded-md text-left transition-colors hover:bg-surface-hover", accountClassName)} title={creditsEnabled ? `${user.displayName || user.username} · ${balance} 积分` : user.displayName || user.username}>
<UserAvatar user={user} className="size-7" />
<span className={cn("min-w-0 flex-1 flex-col", expandedClassName)}>
<span className="truncate text-xs font-medium">{user.displayName || user.username}</span>

View File

@@ -41,7 +41,7 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
active: "#ffffff",
border: "rgba(255, 255, 255, 0.3)",
disabledBackground: "rgba(255, 255, 255, 0.06)",
focus: "transparent",
focus: "rgba(255, 255, 255, 0.22)",
hover: "#e5e5e5",
primary: "#f5f5f5",
surface: "rgba(255, 255, 255, 0.035)",
@@ -151,7 +151,7 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
...(selectionControl
? {
buttonBg: selectionControl.surface,
buttonCheckedBg: "rgba(91, 110, 225, 0.12)",
buttonCheckedBg: color.selectSelectedBg,
buttonSolidCheckedActiveBg: selectionControl.active,
buttonSolidCheckedBg: selectionControl.primary,
buttonSolidCheckedHoverBg: selectionControl.hover,

View File

@@ -7,7 +7,7 @@ export const canvasThemes = {
background: "#ffffff",
dot: "rgba(15,23,42,.14)",
line: "rgba(15,23,42,.065)",
selectionFill: "rgba(79,110,232,.10)",
selectionFill: "rgba(17,17,17,.10)",
},
node: {
label: "#4b5563",
@@ -23,8 +23,8 @@ export const canvasThemes = {
frame: {
fill: "rgba(17,24,39,.025)",
stroke: "rgba(17,24,39,.18)",
activeFill: "rgba(79,110,232,.05)",
activeStroke: "#4f6ee8",
activeFill: "rgba(17,17,17,.05)",
activeStroke: "#171717",
preview: "rgba(255,255,255,.82)",
},
toolbar: {
@@ -39,8 +39,8 @@ export const canvasThemes = {
surface: "rgba(255,255,255,.72)",
elevated: "rgba(255,255,255,.94)",
dropzone: "rgba(248,250,252,.78)",
glow: "rgba(79,110,232,.18)",
glowStrong: "rgba(79,110,232,.52)",
glow: "rgba(17,17,17,.14)",
glowStrong: "rgba(17,17,17,.42)",
shadow: "rgba(15,23,42,.18)",
},
// 时间线/字幕组件语义色:轨道底、按类型区分的片段、标尺与播放头、字幕列表条目态。
@@ -50,17 +50,18 @@ export const canvasThemes = {
clipVideo: "rgba(79,110,232,.16)",
clipAudio: "rgba(16,185,129,.14)",
clipSubtitle: "rgba(245,158,11,.16)",
clipSelectedBorder: "#4f6ee8",
clipSelectedBorder: "#171717",
handle: "rgba(255,255,255,.72)",
rulerTick: "rgba(17,24,39,.28)",
rulerLabel: "#6b7280",
playhead: "#4f6ee8",
entryActive: "rgba(79,110,232,.10)",
playhead: "#171717",
entryActive: "rgba(17,17,17,.10)",
entryHover: "rgba(17,24,39,.05)",
},
accent: {
primary: "#4f6ee8",
primarySoft: "rgba(79,110,232,.14)",
primary: "#171717",
primarySoft: "rgba(17,17,17,.10)",
onPrimary: "#ffffff",
danger: "#f87171",
},
},
@@ -69,7 +70,7 @@ export const canvasThemes = {
background: "#090a0c",
dot: "rgba(174,184,199,.18)",
line: "rgba(174,184,199,.06)",
selectionFill: "rgba(96,126,234,.18)",
selectionFill: "rgba(255,255,255,.12)",
},
node: {
label: "#a3a3a3",
@@ -85,8 +86,8 @@ export const canvasThemes = {
frame: {
fill: "rgba(255,255,255,.025)",
stroke: "rgba(190,198,210,.15)",
activeFill: "rgba(96,126,234,.1)",
activeStroke: "#8f9bd6",
activeFill: "rgba(255,255,255,.08)",
activeStroke: "#f5f5f5",
preview: "rgba(20,20,20,.94)",
},
toolbar: {
@@ -94,15 +95,15 @@ export const canvasThemes = {
border: "rgba(255,255,255,.1)",
item: "#d4d4d4",
itemHover: "rgba(255,255,255,.07)",
activeBg: "rgba(143,155,214,.14)",
activeBg: "rgba(255,255,255,.10)",
activeText: "#f5f6f8",
},
spatial: {
surface: "rgba(22,22,22,.82)",
elevated: "rgba(15,15,15,.97)",
dropzone: "rgba(8,8,8,.9)",
glow: "rgba(107,120,190,.18)",
glowStrong: "rgba(143,155,214,.48)",
glow: "rgba(255,255,255,.12)",
glowStrong: "rgba(255,255,255,.36)",
shadow: "rgba(0,0,0,.6)",
},
timeline: {
@@ -111,17 +112,18 @@ export const canvasThemes = {
clipVideo: "rgba(96,126,234,.22)",
clipAudio: "rgba(16,185,129,.20)",
clipSubtitle: "rgba(245,158,11,.20)",
clipSelectedBorder: "#8f9bd6",
clipSelectedBorder: "#f5f5f5",
handle: "rgba(255,255,255,.5)",
rulerTick: "rgba(255,255,255,.26)",
rulerLabel: "#a3a3a3",
playhead: "#8f9bd6",
entryActive: "rgba(96,126,234,.16)",
playhead: "#f5f5f5",
entryActive: "rgba(255,255,255,.10)",
entryHover: "rgba(255,255,255,.06)",
},
accent: {
primary: "#8f9bd6",
primarySoft: "rgba(107,120,190,.2)",
primary: "#f5f5f5",
primarySoft: "rgba(255,255,255,.11)",
onPrimary: "#131313",
danger: "#fb7185",
},
},

View File

@@ -108,7 +108,7 @@ export function CanvasTopBar({
return (
<>
<div className="canvas-topbar pointer-events-none absolute left-0 right-0 top-0 z-[var(--z-toolbar)] flex h-[var(--canvas-topbar-h)] items-center justify-between px-4 sm:px-5">
<div className="pointer-events-auto flex min-w-0 items-center gap-3">
<div className="canvas-topbar-cluster canvas-topbar-project-cluster pointer-events-auto flex min-w-0 items-center gap-2">
<Dropdown
trigger={["click"]}
menu={{
@@ -142,7 +142,7 @@ export function CanvasTopBar({
</button>
</Dropdown>
<div ref={titleRef} className="flex min-w-0 flex-col items-start">
<div ref={titleRef} className="canvas-topbar-title-block flex min-w-0 flex-auto flex-col items-start overflow-hidden">
{isTitleEditing ? (
<input
autoFocus
@@ -159,8 +159,8 @@ export function CanvasTopBar({
aria-label="画布名称"
/>
) : (
<div className="flex min-w-0 items-center gap-0.5">
<button type="button" className="max-w-[280px] truncate text-left text-base font-semibold tracking-normal transition-opacity hover:opacity-75" onClick={onStartTitleEditing} title="点击修改画布名称">
<div className="canvas-topbar-title-row flex min-w-0 items-center gap-0.5">
<button type="button" className="min-w-0 flex-1 truncate text-left text-base font-semibold tracking-normal transition-opacity hover:opacity-75" onClick={onStartTitleEditing} title="点击修改画布名称">
{title}
</button>
<Tooltip title="重命名画布">
@@ -171,7 +171,7 @@ export function CanvasTopBar({
</div>
)}
{projectContext && !isTitleEditing ? (
<div className="mt-0.5 flex max-w-[360px] items-center gap-1.5 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>
<div className="canvas-topbar-project-context mt-0.5 flex w-full min-w-0 items-center gap-1.5 overflow-hidden text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>
<Link to={`/projects/${projectContext.projectId}/overview`} className="inline-flex min-w-0 items-center gap-1 hover:underline" title={`返回项目:${projectContext.projectName}`}>
<FolderKanban className="size-3 shrink-0" />
<span className="max-w-[120px] truncate">{projectContext.projectName}</span>
@@ -189,7 +189,7 @@ export function CanvasTopBar({
<CanvasWorkspaceModeSwitch mode={workspaceMode} onChange={onWorkspaceModeChange} open={workspaceModeOpen} onOpenChange={handleWorkspaceModeOpenChange} />
<div className="pointer-events-auto flex items-center gap-1.5">
<div className="canvas-topbar-cluster pointer-events-auto flex items-center gap-1.5">
<Button type="text" className="!hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Search className="size-4" />} onClick={onOpenSearch} aria-label="搜索画布节点" title="搜索画布节点" />
<Dropdown
trigger={["click"]}
@@ -247,7 +247,7 @@ export function CanvasTopBar({
<Button
type="text"
className="!h-10 !rounded-xl !px-3 !font-medium"
style={{ background: agentOpen ? theme.toolbar.activeBg : theme.toolbar.panel, color: theme.node.text, boxShadow: "0 10px 30px rgba(28,25,23,.10)" }}
style={{ background: agentOpen ? theme.toolbar.activeBg : "transparent", color: theme.node.text }}
icon={<Bot className="size-4" />}
onClick={onToggleAgent}
>

View File

@@ -1,11 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type ReactNode, type RefObject } from "react";
import localforage from "localforage";
import { App, Drawer, Modal, Popover, Spin, Tooltip } from "antd";
import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, Upload, X } from "lucide-react";
import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, X } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { Link } from "react-router";
import { AssetMediaPreview } from "@/components/asset-media-preview";
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
import { ModelPicker } from "@/components/model-picker";
@@ -292,6 +292,17 @@ export default function CreatePage() {
};
const maxReferences = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : mode === "image" ? imageProfile.references.maxImages : 6;
const libraryItems = useMemo<AssetLibraryPickerItem[]>(() => assets
.filter((asset): asset is Extract<Asset, { kind: "image" | "video" }> => asset.kind === "image" || asset.kind === "video")
.map((asset) => ({
id: asset.id,
title: asset.title,
category: asset.category || "other",
kindLabel: asset.kind === "video" ? "视频" : "图片",
asset,
searchText: asset.tags.join(" "),
disabledReason: mode !== "video" && asset.kind === "video" ? "视频仅支持视频创作" : undefined,
})), [assets, mode]);
const uploadCreationAsset = async (file: File) => {
if (file.type.startsWith("video/")) {
const uploaded = await uploadMediaFile(file, "create-upload");
@@ -656,79 +667,20 @@ export default function CreatePage() {
</div>}
</div>
<CreationHistoryDrawer open={historyOpen} conversations={historyConversations} activeId={activeConversation.id} onClose={() => setHistoryOpen(false)} onSelect={selectConversation} />
<CreationAssetLibraryModal open={libraryOpen} assets={assets} mode={mode} selectedIds={new Set(attachments.filter((item) => item.id.startsWith("asset:")).map((item) => item.id.slice(6)))} onClose={() => setLibraryOpen(false)} onConfirm={handleLibrarySelect} onUpload={uploadLibraryAssets} />
<AssetLibraryPickerModal
open={libraryOpen}
items={libraryItems}
categoryLabels={creationAssetCategoryLabels}
initialSelectedIds={attachments.filter((item) => item.id.startsWith("asset:")).map((item) => item.id.slice(6))}
upload={{ accept: mode === "video" ? "image/*,video/*" : "image/*", description: `支持图片${mode === "video" ? "和视频" : ""},上传后保存到素材库`, onUpload: uploadLibraryAssets }}
onClose={() => setLibraryOpen(false)}
onConfirm={(ids) => handleLibrarySelect(assets.filter((asset) => ids.includes(asset.id)))}
/>
</>;
}
const creationAssetCategoryLabels: Record<string, string> = { all: "全部素材", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" };
function CreationAssetLibraryModal({ open, assets, mode, selectedIds, onClose, onConfirm, onUpload }: { open: boolean; assets: Asset[]; mode: CreationMode; selectedIds: Set<string>; onClose: () => void; onConfirm: (assets: Asset[]) => void; onUpload: (files: FileList | File[]) => Promise<string[]> }) {
const [category, setCategory] = useState("all");
const [keyword, setKeyword] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [uploading, setUploading] = useState(false);
const [uploadingCount, setUploadingCount] = useState(0);
const uploadInputRef = useRef<HTMLInputElement>(null);
const mediaAssets = useMemo(() => assets.filter((asset): asset is Extract<Asset, { kind: "image" | "video" }> => asset.kind === "image" || asset.kind === "video"), [assets]);
const categories = useMemo(() => ["all", ...Array.from(new Set(mediaAssets.map((asset) => asset.category || "other")))], [mediaAssets]);
const visibleAssets = useMemo(() => {
const query = keyword.trim().toLowerCase();
return mediaAssets.filter((asset) => (category === "all" || (asset.category || "other") === category) && (!query || [asset.title, ...(asset.tags || [])].join(" ").toLowerCase().includes(query)));
}, [category, keyword, mediaAssets]);
useEffect(() => {
if (!open) return;
setCategory("all");
setKeyword("");
setSelected(new Set(selectedIds));
}, [open]);
const toggle = (asset: Asset) => {
if (mode !== "video" && asset.kind === "video") return;
setSelected((current) => {
const next = new Set(current);
if (next.has(asset.id)) next.delete(asset.id); else next.add(asset.id);
return next;
});
};
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
if (!event.target.files || uploading) return;
const fileCount = event.target.files.length;
if (!fileCount) return;
setUploadingCount(fileCount);
setUploading(true);
try {
const assetIds = await onUpload(event.target.files);
if (assetIds.length) setSelected((current) => new Set([...current, ...assetIds]));
} finally {
event.target.value = "";
setUploading(false);
setUploadingCount(0);
}
};
const selectedAssets = mediaAssets.filter((asset) => selected.has(asset.id) && (mode === "video" || asset.kind === "image"));
const count = category === "all" ? mediaAssets.length : mediaAssets.filter((asset) => (asset.category || "other") === category).length;
return <Modal open={open} footer={null} title={null} destroyOnHidden closable={!uploading} maskClosable={!uploading} keyboard={!uploading} onCancel={() => { if (!uploading) onClose(); }} width="min(980px, calc(100vw - 24px))" className="creation-asset-library-modal" styles={{ container: { padding: 0 }, body: { padding: 0 } }}>
<div className="creation-library-shell">
<div className="creation-library-toolbar"><div className="creation-library-toolbar-title"><span>参考内容</span><strong>素材库</strong></div><div className="creation-library-search"><Search /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索素材名称或标签" aria-label="搜索素材" /></div><span className="creation-library-toolbar-count">已选 {selectedAssets.length} · {count} 个素材</span></div>
<div className="creation-library-body">
<nav className="creation-library-categories" aria-label="素材分类">{categories.map((item) => <button key={item} type="button" className={category === item ? "is-active" : ""} onClick={() => setCategory(item)}><span>{creationAssetCategoryLabels[item] || "其他"}</span><em>{item === "all" ? mediaAssets.length : mediaAssets.filter((asset) => (asset.category || "other") === item).length}</em></button>)}</nav>
<div className="creation-library-grid-wrap"><div className="creation-library-grid">{visibleAssets.length ? visibleAssets.map((asset) => <CreationLibraryCard key={asset.id} asset={asset} selected={selected.has(asset.id)} disabled={mode !== "video" && asset.kind === "video"} onToggle={() => toggle(asset)} />) : <div className="creation-library-empty"><FolderOpen /><strong>这个分类还没有素材</strong><span>换个分类,或从底部上传一份新素材。</span></div>}</div></div>
</div>
<footer className="creation-library-footer"><input ref={uploadInputRef} type="file" hidden accept={mode === "video" ? "image/*,video/*" : "image/*"} multiple onChange={handleUpload} /><button type="button" className="creation-library-upload" onClick={() => uploadInputRef.current?.click()} disabled={uploading} aria-busy={uploading} aria-live="polite">{uploading ? <LoaderCircle className="animate-spin" /> : <Upload />}<span><strong>{uploading ? `正在上传 ${uploadingCount} 个素材` : "上传新素材"}</strong><small>{uploading ? "正在保存到素材库,完成后会自动选中" : `支持图片${mode === "video" ? "和视频" : ""},上传后保存到素材库`}</small></span></button><div className="creation-library-actions"><button type="button" onClick={onClose} disabled={uploading}>取消</button><button type="button" className="is-primary" disabled={uploading || !selectedAssets.length} onClick={() => onConfirm(selectedAssets)}><Check />使用已选素材{selectedAssets.length ? `(${selectedAssets.length})` : ""}</button></div></footer>
</div>
</Modal>;
}
function CreationLibraryCard({ asset, selected, disabled, onToggle }: { asset: Extract<Asset, { kind: "image" | "video" }>; selected: boolean; disabled: boolean; onToggle: () => void }) {
const isVideo = asset.kind === "video";
return <button type="button" className={`creation-library-card${selected ? " is-selected" : ""}${disabled ? " is-disabled" : ""}`} onClick={onToggle} disabled={disabled} aria-pressed={selected}>
<div className="creation-library-card-media"><AssetMediaPreview asset={asset} alt={asset.title} fallback={<div className="creation-library-card-fallback">{isVideo ? <Film /> : <ImageIcon />}</div>} /><span className="creation-library-card-check"><Check /></span><span className="creation-library-card-kind">{isVideo ? "视频" : "图片"}</span>{disabled ? <span className="creation-library-card-lock">视频仅支持视频创作</span> : null}</div>
<div className="creation-library-card-title">{asset.title || "未命名素材"}</div>
</button>;
}
function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelect }: { open: boolean; conversations: CreationConversation[]; activeId: string; onClose: () => void; onSelect: (conversation: CreationConversation) => void }) {
const [keyword, setKeyword] = useState("");

View File

@@ -6,6 +6,7 @@ import { Box, Check, ChevronDown, Download, FileText, Image as ImageIcon, Link2,
import { WorkspaceState } from "@/components/layout/workspace-state";
import { AssetMediaPreview } from "@/components/asset-media-preview";
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import {
@@ -34,6 +35,7 @@ import { generateCharacterTurnaround } from "./project-character-media";
import { categoryLabels, categoryLabel, mediaLabel, StatusPill, formatTime, textValue, type ProjectDetailViewProps } from "./shared";
const categories = ["all", "character", "environment", "wardrobe", "prop", "weapon", "style", "other"];
const pickerCategoryLabels = { all: "全部素材", ...categoryLabels };
const characterFields = [
["role", "剧情定位与人物关系"], ["aliases", "别名"], ["appearance", "稳定外貌"], ["physique", "身高、体型与体态"],
["clothing", "默认服装造型"], ["personality", "性格与表演基线"], ["props", "固定道具"],
@@ -52,21 +54,34 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const [category, setCategory] = useState("all");
const [addOpen, setAddOpen] = useState(false);
const [assetId, setAssetId] = useState("");
const [assetCategory, setAssetCategory] = useState("other");
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("");
const [form] = Form.useForm<CharacterForm>();
const projectAssetIds = new Set(detail.assets.map((asset) => asset.id));
const availableAssets = personalAssets.filter((asset) => !projectAssetIds.has(asset.id));
const selectedPersonalAsset = personalAssets.find((asset) => asset.id === assetId);
const imageAssets = personalAssets.filter((asset): asset is ImageAsset => asset.kind === "image");
const availablePickerItems = useMemo<AssetLibraryPickerItem[]>(() => availableAssets.map((asset) => ({
id: asset.id,
title: asset.title,
category: asset.kind === "entity" ? "character" : asset.category || "other",
kindLabel: mediaLabel(asset.kind),
asset,
description: asset.note,
searchText: asset.tags.join(" "),
})), [availableAssets]);
const imagePickerItems = useMemo<AssetLibraryPickerItem[]>(() => imageAssets.map((asset) => ({
id: asset.id,
title: asset.title,
category: asset.category || "other",
kindLabel: "图片",
asset,
searchText: asset.tags.join(" "),
})), [imageAssets]);
const pendingCandidates = detail.assetCandidates.filter((candidate) => candidate.category === "character" && candidate.status === "pending_confirmation");
const characterAssets = detail.assets.filter((asset) => asset.category === "character" && asset.character);
const mediaAssetCount = detail.assets.filter((asset) => asset.category !== "character").length;
@@ -86,7 +101,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
const done = (content: string) => { refreshProject(); message.success(content); };
const failed = (fallback: string) => (error: unknown) => message.error(error instanceof Error ? error.message : fallback);
const addMutation = useMutation({ mutationFn: () => linkProjectAsset(detail.project.id, { assetId, category: assetCategory }), onSuccess: ({ asset }) => { updatePersonalAsset(asset.id, { category: asset.category as AssetCategory, status: asset.status as AssetStatus, primaryVersionId: asset.primaryVersionId }); setAddOpen(false); setAssetId(""); done("资产已加入项目"); }, onError: failed("资产加入失败") });
const addMutation = useMutation({ mutationFn: ({ assetId, category: nextCategory }: { assetId: string; category: string }) => linkProjectAsset(detail.project.id, { assetId, category: nextCategory }), onSuccess: ({ asset }) => { updatePersonalAsset(asset.id, { category: asset.category as AssetCategory, status: asset.status as AssetStatus, primaryVersionId: asset.primaryVersionId }); setAddOpen(false); done("资产已加入项目"); }, onError: failed("资产加入失败") });
const versionMutation = useMutation({ mutationFn: (id: string) => createProjectAssetVersion(detail.project.id, id, {}), onSuccess: () => done("已创建新版本"), onError: failed("版本创建失败") });
const unlinkMutation = useMutation({ mutationFn: (id: string) => unlinkProjectAsset(detail.project.id, id), onSuccess: () => done("资产已移出项目"), onError: failed("资产移除失败") });
const categoryMutation = useMutation({ mutationFn: ({ id, next }: { id: string; next: string }) => updateProjectAssetCategory(detail.project.id, id, next), onSuccess: ({ asset }) => { updatePersonalAsset(asset.id, { category: asset.category as AssetCategory }); done("资产分类已更新"); }, onError: failed("资产分类更新失败") });
@@ -128,11 +143,11 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
onError: failed("三视图生成失败"),
});
const bindImagesMutation = useMutation({
mutationFn: async () => {
mutationFn: async (selectedAssetId: string) => {
if (!imageAsset) throw new Error("未选择角色");
await saveRemoteUserDataNow();
const latest = useAssetStore.getState().assets;
const selected = latest.find((asset) => asset.id === imageSelection);
const selected = latest.find((asset) => asset.id === selectedAssetId);
if (selected?.kind !== "image") throw new Error("请选择一张包含正面、侧面和背面的三视图设定图");
const resourceId = resourceIdFromStorageKey((selected as ImageAsset).data.storageKey);
if (!resourceId) throw new Error("所选图片尚未同步到后端资源库");
@@ -149,7 +164,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
const definition = asset === "new" ? {} : asset.character?.definition || {};
form.setFieldsValue({ name: asset === "new" ? "" : asset.title, ...Object.fromEntries(characterFields.map(([key]) => [key, fieldValue(definition[key])])) } as CharacterForm);
};
const openImages = (asset: ProjectAsset) => { setImageAsset(asset); setImageSelection(""); };
const openImages = (asset: ProjectAsset) => setImageAsset(asset);
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);
@@ -218,10 +233,40 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
</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>
<AssetLibraryPickerModal
open={addOpen}
items={availablePickerItems}
categoryLabels={pickerCategoryLabels}
multiple={false}
title="个人素材"
confirmLabel={() => "加入项目"}
emptyTitle="没有可引用的个人素材"
emptyDescription="个人素材已全部加入项目,或素材库仍为空。"
onClose={() => setAddOpen(false)}
onConfirm={async (ids) => {
const selected = personalAssets.find((asset) => asset.id === ids[0]);
if (!selected) throw new Error("所选素材已不存在,请重新选择");
await addMutation.mutateAsync({ assetId: selected.id, category: selected.kind === "entity" ? "character" : selected.category || "other" });
}}
/>
<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>
<AssetLibraryPickerModal
open={Boolean(imageAsset)}
items={imagePickerItems}
categoryLabels={pickerCategoryLabels}
multiple={false}
eyebrow="绑定图片"
title={imageAsset?.title || "角色三视图"}
confirmLabel={() => "绑定并生成新版本"}
emptyTitle="个人图片素材为空"
emptyDescription="需要一张包含正面、侧面、背面的角色设定图。"
onClose={() => setImageAsset(null)}
onConfirm={async (ids) => {
if (!ids[0]) throw new Error("请选择一张三视图设定图");
await bindImagesMutation.mutateAsync(ids[0]);
}}
/>
<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>
</div>
);

View File

@@ -80,7 +80,7 @@ export default function TestVoiceRecording() {
disabled={!prompt.trim()}
loading={sending}
onClick={handleSubmit}
style={{ background: theme.accent.primary, borderColor: theme.accent.primary }}
style={{ background: theme.accent.primary, borderColor: theme.accent.primary, color: theme.accent.onPrimary }}
>
发送
</Button>
@@ -104,4 +104,4 @@ export default function TestVoiceRecording() {
</div>
</div>
);
}
}

View File

@@ -157,6 +157,9 @@
两者在不同层级里解析结果不稳定,会出现同色不可读的按钮。用显式配对 token。 */
--btn-solid-bg: #171717;
--btn-solid-fg: #ffffff;
--control-selected-bg: var(--btn-solid-bg);
--control-selected-fg: var(--btn-solid-fg);
--control-focus-ring: color-mix(in srgb, var(--ring) 72%, transparent);
/* --- 6. Elevation(z-index 14 档,按视觉层划分,见 ADR-001)--- */
--z-canvas: 0; /* L0 画布底 */
@@ -477,8 +480,8 @@
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-primary: var(--btn-solid-bg);
--sidebar-primary-foreground: var(--btn-solid-fg);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
@@ -524,8 +527,8 @@
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.922 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-primary: var(--btn-solid-bg);
--sidebar-primary-foreground: var(--btn-solid-fg);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
@@ -651,7 +654,6 @@
--page-topbar-surface: var(--bg-secondary);
--page-topbar-border: var(--workspace-border);
--canvas-topbar-surface: var(--workspace-glass-surface);
--canvas-topbar-border: var(--workspace-glass-border);
--rail-icon-size: 18px;
/* Canvas 创建菜单:固定行高保证图标、徽标和单行标签互不挤占 */
@@ -1125,8 +1127,8 @@
background: var(--workspace-accent);
}
.dark .app-spatial-workspace .ant-switch.ant-switch-checked .ant-switch-handle::before {
background: #171717;
.dark .ant-switch.ant-switch-checked .ant-switch-handle::before {
background: var(--control-selected-fg);
}
.app-spatial-workspace .project-overview-focus,
@@ -1227,11 +1229,46 @@
}
.canvas-topbar {
background: transparent;
border-bottom: 0;
backdrop-filter: none;
}
.canvas-topbar-cluster {
min-height: var(--space-10);
border: 0;
border-radius: var(--r-lg);
background: var(--canvas-topbar-surface);
border-bottom: 1px solid var(--canvas-topbar-border);
padding: var(--space-half) var(--space-2);
box-shadow: var(--workspace-overlay-shadow);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
}
.canvas-topbar-project-cluster {
width: max-content;
max-width: min(360px, calc(50vw - 88px));
}
.canvas-topbar-title-block,
.canvas-topbar-title-row,
.canvas-topbar-project-context {
max-width: 100%;
}
.canvas-topbar-title-row {
width: fit-content;
}
@media (max-width: 720px) {
.canvas-topbar-project-cluster {
max-width: min(260px, calc(100vw - 132px));
}
.canvas-topbar-project-context {
display: none;
}
}
.app-workspace-main-row {
display: flex;
min-height: 0;
@@ -1379,6 +1416,16 @@
color: var(--foreground);
}
.app-workspace-account-button:focus-visible {
outline: 2px solid var(--control-focus-ring);
outline-offset: 2px;
box-shadow: none;
}
.app-workspace-account-button :where(span, svg):focus {
outline: none;
}
.app-workspace-nav-link.is-active {
background: var(--surface-hover);
color: var(--foreground);
@@ -5971,6 +6018,43 @@
top: 21px;
}
.canvas-node-inspector-title { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-right: 40px; }
.canvas-node-inspector-id { max-width: 440px; overflow: hidden; margin-top: 4px; color: color-mix(in srgb, currentColor 42%, transparent); font-size: var(--fs-label); text-overflow: ellipsis; white-space: nowrap; }
.canvas-node-inspector { height: min(68vh, 640px); min-height: 420px; font-size: var(--fs-body); }
.canvas-node-inspector-scroll, .canvas-node-inspector-json { height: 100%; overflow: auto; }
.canvas-node-inspector-scroll { padding-right: 4px; }
.canvas-node-inspector-section { padding: 16px 2px; }
.canvas-node-inspector-section + .canvas-node-inspector-section { margin-top: 2px; }
.canvas-node-inspector-section-heading { display: flex; min-width: 0; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 10px; }
.canvas-node-inspector-section-heading span, .canvas-node-inspector-subheading { font-size: var(--fs-label); font-weight: 650; }
.canvas-node-inspector-section-heading p { margin-top: 3px; color: color-mix(in srgb, currentColor 43%, transparent); font-size: var(--fs-micro); font-weight: 400; }
.canvas-node-inspector-section-heading em { flex-shrink: 0; border-radius: var(--r-full); background: color-mix(in srgb, currentColor 7%, transparent); padding: 3px 8px; color: color-mix(in srgb, currentColor 48%, transparent); font-size: var(--fs-micro); font-style: normal; font-weight: 560; }
.canvas-node-inspector-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; border-radius: var(--r-md); background: color-mix(in srgb, currentColor 4%, transparent); padding: 6px; }
.canvas-node-inspector-fact { min-width: 0; padding: 9px 10px; }
.canvas-node-inspector-fact > div { margin-bottom: 4px; color: color-mix(in srgb, currentColor 42%, transparent); font-size: var(--fs-micro); }
.canvas-node-inspector-fact strong { display: block; min-width: 0; overflow-wrap: anywhere; font-size: var(--fs-body); font-weight: 580; line-height: 1.45; }
.canvas-node-inspector-options { display: flex; flex-wrap: wrap; gap: 6px; }
.canvas-node-inspector-options button { height: 30px; border: 0; border-radius: var(--r-sm); background: color-mix(in srgb, currentColor 6%, transparent); padding: 0 10px; color: color-mix(in srgb, currentColor 56%, transparent); font-size: var(--fs-label); font-weight: 560; transition: background-color var(--motion-state) ease, color var(--motion-state) ease; }
.canvas-node-inspector-options button:hover:not(:disabled) { background: color-mix(in srgb, currentColor 10%, transparent); color: currentColor; }
.canvas-node-inspector-options button.is-active { background: var(--inspector-accent-soft); color: var(--inspector-accent); }
.canvas-node-inspector-options button:disabled { cursor: not-allowed; opacity: .55; }
.canvas-node-inspector-help { margin-top: 8px; color: color-mix(in srgb, currentColor 43%, transparent); font-size: var(--fs-micro); line-height: 1.55; }
.canvas-node-inspector-copy, .canvas-node-inspector-json, .canvas-node-inspector-tags, .canvas-node-inspector-notice { border: 0; border-radius: var(--r-md); background: color-mix(in srgb, currentColor 4%, transparent); }
.canvas-node-inspector-copy { padding: 12px 14px; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.7; }
.canvas-node-inspector-subheading { margin: 14px 0 8px; color: color-mix(in srgb, currentColor 58%, transparent); }
.canvas-node-inspector-tag-editor { display: flex; gap: 8px; }
.canvas-node-inspector-tags { display: flex; min-height: 44px; flex-wrap: wrap; gap: 8px; margin-top: 10px; padding: 8px; }
.canvas-node-inspector-notice { margin-bottom: 8px; padding: 9px 12px; color: color-mix(in srgb, currentColor 55%, transparent); font-size: var(--fs-label); }
.canvas-node-inspector-empty-label { padding: 4px; color: color-mix(in srgb, currentColor 40%, transparent); font-size: var(--fs-label); }
.canvas-node-inspector-error { border-radius: var(--r-md); background: color-mix(in srgb, var(--destructive) 10%, transparent); padding: 12px 14px; color: var(--destructive); line-height: 1.6; }
.canvas-node-inspector-json { box-sizing: border-box; margin: 0; padding: 14px; font-size: var(--fs-label); line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; }
@media (max-width: 640px) {
.canvas-node-inspector-title { align-items: flex-start; gap: 10px; }
.canvas-node-inspector-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.canvas-node-inspector-tag-editor { flex-direction: column; }
}
@keyframes canvas-batch-child-in {
from {
opacity: 0;
@@ -7581,7 +7665,7 @@ html:not(.dark) .creation-history-drawer-root {
}
:where(.ant-input:focus, .ant-input-focused, .ant-input-affix-wrapper-focused, .ant-input-number-focused, .ant-select-focused .ant-select-selector) {
border-color: var(--foreground) !important;
border-color: var(--control-focus-ring) !important;
box-shadow: none !important;
outline: none !important;
}
@@ -7652,6 +7736,14 @@ body.app-spatial-overlays :where(.ant-modal-mask, .ant-drawer-mask) {
}
/* 鼠标点击不留下“选中框”;键盘导航仍通过 :focus-visible 保留可发现的焦点提示。 */
:where(button:not(:disabled), a[href], [role="button"]:not([aria-disabled="true"]), label[for], input[type="checkbox"]:not(:disabled), input[type="radio"]:not(:disabled), .ant-btn:not(:disabled), .ant-switch:not(.ant-switch-disabled), .ant-select:not(.ant-select-disabled), .ant-dropdown-trigger, .ant-tabs-tab, .ant-segmented-item, .ant-pagination-item, .ant-pagination-prev, .ant-pagination-next, .ant-dropdown-menu-item, .ant-menu-item, .ant-select-item-option, .ant-checkbox-wrapper, .ant-radio-wrapper, .ant-upload, .ant-collapse-header, .ant-slider, .ant-rate-star, .ant-tag-checkable) {
cursor: pointer;
}
:where(button:disabled, [role="button"][aria-disabled="true"], .ant-btn:disabled, .ant-switch-disabled, .ant-select-disabled, .ant-tabs-tab-disabled, .ant-segmented-item-disabled, .ant-pagination-disabled, .ant-dropdown-menu-item-disabled, .ant-menu-item-disabled, .ant-select-item-option-disabled, .ant-checkbox-wrapper-disabled, .ant-radio-wrapper-disabled, .ant-upload-disabled, .ant-collapse-header[aria-disabled="true"]) {
cursor: not-allowed;
}
:where(button, [role="button"], a, .ant-btn):focus:not(:focus-visible) {
outline: none !important;
box-shadow: none !important;
@@ -7709,73 +7801,90 @@ body.app-spatial-overlays :where(.ant-modal-mask, .ant-drawer-mask) {
.creation-empty-art-caption span:first-child { color: var(--creation-accent, #83d6ec); font-variant-numeric: tabular-nums; }
.creation-empty-workspace .creation-chat-intro { margin-bottom: 24px; }
/* AntD 当前版本的实际内容外壳是 .ant-modal-container;只覆盖 .ant-modal-content 会留下默认内边距。 */
.creation-asset-library-modal { width: min(980px, calc(100vw - 24px)) !important; max-width: calc(100vw - 24px) !important; margin: 0 auto !important; }
.creation-asset-library-modal .ant-modal-container { overflow: hidden; border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); border-radius: var(--r-lg); background: var(--popover); padding: 0 !important; }
.creation-asset-library-modal .ant-modal-body { padding: 0 !important; }
.creation-asset-library-modal .ant-modal-close { top: 10px; right: 12px; }
.creation-library-shell { display: flex; height: min(680px, calc(100vh - 64px)); min-height: 480px; flex-direction: column; color: var(--foreground); }
.creation-library-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 13px 52px 10px 22px; color: color-mix(in srgb, var(--foreground) 46%, transparent); font-size: var(--fs-label); }
.creation-library-search { display: flex; height: 34px; max-width: 320px; flex: 1; align-items: center; gap: 8px; border: 1px solid color-mix(in srgb, var(--border) 82%, transparent); border-radius: var(--r-sm); background: color-mix(in srgb, var(--foreground) 4%, transparent); padding: 0 10px; }
.creation-library-search svg { width: 15px; height: 15px; color: color-mix(in srgb, var(--foreground) 45%, transparent); }
.creation-library-search input { width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--foreground); font-size: var(--fs-label); }
.creation-library-body { display: grid; min-height: 0; flex: 1; grid-template-columns: 148px minmax(0, 1fr); border-top: 1px solid color-mix(in srgb, var(--border) 70%, transparent); }
.creation-library-categories { overflow-y: auto; border-right: 1px solid var(--border); padding: 10px; }
.creation-library-categories button { display: flex; width: 100%; height: 40px; align-items: center; justify-content: space-between; gap: 8px; border: 0; border-radius: var(--r-sm); background: transparent; padding: 0 10px; color: color-mix(in srgb, var(--foreground) 56%, transparent); font-size: var(--fs-label); text-align: left; transition: background-color 140ms ease, color 140ms ease; }
.creation-library-categories button:hover { background: color-mix(in srgb, var(--foreground) 5%, transparent); color: var(--foreground); }
.creation-library-categories button.is-active { background: color-mix(in srgb, var(--workspace-accent) 13%, transparent); color: var(--foreground); font-weight: 650; }
.creation-library-categories em { min-width: 22px; border-radius: var(--r-xs); background: color-mix(in srgb, var(--foreground) 6%, transparent); padding: 2px 5px; color: color-mix(in srgb, var(--foreground) 46%, transparent); font-size: var(--fs-micro); font-style: normal; text-align: center; }
.creation-library-grid-wrap { min-width: 0; overflow-y: auto; padding: 16px; }
.creation-library-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); gap: 12px; align-content: start; }
.creation-library-card { min-width: 0; overflow: hidden; border: 1px solid color-mix(in srgb, var(--border) 86%, transparent); border-radius: var(--r-md); background: color-mix(in srgb, var(--foreground) 2.5%, transparent); color: var(--foreground); padding: 0; text-align: left; transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease; }
.creation-library-card:hover:not(:disabled) { border-color: color-mix(in srgb, var(--workspace-accent) 55%, var(--border)); box-shadow: var(--shadow-md); transform: translateY(-1px); }
.creation-library-card.is-selected { border-color: var(--workspace-accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--workspace-accent) 18%, transparent); }
.creation-library-card.is-disabled { cursor: not-allowed; opacity: .48; }
.creation-library-card-media { position: relative; aspect-ratio: 4 / 3; overflow: hidden; background: color-mix(in srgb, var(--foreground) 7%, transparent); }
.creation-library-card-media img, .creation-library-card-media video { display: block; width: 100%; height: 100%; object-fit: cover; }
.creation-library-card-check { position: absolute; top: 9px; right: 9px; display: grid; width: 22px; height: 22px; place-items: center; border: 1px solid rgba(255, 255, 255, .7); border-radius: var(--r-xs); background: rgba(0, 0, 0, .28); color: transparent; backdrop-filter: blur(5px); }
.creation-library-card-check svg { width: 13px; height: 13px; }
.creation-library-card.is-selected .creation-library-card-check { border-color: var(--workspace-accent); background: var(--workspace-accent); color: #fff; }
.creation-library-card-kind { position: absolute; bottom: 8px; left: 8px; border-radius: var(--r-xs); background: rgba(0, 0, 0, .58); padding: 3px 6px; color: #fff; font-size: var(--fs-micro); }
.creation-library-card-lock { position: absolute; right: 8px; bottom: 8px; max-width: 112px; color: rgba(255, 255, 255, .78); font-size: var(--fs-micro); text-align: right; }
.creation-library-card-title { overflow: hidden; padding: 9px 10px 11px; font-size: var(--fs-label); font-weight: 560; text-overflow: ellipsis; white-space: nowrap; }
.creation-library-card-fallback, .creation-library-empty { display: grid; place-items: center; color: color-mix(in srgb, var(--foreground) 38%, transparent); }
.creation-library-card-fallback { height: 100%; }
.creation-library-card-fallback svg { width: 26px; height: 26px; }
.creation-library-empty { grid-column: 1 / -1; min-height: 240px; gap: 8px; text-align: center; }
.creation-library-empty svg { width: 30px; height: 30px; color: color-mix(in srgb, var(--workspace-accent) 70%, var(--foreground)); }
.creation-library-empty strong { color: color-mix(in srgb, var(--foreground) 70%, transparent); font-size: var(--fs-label); }
.creation-library-empty span { font-size: var(--fs-micro); }
.creation-library-footer { display: flex; align-items: center; gap: 16px; border-top: 1px solid var(--border); padding: 14px 18px; }
.creation-library-upload { display: flex; min-height: 58px; flex: 1; align-items: center; gap: 12px; border: 1px dashed color-mix(in srgb, var(--workspace-accent) 55%, var(--border)); border-radius: var(--r-md); background: color-mix(in srgb, var(--workspace-accent) 7%, transparent); padding: 0 16px; color: var(--foreground); text-align: left; transition: background-color 140ms ease, border-color 140ms ease; }
.creation-library-upload:hover:not(:disabled) { border-color: var(--workspace-accent); background: color-mix(in srgb, var(--workspace-accent) 12%, transparent); }
.creation-library-upload:disabled { cursor: wait; opacity: .72; }
.creation-library-upload > svg { width: 21px; height: 21px; color: var(--workspace-accent); }
.creation-library-upload span { display: flex; flex-direction: column; gap: 3px; }
.creation-library-upload strong { font-size: var(--fs-label); font-weight: 650; }
.creation-library-upload small { color: color-mix(in srgb, var(--foreground) 48%, transparent); font-size: var(--fs-micro); }
.creation-library-actions { display: flex; flex-shrink: 0; align-items: center; gap: 8px; }
.creation-library-actions button { display: inline-flex; height: 36px; align-items: center; gap: 6px; border: 0; border-radius: var(--r-sm); background: transparent; padding: 0 11px; color: color-mix(in srgb, var(--foreground) 58%, transparent); font-size: var(--fs-label); }
.creation-library-actions button:hover:not(:disabled) { background: color-mix(in srgb, var(--foreground) 6%, transparent); color: var(--foreground); }
.creation-library-actions button.is-primary { background: var(--foreground); color: var(--background); font-weight: 650; }
.creation-library-actions button.is-primary:hover:not(:disabled) { background: color-mix(in srgb, var(--foreground) 86%, transparent); }
.creation-library-actions button:disabled { cursor: not-allowed; opacity: .4; }
.creation-library-actions svg { width: 14px; height: 14px; }
/* 素材选择器是创作页、自由画布和项目画布的唯一视觉入口。 */
.asset-library-picker-modal { width: min(1040px, calc(100vw - 24px)) !important; max-width: calc(100vw - 24px) !important; margin: 0 auto !important; }
.asset-library-picker-modal .ant-modal-container { overflow: hidden; border: 0; border-radius: var(--r-2xl); background: var(--popover); padding: 0 !important; box-shadow: var(--elevation-overlay), var(--elevation-hairline); }
.asset-library-picker-modal .ant-modal-body { padding: 0 !important; }
.asset-library-picker-modal .ant-modal-close { top: 12px; right: 12px; }
.asset-picker-shell { display: flex; height: min(680px, calc(100vh - 48px)); min-height: 480px; flex-direction: column; color: var(--foreground); overscroll-behavior: contain; }
.asset-picker-toolbar { display: grid; min-height: 64px; flex-shrink: 0; grid-template-columns: minmax(132px, 1fr) minmax(240px, 360px) minmax(132px, 1fr); align-items: center; gap: 16px; padding: 10px 52px 10px 18px; }
.asset-picker-heading { display: flex; min-width: 0; align-items: baseline; gap: 10px; }
.asset-picker-heading span { color: var(--foreground); font-size: var(--fs-label); font-weight: 650; }
.asset-picker-heading strong { color: color-mix(in srgb, var(--foreground) 40%, transparent); font-size: var(--fs-label); font-weight: 560; }
.asset-picker-search { display: flex; height: 38px; min-width: 0; align-items: center; gap: 9px; border-radius: var(--r-sm); background: color-mix(in srgb, var(--foreground) 6%, transparent); padding: 0 12px; transition: background-color var(--motion-state) ease, box-shadow var(--motion-state) ease; }
.asset-picker-search:focus-within { background: color-mix(in srgb, var(--foreground) 8%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 54%, transparent); }
.asset-picker-search svg { width: 16px; height: 16px; flex-shrink: 0; color: color-mix(in srgb, var(--foreground) 42%, transparent); }
.asset-picker-search input { width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--foreground); font-size: var(--fs-label); }
.asset-picker-count { justify-self: end; color: color-mix(in srgb, var(--foreground) 42%, transparent); font-size: var(--fs-micro); }
.asset-picker-body { display: grid; min-height: 0; flex: 1; grid-template-columns: 156px minmax(0, 1fr); gap: 8px; padding: 0 8px; }
.asset-picker-categories { overflow-y: auto; border-radius: var(--r-md); background: color-mix(in srgb, var(--foreground) 2.5%, transparent); padding: 10px 8px; }
.asset-picker-categories button { display: flex; width: 100%; height: 42px; align-items: center; justify-content: space-between; gap: 8px; border: 0; border-radius: var(--r-sm); background: transparent; padding: 0 10px; color: color-mix(in srgb, var(--foreground) 54%, transparent); font-size: var(--fs-label); text-align: left; transition: background-color var(--motion-state) ease, color var(--motion-state) ease; }
.asset-picker-categories button:hover { background: var(--library-surface-hover); color: var(--foreground); }
.asset-picker-categories button.is-active { background: var(--library-surface-hover); color: var(--foreground); font-weight: 650; }
.asset-picker-categories em { min-width: 24px; border-radius: var(--r-xs); background: color-mix(in srgb, var(--foreground) 6%, transparent); padding: 2px 5px; color: color-mix(in srgb, var(--foreground) 44%, transparent); font-size: var(--fs-micro); font-style: normal; text-align: center; }
.asset-picker-grid-wrap { min-width: 0; overflow-y: auto; padding: 8px 8px 16px; }
.asset-picker-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(152px, 1fr)); gap: 12px; align-content: start; }
.asset-picker-card { min-width: 0; overflow: hidden; border: 0; border-radius: var(--r-md); background: var(--library-surface); color: var(--foreground); padding: 0; text-align: left; transition: background-color var(--motion-state) ease, box-shadow var(--motion-state) ease, transform var(--motion-state) var(--motion-ease-out); }
.asset-picker-card:hover:not(:disabled) { background: var(--library-surface-hover); transform: translateY(var(--library-hover-offset)); }
.asset-picker-card:active:not(:disabled) { transform: translateY(var(--library-active-offset)) scale(.995); }
.asset-picker-card:focus-visible { outline: 2px solid var(--control-focus-ring); outline-offset: 2px; }
.asset-picker-card.is-selected { box-shadow: inset 0 0 0 1px var(--library-selected-stroke); }
.asset-picker-card.is-disabled { cursor: not-allowed; opacity: .46; }
.asset-picker-card-media { position: relative; aspect-ratio: 4 / 3; overflow: hidden; background: var(--library-cover-surface); }
.asset-picker-card-media > img, .asset-picker-card-media > video { display: block; width: 100%; height: 100%; object-fit: cover; }
.asset-picker-card-media > img.is-contain { object-fit: contain; padding: 4px; }
.asset-picker-card-check { position: absolute; top: 9px; right: 9px; display: grid; width: 22px; height: 22px; place-items: center; border: 1px solid rgba(255, 255, 255, .72); border-radius: var(--r-xs); background: rgba(0, 0, 0, .28); color: transparent; backdrop-filter: blur(5px); }
.asset-picker-card-check svg { width: 13px; height: 13px; }
.asset-picker-card.is-selected .asset-picker-card-check { border-color: var(--control-selected-bg); background: var(--control-selected-bg); color: var(--control-selected-fg); }
.asset-picker-card-kind { position: absolute; bottom: 8px; left: 8px; border-radius: var(--r-xs); background: rgba(0, 0, 0, .58); padding: 3px 6px; color: #fff; font-size: var(--fs-micro); }
.asset-picker-card-lock { position: absolute; right: 8px; bottom: 8px; max-width: 112px; color: rgba(255, 255, 255, .82); font-size: var(--fs-micro); text-align: right; }
.asset-picker-card-copy { display: flex; min-height: 42px; min-width: 0; flex-direction: column; justify-content: center; gap: 2px; padding: 8px 10px 10px; }
.asset-picker-card-copy strong, .asset-picker-card-copy span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.asset-picker-card-copy strong { font-size: var(--fs-label); font-weight: 560; }
.asset-picker-card-copy span { color: color-mix(in srgb, var(--foreground) 42%, transparent); font-size: var(--fs-micro); }
.asset-picker-card-fallback, .asset-picker-empty { display: grid; place-items: center; color: color-mix(in srgb, var(--foreground) 36%, transparent); }
.asset-picker-card-fallback { height: 100%; }
.asset-picker-card-fallback svg { width: 27px; height: 27px; }
.asset-picker-empty { grid-column: 1 / -1; min-height: 260px; align-content: center; gap: 8px; text-align: center; }
.asset-picker-empty svg { width: 30px; height: 30px; color: color-mix(in srgb, var(--workspace-accent) 68%, var(--foreground)); }
.asset-picker-empty strong { color: color-mix(in srgb, var(--foreground) 70%, transparent); font-size: var(--fs-label); }
.asset-picker-empty span { font-size: var(--fs-micro); }
.asset-picker-footer { display: flex; flex-shrink: 0; align-items: center; gap: 16px; padding: 12px 16px 14px; }
.asset-picker-footer.is-compact { min-height: 64px; justify-content: space-between; }
.asset-picker-upload { display: flex; min-height: 56px; flex: 1; align-items: center; gap: 12px; border: 0; border-radius: var(--r-md); background: color-mix(in srgb, var(--foreground) 5%, transparent); padding: 0 16px; color: var(--foreground); text-align: left; transition: background-color var(--motion-state) ease; }
.asset-picker-upload:hover:not(:disabled) { background: var(--library-surface-hover); }
.asset-picker-upload:disabled { cursor: wait; opacity: .72; }
.asset-picker-upload > svg { width: 20px; height: 20px; color: var(--workspace-accent); }
.asset-picker-upload span { display: flex; flex-direction: column; gap: 3px; }
.asset-picker-upload strong { font-size: var(--fs-label); font-weight: 650; }
.asset-picker-upload small, .asset-picker-footer-note { color: color-mix(in srgb, var(--foreground) 46%, transparent); font-size: var(--fs-micro); }
.asset-picker-footer-error { max-width: 240px; color: var(--destructive); font-size: var(--fs-micro); line-height: 1.45; }
.asset-picker-actions { display: flex; flex-shrink: 0; align-items: center; gap: 8px; }
.asset-picker-actions button { display: inline-flex; height: 36px; align-items: center; gap: 6px; border: 0; border-radius: var(--r-sm); background: transparent; padding: 0 12px; color: color-mix(in srgb, var(--foreground) 58%, transparent); font-size: var(--fs-label); }
.asset-picker-actions button:hover:not(:disabled) { background: color-mix(in srgb, var(--foreground) 6%, transparent); color: var(--foreground); }
.asset-picker-categories button:focus-visible, .asset-picker-upload:focus-visible, .asset-picker-actions button:focus-visible { outline: 2px solid var(--control-focus-ring); outline-offset: -2px; }
.asset-picker-actions button.is-primary { min-width: 148px; justify-content: center; background: var(--control-selected-bg); color: var(--control-selected-fg); font-weight: 650; }
.asset-picker-actions button.is-primary:hover:not(:disabled) { background: color-mix(in srgb, var(--control-selected-bg) 88%, var(--control-selected-fg)); color: var(--control-selected-fg); }
.asset-picker-actions button:disabled { cursor: not-allowed; opacity: .42; }
.asset-picker-actions svg { width: 14px; height: 14px; }
@media (max-width: 680px) {
.creation-empty-workspace { width: calc(100% - 28px); padding-top: 28px; }
.creation-empty-art { width: min(390px, 90vw); height: 142px; }
.creation-empty-art-frame.is-main { height: 130px; }
.creation-empty-art-frame.is-back, .creation-empty-art-frame.is-front { height: 102px; }
.creation-library-shell { min-height: 0; height: min(720px, calc(100vh - 32px)); }
.creation-library-toolbar { padding: 10px 16px; }
.creation-library-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
.creation-library-categories { display: flex; gap: 6px; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--border); padding: 8px 10px; }
.creation-library-categories button { width: auto; min-width: 92px; }
.creation-library-grid-wrap { padding: 12px; }
.creation-library-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; }
.creation-library-footer { flex-direction: column; align-items: stretch; gap: 10px; padding: 12px; }
.creation-library-actions { justify-content: flex-end; }
.asset-picker-shell { min-height: 0; height: min(680px, calc(100vh - 32px)); }
.asset-picker-toolbar { grid-template-columns: minmax(0, 1fr) auto; gap: 8px 12px; padding: 10px 42px 10px 12px; }
.asset-picker-search { grid-column: 1 / -1; grid-row: 2; max-width: none; }
.asset-picker-count { grid-column: 2; grid-row: 1; }
.asset-picker-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
.asset-picker-categories { display: flex; gap: 6px; overflow-x: auto; padding: 8px; }
.asset-picker-categories button { width: auto; min-width: 96px; }
.asset-picker-grid-wrap { padding: 10px 4px; }
.asset-picker-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; }
.asset-picker-footer { flex-direction: column; align-items: stretch; gap: 10px; padding: 10px 12px 12px; }
.asset-picker-footer.is-compact { min-height: 0; }
.asset-picker-actions { justify-content: flex-end; }
}
/* 轮播承担首屏视觉主角,工作区只保留很窄的安全边距。 */
@@ -7794,16 +7903,6 @@ body.app-spatial-overlays :where(.ant-modal-mask, .ant-drawer-mask) {
to { opacity: 1; transform: scale(1); }
}
.creation-library-shell { height: min(640px, calc(100vh - 48px)); }
.creation-library-toolbar { min-height: 58px; border-bottom: 1px solid var(--border); padding: 10px 52px 10px 18px; }
.creation-library-toolbar-title { display: flex; flex-shrink: 0; align-items: baseline; gap: 8px; }
.creation-library-toolbar-title span { color: var(--workspace-accent); font-size: var(--fs-micro); font-weight: 650; letter-spacing: .08em; }
.creation-library-toolbar-title strong { font-size: var(--fs-label); font-weight: 650; }
.creation-library-toolbar-count { flex-shrink: 0; color: color-mix(in srgb, var(--foreground) 48%, transparent); font-size: var(--fs-micro); }
.creation-library-body { border-top: 0; }
.creation-library-actions button.is-primary { min-width: 148px; border: 1px solid var(--workspace-accent); background: var(--workspace-accent); color: var(--background); opacity: 1; }
.creation-library-actions button.is-primary:disabled { border-color: color-mix(in srgb, var(--foreground) 16%, transparent); background: color-mix(in srgb, var(--foreground) 7%, transparent); color: color-mix(in srgb, var(--foreground) 42%, transparent); opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.creation-empty-art-frame img { animation: none; }
}
@@ -7817,10 +7916,6 @@ body.app-spatial-overlays :where(.ant-modal-mask, .ant-drawer-mask) {
.creation-empty-art-caption { right: 14%; }
.creation-empty-workspace .creation-chat-intro { margin-top: 28px; }
.creation-empty-composer { width: 100%; }
.creation-library-shell { height: min(680px, calc(100vh - 32px)); }
.creation-library-toolbar { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 12px; padding: 10px 42px 10px 12px; }
.creation-library-toolbar-count { grid-column: 1 / -1; }
.creation-library-search { max-width: none; }
}
/* 用户端浮层:更像创作工作室的工作面,不保留默认后台表单的硬边和密集分隔。 */
@@ -9384,20 +9479,20 @@ html:not(.dark) .creation-home .storyboard-workbench-bar-action:hover { backgrou
display: flex;
align-items: flex-start;
gap: 11px;
padding: 12px 14px;
border: 1px solid var(--creation-border);
border-radius: 12px;
background: var(--creation-surface);
min-height: 72px;
padding: 14px 16px;
border: 0;
border-radius: var(--r-md);
background: var(--library-surface);
color: inherit;
text-align: left;
cursor: pointer;
transition: background var(--motion-dur-fast) var(--motion-ease-out), border-color var(--motion-dur-fast) var(--motion-ease-out), transform var(--motion-dur-fast) var(--motion-ease-out);
transition: background var(--motion-state) var(--motion-ease-out), transform var(--motion-state) var(--motion-ease-out);
}
.creation-home .creation-empty-suggest .suggest-card:hover {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--creation-accent) 30%, var(--creation-border));
background: var(--creation-surface-hover);
background: var(--library-surface-hover);
}
.creation-home .creation-empty-suggest .suggest-card:focus-visible {
@@ -9407,13 +9502,13 @@ html:not(.dark) .creation-home .storyboard-workbench-bar-action:hover { backgrou
.creation-home .creation-empty-suggest .suggest-icon {
display: grid;
width: 32px;
height: 32px;
width: 36px;
height: 36px;
flex: 0 0 auto;
place-items: center;
border-radius: 9px;
background: color-mix(in srgb, var(--creation-text) 7%, transparent);
color: var(--creation-accent);
border-radius: var(--r-sm);
background: var(--library-icon-surface);
color: color-mix(in srgb, var(--creation-text) 82%, transparent);
}
.creation-home .creation-empty-suggest .suggest-icon svg { width: 15px; height: 15px; stroke-width: 2; }
@@ -9506,7 +9601,7 @@ html:not(.dark) .creation-home .storyboard-workbench-bar-action:hover { backgrou
transition: background-color 160ms ease-out, color 160ms ease-out;
}
.assets-filter-item:hover { background: color-mix(in srgb, var(--foreground) 6%, transparent); color: var(--foreground); }
.assets-filter-item:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; }
.assets-filter-item:focus-visible { outline: 2px solid var(--control-focus-ring); outline-offset: -2px; }
.assets-filter-item-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.assets-filter-count {
display: inline-grid;
@@ -9521,9 +9616,9 @@ html:not(.dark) .creation-home .storyboard-workbench-bar-action:hover { backgrou
font-variant-numeric: tabular-nums;
transition: background-color 160ms ease-out, color 160ms ease-out;
}
.assets-filter-item.is-active { background: var(--workspace-accent); color: var(--btn-solid-fg); font-weight: 560; }
.assets-filter-item.is-active:hover { background: var(--workspace-accent); color: var(--btn-solid-fg); }
.assets-filter-item.is-active .assets-filter-count { background: color-mix(in srgb, var(--btn-solid-fg) 18%, transparent); color: var(--btn-solid-fg); }
.assets-filter-item.is-active { background: var(--control-selected-bg); color: var(--control-selected-fg); font-weight: 560; }
.assets-filter-item.is-active:hover { background: var(--control-selected-bg); color: var(--control-selected-fg); }
.assets-filter-item.is-active .assets-filter-count { background: color-mix(in srgb, var(--control-selected-fg) 18%, transparent); color: var(--control-selected-fg); }
@media (min-width: 1024px) {
.assets-filter-item { width: 100%; flex: none; }
}
@@ -9550,7 +9645,7 @@ html:not(.dark) .creation-home .storyboard-workbench-bar-action:hover { backgrou
backdrop-filter: blur(6px);
}
.assets-cover-badge svg { width: 11px; height: 11px; }
.assets-cover-badge.is-kind { background: var(--workspace-accent); color: var(--btn-solid-fg); }
.assets-cover-badge.is-kind { background: var(--control-selected-bg); color: var(--control-selected-fg); }
.assets-cover-badge.is-category { border: 1px solid rgba(255, 255, 255, .2); background: rgba(10, 10, 10, .5); color: rgba(255, 255, 255, .86); }
.assets-cover-clock {
position: absolute;
@@ -9599,16 +9694,16 @@ html:not(.dark) .creation-home .storyboard-workbench-bar-action:hover { backgrou
left: 6px;
width: 6px;
height: 11px;
border: solid var(--btn-solid-fg);
border: solid var(--control-selected-fg);
border-width: 0 2px 2px 0;
content: "";
opacity: 0;
transform: rotate(45deg) scale(.6);
transition: opacity 120ms ease-out, transform 180ms var(--motion-ease-out);
}
.assets-select-check:checked { border-color: var(--workspace-accent); background: var(--workspace-accent); }
.assets-select-check:checked { border-color: var(--control-selected-bg); background: var(--control-selected-bg); }
.assets-select-check:checked::after { opacity: 1; transform: rotate(45deg) scale(1); }
.assets-select-check:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.assets-select-check:focus-visible { outline: 2px solid var(--control-focus-ring); outline-offset: 2px; }
.asset-library-card:hover .assets-select-check, .asset-library-card:focus-within .assets-select-check { opacity: 1; }
.assets-select-check:checked { opacity: 1; }
.assets-cover-more {