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

This commit is contained in:
ddCat
2026-08-14 18:35:43 +08:00
parent 9973c656a6
commit 7ba4cb148e
18 changed files with 673 additions and 448 deletions

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>
);
}
}