fix(canvas): 创作界面 - 统一素材选择并修复画布顶栏与暗色交互
This commit is contained in:
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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("");
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user