1
This commit is contained in:
@@ -9,7 +9,11 @@ import { mockVisualNovelDraft } from '../visual-novel-runtime/visualNovelMockDat
|
||||
import { VisualNovelResultView } from './VisualNovelResultView';
|
||||
|
||||
vi.mock('../../services/visual-novel-creation', () => ({
|
||||
createVisualNovelBackgroundMusicTask: vi.fn(),
|
||||
createVisualNovelSoundEffectTask: vi.fn(),
|
||||
listVisualNovelHistoryAssets: vi.fn().mockResolvedValue([]),
|
||||
publishVisualNovelBackgroundMusicAsset: vi.fn(),
|
||||
publishVisualNovelSoundEffectAsset: vi.fn(),
|
||||
uploadVisualNovelAsset: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -91,8 +95,10 @@ test('visual novel result uploads scene and character assets into platform refer
|
||||
uploadMock.mockResolvedValue({
|
||||
assetObjectId: 'asset-scene-1',
|
||||
assetKind: 'scene_image',
|
||||
objectKey: 'generated-custom-world-scenes/vn-profile/scene-1/background.png',
|
||||
imageSrc: '/generated-custom-world-scenes/vn-profile/scene-1/background.png',
|
||||
objectKey:
|
||||
'generated-custom-world-scenes/vn-profile/scene-1/background.png',
|
||||
imageSrc:
|
||||
'/generated-custom-world-scenes/vn-profile/scene-1/background.png',
|
||||
});
|
||||
|
||||
render(
|
||||
@@ -112,9 +118,9 @@ test('visual novel result uploads scene and character assets into platform refer
|
||||
});
|
||||
await user.click(backgroundButtons[0]!);
|
||||
|
||||
const fileInput = within(screen.getByRole('dialog', { name: '背景图' })).getByLabelText(
|
||||
'上传背景图文件',
|
||||
) as HTMLInputElement;
|
||||
const fileInput = within(
|
||||
screen.getByRole('dialog', { name: '背景图' }),
|
||||
).getByLabelText('上传背景图文件') as HTMLInputElement;
|
||||
await user.upload(
|
||||
fileInput,
|
||||
new File(['image-bytes'], 'scene.png', { type: 'image/png' }),
|
||||
@@ -124,7 +130,7 @@ test('visual novel result uploads scene and character assets into platform refer
|
||||
await user.click(screen.getAllByRole('button', { name: '保存草稿' })[1]!);
|
||||
|
||||
expect(onSaveDraft).toHaveBeenCalled();
|
||||
expect(onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc).toContain(
|
||||
'/generated-custom-world-scenes/',
|
||||
);
|
||||
expect(
|
||||
onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc,
|
||||
).toContain('/generated-custom-world-scenes/');
|
||||
});
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
PenLine,
|
||||
Play,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Upload,
|
||||
Waves,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
@@ -26,7 +28,11 @@ import type {
|
||||
VisualNovelValidationIssue,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import {
|
||||
createVisualNovelBackgroundMusicTask,
|
||||
createVisualNovelSoundEffectTask,
|
||||
listVisualNovelHistoryAssets,
|
||||
publishVisualNovelBackgroundMusicAsset,
|
||||
publishVisualNovelSoundEffectAsset,
|
||||
uploadVisualNovelAsset,
|
||||
type VisualNovelAssetReference,
|
||||
type VisualNovelHistoryAssetKind,
|
||||
@@ -98,6 +104,17 @@ type VisualNovelAssetPickerConfig = {
|
||||
previewTone: 'image' | 'audio';
|
||||
};
|
||||
|
||||
type VisualNovelAudioGeneratorKind = 'background_music' | 'sound_effect';
|
||||
|
||||
type VisualNovelAudioGeneratorConfig = {
|
||||
kind: VisualNovelAudioGeneratorKind;
|
||||
scene: VisualNovelSceneDraft;
|
||||
profileId?: string | null;
|
||||
};
|
||||
|
||||
const AUDIO_POLL_INTERVAL_MS = 3600;
|
||||
const AUDIO_POLL_MAX_ATTEMPTS = 36;
|
||||
|
||||
const RESULT_TABS: Array<{ id: VisualNovelResultTab; label: string }> = [
|
||||
{ id: 'profile', label: '作品' },
|
||||
{ id: 'world', label: '世界' },
|
||||
@@ -537,7 +554,9 @@ function VisualNovelAssetPickerDialog({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoadingHistory && config.historyKind && historyAssets.length <= 0 ? (
|
||||
{!isLoadingHistory &&
|
||||
config.historyKind &&
|
||||
historyAssets.length <= 0 ? (
|
||||
<div className="flex min-h-40 items-center justify-center rounded-[1.15rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/56 text-sm text-[var(--platform-text-base)]">
|
||||
暂无历史素材
|
||||
</div>
|
||||
@@ -704,6 +723,299 @@ function VisualNovelAssetField({
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForVisualNovelGeneratedAudioAsset(
|
||||
config: VisualNovelAudioGeneratorConfig,
|
||||
taskId: string,
|
||||
) {
|
||||
for (let attempt = 0; attempt < AUDIO_POLL_MAX_ATTEMPTS; attempt += 1) {
|
||||
if (attempt > 0) {
|
||||
await new Promise((resolve) => {
|
||||
window.setTimeout(resolve, AUDIO_POLL_INTERVAL_MS);
|
||||
});
|
||||
}
|
||||
|
||||
const payload = {
|
||||
sceneId: config.scene.sceneId,
|
||||
profileId: config.profileId ?? null,
|
||||
};
|
||||
const asset =
|
||||
config.kind === 'background_music'
|
||||
? await publishVisualNovelBackgroundMusicAsset(taskId, payload)
|
||||
: await publishVisualNovelSoundEffectAsset(taskId, payload);
|
||||
|
||||
if (asset.audioSrc?.trim()) {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('音频生成仍在处理中,请稍后重试。');
|
||||
}
|
||||
|
||||
function buildDefaultAudioPrompt(
|
||||
kind: VisualNovelAudioGeneratorKind,
|
||||
scene: VisualNovelSceneDraft,
|
||||
) {
|
||||
const name = scene.name.trim() || '当前场景';
|
||||
const description = scene.description.trim();
|
||||
if (kind === 'background_music') {
|
||||
return [name, description, '适合作为视觉小说循环播放的无歌词背景音乐']
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
}
|
||||
return [name, description, '短促、清晰、适合场景切换时播放的环境音效']
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
function VisualNovelAudioGeneratorDialog({
|
||||
config,
|
||||
disabled,
|
||||
onClose,
|
||||
onGenerated,
|
||||
}: {
|
||||
config: VisualNovelAudioGeneratorConfig;
|
||||
disabled: boolean;
|
||||
onClose: () => void;
|
||||
onGenerated: (asset: VisualNovelAssetReference) => void;
|
||||
}) {
|
||||
const authUi = useAuthUi();
|
||||
const platformTheme = authUi?.platformTheme ?? 'light';
|
||||
const isBackgroundMusic = config.kind === 'background_music';
|
||||
const [prompt, setPrompt] = useState(() =>
|
||||
buildDefaultAudioPrompt(config.kind, config.scene),
|
||||
);
|
||||
const [title, setTitle] = useState(() =>
|
||||
(config.scene.name.trim() || '视觉小说场景音乐').slice(0, 40),
|
||||
);
|
||||
const [tags, setTags] = useState('cinematic, ambient, emotional');
|
||||
const [duration, setDuration] = useState(5);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setPrompt(buildDefaultAudioPrompt(config.kind, config.scene));
|
||||
setTitle((config.scene.name.trim() || '视觉小说场景音乐').slice(0, 40));
|
||||
setTags('cinematic, ambient, emotional');
|
||||
setDuration(5);
|
||||
setError(null);
|
||||
}, [config]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) {
|
||||
setError('提示词不能为空。');
|
||||
return;
|
||||
}
|
||||
if (isBackgroundMusic && !title.trim()) {
|
||||
setError('标题不能为空。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const task = isBackgroundMusic
|
||||
? await createVisualNovelBackgroundMusicTask({
|
||||
prompt,
|
||||
title,
|
||||
tags: tags.trim() || null,
|
||||
model: 'chirp-v4',
|
||||
})
|
||||
: await createVisualNovelSoundEffectTask({
|
||||
prompt,
|
||||
duration,
|
||||
});
|
||||
const asset = await waitForVisualNovelGeneratedAudioAsset(
|
||||
config,
|
||||
task.taskId,
|
||||
);
|
||||
onGenerated({
|
||||
assetObjectId: asset.assetObjectId ?? task.taskId,
|
||||
assetKind:
|
||||
asset.assetKind ??
|
||||
(isBackgroundMusic
|
||||
? 'visual_novel_music'
|
||||
: 'visual_novel_ambient_sound'),
|
||||
objectKey: '',
|
||||
imageSrc: asset.audioSrc ?? '',
|
||||
profileId: config.profileId ?? null,
|
||||
entityId: config.scene.sceneId,
|
||||
});
|
||||
onClose();
|
||||
} catch (generateError) {
|
||||
setError(
|
||||
generateError instanceof Error
|
||||
? generateError.message
|
||||
: '音频生成失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[180] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget && !isGenerating) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={isBackgroundMusic ? '生成音乐' : '生成音效'}
|
||||
className="platform-modal-shell platform-remap-surface flex max-h-[min(88vh,34rem)] w-full max-w-lg flex-col overflow-hidden rounded-t-[1.45rem] sm:rounded-[1.45rem]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-3">
|
||||
<h2 className="min-w-0 truncate text-base font-black text-[var(--platform-text-strong)]">
|
||||
{isBackgroundMusic ? '生成音乐' : '生成音效'}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-icon-button h-9 w-9"
|
||||
onClick={onClose}
|
||||
disabled={isGenerating}
|
||||
aria-label="关闭"
|
||||
title="关闭"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-4">
|
||||
{isBackgroundMusic ? (
|
||||
<>
|
||||
<label className="block">
|
||||
<FieldLabel>标题</FieldLabel>
|
||||
<input
|
||||
value={title}
|
||||
disabled={disabled || isGenerating}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<FieldLabel>风格</FieldLabel>
|
||||
<input
|
||||
value={tags}
|
||||
disabled={disabled || isGenerating}
|
||||
onChange={(event) => setTags(event.target.value)}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<label className="block">
|
||||
<FieldLabel>时长</FieldLabel>
|
||||
<input
|
||||
type="range"
|
||||
min={2}
|
||||
max={10}
|
||||
step={1}
|
||||
value={duration}
|
||||
disabled={disabled || isGenerating}
|
||||
onChange={(event) => setDuration(Number(event.target.value))}
|
||||
className="mt-3 w-full accent-[var(--platform-primary)]"
|
||||
/>
|
||||
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
|
||||
{duration} 秒
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
<label className="block">
|
||||
<FieldLabel>提示词</FieldLabel>
|
||||
<textarea
|
||||
value={prompt}
|
||||
disabled={disabled || isGenerating}
|
||||
rows={5}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<footer className="flex justify-end gap-2 border-t border-[var(--platform-subpanel-border)] px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isGenerating}
|
||||
onClick={onClose}
|
||||
className="platform-button platform-button--ghost min-h-10 px-4 text-sm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || isGenerating}
|
||||
onClick={() => {
|
||||
void handleGenerate();
|
||||
}}
|
||||
className="platform-button platform-button--primary min-h-10 px-4 text-sm"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
生成
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function VisualNovelAudioGenerateButton({
|
||||
config,
|
||||
disabled,
|
||||
icon: Icon,
|
||||
label,
|
||||
onGenerated,
|
||||
}: {
|
||||
config: VisualNovelAudioGeneratorConfig;
|
||||
disabled: boolean;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
onGenerated: (asset: VisualNovelAssetReference) => void;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="platform-icon-button h-9 w-9"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<VisualNovelAudioGeneratorDialog
|
||||
config={config}
|
||||
disabled={disabled}
|
||||
onClose={() => setIsOpen(false)}
|
||||
onGenerated={onGenerated}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VisualNovelProfileTab({
|
||||
draft,
|
||||
disabled,
|
||||
@@ -777,7 +1089,10 @@ function VisualNovelProfileTab({
|
||||
value={draft.workTags.join(',')}
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
onChange({ ...draft, workTags: normalizeTags(event.target.value) })
|
||||
onChange({
|
||||
...draft,
|
||||
workTags: normalizeTags(event.target.value),
|
||||
})
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
@@ -870,7 +1185,9 @@ function VisualNovelOpeningTab({
|
||||
<label>
|
||||
<FieldLabel>初始选项</FieldLabel>
|
||||
<textarea
|
||||
value={draft.opening.initialChoices.map((choice) => choice.text).join('\n')}
|
||||
value={draft.opening.initialChoices
|
||||
.map((choice) => choice.text)
|
||||
.join('\n')}
|
||||
disabled={disabled}
|
||||
rows={4}
|
||||
onChange={(event) =>
|
||||
@@ -878,16 +1195,16 @@ function VisualNovelOpeningTab({
|
||||
...draft,
|
||||
opening: {
|
||||
...draft.opening,
|
||||
initialChoices: normalizeListInput(event.target.value).slice(0, 4).map(
|
||||
(text, index) => ({
|
||||
initialChoices: normalizeListInput(event.target.value)
|
||||
.slice(0, 4)
|
||||
.map((text, index) => ({
|
||||
choiceId:
|
||||
draft.opening.initialChoices[index]?.choiceId ??
|
||||
`${draft.profileId ?? 'vn'}-opening-choice-${index + 1}`,
|
||||
text,
|
||||
actionHint:
|
||||
draft.opening.initialChoices[index]?.actionHint ?? null,
|
||||
}),
|
||||
),
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -984,7 +1301,8 @@ function VisualNovelRuntimeConfigTab({
|
||||
...draft,
|
||||
runtimeConfig: {
|
||||
...draft.runtimeConfig,
|
||||
attributePanelMode: event.target.value as VisualNovelResultDraft['runtimeConfig']['attributePanelMode'],
|
||||
attributePanelMode: event.target
|
||||
.value as VisualNovelResultDraft['runtimeConfig']['attributePanelMode'],
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1113,10 +1431,12 @@ function VisualNovelCharacterEditor({
|
||||
function VisualNovelSceneEditor({
|
||||
item,
|
||||
disabled,
|
||||
profileId,
|
||||
onChange,
|
||||
}: {
|
||||
item: VisualNovelSceneDraft;
|
||||
disabled: boolean;
|
||||
profileId?: string | null;
|
||||
onChange: (item: VisualNovelSceneDraft) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -1151,7 +1471,8 @@ function VisualNovelSceneEditor({
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...item,
|
||||
availability: event.target.value as VisualNovelSceneAvailability,
|
||||
availability: event.target
|
||||
.value as VisualNovelSceneAvailability,
|
||||
})
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
@@ -1169,7 +1490,10 @@ function VisualNovelSceneEditor({
|
||||
value={item.phaseIds.join(',')}
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
onChange({ ...item, phaseIds: normalizeListInput(event.target.value) })
|
||||
onChange({
|
||||
...item,
|
||||
phaseIds: normalizeListInput(event.target.value),
|
||||
})
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
@@ -1183,7 +1507,7 @@ function VisualNovelSceneEditor({
|
||||
uploadKind="scene_background"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
historyKind="scene_image"
|
||||
profileId={null}
|
||||
profileId={profileId ?? null}
|
||||
entityId={item.sceneId}
|
||||
previewTone="image"
|
||||
onSelect={(asset) =>
|
||||
@@ -1197,11 +1521,49 @@ function VisualNovelSceneEditor({
|
||||
disabled={disabled}
|
||||
uploadKind="music"
|
||||
accept="audio/mpeg,audio/ogg,audio/wav,audio/webm"
|
||||
profileId={null}
|
||||
profileId={profileId ?? null}
|
||||
entityId={item.sceneId}
|
||||
previewTone="audio"
|
||||
onSelect={(asset) => onChange({ ...item, musicSrc: asset.imageSrc })}
|
||||
/>
|
||||
<VisualNovelAudioGenerateButton
|
||||
label="生成音乐"
|
||||
icon={Sparkles}
|
||||
disabled={disabled}
|
||||
config={{
|
||||
kind: 'background_music',
|
||||
scene: item,
|
||||
profileId: profileId ?? null,
|
||||
}}
|
||||
onGenerated={(asset) => onChange({ ...item, musicSrc: asset.imageSrc })}
|
||||
/>
|
||||
<VisualNovelAssetField
|
||||
label="音效"
|
||||
icon={Waves}
|
||||
assetSrc={item.ambientSoundSrc}
|
||||
disabled={disabled}
|
||||
uploadKind="ambient_sound"
|
||||
accept="audio/mpeg,audio/ogg,audio/wav,audio/webm"
|
||||
profileId={profileId ?? null}
|
||||
entityId={item.sceneId}
|
||||
previewTone="audio"
|
||||
onSelect={(asset) =>
|
||||
onChange({ ...item, ambientSoundSrc: asset.imageSrc })
|
||||
}
|
||||
/>
|
||||
<VisualNovelAudioGenerateButton
|
||||
label="生成音效"
|
||||
icon={Sparkles}
|
||||
disabled={disabled}
|
||||
config={{
|
||||
kind: 'sound_effect',
|
||||
scene: item,
|
||||
profileId: profileId ?? null,
|
||||
}}
|
||||
onGenerated={(asset) =>
|
||||
onChange({ ...item, ambientSoundSrc: asset.imageSrc })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1238,7 +1600,10 @@ function VisualNovelPhaseEditor({
|
||||
value={item.sceneIds.join(',')}
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
onChange({ ...item, sceneIds: normalizeListInput(event.target.value) })
|
||||
onChange({
|
||||
...item,
|
||||
sceneIds: normalizeListInput(event.target.value),
|
||||
})
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
@@ -1334,10 +1699,24 @@ function VisualNovelEntityGrid({
|
||||
onCreate,
|
||||
onOpen,
|
||||
}: {
|
||||
items: Array<VisualNovelCharacterDraft | VisualNovelSceneDraft | VisualNovelStoryPhaseDraft>;
|
||||
items: Array<
|
||||
| VisualNovelCharacterDraft
|
||||
| VisualNovelSceneDraft
|
||||
| VisualNovelStoryPhaseDraft
|
||||
>;
|
||||
emptyText: string;
|
||||
getTitle: (item: VisualNovelCharacterDraft | VisualNovelSceneDraft | VisualNovelStoryPhaseDraft) => string;
|
||||
getMeta: (item: VisualNovelCharacterDraft | VisualNovelSceneDraft | VisualNovelStoryPhaseDraft) => string;
|
||||
getTitle: (
|
||||
item:
|
||||
| VisualNovelCharacterDraft
|
||||
| VisualNovelSceneDraft
|
||||
| VisualNovelStoryPhaseDraft,
|
||||
) => string;
|
||||
getMeta: (
|
||||
item:
|
||||
| VisualNovelCharacterDraft
|
||||
| VisualNovelSceneDraft
|
||||
| VisualNovelStoryPhaseDraft,
|
||||
) => string;
|
||||
kind: VisualNovelEditorKind;
|
||||
onCreate: () => void;
|
||||
onOpen: (target: VisualNovelEditorTarget) => void;
|
||||
@@ -1371,7 +1750,10 @@ function VisualNovelEntityGrid({
|
||||
} else if (kind === 'scene') {
|
||||
onOpen({ kind, item: item as VisualNovelSceneDraft });
|
||||
} else {
|
||||
onOpen({ kind: 'phase', item: item as VisualNovelStoryPhaseDraft });
|
||||
onOpen({
|
||||
kind: 'phase',
|
||||
item: item as VisualNovelStoryPhaseDraft,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="platform-subpanel min-h-32 rounded-[1.25rem] p-4 text-left transition hover:-translate-y-0.5"
|
||||
@@ -1515,6 +1897,7 @@ function VisualNovelEditorDialog({
|
||||
<VisualNovelSceneEditor
|
||||
item={target.item}
|
||||
disabled={disabled}
|
||||
profileId={draft.profileId}
|
||||
onChange={updateScene}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1561,11 +1944,12 @@ export function VisualNovelResultView({
|
||||
onStartTestRun,
|
||||
onPublish,
|
||||
}: VisualNovelResultViewProps) {
|
||||
const [editDraft, setEditDraft] = useState(() => cloneDraft(draft ?? mockVisualNovelDraft));
|
||||
const [activeTab, setActiveTab] = useState<VisualNovelResultTab>('profile');
|
||||
const [editorTarget, setEditorTarget] = useState<VisualNovelEditorTarget | null>(
|
||||
null,
|
||||
const [editDraft, setEditDraft] = useState(() =>
|
||||
cloneDraft(draft ?? mockVisualNovelDraft),
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<VisualNovelResultTab>('profile');
|
||||
const [editorTarget, setEditorTarget] =
|
||||
useState<VisualNovelEditorTarget | null>(null);
|
||||
const blockers = useMemo(() => buildPublishBlockers(editDraft), [editDraft]);
|
||||
const canPublish = blockers.length === 0;
|
||||
const publishIssues = useMemo(
|
||||
@@ -1612,7 +1996,9 @@ export function VisualNovelResultView({
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setEditorTarget({ kind: 'runtime', item: editDraft })}
|
||||
onClick={() =>
|
||||
setEditorTarget({ kind: 'runtime', item: editDraft })
|
||||
}
|
||||
className="platform-icon-button h-10 w-10"
|
||||
aria-label="运行配置"
|
||||
title="运行配置"
|
||||
@@ -1675,7 +2061,10 @@ export function VisualNovelResultView({
|
||||
onCreate={() =>
|
||||
updateDraft({
|
||||
...editDraft,
|
||||
scenes: [...editDraft.scenes, createSceneDraft(editDraft.scenes.length)],
|
||||
scenes: [
|
||||
...editDraft.scenes,
|
||||
createSceneDraft(editDraft.scenes.length),
|
||||
],
|
||||
})
|
||||
}
|
||||
onOpen={setEditorTarget}
|
||||
|
||||
Reference in New Issue
Block a user