ce13bdbb02
将统一创作页与多个浅色工作台返回入口切换为 PlatformBackActionButton 按页面保留 compact 与 regular 返回尺寸及禁用态 清理冗余 ArrowLeft 引用并完成对应页面定向验证
365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
import { Loader2, Sparkles, WandSparkles } from 'lucide-react';
|
||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
|
||
import type { VisualNovelAgentSessionSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
|
||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||
import { PlatformBackActionButton } from '../common/PlatformBackActionButton';
|
||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||
import { PlatformTextField } from '../common/PlatformTextField';
|
||
import type { VisualNovelEntryFormPayload } from './visualNovelEntryGeneration';
|
||
|
||
type VisualNovelAgentWorkspaceProps = {
|
||
session?: VisualNovelAgentSessionSnapshot | null;
|
||
isBusy?: boolean;
|
||
error?: string | null;
|
||
onBack: () => void;
|
||
onCreateFromForm?: (payload: VisualNovelEntryFormPayload) => void;
|
||
initialFormPayload?: VisualNovelEntryFormPayload | null;
|
||
showBackButton?: boolean;
|
||
title?: string | null;
|
||
};
|
||
|
||
type VisualNovelFormState = {
|
||
ideaText: string;
|
||
visualStyleId: VisualNovelStyleOptionId;
|
||
};
|
||
|
||
const VISUAL_NOVEL_STYLE_OPTIONS = [
|
||
{
|
||
id: 'cinematic-anime',
|
||
label: '映画动画',
|
||
imageSrc: '/visual-novel-style-references/cinematic-anime.png',
|
||
prompt: '电影感动画视觉小说画风,光影层次清晰,角色立绘精致,背景有景深。',
|
||
},
|
||
{
|
||
id: 'watercolor',
|
||
label: '水彩绘本',
|
||
imageSrc: '/visual-novel-style-references/watercolor.png',
|
||
prompt: '透明水彩与绘本质感,色彩柔和,边缘带手绘晕染,适合温柔叙事。',
|
||
},
|
||
{
|
||
id: 'pixel-noir',
|
||
label: '像素霓虹',
|
||
imageSrc: '/visual-novel-style-references/pixel-noir.png',
|
||
prompt: '高可读像素视觉小说画风,霓虹反差、硬朗轮廓和复古界面气质。',
|
||
},
|
||
{
|
||
id: 'ink-fantasy',
|
||
label: '水墨幻想',
|
||
imageSrc: '/visual-novel-style-references/ink-fantasy.png',
|
||
prompt: '东方水墨幻想画风,留白、墨色层次和淡彩点染并重。',
|
||
},
|
||
{
|
||
id: 'soft-pastel',
|
||
label: '柔彩校园',
|
||
imageSrc: '/visual-novel-style-references/soft-pastel.png',
|
||
prompt: '柔和粉彩校园画风,干净明亮,角色表情细腻,日常氛围轻盈。',
|
||
},
|
||
{
|
||
id: 'dark-gothic',
|
||
label: '暗色哥特',
|
||
imageSrc: '/visual-novel-style-references/dark-gothic.png',
|
||
prompt: '暗色哥特视觉小说画风,深色场景、烛光高光和华丽服装细节。',
|
||
},
|
||
] as const;
|
||
|
||
type VisualNovelStyleOptionId =
|
||
(typeof VISUAL_NOVEL_STYLE_OPTIONS)[number]['id'];
|
||
|
||
const EMPTY_FORM_STATE: VisualNovelFormState = {
|
||
ideaText: '',
|
||
visualStyleId: 'cinematic-anime',
|
||
};
|
||
|
||
function getVisualNovelStyleOption(optionId: VisualNovelStyleOptionId) {
|
||
return (
|
||
VISUAL_NOVEL_STYLE_OPTIONS.find((option) => option.id === optionId) ??
|
||
VISUAL_NOVEL_STYLE_OPTIONS[0]
|
||
);
|
||
}
|
||
|
||
function resolveStyleOptionId(
|
||
value: string | null | undefined,
|
||
): VisualNovelStyleOptionId {
|
||
return (
|
||
VISUAL_NOVEL_STYLE_OPTIONS.find((option) => option.id === value)?.id ??
|
||
'cinematic-anime'
|
||
);
|
||
}
|
||
|
||
function resolveIdeaTextFromSession(
|
||
session: VisualNovelAgentSessionSnapshot | null | undefined,
|
||
) {
|
||
const userText =
|
||
session?.messages.find((message) => message.role === 'user')?.text ?? '';
|
||
return userText
|
||
.replace(/视觉画风[::][^\n]*(\n|$)/u, '')
|
||
.replace(/画风要求[::][^\n]*(\n|$)/u, '')
|
||
.trim();
|
||
}
|
||
|
||
function resolveInitialFormState(
|
||
session: VisualNovelAgentSessionSnapshot | null | undefined,
|
||
initialFormPayload: VisualNovelEntryFormPayload | null = null,
|
||
): VisualNovelFormState {
|
||
return {
|
||
...EMPTY_FORM_STATE,
|
||
ideaText:
|
||
initialFormPayload?.ideaText?.trim() ||
|
||
resolveIdeaTextFromSession(session) ||
|
||
'',
|
||
visualStyleId: resolveStyleOptionId(initialFormPayload?.visualStyleId),
|
||
};
|
||
}
|
||
|
||
function buildVisualNovelSeedText(
|
||
ideaText: string,
|
||
visualStyleLabel: string,
|
||
visualStylePrompt: string,
|
||
) {
|
||
return [
|
||
ideaText.trim(),
|
||
`视觉画风:${visualStyleLabel}`,
|
||
`画风要求:${visualStylePrompt}`,
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n');
|
||
}
|
||
|
||
function VisualNovelStyleButton({
|
||
active,
|
||
disabled,
|
||
imageSrc,
|
||
label,
|
||
onClick,
|
||
}: {
|
||
active: boolean;
|
||
disabled: boolean;
|
||
imageSrc: string;
|
||
label: string;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
disabled={disabled}
|
||
aria-pressed={active}
|
||
aria-label={label}
|
||
onClick={onClick}
|
||
className={`group relative h-[4.9rem] w-[5.85rem] shrink-0 snap-start overflow-hidden rounded-[0.95rem] border p-0 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-warm-border)] sm:h-[5.45rem] sm:w-[6.4rem] ${
|
||
active
|
||
? 'border-[var(--platform-surface-hover-border)] bg-white shadow-[0_8px_18px_rgba(112,57,30,0.10)] ring-2 ring-[var(--platform-warm-border)]'
|
||
: 'border-[var(--platform-subpanel-border)] bg-white/70 hover:border-[var(--platform-surface-hover-border)] hover:bg-white/95'
|
||
} ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
|
||
>
|
||
{imageSrc ? (
|
||
<img
|
||
src={imageSrc}
|
||
alt=""
|
||
className="absolute inset-0 h-full w-full object-cover transition duration-200 group-hover:scale-[1.03]"
|
||
loading="lazy"
|
||
/>
|
||
) : (
|
||
<span className="absolute inset-0 bg-[radial-gradient(circle_at_32%_24%,rgba(255,255,255,0.98),transparent_30%),linear-gradient(135deg,rgba(255,247,250,0.98),rgba(255,236,241,0.92))]" />
|
||
)}
|
||
<span className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.02)_0%,rgba(255,255,255,0.18)_44%,rgba(255,255,255,0.82)_100%)]" />
|
||
{active ? (
|
||
<span className="absolute right-1.5 top-1.5 h-2.5 w-2.5 rounded-full bg-[var(--platform-accent)] shadow-[0_0_0_3px_rgba(255,255,255,0.84)]" />
|
||
) : null}
|
||
<span
|
||
className={`absolute inset-x-2 bottom-1.5 truncate rounded-full px-1.5 py-0.5 text-center text-[11px] font-black shadow-[0_3px_10px_rgba(15,23,42,0.10)] ${
|
||
active
|
||
? 'bg-[var(--platform-warm-bg)] text-[var(--platform-warm-text)]'
|
||
: 'bg-white/88 text-[var(--platform-text-strong)]'
|
||
}`}
|
||
>
|
||
{label}
|
||
</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export function VisualNovelAgentWorkspace({
|
||
session = null,
|
||
isBusy = false,
|
||
error = null,
|
||
onBack,
|
||
onCreateFromForm,
|
||
initialFormPayload = null,
|
||
showBackButton = true,
|
||
title = null,
|
||
}: VisualNovelAgentWorkspaceProps) {
|
||
const [formState, setFormState] = useState<VisualNovelFormState>(() =>
|
||
resolveInitialFormState(session, initialFormPayload),
|
||
);
|
||
const appliedInitialFormKeyRef = useRef<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
const nextInitialFormKey =
|
||
session?.sessionId ?? JSON.stringify(initialFormPayload ?? null);
|
||
if (appliedInitialFormKeyRef.current === nextInitialFormKey) {
|
||
return;
|
||
}
|
||
|
||
appliedInitialFormKeyRef.current = nextInitialFormKey;
|
||
setFormState(resolveInitialFormState(session, initialFormPayload));
|
||
}, [initialFormPayload, session]);
|
||
|
||
const ideaText = formState.ideaText.trim();
|
||
const selectedStyleOption = getVisualNovelStyleOption(
|
||
formState.visualStyleId,
|
||
);
|
||
const formPayload = useMemo<VisualNovelEntryFormPayload>(
|
||
() => ({
|
||
sourceMode: 'idea',
|
||
seedText: buildVisualNovelSeedText(
|
||
ideaText,
|
||
selectedStyleOption.label,
|
||
selectedStyleOption.prompt,
|
||
),
|
||
sourceAssetIds: [],
|
||
ideaText,
|
||
visualStyleId: selectedStyleOption.id,
|
||
visualStyleLabel: selectedStyleOption.label,
|
||
visualStylePrompt: selectedStyleOption.prompt,
|
||
}),
|
||
[ideaText, selectedStyleOption],
|
||
);
|
||
const canSubmit = Boolean(ideaText && !isBusy);
|
||
|
||
const startDraft = () => {
|
||
if (!canSubmit) {
|
||
return;
|
||
}
|
||
|
||
onCreateFromForm?.(formPayload);
|
||
};
|
||
|
||
return (
|
||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden">
|
||
{showBackButton ? (
|
||
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
|
||
<PlatformBackActionButton
|
||
onClick={onBack}
|
||
disabled={isBusy}
|
||
className="px-3"
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden pr-0">
|
||
{title ? (
|
||
<div className="mb-3 shrink-0 sm:mb-5">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
|
||
{title}
|
||
</h1>
|
||
<PlatformPillBadge tone="warning" size="xs">
|
||
BETA
|
||
</PlatformPillBadge>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<section className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||
<div
|
||
className={`grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] gap-3 lg:grid-cols-[minmax(0,1.1fr)_minmax(16rem,0.9fr)] lg:grid-rows-1 ${isBusy ? 'opacity-55' : ''}`}
|
||
>
|
||
<label className="block min-h-0">
|
||
<PlatformFieldLabel variant="form">一句话创作</PlatformFieldLabel>
|
||
<PlatformTextField
|
||
variant="textarea"
|
||
value={formState.ideaText}
|
||
disabled={isBusy}
|
||
rows={5}
|
||
placeholder=""
|
||
onChange={(event) =>
|
||
setFormState((current) => ({
|
||
...current,
|
||
ideaText: event.target.value,
|
||
}))
|
||
}
|
||
size="lg"
|
||
density="roomy"
|
||
className="h-full min-h-[7.75rem] rounded-[1.15rem] font-normal leading-6 sm:min-h-[9rem] lg:min-h-[14rem]"
|
||
aria-label="一句话创作"
|
||
/>
|
||
</label>
|
||
|
||
<div className="flex min-h-0 flex-col gap-2 overflow-hidden">
|
||
<PlatformSubpanel
|
||
as="div"
|
||
surface="flat"
|
||
radius="sm"
|
||
padding="sm"
|
||
className="min-h-0 bg-white/52 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)]"
|
||
>
|
||
<PlatformFieldLabel variant="form" className="mb-1.5">
|
||
视觉画风
|
||
</PlatformFieldLabel>
|
||
<div
|
||
className="flex snap-x gap-2.5 overflow-x-auto overscroll-x-contain pb-0.5 scrollbar-hide touch-pan-x [-webkit-overflow-scrolling:touch]"
|
||
aria-label="视觉画风"
|
||
>
|
||
{VISUAL_NOVEL_STYLE_OPTIONS.map((option) => (
|
||
<VisualNovelStyleButton
|
||
key={option.id}
|
||
active={formState.visualStyleId === option.id}
|
||
disabled={isBusy}
|
||
imageSrc={option.imageSrc}
|
||
label={option.label}
|
||
onClick={() =>
|
||
setFormState((current) => ({
|
||
...current,
|
||
visualStyleId: option.id,
|
||
}))
|
||
}
|
||
/>
|
||
))}
|
||
</div>
|
||
</PlatformSubpanel>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-2 shrink-0 space-y-3">
|
||
{error ? (
|
||
<PlatformStatusMessage
|
||
tone="error"
|
||
surface="platform"
|
||
size="md"
|
||
className="rounded-2xl"
|
||
>
|
||
{error}
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
|
||
<PlatformActionButton
|
||
disabled={!canSubmit}
|
||
onClick={startDraft}
|
||
className="min-h-10 gap-1.5 px-4 py-2 text-sm sm:min-h-11 sm:gap-2 sm:px-5"
|
||
>
|
||
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||
{session ? (
|
||
<Sparkles className="h-4 w-4" />
|
||
) : (
|
||
<WandSparkles className="h-4 w-4" />
|
||
)}
|
||
<span>生成视觉小说草稿</span>
|
||
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
|
||
消耗20泥点
|
||
</span>
|
||
</PlatformActionButton>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default VisualNovelAgentWorkspace;
|