Commit 0aa3bd46 authored by yaoke.yk's avatar yaoke.yk

角色设定分镜ui调整

parent ea37c382
......@@ -303,6 +303,39 @@ export function CharacterGeneration() {
});
};
const handleGenerateFromModal = async () => {
if (!editingChar.name?.trim()) return;
const draft: Partial<Character> = {
...editingChar,
status: editingChar.status ?? "draft",
imagePrompt: editingChar.imagePrompt?.trim() || buildChineseCharacterPrompt(editingChar),
};
setSaving(true);
setGeneratingImageId(editingChar.id ? String(editingChar.id) : "draft");
try {
const saved = await saveCharacter.mutateAsync(draft);
const generated = await generateImage.mutateAsync(String(saved.id));
revokeImagePreviews(imagePreviews);
setEditingChar({
...generated,
imagePrompt: generated.imagePrompt ?? draft.imagePrompt,
});
setImagePreviews({
front: getCharacterViewUrl(generated, "front"),
side: getCharacterViewUrl(generated, "side"),
back: getCharacterViewUrl(generated, "back"),
});
setPendingFiles(emptyPendingFiles());
fileInputRefs.current.front && (fileInputRefs.current.front.value = "");
} catch (error) {
alert(`三视图生成失败: ${(error as Error).message}`);
} finally {
setSaving(false);
setGeneratingImageId(null);
}
};
const handleSaveState = async () => {
if (!editingCharacterId || !stateDraft.name.trim()) return;
setSavingState(true);
......@@ -679,14 +712,43 @@ export function CharacterGeneration() {
</div>
)}
</div>
{imagePreviews.front && (
{imagePreviews.front ? (
<div className="mt-2 flex items-center gap-2">
<button
type="button"
onClick={() => fileInputRefs.current.front?.click()}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1 text-xs text-foreground hover:bg-muted"
>
<Upload className="h-3.5 w-3.5" />
更换图片
</button>
<button
type="button"
onClick={handleGenerateFromModal}
disabled={!editingChar.name?.trim() || saving || uploadImage.isPending || generateImage.isPending}
className="inline-flex items-center gap-1.5 rounded-md border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
>
{generatingImageId === (editingChar.id ? String(editingChar.id) : "draft") ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
AI重新生成
</button>
</div>
) : (
<button
type="button"
onClick={() => fileInputRefs.current.front?.click()}
className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1 text-xs text-foreground hover:bg-muted"
onClick={handleGenerateFromModal}
disabled={!editingChar.name?.trim() || saving || uploadImage.isPending || generateImage.isPending}
className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
>
<Upload className="h-3.5 w-3.5" />
更换图片
{generatingImageId === (editingChar.id ? String(editingChar.id) : "draft") ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
{editingChar.id ? "AI生成三视图" : "保存并AI生成"}
</button>
)}
<input
......
......@@ -116,6 +116,26 @@ export function SceneGeneration() {
});
};
const handleGenerateFromModal = async () => {
if (!editingScene.name?.trim()) return;
setSaving(true);
setGeneratingImageId(editingScene.id ? String(editingScene.id) : "draft");
try {
const saved = await saveScene.mutateAsync({ ...editingScene, status: editingScene.status ?? "draft" });
const generated = await generateImage.mutateAsync(String(saved.id));
revokeObjectPreview(imagePreview);
setEditingScene({ ...generated });
setImagePreview(generated.imageUrl ?? null);
setPendingFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
} catch (e) {
alert("图片生成失败: " + (e as Error).message);
} finally {
setSaving(false);
setGeneratingImageId(null);
}
};
const handleDelete = (scene: Scene) => {
if (!confirm(`确定要删除场景「${scene.name}」吗?`)) return;
deleteScene.mutate(scene.id, { onError: (e) => alert("删除失败: " + (e as Error).message) });
......@@ -404,14 +424,43 @@ export function SceneGeneration() {
</div>
)}
</div>
{imagePreview && (
{imagePreview ? (
<div className="mt-2 flex items-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1 text-xs text-foreground hover:bg-muted"
>
<Upload className="h-3.5 w-3.5" />
更换图片
</button>
<button
type="button"
onClick={handleGenerateFromModal}
disabled={!editingScene.name?.trim() || saving || uploadImage.isPending || generateImage.isPending}
className="inline-flex items-center gap-1.5 rounded-md border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
>
{generatingImageId === (editingScene.id ? String(editingScene.id) : "draft") ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
AI重新生成
</button>
</div>
) : (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1 text-xs text-foreground hover:bg-muted"
onClick={handleGenerateFromModal}
disabled={!editingScene.name?.trim() || saving || uploadImage.isPending || generateImage.isPending}
className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
>
<Upload className="h-3.5 w-3.5" />
更换图片
{generatingImageId === (editingScene.id ? String(editingScene.id) : "draft") ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
{editingScene.id ? "AI生成场景图" : "保存并AI生成"}
</button>
)}
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileSelect} />
......
......@@ -49,6 +49,7 @@ const DEFAULT_VIDEO_MODEL = VIDEO_MODEL_OPTIONS[0].value;
const MAX_CHARACTERS = 5;
const MAX_PROPS = 5;
const PROMPT_PREVIEW_LIMIT = 80;
type VideoGenerationMode = "reference" | "frames";
// 分镜编辑器草稿存到 localStorage,按 (project, episode, storyboard) 分 key。
// 跨页面切换/刷新都不会丢;展开分镜时优先用草稿,没草稿才回退到上一次任务。
......@@ -58,6 +59,7 @@ const sbDraftKey = (pid: string, eid: string, sbId: string) =>
`${SB_DRAFT_KEY_PREFIX}:${pid}:${eid}:${sbId}`;
interface SbDraft {
generationMode?: VideoGenerationMode;
shortDescription: string;
characterKeys: string[];
sceneKey: string;
......@@ -321,6 +323,7 @@ export function StoryboardWorkspace() {
const [lastFrameImageKey, setLastFrameImageKey] = useState<string>("");
const [lastFrameImageUrl, setLastFrameImageUrl] = useState<string>("");
const [styleKey] = useState<string>(""); // reserved; not exposed in this UI
const [generationMode, setGenerationMode] = useState<VideoGenerationMode>("reference");
const [shortDescription, setShortDescription] = useState<string>("");
const [freeformPrompt, setFreeformPrompt] = useState<string>("");
const [selectedDuration, setSelectedDuration] = useState(DEFAULT_VIDEO_DURATION);
......@@ -392,6 +395,7 @@ export function StoryboardWorkspace() {
setFirstFrameImageUrl("");
setLastFrameImageKey("");
setLastFrameImageUrl("");
setGenerationMode("reference");
setShortDescription("");
setActiveVideoSbId(null);
lastSyncedExpandedIdRef.current = null;
......@@ -429,6 +433,7 @@ export function StoryboardWorkspace() {
setFirstFrameImageUrl(draft.firstFrameImageUrl || expanded.firstFrameImageUrl || "");
setLastFrameImageKey(draft.lastFrameImageKey || expanded.lastFrameImageKey || "");
setLastFrameImageUrl(draft.lastFrameImageUrl || expanded.lastFrameImageUrl || "");
setGenerationMode(draft.generationMode ?? "reference");
setFreeformPrompt(draft.freeformPrompt);
setSelectedDuration(normalizeVideoDuration(draft.selectedDuration));
setSelectedRatio(draft.selectedRatio);
......@@ -465,6 +470,7 @@ export function StoryboardWorkspace() {
setFirstFrameImageUrl(expanded.firstFrameImageUrl ?? "");
setLastFrameImageKey(expanded.lastFrameImageKey ?? "");
setLastFrameImageUrl(expanded.lastFrameImageUrl ?? "");
setGenerationMode("reference");
setFreeformPrompt(expanded.videoPrompt?.trim() || (lastTask?.userPrompt?.freeformPrompt ?? ""));
if (typeof lastTask?.videoDuration === "number") {
setSelectedDuration(normalizeVideoDuration(lastTask.videoDuration));
......@@ -485,6 +491,7 @@ export function StoryboardWorkspace() {
return;
}
saveSbDraft(sbDraftKey(pid, eid, expandedId), {
generationMode,
shortDescription,
characterKeys,
sceneKey,
......@@ -501,7 +508,7 @@ export function StoryboardWorkspace() {
});
}, [
expandedId, pid, eid,
shortDescription, characterKeys, sceneKey, propKeys,
generationMode, shortDescription, characterKeys, sceneKey, propKeys,
firstFrameImageKey, firstFrameImageUrl, lastFrameImageKey, lastFrameImageUrl,
freeformPrompt,
selectedDuration, selectedRatio, selectedModel, audioOn,
......@@ -635,11 +642,12 @@ export function StoryboardWorkspace() {
const promptHasContent = freeformPrompt.trim().length > 0;
const frameReferenceCount = (firstFrameImageKey ? 1 : 0) + (lastFrameImageKey ? 1 : 0);
// 至少一张参考图(角色/场景/道具/首尾帧任一)+ 提示词非空
const hasAnyReferenceImage = characterKeys.length > 0 || !!sceneKey || propKeys.length > 0 || frameReferenceCount > 0;
const canGenerate = !!expanded && hasAnyReferenceImage
&& promptHasContent && !generateStructured.isPending;
const referenceCount = characterKeys.length + (sceneKey ? 1 : 0) + propKeys.length + frameReferenceCount;
const referenceImageCount = characterKeys.length + (sceneKey ? 1 : 0) + propKeys.length;
const hasRequiredImages = generationMode === "frames"
? !!firstFrameImageKey && !!lastFrameImageKey
: referenceImageCount > 0;
const canGenerate = !!expanded && hasRequiredImages && promptHasContent && !generateStructured.isPending;
const referenceCount = generationMode === "frames" ? frameReferenceCount : referenceImageCount;
const handleGenerate = async () => {
if (!expanded || !canGenerate) return;
......@@ -663,11 +671,11 @@ export function StoryboardWorkspace() {
await generateStructured.mutateAsync({
episodeId: expanded.episodeId,
storyboardId: expanded.id,
characterImageKeys: characterKeys,
sceneImageKey: sceneKey,
propImageKeys: propKeys.length > 0 ? propKeys : null,
firstFrameImageKey: firstFrameImageKey || null,
lastFrameImageKey: lastFrameImageKey || null,
characterImageKeys: generationMode === "reference" ? characterKeys : [],
sceneImageKey: generationMode === "reference" ? sceneKey : "",
propImageKeys: generationMode === "reference" && propKeys.length > 0 ? propKeys : null,
firstFrameImageKey: generationMode === "frames" ? firstFrameImageKey : null,
lastFrameImageKey: generationMode === "frames" ? lastFrameImageKey : null,
styleImageKey: styleKey || null,
freeformPrompt: freeformPrompt.trim(),
duration: selectedDuration,
......@@ -716,6 +724,10 @@ export function StoryboardWorkspace() {
const currentEpisodeSummary = currentEpisode?.summary
|| "当前项目还没有可用分集。生成分集后,可以在这里继续生成分镜。";
const selectedScene = sceneKey ? sceneByKey.get(sceneKey) ?? null : null;
const generationModeLabel = generationMode === "frames" ? "首尾帧生成" : "参考图生成";
const generateRequirementText = generationMode === "frames"
? "需要:上传首帧和尾帧,并填写提示词"
: "需要:至少 1 张参考图(角色/场景/道具)+ 提示词";
return (
<div className="h-full flex overflow-hidden bg-[#f7f8fb] text-[#111827]">
......@@ -814,35 +826,12 @@ export function StoryboardWorkspace() {
{/* ─── Column 2: Storyboard accordion list (top: prompt summary, then cards) ─── */}
<div className="flex flex-col flex-shrink-0 overflow-hidden rounded-lg border border-[#eef1f6] bg-[#fbfcff]" style={{ width: 394 }}>
{/* Top: 分镜表 / 备注描述 */}
<div className="px-3 py-3 border-b border-[#edf0f5] bg-white flex-shrink-0 relative">
<div className="flex items-center justify-between mb-1.5">
<span className="text-sm font-semibold text-[#111827]">
{expanded ? `分镜信息${String(expanded.sequenceNum).padStart(3, "0")}` : "分镜信息"}
</span>
{(promptPreviewText || expanded) && (
<button
onClick={() => setShowPromptDetails(true)}
className="text-xs text-[#7657ff] hover:text-[#5e42df] flex items-center gap-0.5 font-medium transition-colors"
title="查看完整内容"
>
<Info className="w-3 h-3" />
详情
</button>
)}
</div>
<div className="mb-2 text-xs font-medium text-[#344054]">分镜描述</div>
<p className={`min-h-[96px] rounded-md border border-[#d9dee8] bg-white px-3 py-2 text-xs leading-relaxed ${shortDescription ? "text-[#111827]" : "text-[#98a2b3]"}`}>
{shortDescription || "暂无分镜描述"}
</p>
</div>
{/* Action row: new + AI generate */}
<div className="h-10 px-3 border-b border-[#edf0f5] flex items-center gap-2 flex-shrink-0 bg-white">
<div className="h-11 px-3 border-b border-[#edf0f5] flex items-center gap-2.5 flex-shrink-0 bg-white">
<button
onClick={handleCreate}
disabled={createSb.isPending || !eid}
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md border border-[#dde3ee] bg-white text-xs font-medium text-[#111827] transition hover:border-[#b9c3d6] disabled:opacity-50"
className="flex-1 flex items-center justify-center gap-1.5 px-4 py-2 rounded-md border border-[#dde3ee] bg-white text-xs font-medium text-[#111827] transition hover:border-[#b9c3d6] disabled:opacity-50"
>
{createSb.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Plus className="w-3 h-3" />}
新建分镜
......@@ -851,7 +840,7 @@ export function StoryboardWorkspace() {
onClick={handleGenerateAll}
disabled={generateSbs.isPending || !eid}
title="AI批量生成分镜"
className={`p-2 rounded-md text-[#475467] hover:bg-[#f6f9ff] hover:border-[#7657ff]/30 hover:text-[#7657ff] transition-colors disabled:opacity-50 ${CONTROL_SURFACE}`}
className={`flex h-8 w-11 items-center justify-center rounded-md text-[#475467] hover:bg-[#f6f9ff] hover:border-[#7657ff]/30 hover:text-[#7657ff] transition-colors disabled:opacity-50 ${CONTROL_SURFACE}`}
>
{generateSbs.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Wand2 className="w-4 h-4" />}
</button>
......@@ -900,6 +889,8 @@ export function StoryboardWorkspace() {
onSelectScene={setSceneKey}
propKeys={expandedId === sb.id ? propKeys : []}
onTogglePropKey={togglePropKey}
generationMode={expandedId === sb.id ? generationMode : "reference"}
onGenerationModeChange={setGenerationMode}
firstFrameImageKey={expandedId === sb.id ? firstFrameImageKey : sb.firstFrameImageKey ?? ""}
firstFrameImageUrl={expandedId === sb.id ? firstFrameImageUrl : sb.firstFrameImageUrl ?? ""}
lastFrameImageKey={expandedId === sb.id ? lastFrameImageKey : sb.lastFrameImageKey ?? ""}
......@@ -935,12 +926,14 @@ export function StoryboardWorkspace() {
</div>
) : (
<>
<div className="flex items-start gap-1.5 px-3 py-2.5 rounded-xl bg-white border border-[#1c64ff]/15 shadow-[0_4px_14px_rgba(28,100,255,0.06)]">
<Plus className="w-3.5 h-3.5 text-[#98a2b3] mt-0.5 flex-shrink-0" />
<div className="flex items-start gap-1.5 rounded-md bg-[#f8fafc] px-2.5 py-1.5">
<Info className="w-3.5 h-3.5 text-[#98a2b3] mt-0.5 flex-shrink-0" />
<p className="text-[11px] text-[#98a2b3] leading-relaxed">
{referenceCount > 0
? `已预选 ${referenceCount} 个参考素材,可继续手工调整`
: "可手工选择参考素材;AI 批量生成会自动预选可用角色/场景"}
{generationMode === "frames"
? `已上传 ${frameReferenceCount}/2 张关键帧,将按首帧、尾帧顺序参与生成`
: referenceCount > 0
? `已预选 ${referenceCount} 个参考素材,可继续手工调整`
: "可手工选择参考素材;AI 批量生成会自动预选可用角色/场景"}
</p>
</div>
<textarea
......@@ -1012,7 +1005,7 @@ export function StoryboardWorkspace() {
<button
onClick={handleGenerate}
disabled={!canGenerate || generatingVideoId === expanded?.id}
title={!canGenerate ? "需要:至少 1 张参考图(角色/场景/道具/首尾帧任一)+ 提示词" : ""}
title={!canGenerate ? generateRequirementText : ""}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-md bg-[#7657ff] text-white text-xs font-semibold tracking-wide shadow-[0_9px_20px_rgba(118,87,255,0.24)] transition-all duration-150 hover:bg-[#6747f4] hover:-translate-y-px disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-none whitespace-nowrap"
>
{generatingVideoId === expanded?.id ? (
......@@ -1020,7 +1013,7 @@ export function StoryboardWorkspace() {
) : (
<Play className="w-3.5 h-3.5" />
)}
生成视频{videoCredits != null ? ` (${videoCredits.toLocaleString()}积分)` : ""}
{generationModeLabel}视频{videoCredits != null ? ` (${videoCredits.toLocaleString()}积分)` : ""}
</button>
</div>
</div>
......@@ -1275,6 +1268,8 @@ interface StoryboardCardProps {
onSelectScene: (key: string) => void;
propKeys: string[];
onTogglePropKey: (key: string) => void;
generationMode: VideoGenerationMode;
onGenerationModeChange: (mode: VideoGenerationMode) => void;
firstFrameImageKey: string;
firstFrameImageUrl: string;
lastFrameImageKey: string;
......@@ -1289,6 +1284,10 @@ interface StoryboardCardProps {
function StoryboardCard(props: StoryboardCardProps) {
const { sb, isExpanded, dotClass, onToggle, onDelete } = props;
const [descCollapsed, setDescCollapsed] = useState(false);
const modeOptions = [
{ value: "reference", label: "参考图生成", icon: Sparkles },
{ value: "frames", label: "首尾帧生成", icon: ImageIcon },
] as const;
return (
<div className={`my-2 rounded-lg border transition-all duration-200 ${
......@@ -1308,6 +1307,30 @@ function StoryboardCard(props: StoryboardCardProps) {
{isExpanded && (
<div className="px-3 pb-3 space-y-3 border-t border-[#f1f3f7]">
<div className="pt-3">
<div className="grid grid-cols-2 gap-1 rounded-lg border border-[#dfe3ee] bg-[#f8fafc] p-1">
{modeOptions.map((option) => {
const Icon = option.icon;
const active = props.generationMode === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => props.onGenerationModeChange(option.value)}
className={`flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-[11px] font-medium transition-all ${
active
? "bg-white text-[#7657ff] shadow-sm ring-1 ring-[#7657ff]/20"
: "text-muted-foreground hover:bg-white/70 hover:text-[#475467]"
}`}
>
<Icon className="h-3.5 w-3.5" />
{option.label}
</button>
);
})}
</div>
</div>
{/* 分镜描述 */}
<div>
<button
......@@ -1330,41 +1353,44 @@ function StoryboardCard(props: StoryboardCardProps) {
)}
</div>
{/* 出镜角色 */}
<CharacterRowList
characterKeys={props.characterKeys}
keyToCharacterRef={props.keyToCharacterRef}
characterRefOptions={props.characterRefOptions}
onRemove={props.onRemoveCharacter}
onSwap={props.onSwapCharacter}
onAdd={props.onAddCharacter}
/>
{/* 分镜场景 */}
<SceneSection
scenes={props.scenes}
selectedKey={props.sceneKey}
onSelect={props.onSelectScene}
/>
{/* 场景道具 */}
<PropGrid
scenes={props.scenes}
sceneKey={props.sceneKey}
selectedKeys={props.propKeys}
onToggle={props.onTogglePropKey}
/>
{/* 首尾帧图 */}
<FrameReferenceSection
firstFrameImageKey={props.firstFrameImageKey}
firstFrameImageUrl={props.firstFrameImageUrl}
lastFrameImageKey={props.lastFrameImageKey}
lastFrameImageUrl={props.lastFrameImageUrl}
onUpload={props.onUploadFrame}
onClear={props.onClearFrame}
uploading={props.frameUploading}
/>
{props.generationMode === "reference" ? (
<>
{/* 出镜角色 */}
<CharacterRowList
characterKeys={props.characterKeys}
keyToCharacterRef={props.keyToCharacterRef}
characterRefOptions={props.characterRefOptions}
onRemove={props.onRemoveCharacter}
onSwap={props.onSwapCharacter}
onAdd={props.onAddCharacter}
/>
{/* 分镜场景 */}
<SceneSection
scenes={props.scenes}
selectedKey={props.sceneKey}
onSelect={props.onSelectScene}
/>
{/* 场景道具 */}
<PropGrid
scenes={props.scenes}
sceneKey={props.sceneKey}
selectedKeys={props.propKeys}
onToggle={props.onTogglePropKey}
/>
</>
) : (
<FrameReferenceSection
firstFrameImageKey={props.firstFrameImageKey}
firstFrameImageUrl={props.firstFrameImageUrl}
lastFrameImageKey={props.lastFrameImageKey}
lastFrameImageUrl={props.lastFrameImageUrl}
onUpload={props.onUploadFrame}
onClear={props.onClearFrame}
uploading={props.frameUploading}
/>
)}
{/* 删除按钮 */}
<div className="pt-1 flex justify-end">
......@@ -1564,23 +1590,20 @@ function SceneSection({ scenes, selectedKey, onSelect }: SceneSectionProps) {
)}
</button>
{/* Bottom row: scene name + role label + actions menu */}
{/* Bottom row: selected scene text + actions menu */}
<div className="flex items-center gap-1.5">
<div className="flex-1 min-w-0 px-2.5 py-1.5 rounded-md border border-[#dfe3ee] bg-white text-xs text-[#111827] truncate">
{selected?.name || <span className="text-muted-foreground">请选择场景</span>}
</div>
<div className="px-2.5 py-1.5 rounded-md border border-[#dfe3ee] bg-white/80 text-xs text-muted-foreground flex-shrink-0">
主场景图
<div className="flex-1 min-w-0 px-1 py-1.5 text-xs text-[#111827] truncate">
{selected?.name || <span className="text-muted-foreground">未选择场景</span>}
</div>
<div className="relative flex-shrink-0">
<button
onClick={() => setMenuOpen((v) => !v)}
disabled={!selected}
disabled={usable.length === 0}
className="p-1.5 rounded-md border border-[#dfe3ee] bg-white text-muted-foreground hover:bg-[#f6f9ff] hover:text-[#7657ff] disabled:opacity-40 transition-colors"
>
<MoreHorizontal className="w-3.5 h-3.5" />
</button>
{menuOpen && selected && (
{menuOpen && usable.length > 0 && (
<>
<button
className="fixed inset-0 z-10 cursor-default"
......@@ -1591,14 +1614,16 @@ function SceneSection({ scenes, selectedKey, onSelect }: SceneSectionProps) {
onClick={() => { setShowPicker(true); setMenuOpen(false); }}
className="w-full px-3 py-1.5 text-left text-xs text-foreground hover:bg-[#f6f9ff]"
>
更换场景
</button>
<button
onClick={() => { onSelect(""); setMenuOpen(false); }}
className="w-full px-3 py-1.5 text-left text-xs text-destructive hover:bg-red-50"
>
清除选择
场景图
</button>
{selected && (
<button
onClick={() => { onSelect(""); setMenuOpen(false); }}
className="w-full px-3 py-1.5 text-left text-xs text-destructive hover:bg-red-50"
>
清除选择
</button>
)}
</div>
</>
)}
......@@ -1610,38 +1635,42 @@ function SceneSection({ scenes, selectedKey, onSelect }: SceneSectionProps) {
{usable.length === 0 ? (
<p className="text-[11px] text-muted-foreground italic text-center py-2">先到「场景」页创建场景</p>
) : (
<div className="grid grid-cols-3 gap-2">
{usable.map((s) => {
const active = selectedKey === s.imageTosKey;
return (
<button
key={s.id}
onClick={() => {
onSelect(active ? "" : s.imageTosKey!);
if (!active) setShowPicker(false);
}}
title={s.name}
className={`relative aspect-square rounded-md overflow-hidden border-2 ${
active ? "border-[#1c64ff] ring-2 ring-[#1c64ff]/25" : "border-slate-200 hover:border-[#1c64ff]/40"
}`}
>
{s.imageUrl ? (
<img src={s.imageUrl} alt={s.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-[9px] text-muted-foreground">无图</div>
)}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-1 pt-3 pb-0.5">
<span className="text-[10px] text-white truncate block">{s.name}</span>
</div>
{active && (
<div className="absolute top-1 right-1 w-4 h-4 rounded-full bg-[#1c64ff] flex items-center justify-center shadow-sm">
<Check className="w-2.5 h-2.5 text-white" />
<>
<div className="mb-2 flex items-center justify-between">
<span className="text-[11px] font-medium text-[#344054]">选择一个场景图</span>
<span className="text-[10px] text-muted-foreground">单选</span>
</div>
<div className="grid grid-cols-3 gap-2">
{usable.map((s) => {
const active = selectedKey === s.imageTosKey;
return (
<button
key={s.id}
onClick={() => {
onSelect(s.imageTosKey!);
setShowPicker(false);
}}
title={s.name}
className={`relative aspect-square rounded-md overflow-hidden border-2 text-left transition ${
active ? "border-[#1c64ff] ring-2 ring-[#1c64ff]/25" : "border-slate-200 hover:border-[#1c64ff]/40"
}`}
>
{s.imageUrl ? (
<img src={s.imageUrl} alt={s.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-[9px] text-muted-foreground">无图</div>
)}
<div className="absolute left-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-white/90 shadow-sm ring-1 ring-black/10">
<span className={`h-2.5 w-2.5 rounded-full ${active ? "bg-[#1c64ff]" : "bg-transparent ring-1 ring-slate-400"}`} />
</div>
)}
</button>
);
})}
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-1 pt-3 pb-0.5">
<span className="text-[10px] text-white truncate block">{s.name}</span>
</div>
</button>
);
})}
</div>
</>
)}
</div>
)}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment