Merge branch 'codex/backend-rewrite-spacetimedb' of http://82.157.175.59:3000/GenarrativeAI/Genarrative into codex/backend-rewrite-spacetimedb
# Conflicts: # server-rs/crates/spacetime-client/src/lib.rs # src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
This commit is contained in:
@@ -18,7 +18,6 @@ import {
|
||||
resolveCustomWorldLandmarkImageMap,
|
||||
} from '../data/customWorldVisuals';
|
||||
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
||||
import { resolveCustomWorldCoverPresentation } from '../services/customWorldCover';
|
||||
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
|
||||
import {
|
||||
AnimationState,
|
||||
@@ -28,7 +27,6 @@ import {
|
||||
type SceneChapterBlueprint,
|
||||
} from '../types';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork';
|
||||
import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal';
|
||||
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
@@ -54,8 +52,6 @@ interface CustomWorldEntityCatalogProps {
|
||||
onProfileChange: (profile: CustomWorldProfile) => void;
|
||||
onDeleteStoryNpcs?: (ids: string[]) => void;
|
||||
onDeleteLandmarks?: (ids: string[]) => void;
|
||||
onGenerateRoleAssets?: (roleId: string) => void;
|
||||
onGenerateSceneAssets?: (sceneId: string, sceneKind: 'camp' | 'landmark') => void;
|
||||
createActionLabel?: string;
|
||||
onCreateAction?: () => void;
|
||||
createActionDisabled?: boolean;
|
||||
@@ -389,21 +385,21 @@ function CatalogCard({
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
aria-disabled={disabled}
|
||||
className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors ${
|
||||
className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors xl:p-3 ${
|
||||
isSelected
|
||||
? 'border-rose-300/35 bg-rose-500/10'
|
||||
: 'platform-subpanel'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-start gap-3 xl:gap-3.5">
|
||||
<div
|
||||
className={`platform-subpanel shrink-0 overflow-hidden rounded-[1rem] ${mediaClassName ?? 'h-[4.75rem] w-[4.75rem]'}`}
|
||||
>
|
||||
{media}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0 flex-1 xl:min-h-[5.6rem]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 text-[15px] font-semibold leading-5 text-white">
|
||||
<div className="min-w-0 text-[15px] font-semibold leading-5 text-white xl:line-clamp-1">
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -411,7 +407,7 @@ function CatalogCard({
|
||||
{selectionBadge}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 text-sm leading-5 text-zinc-300">
|
||||
<div className="mt-1.5 text-sm leading-5 text-zinc-300 xl:line-clamp-2">
|
||||
{description || '暂无描述'}
|
||||
</div>
|
||||
{actions ? <div className="mt-2 flex flex-wrap gap-2">{actions}</div> : null}
|
||||
@@ -891,8 +887,6 @@ export function CustomWorldEntityCatalog({
|
||||
onProfileChange,
|
||||
onDeleteStoryNpcs,
|
||||
onDeleteLandmarks,
|
||||
onGenerateRoleAssets,
|
||||
onGenerateSceneAssets,
|
||||
createActionLabel,
|
||||
onCreateAction,
|
||||
createActionDisabled = false,
|
||||
@@ -1104,11 +1098,6 @@ export function CustomWorldEntityCatalog({
|
||||
1 +
|
||||
(pendingGeneratedEntity?.kind === 'landmark' ? 1 : 0),
|
||||
} satisfies Record<ResultTab, number>;
|
||||
const coverPresentation = useMemo(
|
||||
() => resolveCustomWorldCoverPresentation(profile),
|
||||
[profile],
|
||||
);
|
||||
|
||||
const bulkDeleteTab: BulkDeleteTab | null =
|
||||
activeTab === 'story' || activeTab === 'landmarks' ? activeTab : null;
|
||||
const isBulkDeleteMode =
|
||||
@@ -1185,28 +1174,28 @@ export function CustomWorldEntityCatalog({
|
||||
return (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="h-full min-h-0 space-y-3 overflow-y-auto overscroll-contain pr-1 scrollbar-hide"
|
||||
className="h-full min-h-0 space-y-3 overflow-y-auto overscroll-contain pr-1 scrollbar-hide xl:space-y-4 xl:pr-2"
|
||||
>
|
||||
<div className="px-1 pb-1 text-center">
|
||||
<div className="px-1 pb-1 text-center xl:rounded-[2rem] xl:border xl:border-[var(--platform-subpanel-border)] xl:bg-white/55 xl:px-6 xl:py-4 xl:text-left xl:shadow-[0_18px_70px_rgba(255,79,139,0.08)] xl:backdrop-blur-sm">
|
||||
<div className="text-[11px] font-bold tracking-[0.28em] text-zinc-500">
|
||||
世界档案
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-black text-[var(--platform-text-strong)] sm:text-[2.2rem]">
|
||||
<div className="mt-2 text-3xl font-black text-[var(--platform-text-strong)] sm:text-[2.2rem] xl:mt-1 xl:text-[2rem]">
|
||||
{profile.name}
|
||||
</div>
|
||||
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-400">
|
||||
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-400 xl:mt-1 xl:text-xs">
|
||||
{profile.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="platform-sticky-fade sticky top-0 z-10 -mx-1 space-y-3 px-1 pb-3 pt-1 backdrop-blur-sm">
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
<div className="platform-sticky-fade sticky top-0 z-10 -mx-1 space-y-3 px-1 pb-3 pt-1 backdrop-blur-sm xl:rounded-[1.75rem] xl:border xl:border-[var(--platform-subpanel-border)] xl:bg-white/70 xl:px-4 xl:py-3 xl:shadow-[0_16px_48px_rgba(255,79,139,0.08)]">
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide xl:pb-0">
|
||||
{RESULT_TABS.map((tab) => (
|
||||
<div key={tab.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActiveTabChange(tab.id)}
|
||||
className={`platform-tab px-3 py-2 text-left text-sm ${activeTab === tab.id ? 'platform-tab--active' : ''}`}
|
||||
className={`platform-tab px-3 py-2 text-left text-sm xl:min-w-[5.25rem] xl:px-4 xl:py-2 ${activeTab === tab.id ? 'platform-tab--active' : ''}`}
|
||||
>
|
||||
<div className="font-semibold">{tab.label}</div>
|
||||
<div className="mt-1 text-[10px] tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
@@ -1218,7 +1207,7 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
|
||||
{activeTab !== 'world' ? (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center xl:gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SearchBox
|
||||
value={searchDraft}
|
||||
@@ -1267,7 +1256,7 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
|
||||
{activeTab === 'world' ? (
|
||||
<>
|
||||
<div className="space-y-3 xl:grid xl:grid-cols-[0.8fr_1.2fr] xl:items-start xl:gap-3 xl:space-y-0">
|
||||
<Section title="档案规模">
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-[11px] text-zinc-300">
|
||||
<div className="platform-subpanel rounded-xl px-2 py-3">
|
||||
@@ -1291,40 +1280,6 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="作品封面"
|
||||
badge={
|
||||
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||||
{coverPresentation.sourceType === 'uploaded'
|
||||
? '上传封面'
|
||||
: coverPresentation.sourceType === 'generated'
|
||||
? 'AI封面'
|
||||
: '默认封面'}
|
||||
</span>
|
||||
}
|
||||
actions={
|
||||
!readOnly ? (
|
||||
<SmallButton
|
||||
onClick={() => onEditTarget({ kind: 'cover' })}
|
||||
tone="sky"
|
||||
>
|
||||
编辑
|
||||
</SmallButton>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={coverPresentation.imageSrc}
|
||||
title={profile.name}
|
||||
fallbackLabel={profile.name.slice(0, 4) || '封面'}
|
||||
renderMode={coverPresentation.renderMode}
|
||||
characterImageSrcs={coverPresentation.characterImageSrcs}
|
||||
className="aspect-[16/9] rounded-[1.4rem] border border-[var(--platform-subpanel-border)]"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="世界概述"
|
||||
actions={
|
||||
@@ -1394,11 +1349,11 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'playable' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 xl:grid xl:grid-cols-2 xl:gap-3 xl:space-y-0 2xl:grid-cols-3">
|
||||
{pendingGeneratedEntity?.kind === 'playable' ? (
|
||||
<PendingEntityCard
|
||||
title={pendingGeneratedEntity.title}
|
||||
@@ -1433,7 +1388,7 @@ export function CustomWorldEntityCatalog({
|
||||
isSelectionMode={false}
|
||||
isSelected={false}
|
||||
layout="compact"
|
||||
mediaClassName="h-[4.75rem] w-[4.75rem] sm:h-[5.25rem] sm:w-[5.25rem]"
|
||||
mediaClassName="h-[4.75rem] w-[4.75rem] sm:h-[5.25rem] sm:w-[5.25rem] xl:h-[5.75rem] xl:w-[5.75rem]"
|
||||
onClick={() =>
|
||||
onEditTarget({
|
||||
kind: 'playable',
|
||||
@@ -1441,19 +1396,6 @@ export function CustomWorldEntityCatalog({
|
||||
id: role.id,
|
||||
})
|
||||
}
|
||||
actions={
|
||||
!readOnly && onGenerateRoleAssets ? (
|
||||
<SmallButton
|
||||
onClick={(event) => {
|
||||
event?.stopPropagation();
|
||||
onGenerateRoleAssets(role.id);
|
||||
}}
|
||||
tone="sky"
|
||||
>
|
||||
生成资产
|
||||
</SmallButton>
|
||||
) : null
|
||||
}
|
||||
media={
|
||||
role.imageSrc?.trim() ? (
|
||||
<ResolvedAssetImage
|
||||
@@ -1522,7 +1464,7 @@ export function CustomWorldEntityCatalog({
|
||||
) : null}
|
||||
|
||||
{activeTab === 'story' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 xl:grid xl:grid-cols-2 xl:gap-3 xl:space-y-0 2xl:grid-cols-3">
|
||||
{pendingGeneratedEntity?.kind === 'story' ? (
|
||||
<PendingEntityCard
|
||||
title={pendingGeneratedEntity.title}
|
||||
@@ -1547,7 +1489,7 @@ export function CustomWorldEntityCatalog({
|
||||
isSelectionMode={isBulkDeleteMode}
|
||||
isSelected={selectedBulkIds.includes(npc.id)}
|
||||
layout="compact"
|
||||
mediaClassName="h-[4.75rem] w-[4.75rem] sm:h-[5.25rem] sm:w-[5.25rem]"
|
||||
mediaClassName="h-[4.75rem] w-[4.75rem] sm:h-[5.25rem] sm:w-[5.25rem] xl:h-[5.75rem] xl:w-[5.75rem]"
|
||||
onClick={() =>
|
||||
isBulkDeleteMode
|
||||
? toggleBulkSelected(npc.id)
|
||||
@@ -1563,19 +1505,6 @@ export function CustomWorldEntityCatalog({
|
||||
id: npc.id,
|
||||
})
|
||||
}
|
||||
actions={
|
||||
!readOnly && !isBulkDeleteMode && onGenerateRoleAssets ? (
|
||||
<SmallButton
|
||||
onClick={(event) => {
|
||||
event?.stopPropagation();
|
||||
onGenerateRoleAssets(npc.id);
|
||||
}}
|
||||
tone="sky"
|
||||
>
|
||||
生成资产
|
||||
</SmallButton>
|
||||
) : null
|
||||
}
|
||||
media={
|
||||
<CustomWorldNpcPortrait
|
||||
npc={npc}
|
||||
@@ -1595,7 +1524,7 @@ export function CustomWorldEntityCatalog({
|
||||
) : null}
|
||||
|
||||
{activeTab === 'landmarks' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 xl:grid xl:grid-cols-2 xl:gap-3 xl:space-y-0 2xl:grid-cols-3">
|
||||
{pendingGeneratedEntity?.kind === 'landmark' ? (
|
||||
<PendingEntityCard
|
||||
title={pendingGeneratedEntity.title}
|
||||
@@ -1639,20 +1568,6 @@ export function CustomWorldEntityCatalog({
|
||||
id: scene.id,
|
||||
})
|
||||
}
|
||||
actions={
|
||||
!readOnly && !isBulkDeleteMode && onGenerateSceneAssets ? (
|
||||
<SmallButton
|
||||
onClick={(event) => {
|
||||
event?.stopPropagation();
|
||||
onGenerateSceneAssets(scene.id, scene.kind);
|
||||
}}
|
||||
tone="sky"
|
||||
disabled={scene.kind === 'camp' && isBulkDeleteMode}
|
||||
>
|
||||
生成场景图
|
||||
</SmallButton>
|
||||
) : null
|
||||
}
|
||||
media={
|
||||
<ImageFrame
|
||||
src={scene.imageSrc}
|
||||
|
||||
@@ -117,14 +117,14 @@ export function CustomWorldGenerationView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none flex-col gap-4 xl:min-h-0 xl:flex-1">
|
||||
<section className="platform-surface platform-surface--soft flex flex-col px-4 py-3.5 xl:min-h-0 xl:flex-1">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-none flex-col gap-4 xl:grid xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,1.35fr)_minmax(22rem,0.65fr)] xl:items-stretch">
|
||||
<section className="platform-surface platform-surface--soft flex flex-col px-4 py-3.5 xl:min-h-0 xl:flex-1 xl:px-5 xl:py-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between xl:gap-6">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
{progressTitle}
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-black leading-tight text-[var(--platform-text-strong)] sm:text-[2rem]">
|
||||
<div className="mt-1 text-xl font-black leading-tight text-[var(--platform-text-strong)] sm:text-[2rem] xl:text-[2.4rem]">
|
||||
{progress?.phaseLabel ?? '正在启动世界生成'}
|
||||
</div>
|
||||
<div className="mt-2 max-w-[36rem] text-sm leading-6 text-zinc-300">
|
||||
@@ -141,7 +141,7 @@ export function CustomWorldGenerationView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="platform-progress-track mt-4 h-4 overflow-hidden rounded-full">
|
||||
<div className="platform-progress-track mt-4 h-4 overflow-hidden rounded-full xl:mt-5 xl:h-5">
|
||||
<motion.div
|
||||
className="h-full bg-[linear-gradient(90deg,#ff4f8b_0%,#ff8a73_52%,#ffd2a6_100%)]"
|
||||
animate={{ width: `${progressValue}%` }}
|
||||
@@ -149,7 +149,7 @@ export function CustomWorldGenerationView({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-3">
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-3 xl:gap-3">
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
当前批次
|
||||
@@ -176,7 +176,7 @@ export function CustomWorldGenerationView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 xl:min-h-0 xl:flex-1 xl:overflow-y-auto xl:pr-1">
|
||||
<div className="mt-4 space-y-2 xl:grid xl:min-h-0 xl:flex-1 xl:grid-cols-2 xl:content-start xl:gap-2 xl:space-y-0 xl:overflow-y-auto xl:pr-1">
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={buildFallbackRenderKey(step.id, `progress-step-${index}`)}
|
||||
@@ -241,8 +241,8 @@ export function CustomWorldGenerationView({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="platform-surface platform-surface--soft overflow-hidden px-4 py-3.5">
|
||||
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<section className="platform-surface platform-surface--soft overflow-hidden px-4 py-3.5 xl:flex xl:min-h-0 xl:flex-col xl:px-5 xl:py-4">
|
||||
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between xl:flex-col xl:items-start xl:gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-[var(--platform-cool-text)]">
|
||||
{settingTitle}
|
||||
@@ -265,14 +265,14 @@ export function CustomWorldGenerationView({
|
||||
) : null}
|
||||
</div>
|
||||
{hasStructuredAnchors ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:min-h-0 xl:flex-1 xl:grid-cols-1 xl:overflow-y-auto xl:pr-1">
|
||||
{anchorEntries.map((entry, index) => (
|
||||
<div
|
||||
key={buildFallbackRenderKey(
|
||||
entry.id,
|
||||
`anchor-entry-${index}`,
|
||||
)}
|
||||
className="platform-subpanel rounded-2xl px-4 py-4"
|
||||
className="platform-subpanel rounded-2xl px-4 py-4 xl:py-3"
|
||||
>
|
||||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
|
||||
{entry.label}
|
||||
@@ -284,7 +284,7 @@ export function CustomWorldGenerationView({
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="platform-subpanel whitespace-pre-line rounded-2xl px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto">
|
||||
<div className="platform-subpanel whitespace-pre-line rounded-2xl px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto xl:max-h-none xl:min-h-0 xl:flex-1">
|
||||
{settingText || structuredEmptyText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -47,6 +47,7 @@ export type CharacterVisualDraft = {
|
||||
|
||||
export type CharacterAssetWorkflowCache = {
|
||||
characterId: string;
|
||||
cacheScopeId?: string;
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
animationPromptTextByKey?: Record<string, string>;
|
||||
@@ -154,12 +155,19 @@ export async function generateCharacterVisualCandidates(
|
||||
}>(CHARACTER_VISUAL_GENERATE_API_PATH, payload, '生成角色主形象失败');
|
||||
}
|
||||
|
||||
export async function fetchCharacterWorkflowCache(characterId: string) {
|
||||
export async function fetchCharacterWorkflowCache(
|
||||
characterId: string,
|
||||
cacheScopeId?: string,
|
||||
) {
|
||||
return fetchJson<{
|
||||
ok: true;
|
||||
cache: CharacterAssetWorkflowCache | null;
|
||||
}>(
|
||||
`${CHARACTER_WORKFLOW_CACHE_API_PATH}/${encodeURIComponent(characterId)}`,
|
||||
`${CHARACTER_WORKFLOW_CACHE_API_PATH}/${encodeURIComponent(characterId)}${
|
||||
cacheScopeId
|
||||
? `?cacheScopeId=${encodeURIComponent(cacheScopeId)}`
|
||||
: ''
|
||||
}`,
|
||||
'读取角色形象生成缓存失败',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* @vitest-environment jsdom */
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { expect, test } from 'vitest';
|
||||
@@ -127,5 +127,5 @@ test('creation hub shows delete action for persisted rpg drafts', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /删除作品/u })).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -30,9 +30,11 @@ type CustomWorldCreationHubProps = {
|
||||
bigFishItems?: BigFishWorkSummary[];
|
||||
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
|
||||
onExperienceBigFish?: ((item: BigFishWorkSummary) => void) | null;
|
||||
onDeleteBigFish?: ((item: BigFishWorkSummary) => void) | null;
|
||||
puzzleItems?: PuzzleWorkSummary[];
|
||||
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
||||
onExperiencePuzzle?: ((profileId: string) => void) | null;
|
||||
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
|
||||
};
|
||||
|
||||
function EmptyState({ title }: { title: string }) {
|
||||
@@ -61,9 +63,11 @@ export function CustomWorldCreationHub({
|
||||
bigFishItems = [],
|
||||
onOpenBigFishDetail,
|
||||
onExperienceBigFish = null,
|
||||
onDeleteBigFish = null,
|
||||
puzzleItems = [],
|
||||
onOpenPuzzleDetail,
|
||||
onExperiencePuzzle = null,
|
||||
onDeletePuzzle = null,
|
||||
}: CustomWorldCreationHubProps) {
|
||||
const [activeFilter, setActiveFilter] =
|
||||
useState<CustomWorldWorkFilter>('all');
|
||||
@@ -104,8 +108,8 @@ export function CustomWorldCreationHub({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="platform-page-stage platform-remap-surface space-y-4 px-3 pb-4 pt-3 sm:px-4 sm:pt-4">
|
||||
<div className="space-y-4">
|
||||
<div className="platform-page-stage platform-remap-surface space-y-4 px-3 pb-4 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-5 xl:pt-5">
|
||||
<div className="space-y-4 xl:space-y-3">
|
||||
<CustomWorldCreationStartCard
|
||||
busy={createBusy}
|
||||
error={createError}
|
||||
@@ -133,7 +137,7 @@ export function CustomWorldCreationHub({
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={`skeleton-${index}`}
|
||||
@@ -151,7 +155,7 @@ export function CustomWorldCreationHub({
|
||||
))}
|
||||
</div>
|
||||
) : filteredItems.length > 0 ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{filteredItems.map((item) => (
|
||||
<CustomWorldWorkCard
|
||||
key={`${item.kind}-${item.item.workId}`}
|
||||
@@ -199,11 +203,17 @@ export function CustomWorldCreationHub({
|
||||
: null
|
||||
}
|
||||
onDelete={
|
||||
item.kind === 'rpg' && item.item.profileId
|
||||
item.kind === 'puzzle'
|
||||
? () => {
|
||||
onDeletePublished?.(item.item);
|
||||
onDeletePuzzle?.(item.item);
|
||||
}
|
||||
: null
|
||||
: item.kind === 'big-fish'
|
||||
? () => {
|
||||
onDeleteBigFish?.(item.item);
|
||||
}
|
||||
: () => {
|
||||
onDeletePublished?.(item.item);
|
||||
}
|
||||
}
|
||||
deleteBusy={deletingWorkId === item.item.workId}
|
||||
/>
|
||||
|
||||
@@ -18,14 +18,14 @@ export function CustomWorldCreationStartCard({
|
||||
}: CustomWorldCreationStartCardProps) {
|
||||
return (
|
||||
// 移动端限制模块高度,模板入口改为横向滚动,避免挤占作品列表首屏空间。
|
||||
<div className="platform-surface platform-surface--hero relative max-h-[33svh] overflow-hidden px-3 py-3 sm:max-h-none sm:px-5 sm:py-5">
|
||||
<div className="platform-surface platform-surface--hero relative max-h-[33svh] overflow-hidden px-3 py-3 sm:max-h-none sm:px-5 sm:py-5 xl:px-5 xl:py-4">
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 space-y-2.5 sm:space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xl font-black leading-none text-white sm:text-3xl">
|
||||
<div className="relative z-10 space-y-2.5 sm:space-y-4 xl:space-y-3">
|
||||
<div className="flex items-center justify-between gap-3 xl:items-end">
|
||||
<div className="text-xl font-black leading-none text-white sm:text-3xl xl:text-2xl">
|
||||
新建作品
|
||||
</div>
|
||||
<div className="hidden text-sm leading-6 text-zinc-200/88 sm:block">
|
||||
<div className="hidden text-sm leading-6 text-zinc-200/88 sm:block xl:text-xs xl:leading-5">
|
||||
直接选择游戏创作模板,立刻进入对应的共创工作台。
|
||||
</div>
|
||||
<span className="platform-pill platform-pill--neutral shrink-0 border-white/25 bg-white/14 px-2.5 text-xs text-white sm:hidden">
|
||||
@@ -33,7 +33,7 @@ export function CustomWorldCreationStartCard({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="-mx-1 flex snap-x gap-2 overflow-x-auto px-1 pb-1 sm:mx-0 sm:grid sm:gap-3 sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<div className="-mx-1 flex snap-x gap-2 overflow-x-auto px-1 pb-1 sm:mx-0 sm:grid sm:gap-3 sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 xl:grid-cols-5 xl:gap-2.5">
|
||||
{PLATFORM_CREATION_TYPES.map((item) => {
|
||||
const disabled = item.locked || busy;
|
||||
|
||||
@@ -45,7 +45,7 @@ export function CustomWorldCreationStartCard({
|
||||
onClick={() => {
|
||||
onCreateType(item.id);
|
||||
}}
|
||||
className={`platform-interactive-card relative min-h-[4rem] w-[11.25rem] shrink-0 snap-start overflow-hidden rounded-[1.15rem] border px-3 py-2.5 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] sm:px-4 sm:py-4 ${
|
||||
className={`platform-interactive-card relative min-h-[4rem] w-[11.25rem] shrink-0 snap-start overflow-hidden rounded-[1.15rem] border px-3 py-2.5 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] sm:px-4 sm:py-4 xl:min-h-[6.4rem] xl:px-3.5 xl:py-3 ${
|
||||
item.locked
|
||||
? 'cursor-not-allowed border-white/10 bg-white/8 text-zinc-300/70'
|
||||
: 'border-white/18 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_36%),linear-gradient(135deg,rgba(255,255,255,0.18),rgba(255,255,255,0.08))] text-white'
|
||||
@@ -68,11 +68,11 @@ export function CustomWorldCreationStartCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 truncate text-base font-black leading-tight text-inherit sm:mt-7 sm:text-lg">
|
||||
<div className="mt-2.5 truncate text-base font-black leading-tight text-inherit sm:mt-7 sm:text-lg xl:mt-4 xl:text-base">
|
||||
{item.title}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-1 truncate text-xs sm:mt-2 sm:text-sm ${
|
||||
className={`mt-1 truncate text-xs sm:mt-2 sm:text-sm xl:mt-1 xl:text-xs ${
|
||||
item.locked ? 'text-zinc-400' : 'text-zinc-200/82'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -76,7 +76,7 @@ export function CustomWorldWorkCard({
|
||||
item.kind === 'rpg' ? item.item.coverCharacterImageSrcs : [];
|
||||
|
||||
return (
|
||||
<div className="platform-surface platform-interactive-card relative min-h-[13.5rem] overflow-hidden px-4 py-4 sm:min-h-[14rem]">
|
||||
<div className="platform-surface platform-interactive-card relative min-h-[13.5rem] overflow-hidden px-4 py-4 sm:min-h-[14rem] xl:min-h-[12.25rem] xl:px-4 xl:py-3.5">
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={coverImageSrc}
|
||||
title={title}
|
||||
@@ -86,7 +86,7 @@ export function CustomWorldWorkCard({
|
||||
className="platform-cover-artwork absolute inset-0"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
|
||||
<div className="relative z-10 flex h-full min-h-[12rem] flex-col">
|
||||
<div className="relative z-10 flex h-full min-h-[12rem] flex-col xl:min-h-[10.75rem]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span
|
||||
@@ -117,24 +117,57 @@ export function CustomWorldWorkCard({
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
<div className="shrink-0 text-[11px] text-[var(--platform-text-soft)]">
|
||||
{formatUpdatedAt(updatedAt)}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-[11px] text-[var(--platform-text-soft)]">
|
||||
{formatUpdatedAt(updatedAt)}
|
||||
</span>
|
||||
{onDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={deleteBusy}
|
||||
aria-label={deleteBusy ? '删除中' : `删除作品《${title}》`}
|
||||
title={deleteBusy ? '删除中' : '删除作品'}
|
||||
className="grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-danger)] disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
{deleteBusy ? (
|
||||
<span className="text-xs leading-none">…</span>
|
||||
) : (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4h8v2" />
|
||||
<path d="M19 6l-1 14H6L5 6" />
|
||||
<path d="M10 11v5" />
|
||||
<path d="M14 11v5" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="text-2xl font-black text-[var(--platform-text-strong)]">
|
||||
<div className="mt-4 xl:mt-3">
|
||||
<div className="text-2xl font-black text-[var(--platform-text-strong)] xl:text-xl">
|
||||
{title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs tracking-[0.12em] text-[var(--platform-text-soft)]">
|
||||
{subtitle}
|
||||
</div>
|
||||
<div className="mt-3 line-clamp-3 text-sm leading-7 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)]">
|
||||
<div className="mt-3 line-clamp-3 text-sm leading-7 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)] xl:mt-2 xl:line-clamp-2 xl:leading-6">
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-center sm:justify-between xl:gap-2 xl:pt-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isPuzzle ? (
|
||||
<>
|
||||
@@ -188,11 +221,11 @@ export function CustomWorldWorkCard({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 sm:justify-end">
|
||||
<div className="flex flex-wrap gap-2 sm:justify-end xl:gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="platform-button platform-button--primary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm"
|
||||
className="platform-button platform-button--primary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm xl:px-3.5 xl:py-1.5 xl:text-xs"
|
||||
>
|
||||
{openActionLabel}
|
||||
</button>
|
||||
@@ -200,21 +233,11 @@ export function CustomWorldWorkCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExperience}
|
||||
className="platform-button platform-button--secondary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm"
|
||||
className="platform-button platform-button--secondary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm xl:px-3.5 xl:py-1.5 xl:text-xs"
|
||||
>
|
||||
体验
|
||||
</button>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={deleteBusy}
|
||||
className="platform-button platform-button--danger min-h-0 shrink-0 rounded-full px-4 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
{deleteBusy ? '删除中...' : '删除'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function CustomWorldWorkTabs({
|
||||
onChange,
|
||||
}: CustomWorldWorkTabsProps) {
|
||||
return (
|
||||
<div className="platform-remap-surface flex items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
<div className="platform-remap-surface flex items-center gap-2 overflow-x-auto pb-1 scrollbar-hide xl:pb-0">
|
||||
{FILTER_OPTIONS.map((option) => {
|
||||
const count =
|
||||
option.id === 'draft'
|
||||
@@ -37,7 +37,7 @@ export function CustomWorldWorkTabs({
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onChange(option.id)}
|
||||
className={`platform-tab shrink-0 px-4 py-2 text-sm ${
|
||||
className={`platform-tab shrink-0 px-4 py-2 text-sm xl:px-4 xl:py-1.5 xl:text-xs ${
|
||||
activeFilter === option.id ? 'platform-tab--active' : ''
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
@@ -43,7 +43,10 @@ import {
|
||||
getBigFishCreationSession,
|
||||
streamBigFishCreationMessage,
|
||||
} from '../../services/big-fish-creation';
|
||||
import { listBigFishWorks } from '../../services/big-fish-works';
|
||||
import {
|
||||
deleteBigFishWork,
|
||||
listBigFishWorks,
|
||||
} from '../../services/big-fish-works';
|
||||
import {
|
||||
startBigFishRuntimeRun,
|
||||
submitBigFishRuntimeInput,
|
||||
@@ -63,9 +66,10 @@ import {
|
||||
startLocalPuzzleRun,
|
||||
swapLocalPuzzlePieces,
|
||||
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
|
||||
import { listPuzzleWorks } from '../../services/puzzle-works';
|
||||
import { deletePuzzleWork, listPuzzleWorks } from '../../services/puzzle-works';
|
||||
import { deleteRpgEntryWorldProfile } from '../../services/rpg-entry';
|
||||
import { getRpgEntryWorldGalleryDetailByCode } from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||
import { deleteRpgCreationAgentSession } from '../../services/rpg-creation';
|
||||
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
@@ -1228,7 +1232,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const handleDeletePublishedWork = useCallback(
|
||||
(work: (typeof creationHubItems)[number]) => {
|
||||
if (!work.profileId || deletingCreationWorkId) {
|
||||
if (deletingCreationWorkId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1239,18 +1243,22 @@ export function PlatformEntryFlowShellImpl({
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
if (!work.profileId) {
|
||||
platformBootstrap.setPlatformError('当前作品缺少 profileId,暂时无法删除。');
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingCreationWorkId(work.workId);
|
||||
platformBootstrap.setPlatformError(null);
|
||||
|
||||
void deleteRpgEntryWorldProfile(work.profileId)
|
||||
.then(async (entries) => {
|
||||
platformBootstrap.setSavedCustomWorldEntries(entries);
|
||||
await platformBootstrap.refreshCustomWorldWorks().catch(() => []);
|
||||
const deleteTask = work.profileId
|
||||
? deleteRpgEntryWorldProfile(work.profileId).then(async (entries) => {
|
||||
platformBootstrap.setSavedCustomWorldEntries(entries);
|
||||
await platformBootstrap.refreshCustomWorldWorks().catch(() => []);
|
||||
})
|
||||
: work.sessionId
|
||||
? deleteRpgCreationAgentSession(work.sessionId).then((items) => {
|
||||
platformBootstrap.setCustomWorldWorkEntries(items);
|
||||
})
|
||||
: Promise.reject(new Error('当前 RPG 作品缺少可删除 ID。'));
|
||||
|
||||
void deleteTask
|
||||
.then(async () => {
|
||||
await platformBootstrap.refreshPublishedGallery().catch(() => []);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -1266,6 +1274,72 @@ export function PlatformEntryFlowShellImpl({
|
||||
[deletingCreationWorkId, platformBootstrap, runProtectedAction],
|
||||
);
|
||||
|
||||
const handleDeleteBigFishWork = useCallback(
|
||||
(work: BigFishWorkSummary) => {
|
||||
if (deletingCreationWorkId) {
|
||||
return;
|
||||
}
|
||||
|
||||
runProtectedAction(() => {
|
||||
const confirmed = window.confirm(
|
||||
`确认删除作品《${work.title}》吗?删除后会从你的作品列表中移除。`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingCreationWorkId(work.workId);
|
||||
setBigFishError(null);
|
||||
|
||||
void deleteBigFishWork(work.sourceSessionId)
|
||||
.then((response) => {
|
||||
setBigFishWorks(response.items);
|
||||
})
|
||||
.catch((error) => {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '删除大鱼吃小鱼作品失败。'),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setDeletingCreationWorkId(null);
|
||||
});
|
||||
});
|
||||
},
|
||||
[deletingCreationWorkId, resolveBigFishErrorMessage, runProtectedAction],
|
||||
);
|
||||
|
||||
const handleDeletePuzzleWork = useCallback(
|
||||
(work: PuzzleWorkSummary) => {
|
||||
if (deletingCreationWorkId) {
|
||||
return;
|
||||
}
|
||||
|
||||
runProtectedAction(() => {
|
||||
const confirmed = window.confirm(
|
||||
`确认删除作品《${work.levelName}》吗?删除后会从你的作品列表和公开广场中移除。`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingCreationWorkId(work.workId);
|
||||
setPuzzleError(null);
|
||||
|
||||
void deletePuzzleWork(work.profileId)
|
||||
.then((response) => {
|
||||
setPuzzleWorks(response.items);
|
||||
})
|
||||
.catch((error) => {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '删除拼图作品失败。'));
|
||||
})
|
||||
.finally(() => {
|
||||
setDeletingCreationWorkId(null);
|
||||
});
|
||||
});
|
||||
},
|
||||
[deletingCreationWorkId, resolvePuzzleErrorMessage, runProtectedAction],
|
||||
);
|
||||
|
||||
const openPuzzleDetail = useCallback(
|
||||
async (profileId: string) => {
|
||||
setIsPuzzleBusy(true);
|
||||
@@ -1494,6 +1568,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
void startBigFishRunFromWork(item);
|
||||
});
|
||||
}}
|
||||
onDeleteBigFish={(item) => {
|
||||
handleDeleteBigFishWork(item);
|
||||
}}
|
||||
puzzleItems={puzzleWorks}
|
||||
onOpenPuzzleDetail={(item) => {
|
||||
runProtectedAction(() => {
|
||||
@@ -1509,6 +1586,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
void startPuzzleRunFromProfile(profileId);
|
||||
});
|
||||
}}
|
||||
onDeletePuzzle={(item) => {
|
||||
handleDeletePuzzleWork(item);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1982,6 +2062,23 @@ export function PlatformEntryFlowShellImpl({
|
||||
});
|
||||
});
|
||||
}}
|
||||
onTestWorld={() => {
|
||||
runProtectedAction(() => {
|
||||
void enterWorldCoordinator
|
||||
.enterWorldForTestFromCurrentResult()
|
||||
.catch((error) => {
|
||||
sessionController.setCustomWorldError(
|
||||
resolveRpgCreationErrorMessage(
|
||||
error,
|
||||
'进入作品测试失败。',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}}
|
||||
onPublishWorld={async () => {
|
||||
await enterWorldCoordinator.publishCurrentResult();
|
||||
}}
|
||||
onGenerateEntity={
|
||||
sessionController.isAgentDraftResultView
|
||||
? async (kind) => {
|
||||
@@ -2036,49 +2133,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onGenerateRoleAssets={
|
||||
sessionController.isAgentDraftResultView
|
||||
? async (roleId) => {
|
||||
const latestSession =
|
||||
await autosaveCoordinator.executeAgentActionAndWait({
|
||||
action: 'generate_role_assets',
|
||||
roleIds: [roleId],
|
||||
});
|
||||
const latestProfile = latestSession
|
||||
? rpgCreationPreviewAdapter.buildPreviewFromSession(
|
||||
latestSession,
|
||||
)
|
||||
: null;
|
||||
if (latestProfile) {
|
||||
sessionController.setGeneratedCustomWorldProfile(
|
||||
latestProfile,
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onGenerateSceneAssets={
|
||||
sessionController.isAgentDraftResultView
|
||||
? async (sceneId, sceneKind) => {
|
||||
const latestSession =
|
||||
await autosaveCoordinator.executeAgentActionAndWait({
|
||||
action: 'generate_scene_assets',
|
||||
sceneIds: [sceneId],
|
||||
sceneKind,
|
||||
});
|
||||
const latestProfile = latestSession
|
||||
? rpgCreationPreviewAdapter.buildPreviewFromSession(
|
||||
latestSession,
|
||||
)
|
||||
: null;
|
||||
if (latestProfile) {
|
||||
sessionController.setGeneratedCustomWorldProfile(
|
||||
latestProfile,
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
readOnly={false}
|
||||
compactAgentResultMode={
|
||||
sessionController.isAgentDraftResultView
|
||||
|
||||
@@ -64,6 +64,7 @@ function buildDefaultAnimationPromptTextByKey(defaultText: string) {
|
||||
function pickCachedAnimationPromptTextByKey(
|
||||
cache: CharacterAssetWorkflowCache,
|
||||
fallbackText: string,
|
||||
preferFreshRoleText: boolean,
|
||||
) {
|
||||
const fromCache = cache.animationPromptTextByKey ?? {};
|
||||
|
||||
@@ -73,8 +74,9 @@ function pickCachedAnimationPromptTextByKey(
|
||||
const legacyText = cache.animationPromptText?.trim();
|
||||
return {
|
||||
...result,
|
||||
[action.animation]:
|
||||
cachedText && !isLegacyGeneratedActionDescription(cachedText)
|
||||
[action.animation]: preferFreshRoleText
|
||||
? fallbackText
|
||||
: cachedText && !isLegacyGeneratedActionDescription(cachedText)
|
||||
? cachedText
|
||||
: legacyText && !isLegacyGeneratedActionDescription(legacyText)
|
||||
? legacyText
|
||||
@@ -487,6 +489,7 @@ function buildAnimationPreviewCharacter(params: {
|
||||
export interface RpgCreationRoleAssetStudioModalProps {
|
||||
role: EditableCustomWorldRole;
|
||||
roleKind: 'playable' | 'story';
|
||||
cacheScopeId?: string;
|
||||
onApply?: (nextRole: EditableCustomWorldRole) => void;
|
||||
onPublishSuccess?: (
|
||||
payload: {
|
||||
@@ -509,6 +512,7 @@ export interface RpgCreationRoleAssetStudioModalProps {
|
||||
export function RpgCreationRoleAssetStudioModal({
|
||||
role,
|
||||
roleKind,
|
||||
cacheScopeId,
|
||||
onApply,
|
||||
onPublishSuccess,
|
||||
onClose,
|
||||
@@ -746,13 +750,16 @@ export function RpgCreationRoleAssetStudioModal({
|
||||
setSaveStatus(null);
|
||||
setIsHydratingCache(true);
|
||||
|
||||
void fetchCharacterWorkflowCache(baseRole.id)
|
||||
void fetchCharacterWorkflowCache(baseRole.id, cacheScopeId)
|
||||
.then((result) => {
|
||||
if (cancelled || !result.cache) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cache = result.cache;
|
||||
if (cacheScopeId && cache.cacheScopeId !== cacheScopeId) {
|
||||
return;
|
||||
}
|
||||
const nextRole = mergeRole(baseRole, {
|
||||
imageSrc: cache.imageSrc ?? baseRole.imageSrc,
|
||||
generatedVisualAssetId:
|
||||
@@ -765,7 +772,8 @@ export function RpgCreationRoleAssetStudioModal({
|
||||
});
|
||||
setWorkingRole(nextRole);
|
||||
setVisualPromptText(
|
||||
cache.visualPromptText &&
|
||||
!baseRole.visualDescription?.trim() &&
|
||||
cache.visualPromptText &&
|
||||
!isLegacyGeneratedVisualDescription(cache.visualPromptText)
|
||||
? cache.visualPromptText
|
||||
: initialPromptBundle.visualPromptText,
|
||||
@@ -774,6 +782,7 @@ export function RpgCreationRoleAssetStudioModal({
|
||||
pickCachedAnimationPromptTextByKey(
|
||||
cache,
|
||||
initialPromptBundle.animationPromptText,
|
||||
Boolean(baseRole.actionDescription?.trim()),
|
||||
),
|
||||
);
|
||||
setVisualDrafts(cache.visualDrafts ?? []);
|
||||
@@ -798,7 +807,7 @@ export function RpgCreationRoleAssetStudioModal({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [baseRole, initialPromptBundle, roleSnapshotKey]);
|
||||
}, [baseRole, cacheScopeId, initialPromptBundle, roleSnapshotKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isHydratingCache) {
|
||||
@@ -808,8 +817,10 @@ export function RpgCreationRoleAssetStudioModal({
|
||||
const timer = window.setTimeout(() => {
|
||||
const payload: CharacterAssetWorkflowCache = {
|
||||
characterId: workingRole.id,
|
||||
cacheScopeId,
|
||||
visualPromptText,
|
||||
animationPromptText,
|
||||
animationPromptTextByKey,
|
||||
visualDrafts,
|
||||
selectedVisualDraftId,
|
||||
selectedAnimation,
|
||||
@@ -829,9 +840,11 @@ export function RpgCreationRoleAssetStudioModal({
|
||||
};
|
||||
}, [
|
||||
animationPromptText,
|
||||
animationPromptTextByKey,
|
||||
isHydratingCache,
|
||||
selectedAnimation,
|
||||
selectedVisualDraftId,
|
||||
cacheScopeId,
|
||||
visualDrafts,
|
||||
visualPromptText,
|
||||
workingRole.animationMap,
|
||||
@@ -1137,7 +1150,12 @@ export function RpgCreationRoleAssetStudioModal({
|
||||
workingRoleGeneratedVisualAssetId={workingRole.generatedVisualAssetId}
|
||||
workingRoleImageSrc={workingRole.imageSrc}
|
||||
workingRoleName={workingRole.name}
|
||||
onAnimationPromptChange={setAnimationPromptText}
|
||||
onAnimationPromptChange={(value) => {
|
||||
setAnimationPromptTextByKey((current) => ({
|
||||
...current,
|
||||
[selectedAnimation]: value,
|
||||
}));
|
||||
}}
|
||||
onGenerateAnimation={() => {
|
||||
void handleGenerateAnimation();
|
||||
}}
|
||||
|
||||
@@ -202,6 +202,10 @@ function dedupeTextValues(values: Array<string | null | undefined>) {
|
||||
];
|
||||
}
|
||||
|
||||
function compactTextList(values: Array<string | null | undefined>) {
|
||||
return values.map((value) => value?.trim() ?? '').filter(Boolean);
|
||||
}
|
||||
|
||||
function moveArrayItem<T>(values: T[], fromIndex: number, toIndex: number) {
|
||||
if (
|
||||
fromIndex < 0 ||
|
||||
@@ -326,19 +330,26 @@ function buildDefaultSceneActBlueprint(params: {
|
||||
const encounterNpcIds = dedupeTextValues(params.encounterNpcIds).slice(0, 1);
|
||||
const actTitle = buildDefaultSceneActTitle(params.index);
|
||||
const sceneLabel = params.sceneName.trim() || '当前场景';
|
||||
const sceneSummary = params.sceneSummary.trim();
|
||||
const stageCoverage = buildSceneActStageCoverage(params.index, params.actCount);
|
||||
const actSummary =
|
||||
params.index === 0
|
||||
? `玩家会在${sceneLabel}接住这一章的开场入口。`
|
||||
: params.index >= params.actCount - 1
|
||||
? `${sceneLabel}这一章会在这里把下一步方向抛给玩家。`
|
||||
: `${sceneLabel}的主要压力会在这一幕继续加深。`;
|
||||
|
||||
return {
|
||||
id: `${params.sceneId}-act-${params.index + 1}`,
|
||||
sceneId: params.sceneId,
|
||||
title: actTitle,
|
||||
summary:
|
||||
params.index === 0
|
||||
? `玩家会在${sceneLabel}接住这一章的开场入口。`
|
||||
: params.index >= params.actCount - 1
|
||||
? `${sceneLabel}这一章会在这里把下一步方向抛给玩家。`
|
||||
: `${sceneLabel}的主要压力会在这一幕继续加深。`,
|
||||
summary: actSummary,
|
||||
stageCoverage,
|
||||
backgroundPromptText: compactTextList([
|
||||
`${sceneLabel}${actTitle}背景`,
|
||||
sceneSummary,
|
||||
actSummary,
|
||||
]).join(';'),
|
||||
backgroundImageSrc: params.backgroundImageSrc || undefined,
|
||||
encounterNpcIds,
|
||||
primaryNpcId: encounterNpcIds[0] ?? '',
|
||||
@@ -461,6 +472,9 @@ function sanitizeSceneChapterBlueprint(params: {
|
||||
title: currentAct?.title?.trim() || fallbackAct.title,
|
||||
summary: currentAct?.summary?.trim() || fallbackAct.summary,
|
||||
stageCoverage: buildSceneActStageCoverage(index, targetActCount),
|
||||
backgroundPromptText:
|
||||
currentAct?.backgroundPromptText?.trim() ||
|
||||
fallbackAct.backgroundPromptText,
|
||||
backgroundImageSrc:
|
||||
currentAct?.backgroundImageSrc?.trim() ||
|
||||
params.fallbackImageSrc ||
|
||||
@@ -2391,15 +2405,18 @@ const FIXED_SCENE_IMAGE_SIZE = '1280*720';
|
||||
function SceneImageGenerationModal({
|
||||
profile,
|
||||
landmark,
|
||||
initialPromptText,
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
profile: CustomWorldProfile;
|
||||
landmark: CustomWorldLandmark;
|
||||
initialPromptText?: string;
|
||||
onApply: (result: CustomWorldSceneImageResult) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [userPrompt, setUserPrompt] = useDraft(
|
||||
initialPromptText?.trim() ||
|
||||
landmark.visualDescription?.trim() ||
|
||||
landmark.description.trim() ||
|
||||
landmark.name.trim(),
|
||||
@@ -2504,12 +2521,12 @@ function SceneImageGenerationModal({
|
||||
<ModalShell
|
||||
title={`智能生成:${landmark.name || '当前场景'}`}
|
||||
onClose={handleRequestClose}
|
||||
panelClassName="sm:max-w-5xl"
|
||||
panelClassName="sm:max-w-4xl"
|
||||
overlayClassName="z-[99]"
|
||||
disableClose={isGenerating}
|
||||
usePixelFont
|
||||
>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.1fr)_minmax(20rem,0.9fr)]">
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.15fr)_minmax(17rem,0.85fr)]">
|
||||
<div className="space-y-4">
|
||||
<Field label="画面内容描述">
|
||||
<TextArea
|
||||
@@ -2640,6 +2657,7 @@ function SceneImageGenerationModal({
|
||||
function SceneActBackgroundModal({
|
||||
profile,
|
||||
landmark,
|
||||
act,
|
||||
actLabel,
|
||||
currentImageSrc,
|
||||
fallbackImageSrc,
|
||||
@@ -2648,6 +2666,7 @@ function SceneActBackgroundModal({
|
||||
}: {
|
||||
profile: CustomWorldProfile;
|
||||
landmark: CustomWorldLandmark;
|
||||
act: SceneActBlueprint;
|
||||
actLabel: string;
|
||||
currentImageSrc?: string | null;
|
||||
fallbackImageSrc?: string | null;
|
||||
@@ -2738,6 +2757,10 @@ function SceneActBackgroundModal({
|
||||
<SceneImageGenerationModal
|
||||
profile={profile}
|
||||
landmark={landmark}
|
||||
initialPromptText={
|
||||
act.backgroundPromptText?.trim() ||
|
||||
compactTextList([act.title, act.summary, act.actGoal]).join(';')
|
||||
}
|
||||
onApply={(result) => {
|
||||
setDraftImageSrc(result.imageSrc);
|
||||
}}
|
||||
@@ -3027,7 +3050,7 @@ function CoverImageGenerationModal({
|
||||
<TextArea
|
||||
value={userPrompt}
|
||||
onChange={(value) => setUserPrompt(value)}
|
||||
rows={7}
|
||||
rows={5}
|
||||
placeholder="例如:海雾压进旧码头,主角站在残灯与潮水之间,整体像一张正式 RPG 作品封面。"
|
||||
/>
|
||||
</Field>
|
||||
@@ -3077,7 +3100,7 @@ function CoverImageGenerationModal({
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/18 p-2.5">
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={previewImageSrc}
|
||||
title={profile.name}
|
||||
@@ -3088,7 +3111,7 @@ function CoverImageGenerationModal({
|
||||
characterImageSrcs={
|
||||
latestResult ? [] : initialPresentation.characterImageSrcs
|
||||
}
|
||||
className="aspect-[16/9] rounded-2xl"
|
||||
className="aspect-[16/9] max-h-[14rem] rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3249,77 +3272,83 @@ export function WorldCoverEditor({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalShell title="编辑作品封面" onClose={onClose}>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
|
||||
<ModalShell
|
||||
title="编辑作品封面"
|
||||
onClose={onClose}
|
||||
panelClassName="sm:max-w-3xl"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-[minmax(0,0.95fr)_minmax(17rem,1.05fr)]">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/18 p-2.5">
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={previewPresentation.imageSrc}
|
||||
title={profile.name}
|
||||
fallbackLabel={profile.name.slice(0, 4) || '封面'}
|
||||
renderMode={previewPresentation.renderMode}
|
||||
characterImageSrcs={previewPresentation.characterImageSrcs}
|
||||
className="aspect-[16/9] rounded-2xl"
|
||||
className="aspect-[16/9] max-h-[13rem] rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-200">
|
||||
{draftCover.sourceType === 'uploaded'
|
||||
? '当前为上传封面'
|
||||
: draftCover.sourceType === 'generated'
|
||||
? '当前为 AI 封面'
|
||||
: '当前为默认封面'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-200">
|
||||
{draftCover.sourceType === 'uploaded'
|
||||
? '当前为上传封面'
|
||||
: draftCover.sourceType === 'generated'
|
||||
? '当前为 AI 封面'
|
||||
: '当前为默认封面'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
|
||||
<div className="mb-3 text-[11px] font-bold tracking-[0.14em] text-zinc-300">
|
||||
上传封面
|
||||
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
|
||||
<div className="mb-3 text-[11px] font-bold tracking-[0.14em] text-zinc-300">
|
||||
上传封面
|
||||
</div>
|
||||
<div className="mb-3 text-xs leading-5 text-zinc-400">
|
||||
支持 png、jpg、webp。上传后会先裁剪成 16:9,再保存成封面。
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(event) => {
|
||||
void handleUploadCover(event);
|
||||
}}
|
||||
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ActionButton
|
||||
label="AI 生成"
|
||||
onClick={() => setIsGenerating(true)}
|
||||
tone="sky"
|
||||
/>
|
||||
<ActionButton
|
||||
label="重置为默认"
|
||||
onClick={() =>
|
||||
setDraftCover(buildDefaultCustomWorldCoverProfile(profile))
|
||||
}
|
||||
disabled={draftCover.sourceType === 'default'}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3 text-xs leading-5 text-zinc-400">
|
||||
支持 png、jpg、webp。上传后会先裁剪成 16:9,再保存成封面。
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(event) => {
|
||||
void handleUploadCover(event);
|
||||
|
||||
{uploadError ? (
|
||||
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{uploadError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SaveBar
|
||||
onClose={onClose}
|
||||
onSave={() => {
|
||||
onSaveProfile({
|
||||
...profile,
|
||||
cover: draftCover,
|
||||
});
|
||||
onClose();
|
||||
}}
|
||||
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ActionButton
|
||||
label="AI 生成"
|
||||
onClick={() => setIsGenerating(true)}
|
||||
tone="sky"
|
||||
/>
|
||||
<ActionButton
|
||||
label="重置为默认"
|
||||
onClick={() =>
|
||||
setDraftCover(buildDefaultCustomWorldCoverProfile(profile))
|
||||
}
|
||||
disabled={draftCover.sourceType === 'default'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{uploadError ? (
|
||||
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{uploadError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SaveBar
|
||||
onClose={onClose}
|
||||
onSave={() => {
|
||||
onSaveProfile({
|
||||
...profile,
|
||||
cover: draftCover,
|
||||
});
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ModalShell>
|
||||
|
||||
@@ -4787,6 +4816,7 @@ export function PlayableNpcEditor({
|
||||
<RpgCreationRoleAssetStudioModal
|
||||
role={draft}
|
||||
roleKind="playable"
|
||||
cacheScopeId={profile.id}
|
||||
onApply={(nextRole) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
@@ -5083,6 +5113,7 @@ export function StoryNpcEditor({
|
||||
<RpgCreationRoleAssetStudioModal
|
||||
role={draft}
|
||||
roleKind="story"
|
||||
cacheScopeId={profile.id}
|
||||
onApply={(nextRole) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
@@ -5781,6 +5812,7 @@ export function LandmarkEditor({
|
||||
activeSceneActBackgroundDraft.title.trim() ||
|
||||
buildDefaultSceneActTitle(activeSceneActBackgroundIndex)
|
||||
}
|
||||
act={activeSceneActBackgroundDraft}
|
||||
currentImageSrc={activeSceneActBackgroundDraft.backgroundImageSrc}
|
||||
fallbackImageSrc={resolvedDraftImageSrc}
|
||||
onApply={(imageSrc) =>
|
||||
|
||||
@@ -2,8 +2,10 @@ import { X } from 'lucide-react';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { resolveCustomWorldCoverPresentation } from '../../services/customWorldCover';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||||
|
||||
function SmallButton({
|
||||
children,
|
||||
@@ -32,14 +34,25 @@ function SmallButton({
|
||||
);
|
||||
}
|
||||
|
||||
function PublishBlockersDialog({
|
||||
function PublishPanelDialog({
|
||||
blockers,
|
||||
profile,
|
||||
publishReady,
|
||||
isPublishing,
|
||||
onClose,
|
||||
onEditCover,
|
||||
onPublish,
|
||||
}: {
|
||||
blockers: string[];
|
||||
profile: CustomWorldProfile;
|
||||
publishReady: boolean;
|
||||
isPublishing: boolean;
|
||||
onClose: () => void;
|
||||
onEditCover: () => void;
|
||||
onPublish: () => void;
|
||||
}) {
|
||||
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
|
||||
const coverPresentation = resolveCustomWorldCoverPresentation(profile);
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
@@ -57,17 +70,17 @@ function PublishBlockersDialog({
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="发布前检查"
|
||||
className="platform-modal-shell platform-remap-surface flex max-h-[min(88vh,42rem)] w-full max-w-lg flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
|
||||
aria-label="发布作品"
|
||||
className="platform-modal-shell platform-remap-surface flex max-h-[min(90vh,46rem)] w-full max-w-4xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
发布前还需要补齐这些内容
|
||||
发布作品
|
||||
</div>
|
||||
<div className="mt-1 text-sm leading-6 text-[var(--platform-text-soft)]">
|
||||
当前还有 {blockers.length} 个阻断项,补齐后再发布并进入世界。
|
||||
发布前检查与封面设置
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -80,29 +93,72 @@ function PublishBlockersDialog({
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<div className="space-y-2">
|
||||
{blockers.map((blocker, index) => (
|
||||
<div
|
||||
key={`publish-blocker-${index}-${blocker}`}
|
||||
className="platform-banner platform-banner--warning text-sm leading-6"
|
||||
>
|
||||
<div className="text-xs font-semibold tracking-[0.14em] text-[var(--platform-warm-text)] opacity-80">
|
||||
阻断项 {index + 1}
|
||||
</div>
|
||||
<div className="mt-1 text-[var(--platform-text-strong)]">
|
||||
{blocker}
|
||||
</div>
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,0.78fr)]">
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
发布检查
|
||||
</div>
|
||||
))}
|
||||
{blockers.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{blockers.map((blocker, index) => (
|
||||
<div
|
||||
key={`publish-blocker-${index}-${blocker}`}
|
||||
className="platform-banner platform-banner--warning text-sm leading-6"
|
||||
>
|
||||
<div className="text-xs font-semibold tracking-[0.14em] text-[var(--platform-warm-text)] opacity-80">
|
||||
阻断项 {index + 1}
|
||||
</div>
|
||||
<div className="mt-1 text-[var(--platform-text-strong)]">
|
||||
{blocker}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="platform-banner platform-banner--success text-sm leading-6">
|
||||
当前作品已满足发布条件。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
封面设置
|
||||
</div>
|
||||
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||||
{coverPresentation.sourceType === 'uploaded'
|
||||
? '上传封面'
|
||||
: coverPresentation.sourceType === 'generated'
|
||||
? 'AI封面'
|
||||
: '默认封面'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="platform-subpanel rounded-[1.25rem] p-2">
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={coverPresentation.imageSrc}
|
||||
title={profile.name}
|
||||
fallbackLabel={profile.name.slice(0, 4) || '封面'}
|
||||
renderMode={coverPresentation.renderMode}
|
||||
characterImageSrcs={coverPresentation.characterImageSrcs}
|
||||
className="aspect-[16/9] max-h-[15rem] rounded-[1rem]"
|
||||
/>
|
||||
</div>
|
||||
<SmallButton onClick={onEditCover} tone="sky">
|
||||
设置封面
|
||||
</SmallButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-[var(--platform-subpanel-border)] px-5 py-4">
|
||||
<div className="flex flex-col-reverse gap-3 border-t border-[var(--platform-subpanel-border)] px-5 py-4 sm:flex-row sm:justify-end">
|
||||
<SmallButton onClick={onClose}>取消</SmallButton>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="platform-button platform-button--primary"
|
||||
onClick={onPublish}
|
||||
disabled={!publishReady || isPublishing}
|
||||
className={`platform-button platform-button--primary ${!publishReady || isPublishing ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
我知道了
|
||||
{isPublishing ? '发布中...' : '发布到广场'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,6 +174,9 @@ interface RpgCreationResultActionBarProps {
|
||||
onContinueExpand?: () => void;
|
||||
onEditSetting?: () => void;
|
||||
onEnterWorld?: () => void;
|
||||
onOpenCoverEditor?: () => void;
|
||||
onPublishWorld?: () => Promise<void> | void;
|
||||
onTestWorld?: () => void;
|
||||
onRegenerate?: () => void;
|
||||
profile: CustomWorldProfile;
|
||||
regenerateActionLabel: string;
|
||||
@@ -132,6 +191,9 @@ export function RpgCreationResultActionBar({
|
||||
onContinueExpand,
|
||||
onEditSetting,
|
||||
onEnterWorld,
|
||||
onOpenCoverEditor,
|
||||
onPublishWorld,
|
||||
onTestWorld,
|
||||
onRegenerate,
|
||||
profile,
|
||||
regenerateActionLabel,
|
||||
@@ -140,6 +202,7 @@ export function RpgCreationResultActionBar({
|
||||
}: RpgCreationResultActionBarProps) {
|
||||
const [showPublishBlockersDialog, setShowPublishBlockersDialog] =
|
||||
useState(false);
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
|
||||
// 结果页只在用户点击发布动作时展示阻断项,不做吸底常驻提示。
|
||||
const handleEnterWorld = () => {
|
||||
@@ -151,6 +214,20 @@ export function RpgCreationResultActionBar({
|
||||
onEnterWorld?.();
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!publishReady || isPublishing || !onPublishWorld) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
await onPublishWorld();
|
||||
setShowPublishBlockersDialog(false);
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{profile.generationStatus === 'key_only' ? (
|
||||
@@ -176,7 +253,26 @@ export function RpgCreationResultActionBar({
|
||||
继续补全世界
|
||||
</SmallButton>
|
||||
) : null}
|
||||
{onEnterWorld ? (
|
||||
{onTestWorld ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTestWorld}
|
||||
disabled={isGenerating}
|
||||
className={`platform-button platform-button--secondary ${isGenerating ? 'opacity-55' : ''}`}
|
||||
>
|
||||
作品测试
|
||||
</button>
|
||||
) : null}
|
||||
{onPublishWorld ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPublishBlockersDialog(true)}
|
||||
disabled={isGenerating}
|
||||
className={`platform-button platform-button--primary ${isGenerating ? 'opacity-55' : ''}`}
|
||||
>
|
||||
发布
|
||||
</button>
|
||||
) : onEnterWorld ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEnterWorld}
|
||||
@@ -188,13 +284,18 @@ export function RpgCreationResultActionBar({
|
||||
) : null}
|
||||
</div>
|
||||
{showPublishBlockersDialog ? (
|
||||
<PublishBlockersDialog
|
||||
blockers={
|
||||
publishBlockers.length > 0
|
||||
? publishBlockers
|
||||
: ['当前草稿还没有通过发布门槛,请先补齐必要内容。']
|
||||
}
|
||||
<PublishPanelDialog
|
||||
blockers={publishBlockers}
|
||||
profile={profile}
|
||||
publishReady={publishReady}
|
||||
isPublishing={isPublishing}
|
||||
onClose={() => setShowPublishBlockersDialog(false)}
|
||||
onEditCover={() => {
|
||||
onOpenCoverEditor?.();
|
||||
}}
|
||||
onPublish={() => {
|
||||
void handlePublish();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -26,12 +26,13 @@ export interface RpgCreationResultViewProps {
|
||||
onRegenerate?: () => void;
|
||||
onContinueExpand?: () => void;
|
||||
onEnterWorld?: () => void;
|
||||
onOpenCoverEditor?: () => void;
|
||||
onPublishWorld?: () => Promise<void> | void;
|
||||
onTestWorld?: () => void;
|
||||
onDeleteEntities?: (kind: 'story' | 'landmark', ids: string[]) => Promise<void> | void;
|
||||
onGenerateEntity?:
|
||||
| ((kind: EntityGenerationKind) => Promise<{ profile?: CustomWorldProfile | null } | void> | { profile?: CustomWorldProfile | null } | void)
|
||||
| undefined;
|
||||
onGenerateRoleAssets?: (roleId: string) => Promise<void> | void;
|
||||
onGenerateSceneAssets?: (sceneId: string, sceneKind: 'camp' | 'landmark') => Promise<void> | void;
|
||||
onProfileChange: (profile: CustomWorldProfile) => void;
|
||||
readOnly?: boolean;
|
||||
backLabel?: string;
|
||||
@@ -63,11 +64,12 @@ export function RpgCreationResultView({
|
||||
onEditSetting,
|
||||
onRegenerate: triggerRegenerate,
|
||||
onContinueExpand,
|
||||
onOpenCoverEditor,
|
||||
onPublishWorld,
|
||||
onTestWorld,
|
||||
onDeleteEntities,
|
||||
onEnterWorld,
|
||||
onGenerateEntity,
|
||||
onGenerateRoleAssets,
|
||||
onGenerateSceneAssets,
|
||||
onProfileChange,
|
||||
readOnly = false,
|
||||
backLabel = '返回',
|
||||
@@ -142,8 +144,6 @@ export function RpgCreationResultView({
|
||||
onProfileChange={onProfileChange}
|
||||
onDeleteStoryNpcs={deleteStoryNpcs}
|
||||
onDeleteLandmarks={deleteLandmarks}
|
||||
onGenerateRoleAssets={onGenerateRoleAssets ? (roleId) => { void onGenerateRoleAssets(roleId); } : undefined}
|
||||
onGenerateSceneAssets={onGenerateSceneAssets ? (sceneId, sceneKind) => { void onGenerateSceneAssets(sceneId, sceneKind); } : undefined}
|
||||
createActionLabel={
|
||||
readOnly || (compactAgentResultMode && !onGenerateEntity)
|
||||
? undefined
|
||||
@@ -223,6 +223,11 @@ export function RpgCreationResultView({
|
||||
onContinueExpand={onContinueExpand}
|
||||
onEditSetting={onEditSetting}
|
||||
onEnterWorld={onEnterWorld}
|
||||
onOpenCoverEditor={
|
||||
onOpenCoverEditor ?? (() => setEditorTarget({ kind: 'cover' }))
|
||||
}
|
||||
onPublishWorld={onPublishWorld}
|
||||
onTestWorld={onTestWorld}
|
||||
onRegenerate={triggerRegenerate ? handleRegenerate : undefined}
|
||||
profile={profile}
|
||||
regenerateActionLabel={regenerateActionLabel}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function useRpgCreationEnterWorld(
|
||||
setGeneratedCustomWorldProfile,
|
||||
} = params;
|
||||
|
||||
const enterWorldFromCurrentResult = useCallback(async () => {
|
||||
const enterWorldForTestFromCurrentResult = useCallback(async () => {
|
||||
if (!generatedCustomWorldProfile) {
|
||||
return;
|
||||
}
|
||||
@@ -50,6 +50,32 @@ export function useRpgCreationEnterWorld(
|
||||
return;
|
||||
}
|
||||
|
||||
const latestResult = await syncAgentDraftResultProfile(
|
||||
generatedCustomWorldProfile,
|
||||
);
|
||||
const latestProfile =
|
||||
latestResult.profile ?? agentSessionProfile ?? generatedCustomWorldProfile;
|
||||
setGeneratedCustomWorldProfile(latestProfile);
|
||||
handleCustomWorldSelect(latestProfile);
|
||||
}, [
|
||||
activeAgentSessionId,
|
||||
agentSessionProfile,
|
||||
generatedCustomWorldProfile,
|
||||
handleCustomWorldSelect,
|
||||
isAgentDraftResultView,
|
||||
setGeneratedCustomWorldProfile,
|
||||
syncAgentDraftResultProfile,
|
||||
]);
|
||||
|
||||
const publishCurrentResult = useCallback(async () => {
|
||||
if (!generatedCustomWorldProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isAgentDraftResultView || !activeAgentSessionId) {
|
||||
return generatedCustomWorldProfile;
|
||||
}
|
||||
|
||||
const latestResult = await syncAgentDraftResultProfile(
|
||||
generatedCustomWorldProfile,
|
||||
);
|
||||
@@ -63,8 +89,7 @@ export function useRpgCreationEnterWorld(
|
||||
latestSession.resultPreview?.canEnterWorld;
|
||||
|
||||
if (canEnterPublishedWorld) {
|
||||
handleCustomWorldSelect(latestProfile);
|
||||
return;
|
||||
return latestProfile;
|
||||
}
|
||||
|
||||
const publishedSession = await executePublishWorld();
|
||||
@@ -73,7 +98,7 @@ export function useRpgCreationEnterWorld(
|
||||
latestProfile;
|
||||
|
||||
setGeneratedCustomWorldProfile(publishedProfile);
|
||||
handleCustomWorldSelect(publishedProfile);
|
||||
return publishedProfile;
|
||||
}, [
|
||||
activeAgentSessionId,
|
||||
agentSession,
|
||||
@@ -86,7 +111,16 @@ export function useRpgCreationEnterWorld(
|
||||
syncAgentDraftResultProfile,
|
||||
]);
|
||||
|
||||
const enterWorldFromCurrentResult = useCallback(async () => {
|
||||
const publishedProfile = await publishCurrentResult();
|
||||
if (publishedProfile) {
|
||||
handleCustomWorldSelect(publishedProfile);
|
||||
}
|
||||
}, [handleCustomWorldSelect, publishCurrentResult]);
|
||||
|
||||
return {
|
||||
enterWorldFromCurrentResult,
|
||||
enterWorldForTestFromCurrentResult,
|
||||
publishCurrentResult,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@ const BIG_FISH_WORKS_READ_RETRY: ApiRetryOptions = {
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
};
|
||||
const BIG_FISH_WORKS_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取当前用户的大鱼吃小鱼创作作品列表。
|
||||
@@ -24,6 +30,23 @@ export async function listBigFishWorks() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除当前用户的大鱼吃小鱼作品,并返回删除后的作品列表。
|
||||
*/
|
||||
export async function deleteBigFishWork(sessionId: string) {
|
||||
return requestJson<BigFishWorksResponse>(
|
||||
`${BIG_FISH_WORKS_API_BASE}/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
'删除大鱼吃小鱼作品失败',
|
||||
{
|
||||
retry: BIG_FISH_WORKS_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const bigFishWorksClient = {
|
||||
delete: deleteBigFishWork,
|
||||
list: listBigFishWorks,
|
||||
};
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
export { bigFishWorksClient, listBigFishWorks } from './bigFishWorksClient';
|
||||
export {
|
||||
bigFishWorksClient,
|
||||
deleteBigFishWork,
|
||||
listBigFishWorks,
|
||||
} from './bigFishWorksClient';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
getPuzzleWorkDetail,
|
||||
deletePuzzleWork,
|
||||
listPuzzleWorks,
|
||||
puzzleWorksClient,
|
||||
updatePuzzleWork,
|
||||
|
||||
@@ -78,7 +78,24 @@ export async function updatePuzzleWork(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除当前用户的拼图作品,并返回删除后的作品列表。
|
||||
*/
|
||||
export async function deletePuzzleWork(profileId: string) {
|
||||
return requestJson<PuzzleWorksResponse>(
|
||||
`${PUZZLE_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
'删除拼图作品失败',
|
||||
{
|
||||
retry: PUZZLE_WORKS_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const puzzleWorksClient = {
|
||||
delete: deletePuzzleWork,
|
||||
getDetail: getPuzzleWorkDetail,
|
||||
list: listPuzzleWorks,
|
||||
update: updatePuzzleWork,
|
||||
|
||||
@@ -43,6 +43,7 @@ export {
|
||||
rpgCreationPreviewAdapter,
|
||||
} from './rpgCreationPreviewAdapter';
|
||||
export {
|
||||
deleteRpgCreationAgentSession,
|
||||
listRpgCreationWorks,
|
||||
rpgCreationWorkClient,
|
||||
} from './rpgCreationWorkClient';
|
||||
|
||||
@@ -11,9 +11,20 @@ export async function listRpgCreationWorks() {
|
||||
return Array.isArray(response?.items) ? response.items : [];
|
||||
}
|
||||
|
||||
export async function deleteRpgCreationAgentSession(sessionId: string) {
|
||||
const response = await requestRpgCreationRuntimeJson<ListRpgCreationWorksResponse>(
|
||||
`/custom-world/agent/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除 RPG 草稿失败',
|
||||
);
|
||||
|
||||
return Array.isArray(response?.items) ? response.items : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作包 D 把作品列表请求正式迁入 RPG 创作域 client。
|
||||
*/
|
||||
export const rpgCreationWorkClient = {
|
||||
deleteAgentSession: deleteRpgCreationAgentSession,
|
||||
listWorks: listRpgCreationWorks,
|
||||
};
|
||||
|
||||
@@ -344,6 +344,7 @@ export interface SceneActBlueprint {
|
||||
title: string;
|
||||
summary: string;
|
||||
stageCoverage: SceneActStage[];
|
||||
backgroundPromptText?: string | null;
|
||||
backgroundImageSrc?: string | null;
|
||||
backgroundAssetId?: string | null;
|
||||
encounterNpcIds: string[];
|
||||
|
||||
Reference in New Issue
Block a user