import { App, Button, Input } from "antd"; import { AudioLines, IdCard, ImageIcon, Mic, Pause, Play } from "lucide-react"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AppModal } from "@/components/ui/product/app-modal"; import { CharacterImageLibrary, CharacterVoiceLibrary, type CanvasMediaTitles } from "@/components/canvas/character-media-library"; import { characterCoverResourceId, characterInitial, useCharacterCoverSrc } from "@/components/canvas/canvas-character-reference-node"; import { getResourceAccess, resolveResourceAccessURL, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources"; import { bindCharacterVoice, getCharacter, replaceCharacterRepresentations, unbindCharacterVoice, updateCharacter, type ProjectCharacterDetail } from "@/services/api/projects"; import type { CanvasNodeData } from "@/types/canvas"; type Tab = "profile" | "visual" | "voice"; type FieldKey = "role" | "aliases" | "appearance" | "physique" | "clothing" | "personality" | "props" | "consistencyPrompt" | "multiViewPrompt" | "voiceLanguage" | "voiceAge" | "voiceTimbre"; type Drafts = Record; const FIELD_GROUPS: Array<{ title: string; fields: Array<{ key: FieldKey; label: string; placeholder: string; wide?: boolean }> }> = [ { title: "身份", fields: [ { key: "role", label: "剧情身份", placeholder: "例如:落魄剑客,主角的师兄" }, { key: "aliases", label: "别名", placeholder: "多个别名用逗号分隔" }, { key: "personality", label: "性格气质", placeholder: "冷静、克制、偶尔自嘲", wide: true }, ] }, { title: "外观", fields: [ { key: "appearance", label: "外貌特征", placeholder: "脸型、五官、发型、年龄感", wide: true }, { key: "physique", label: "体型姿态", placeholder: "身高、体态、习惯动作" }, { key: "clothing", label: "服装造型", placeholder: "主服装与配色" }, { key: "props", label: "标志道具", placeholder: "随身物品或武器", wide: true }, { key: "consistencyPrompt", label: "一致性要求", placeholder: "生成时必须保持不变的特征", wide: true }, { key: "multiViewPrompt", label: "多视图要求", placeholder: "三视图 / 多角度生成补充说明", wide: true }, ] }, { title: "声音画像", fields: [ { key: "voiceLanguage", label: "语言口音", placeholder: "普通话,略带川音" }, { key: "voiceAge", label: "年龄感", placeholder: "青年" }, { key: "voiceTimbre", label: "音色气质", placeholder: "低沉、沙哑、语速偏慢", wide: true }, ] }, ]; const FIELD_KEYS = FIELD_GROUPS.flatMap((group) => group.fields.map((field) => field.key)); function draftsFrom(name: string, definition: Record): Drafts { const drafts = { name } as Drafts; FIELD_KEYS.forEach((key) => { const value = definition[key]; drafts[key] = key === "aliases" ? (Array.isArray(value) ? value.filter((item) => typeof item === "string").join(",") : "") : typeof value === "string" ? value : ""; }); return drafts; } function coverOf(detail?: ProjectCharacterDetail) { const items = detail?.character.representations || []; return (items.find((item) => item.role === "turnaround_sheet") || items.find((item) => item.role === "primary") || items.find((item) => item.role === "front"))?.resourceId || ""; } export function CanvasCharacterReferenceModal({ node, canvasNodes, open, onClose, onUpdated }: { node: CanvasNodeData | null; canvasNodes?: CanvasNodeData[]; open: boolean; onClose: () => void; onUpdated?: (detail: ProjectCharacterDetail) => void }) { // 资源 ID → 画布节点名:用户在画布上重命名过的图片/音频,选择时按新名字显示并作为声音名保存。 const canvasTitles = useMemo(() => { const titles = new Map(); for (const item of canvasNodes || []) { const resourceId = resourceIdFromStorageKey(item.metadata?.storageKey); const title = item.title?.trim(); if (resourceId && title && !titles.has(resourceId)) titles.set(resourceId, title); } return titles; }, [canvasNodes]); return ( {node?.metadata?.characterAssetId ? : null} ); } function CharacterStudio({ node, assetId, canvasTitles, onUpdated }: { node: CanvasNodeData; assetId: string; canvasTitles: CanvasMediaTitles; onUpdated?: (detail: ProjectCharacterDetail) => void }) { const { message } = App.useApp(); const queryClient = useQueryClient(); const detailQuery = useQuery({ queryKey: ["character-studio", assetId], queryFn: () => getCharacter(assetId) }); const detail = detailQuery.data; const metadata = node.metadata; const name = detail?.asset.title || metadata?.characterName || node.title || "未命名角色"; const definition = detail?.character.definition || metadata?.characterDefinition || {}; // 以序列化结果作依赖,避免每次渲染的新对象反复重置草稿。 const definitionKey = JSON.stringify(definition); const baseline = useMemo(() => draftsFrom(name, JSON.parse(definitionKey) as Record), [name, definitionKey]); const [drafts, setDrafts] = useState(baseline); const [tab, setTab] = useState("profile"); const [busyKey, setBusyKey] = useState(""); // 服务端数据到达或保存成功后,用新基线覆盖草稿。 useEffect(() => setDrafts(baseline), [baseline]); const dirty = (Object.keys(baseline) as Array).some((key) => baseline[key].trim() !== drafts[key].trim()); const coverResourceId = detail ? coverOf(detail) : characterCoverResourceId(metadata?.characterCoverUrl); const cover = useCharacterCoverSrc(coverResourceId ? resourceFileUrl(coverResourceId) : undefined); const voice = detail?.character.voice; const voiceSampleId = voice?.profile.sampleResourceId || ""; // 已绑定的样本如果在画布上被重命名,展示时跟随画布名;重新绑定时会把新名字写回声音档案。 const voiceName = (voiceSampleId && canvasTitles.get(voiceSampleId)) || voice?.profile.name || metadata?.characterVoiceName || ""; const commit = async (key: string, work: () => Promise, success: string) => { setBusyKey(key); try { const next = await work(); queryClient.setQueryData(["character-studio", assetId], next); void queryClient.invalidateQueries({ queryKey: ["characters"] }); onUpdated?.(next); message.success(success); } catch (error) { message.error(error instanceof Error ? error.message : "角色卡保存失败,请稍后重试"); } finally { setBusyKey(""); } }; const bindImage = (resourceId: string) => commit(resourceId, () => replaceCharacterRepresentations(assetId, [{ role: "turnaround_sheet", resourceId }, { role: "primary", resourceId }]), "形象已更新"); const bindVoice = (resourceId: string, title: string) => commit(resourceId, () => bindCharacterVoice(assetId, { sampleResourceId: resourceId, voiceName: title }), "声音已绑定"); const upload = (file: File, kind: "image" | "audio") => commit("upload", async () => { const resource = await uploadResourceFile(file, kind, { fileName: file.name }); return kind === "image" ? replaceCharacterRepresentations(assetId, [{ role: "turnaround_sheet", resourceId: resource.id }, { role: "primary", resourceId: resource.id }]) : bindCharacterVoice(assetId, { sampleResourceId: resource.id, voiceName: file.name.replace(/\.[^.]+$/, "") }); }, kind === "image" ? "形象已更新" : "声音已绑定"); const saveProfile = () => { if (!drafts.name.trim()) { message.error("角色名称不能为空"); return; } const nextDefinition: Record = { ...definition }; FIELD_KEYS.forEach((key) => { nextDefinition[key] = key === "aliases" ? drafts.aliases.split(/[,,、]/).map((item) => item.trim()).filter(Boolean) : drafts[key].trim(); }); void commit("profile", () => updateCharacter(assetId, { name: drafts.name.trim(), definition: nextDefinition }), "角色资料已保存"); }; return (
{tab === "profile" ? setDrafts((current) => ({ ...current, [key]: value }))} /> : null} {tab === "visual" ? void bindImage(id)} onUpload={(file) => void upload(file, "image")} /> : null} {tab === "voice" ? (
{voiceName ? (
当前声音 {voiceName}
) : null} void bindVoice(id, title)} onUpload={(file) => void upload(file, "audio")} />
) : null}
资料有改动,保存后生成新版本
); } const TABS: Array<{ value: Tab; label: string; icon: ReactNode }> = [ { value: "profile", label: "资料", icon: }, { value: "visual", label: "形象", icon: }, { value: "voice", label: "声音", icon: }, ]; /** 右侧主导航:等分整行、带图标和下划线指示,比分段控件更醒目;支持左右方向键切换。 */ function CharacterTabs({ value, onChange }: { value: Tab; onChange: (tab: Tab) => void }) { const refs = useRef>([]); const onKeyDown = (event: KeyboardEvent) => { if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; event.preventDefault(); const index = TABS.findIndex((item) => item.value === value); const next = (index + (event.key === "ArrowRight" ? 1 : TABS.length - 1)) % TABS.length; onChange(TABS[next].value); refs.current[next]?.focus(); }; return (
{TABS.map((item, index) => { const active = item.value === value; return ( ); })}
); } /** 左侧主视觉:模糊底 + 全幅立绘,名字压在底部;声音可直接试听。 */ function CharacterStage({ name, role, cover, hasCover, voiceName, voiceSampleId, version }: { name: string; role: string; cover: string; hasCover: boolean; voiceName: string; voiceSampleId: string; version?: number }) { return (
{cover ? ( <> {`${name}角色形象`} ) : (
{hasCover ? null : (
{characterInitial(name)}

在右侧「形象」中选择或上传立绘

)}
)}
Character{version ? ` · v${version}` : ""}

{name}

{role || "尚未填写剧情身份"}

); } function VoiceChip({ name, sampleId }: { name: string; sampleId: string }) { const audioRef = useRef(null); const [playing, setPlaying] = useState(false); useEffect(() => () => audioRef.current?.pause(), []); useEffect(() => { audioRef.current?.pause(); setPlaying(false); }, [sampleId]); if (!name) return

未绑定声音

; const toggle = async () => { if (!sampleId) return; const audio = audioRef.current || (audioRef.current = new Audio()); if (playing) { audio.pause(); setPlaying(false); return; } setPlaying(true); try { audio.src = resolveResourceAccessURL((await getResourceAccess(resourceStorageKey(sampleId), "display", "playback")).url); audio.onended = () => setPlaying(false); await audio.play(); } catch { setPlaying(false); } }; return ( ); } function ProfileForm({ drafts, onChange }: { drafts: Drafts; onChange: (key: keyof Drafts, value: string) => void }) { return (
onChange("name", event.target.value)} className="!text-base !font-medium" /> {FIELD_GROUPS.map((group) => (
{group.title}
{group.fields.map((field) => ( onChange(field.key, event.target.value)} /> ))}
))}
); } function Field({ label, wide = true, children }: { label: string; wide?: boolean; children: ReactNode }) { return ( ); }