feat(web): 角色与音频工作台 - 完善素材选择、生成参数与同步交互

This commit is contained in:
ddcat666
2026-10-01 09:43:02 +08:00
parent 5650da6d31
commit 22e7331d04
77 changed files with 3641 additions and 721 deletions

View File

@@ -1,26 +1,89 @@
import { Image as ImageIcon, UserRound, Volume2 } from "lucide-react";
import type { ReactNode } from "react";
import { Image as ImageIcon, Mic } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { getResourceAccess, resolveResourceAccessURL, resourceStorageKey } from "@/services/api/resources";
import type { CanvasNodeData } from "@/types/canvas";
export function CanvasCharacterReferenceNodeContent({ node }: { node: CanvasNodeData }) {
const visualReady = node.metadata?.characterVisualStatus === "ready";
const voiceReady = node.metadata?.characterVoiceStatus === "ready";
return <div className="flex h-full w-full flex-col overflow-hidden bg-background">
<div className="relative min-h-0 flex-1 overflow-hidden bg-foreground/[.045]">
{node.metadata?.characterCoverUrl ? <img src={node.metadata.characterCoverUrl} alt={node.metadata?.characterName || node.title} className="h-full w-full object-contain p-2" draggable={false} /> : <div className="grid h-full place-items-center text-foreground/22"><UserRound className="size-12" /></div>}
<span className="absolute left-3 top-3 rounded bg-black/60 px-2 py-1 text-[var(--fs-tiny)] font-medium text-white">角色引用</span>
</div>
<div className="shrink-0 border-t border-border/70 px-3 py-2.5">
<div className="truncate text-sm font-semibold">{node.metadata?.characterName || node.title}</div>
<div className="mt-2 flex min-w-0 gap-1.5">
<State icon={<ImageIcon className="size-3" />} ready={visualReady} label={visualReady ? "形象就绪" : "形象待完善"} />
<State icon={<Volume2 className="size-3" />} ready={voiceReady} label={voiceReady ? node.metadata?.characterVoiceName || "声音已绑定" : "声音未绑定"} />
</div>
</div>
</div>;
const RESOURCE_FILE_PATTERN = /\/resources\/([^/?#]+)\/file/;
/** 角色卡封面存的是 `/resources/:id/file`,展示前换成授权访问地址;换取失败再回落原地址。 */
export function useCharacterCoverSrc(url?: string) {
const [resolved, setResolved] = useState<{ url: string; src: string } | null>(null);
const resourceId = url ? RESOURCE_FILE_PATTERN.exec(url)?.[1] : undefined;
useEffect(() => {
if (!url || !resourceId) return;
let cancelled = false;
getResourceAccess(resourceStorageKey(decodeURIComponent(resourceId)), "display")
.then((access) => {
if (!cancelled) setResolved({ url, src: resolveResourceAccessURL(access.url) || url });
})
.catch(() => {
if (!cancelled) setResolved({ url, src: url });
});
return () => {
cancelled = true;
};
}, [resourceId, url]);
if (!url) return "";
if (!resourceId) return url;
return resolved?.url === url ? resolved.src : "";
}
function State({ icon, ready, label }: { icon: ReactNode; ready: boolean; label: string }) {
return <span className={`inline-flex min-w-0 items-center gap-1 rounded px-1.5 py-1 text-[var(--fs-tiny)] ${ready ? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" : "bg-foreground/[.055] text-foreground/45"}`}>{icon}<span className="truncate">{label}</span></span>;
export function characterCoverResourceId(url?: string) {
const id = url ? RESOURCE_FILE_PATTERN.exec(url)?.[1] : undefined;
return id ? decodeURIComponent(id) : "";
}
export function characterInitial(name: string) {
return Array.from(name.trim())[0] || "角";
}
/** 画布上的角色卡:海报式主视觉,底部压名字与形象/声音状态。 */
export function CanvasCharacterReferenceNodeContent({ node }: { node: CanvasNodeData }) {
const metadata = node.metadata;
const name = metadata?.characterName || node.title || "未命名角色";
const role = typeof metadata?.characterDefinition?.role === "string" ? metadata.characterDefinition.role.trim() : "";
const visualReady = metadata?.characterVisualStatus === "ready";
const voiceReady = metadata?.characterVoiceStatus === "ready";
const cover = useCharacterCoverSrc(metadata?.characterCoverUrl);
return (
<div className="relative isolate h-full w-full overflow-hidden bg-[#111114] text-white">
{cover ? (
<>
<img src={cover} alt="" aria-hidden className="absolute inset-0 -z-10 h-full w-full scale-125 object-cover opacity-50 blur-2xl saturate-125" draggable={false} />
<img src={cover} alt={name} className="absolute inset-x-0 top-0 h-[calc(100%-40px)] w-full object-contain px-3 pt-3 drop-shadow-[0_18px_30px_rgba(0,0,0,.45)]" draggable={false} />
</>
) : (
<div className="absolute inset-0 grid place-items-center bg-[radial-gradient(circle_at_50%_36%,rgba(255,255,255,.1),transparent_60%)] pb-10">
<span className="grid size-20 place-items-center rounded-full border border-white/15 bg-white/[.03] text-3xl font-light text-white/75">{characterInitial(name)}</span>
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-3/5 bg-gradient-to-t from-black/90 via-black/45 to-transparent" />
<span className="absolute left-3.5 top-3 text-[10px] font-medium uppercase tracking-[.24em] text-white/55">Character</span>
<div className="absolute inset-x-0 bottom-0 flex items-end gap-3 px-3.5 pb-3">
<div className="min-w-0 flex-1">
<div className="truncate text-[17px] font-semibold leading-6 tracking-tight">{name}</div>
<div className="truncate text-[11px] leading-4 text-white/55">{role || (voiceReady && metadata?.characterVoiceName ? `声音 · ${metadata.characterVoiceName}` : "角色卡")}</div>
</div>
<div className="flex shrink-0 gap-1">
<Indicator ready={visualReady} label={visualReady ? "形象已就绪" : "形象待完善"}>
<ImageIcon className="size-3" />
</Indicator>
<Indicator ready={voiceReady} label={voiceReady ? `声音:${metadata?.characterVoiceName || "已绑定"}` : "声音未绑定"}>
<Mic className="size-3" />
</Indicator>
</div>
</div>
</div>
);
}
function Indicator({ ready, label, children }: { ready: boolean; label: string; children: ReactNode }) {
return (
<span title={label} aria-label={label} className={`relative grid size-6 place-items-center rounded-full border backdrop-blur ${ready ? "border-white/25 bg-white/15 text-white" : "border-white/10 bg-white/[.04] text-white/35"}`}>
{children}
{ready ? <span className="absolute -right-px -top-px size-1.5 rounded-full bg-emerald-400" /> : null}
</span>
);
}