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:
2026-04-24 22:17:37 +08:00
54 changed files with 2339 additions and 434 deletions

View File

@@ -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}

View File

@@ -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>
)}

View File

@@ -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)}`
: ''
}`,
'读取角色形象生成缓存失败',
);
}

View File

@@ -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();
});

View File

@@ -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}
/>

View File

@@ -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'
}`}
>

View File

@@ -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>

View File

@@ -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' : ''
}`}
>

View File

@@ -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

View File

@@ -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();
}}

View File

@@ -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">
pngjpgwebp 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">
pngjpgwebp 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) =>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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,
};
}

View File

@@ -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,
};

View File

@@ -1 +1,5 @@
export { bigFishWorksClient, listBigFishWorks } from './bigFishWorksClient';
export {
bigFishWorksClient,
deleteBigFishWork,
listBigFishWorks,
} from './bigFishWorksClient';

View File

@@ -1,5 +1,6 @@
export {
getPuzzleWorkDetail,
deletePuzzleWork,
listPuzzleWorks,
puzzleWorksClient,
updatePuzzleWork,

View File

@@ -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,

View File

@@ -43,6 +43,7 @@ export {
rpgCreationPreviewAdapter,
} from './rpgCreationPreviewAdapter';
export {
deleteRpgCreationAgentSession,
listRpgCreationWorks,
rpgCreationWorkClient,
} from './rpgCreationWorkClient';

View File

@@ -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,
};

View File

@@ -344,6 +344,7 @@ export interface SceneActBlueprint {
title: string;
summary: string;
stageCoverage: SceneActStage[];
backgroundPromptText?: string | null;
backgroundImageSrc?: string | null;
backgroundAssetId?: string | null;
encounterNpcIds: string[];