Files
Genarrative/src/components/big-fish-result/BigFishResultView.tsx
kdletters cb01d33944 收口个人中心提示与大鱼摘要标签
将个人中心邀请弹窗奖励说明迁移到共享状态提示组件
将大鱼吃小鱼结果页 hero 摘要标签迁移到共享胶囊标签组件
补充充值商品卡购买胶囊暂不抽共享组件的收口文档与团队决策
2026-06-10 16:24:53 +08:00

652 lines
21 KiB
TypeScript

import {
ArrowLeft,
CheckCircle2,
ImagePlus,
Loader2,
Play,
Sparkles,
Waves,
} from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import type {
BigFishAssetSlotResponse,
BigFishGameDraftResponse,
BigFishLevelBlueprintResponse,
BigFishSessionSnapshotResponse,
ExecuteBigFishActionRequest,
} from '../../../packages/shared/src/contracts/bigFish';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformIconBadge } from '../common/PlatformIconBadge';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { UnifiedConfirmDialog } from '../common/UnifiedConfirmDialog';
type BigFishAssetStudioTarget =
| {
kind: 'level_main_image';
level: BigFishLevelBlueprintResponse;
}
| {
kind: 'level_motion';
level: BigFishLevelBlueprintResponse;
motionKey: 'idle_float' | 'move_swim';
}
| {
kind: 'stage_background';
};
type BigFishResultViewProps = {
session: BigFishSessionSnapshotResponse;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onDismissError?: () => void;
onExecuteAction: (payload: ExecuteBigFishActionRequest) => void;
onStartTestRun: () => void;
};
function findAssetSlot(
slots: BigFishAssetSlotResponse[],
assetKind: string,
level?: number,
motionKey?: string,
) {
return slots.find((slot) => {
if (slot.assetKind !== assetKind) {
return false;
}
if (level !== undefined && slot.level !== level) {
return false;
}
if (motionKey !== undefined && slot.motionKey !== motionKey) {
return false;
}
return true;
});
}
function assetReadyLabel(slot: BigFishAssetSlotResponse | undefined) {
if (slot?.status !== 'ready') {
return '待生成';
}
return isBigFishPlaceholderAsset(slot) ? '占位已生成' : '已生成';
}
function buildLevelAssetPreview(slot: BigFishAssetSlotResponse | undefined) {
if (slot?.assetUrl) {
return slot.assetUrl;
}
return null;
}
function isBigFishPlaceholderAsset(slot: BigFishAssetSlotResponse | undefined) {
return Boolean(slot?.assetUrl?.includes('/generated-big-fish/'));
}
function buildStudioAssetPreview(
slots: BigFishAssetSlotResponse[],
target: BigFishAssetStudioTarget,
) {
if (target.kind === 'stage_background') {
return buildLevelAssetPreview(findAssetSlot(slots, 'stage_background'));
}
if (target.kind === 'level_main_image') {
return buildLevelAssetPreview(
findAssetSlot(slots, 'level_main_image', target.level.level),
);
}
return buildLevelAssetPreview(
findAssetSlot(slots, 'level_motion', target.level.level, target.motionKey),
);
}
function BigFishAssetStudioModal({
draft,
target,
previewUrl,
isBusy,
onClose,
onExecuteAction,
}: {
draft: BigFishGameDraftResponse;
target: BigFishAssetStudioTarget;
previewUrl?: string | null;
isBusy: boolean;
onClose: () => void;
onExecuteAction: (payload: ExecuteBigFishActionRequest) => void;
}) {
const title =
target.kind === 'stage_background'
? '场地背景工坊'
: target.kind === 'level_main_image'
? `Lv.${target.level.level} 主图工坊`
: `Lv.${target.level.level} 动作工坊`;
const prompt =
target.kind === 'stage_background'
? draft.background.backgroundPromptSeed
: target.kind === 'level_main_image'
? target.level.visualDescription || target.level.visualPromptSeed
: target.motionKey === 'move_swim'
? target.level.moveMotionDescription || target.level.motionPromptSeed
: target.level.idleMotionDescription || target.level.motionPromptSeed;
const execute = () => {
if (target.kind === 'stage_background') {
onExecuteAction({ action: 'big_fish_generate_stage_background' });
return;
}
if (target.kind === 'level_main_image') {
onExecuteAction({
action: 'big_fish_generate_level_main_image',
level: target.level.level,
});
return;
}
onExecuteAction({
action: 'big_fish_generate_level_motion',
level: target.level.level,
motionKey: target.motionKey,
});
};
return (
<div className="platform-overlay fixed inset-0 z-[95] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4">
<div className="platform-modal-shell w-full max-w-xl overflow-hidden rounded-[1.8rem]">
<div className="border-b border-[var(--platform-subpanel-border)] px-4 py-4">
<div className="text-lg font-black text-[var(--platform-text-strong)]">
{title}
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
{target.kind === 'stage_background'
? draft.background.theme
: target.level.textDescription || target.level.oneLineFantasy}
</div>
</div>
<div className="space-y-4 px-4 py-4">
<PlatformSubpanel as="div" surface="flat">
<PlatformFieldLabel variant="section">PROMPT</PlatformFieldLabel>
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-strong)]">
{prompt}
</div>
</PlatformSubpanel>
<PlatformMediaFrame
src={previewUrl}
alt={title}
fallbackLabel="AI 资产候选预览"
aspect="wide"
surface="none"
className="rounded-[1.4rem] border border-dashed border-cyan-300/50 bg-cyan-50/40"
fallbackClassName="tracking-normal text-[var(--platform-text-base)]"
/>
</div>
<div className="flex justify-end gap-2 border-t border-[var(--platform-subpanel-border)] px-4 py-4">
<PlatformActionButton
onClick={onClose}
disabled={isBusy}
tone="ghost"
shape="pill"
size="xs"
>
</PlatformActionButton>
<PlatformActionButton
onClick={execute}
disabled={isBusy}
shape="pill"
size="xs"
className="gap-2"
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
</PlatformActionButton>
</div>
</div>
</div>
);
}
function BigFishLevelCard({
level,
slots,
isBusy,
onOpenStudio,
}: {
level: BigFishLevelBlueprintResponse;
slots: BigFishAssetSlotResponse[];
isBusy: boolean;
onOpenStudio: (target: BigFishAssetStudioTarget) => void;
}) {
const mainImageSlot = findAssetSlot(slots, 'level_main_image', level.level);
const idleSlot = findAssetSlot(
slots,
'level_motion',
level.level,
'idle_float',
);
const moveSlot = findAssetSlot(
slots,
'level_motion',
level.level,
'move_swim',
);
const previewUrl = buildLevelAssetPreview(mainImageSlot);
return (
<PlatformSubpanel
as="article"
surface="flat"
radius="xl"
padding="none"
className="overflow-hidden bg-white/78"
>
<div className="flex gap-3 p-3">
<PlatformMediaFrame
src={previewUrl}
alt={level.name}
fallbackLabel="关卡主图"
fallbackContent={<Waves className="h-8 w-8 text-cyan-100/72" />}
aspect="square"
surface="none"
className="h-24 w-24 shrink-0 rounded-[1.15rem] bg-[radial-gradient(circle_at_center,rgba(34,211,238,0.28),transparent_68%),linear-gradient(145deg,rgba(8,47,73,0.88),rgba(15,23,42,0.94))] text-white"
/>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-xs font-black tracking-[0.18em] text-cyan-700">
LV.{level.level}
</div>
<div className="mt-1 text-lg font-black text-[var(--platform-text-strong)]">
{level.name}
</div>
</div>
{level.isFinalLevel ? (
<PlatformPillBadge
tone="warning"
size="xs"
className="px-2 py-1 text-xs font-bold"
>
</PlatformPillBadge>
) : null}
</div>
<div className="mt-2 line-clamp-2 text-sm leading-5 text-[var(--platform-text-base)]">
{level.oneLineFantasy}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<PlatformPillBadge tone="muted" size="xxs">
{level.preyWindow.join('/') || '-'}
</PlatformPillBadge>
<PlatformPillBadge tone="muted" size="xxs">
{level.threatWindow.join('/') || '-'}
</PlatformPillBadge>
<PlatformPillBadge tone="muted" size="xxs">
{assetReadyLabel(mainImageSlot)}
</PlatformPillBadge>
<PlatformPillBadge tone="muted" size="xxs">
{' '}
{[assetReadyLabel(idleSlot), assetReadyLabel(moveSlot)].join('/')}
</PlatformPillBadge>
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-2 border-t border-[var(--platform-subpanel-border)] p-3">
<PlatformActionButton
disabled={isBusy}
onClick={() => {
onOpenStudio({ kind: 'level_main_image', level });
}}
shape="pill"
size="xs"
className="px-3"
>
</PlatformActionButton>
<PlatformActionButton
disabled={isBusy}
onClick={() => {
onOpenStudio({
kind: 'level_motion',
level,
motionKey: 'idle_float',
});
}}
tone="ghost"
shape="pill"
size="xs"
className="px-3"
>
</PlatformActionButton>
<PlatformActionButton
disabled={isBusy}
onClick={() => {
onOpenStudio({
kind: 'level_motion',
level,
motionKey: 'move_swim',
});
}}
tone="ghost"
shape="pill"
size="xs"
className="px-3"
>
</PlatformActionButton>
</div>
</PlatformSubpanel>
);
}
export function BigFishResultView({
session,
isBusy = false,
error = null,
onBack,
onDismissError,
onExecuteAction,
onStartTestRun,
}: BigFishResultViewProps) {
const [studioTarget, setStudioTarget] =
useState<BigFishAssetStudioTarget | null>(null);
const [isPublishSubmitting, setIsPublishSubmitting] = useState(false);
const draft = session.draft;
const backgroundSlot = findAssetSlot(session.assetSlots, 'stage_background');
const backgroundPreviewUrl = buildLevelAssetPreview(backgroundSlot);
const blockers = useMemo(
() => session.assetCoverage.blockers.filter(Boolean),
[session.assetCoverage.blockers],
);
const isPublished = session.stage === 'published';
const canClickPublish = !isPublished && !isBusy;
const studioPreviewUrl = useMemo(() => {
if (!studioTarget) {
return null;
}
return buildStudioAssetPreview(session.assetSlots, studioTarget);
}, [session.assetSlots, studioTarget]);
useEffect(() => {
if (!isBusy || isPublished || error) {
setIsPublishSubmitting(false);
}
}, [error, isBusy, isPublished]);
if (!draft) {
return (
<div className="flex h-full items-center justify-center">
<PlatformEmptyState
surface="subpanel"
size="compact"
tone="base"
>
稿
</PlatformEmptyState>
</div>
);
}
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
<div className="platform-result-hero relative overflow-hidden rounded-[1.8rem] border border-cyan-100/16 bg-[radial-gradient(circle_at_top_left,rgba(45,212,191,0.2),transparent_32%),linear-gradient(135deg,rgba(8,47,73,0.98),rgba(15,23,42,0.98))] px-4 py-4 text-white sm:px-5">
<div className="flex items-start justify-between gap-3">
<PlatformIconButton
onClick={onBack}
disabled={isBusy}
label="返回"
title="返回"
variant="darkMini"
className="h-10 w-10 !border-white/16 !bg-white/10 !text-white/84 backdrop-blur hover:!bg-white/16 hover:!text-white"
icon={<ArrowLeft className="h-4 w-4" />}
/>
<div className="flex gap-2">
<PlatformActionButton
disabled={isBusy}
onClick={() => {
onStartTestRun();
}}
surface="editorDark"
tone="secondary"
shape="pill"
className="!border-white/16 !bg-white/12 !text-white hover:!bg-white/18"
>
<Play className="h-4 w-4" />
</PlatformActionButton>
<PlatformActionButton
disabled={!canClickPublish}
onClick={() => {
setIsPublishSubmitting(true);
onExecuteAction({ action: 'big_fish_publish_game' });
}}
surface="editorDark"
tone="primary"
shape="pill"
className="!border-cyan-200/70 !bg-cyan-200 !text-slate-950 hover:!bg-cyan-100"
>
{isPublishSubmitting && isBusy && !isPublished ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
{isPublished
? '已发布'
: isPublishSubmitting && isBusy
? '发布中'
: '发布'}
</PlatformActionButton>
</div>
</div>
<div className="mt-6">
<div className="text-2xl font-black leading-tight sm:text-3xl">
{draft.title}
</div>
<div className="mt-2 max-w-2xl text-sm leading-6 text-cyan-50/76">
{draft.subtitle}
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2 text-xs text-cyan-50/78">
<PlatformPillBadge
tone="lightOverlay"
className="border-transparent bg-white/10"
>
{draft.coreFun}
</PlatformPillBadge>
<PlatformPillBadge
tone="lightOverlay"
className="border-transparent bg-white/10"
>
{draft.ecologyTheme}
</PlatformPillBadge>
<PlatformPillBadge
tone="lightOverlay"
className="border-transparent bg-white/10"
>
{draft.runtimeParams.levelCount}
</PlatformPillBadge>
</div>
</div>
<div className="grid min-h-0 flex-1 gap-3 overflow-hidden lg:grid-cols-[minmax(0,1fr)_18rem]">
<div className="min-h-0 overflow-y-auto pr-1">
<div className="grid gap-3 sm:grid-cols-2">
{draft.levels.map((level) => (
<BigFishLevelCard
key={level.level}
level={level}
slots={session.assetSlots}
isBusy={isBusy}
onOpenStudio={setStudioTarget}
/>
))}
</div>
</div>
<aside className="min-h-0 space-y-3 overflow-y-auto">
<PlatformSubpanel
as="section"
surface="flat"
radius="xl"
className="bg-[var(--platform-subpanel-fill)]"
>
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-sm font-black text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-xs text-[var(--platform-text-soft)]">
{assetReadyLabel(backgroundSlot)}
</div>
</div>
<ImagePlus className="h-5 w-5 text-cyan-600" />
</div>
<PlatformMediaFrame
src={backgroundPreviewUrl}
alt={`${draft.background.theme} 场地背景`}
fallbackLabel="场地背景"
fallbackContent={<span className="sr-only"></span>}
aspect="portrait"
surface="none"
className="mt-3 rounded-[1.2rem] bg-[radial-gradient(circle_at_center,rgba(34,211,238,0.2),transparent_62%),linear-gradient(180deg,rgba(8,47,73,0.88),rgba(15,23,42,0.94))]"
/>
<PlatformActionButton
disabled={isBusy}
onClick={() => {
setStudioTarget({ kind: 'stage_background' });
}}
shape="pill"
size="xs"
fullWidth
className="mt-3 gap-2"
>
<Sparkles className="h-4 w-4" />
</PlatformActionButton>
</PlatformSubpanel>
<PlatformSubpanel
as="section"
surface="flat"
radius="xl"
className="bg-[var(--platform-subpanel-fill)]"
>
<div className="text-sm font-black text-[var(--platform-text-strong)]">
</div>
<div className="mt-3 space-y-2 text-sm text-[var(--platform-text-base)]">
<div>
{session.assetCoverage.levelMainImageReadyCount}/
{session.assetCoverage.requiredLevelCount}
</div>
<div>
{session.assetCoverage.levelMotionReadyCount}/
{session.assetCoverage.requiredLevelCount * 2}
</div>
<div>
{' '}
{session.assetCoverage.backgroundReady ? '已完成' : '待生成'}
</div>
</div>
{isPublished ? (
<div className="mt-3">
<PlatformPillBadge tone="success" size="sm">
</PlatformPillBadge>
</div>
) : blockers.length > 0 ? (
<PlatformStatusMessage
tone="warning"
surface="platform"
size="xs"
className="mt-3 space-y-1 leading-5"
>
{blockers.slice(0, 4).map((blocker) => (
<div key={blocker}>{blocker}</div>
))}
</PlatformStatusMessage>
) : (
<div className="mt-3">
<PlatformPillBadge tone="success" size="sm">
</PlatformPillBadge>
</div>
)}
</PlatformSubpanel>
</aside>
</div>
{studioTarget ? (
<BigFishAssetStudioModal
draft={draft}
target={studioTarget}
previewUrl={studioPreviewUrl}
isBusy={isBusy}
onClose={() => {
setStudioTarget(null);
}}
onExecuteAction={(payload) => {
onExecuteAction(payload);
setStudioTarget(null);
}}
/>
) : null}
{error ? (
<BigFishResultErrorModal
message={error}
onClose={() => {
onDismissError?.();
}}
/>
) : null}
</div>
);
}
function BigFishResultErrorModal({
message,
onClose,
}: {
message: string;
onClose: () => void;
}) {
return (
<UnifiedConfirmDialog
open
title="发布失败"
onClose={onClose}
closeOnBackdrop={false}
showCloseButton={false}
confirmLabel="知道了"
confirmClassName="w-full justify-center border-slate-950 bg-slate-950 text-white"
size="sm"
zIndexClassName="z-[160]"
overlayClassName="bg-slate-950/58"
panelClassName="border-red-100/80 bg-white text-slate-950 shadow-2xl"
footerClassName="border-t-0 px-5 pb-5 pt-0"
>
<div className="flex items-start gap-3">
<PlatformIconBadge
icon={<Waves className="h-4 w-4" />}
label="发布失败提示"
tone="danger"
className="mt-0.5"
/>
<div className="min-w-0 flex-1 text-sm leading-6 text-slate-600">
{message}
</div>
</div>
</UnifiedConfirmDialog>
);
}
export default BigFishResultView;