feat: move creation entry config to database
This commit is contained in:
@@ -1,13 +1,88 @@
|
||||
/* @vitest-environment jsdom */
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
|
||||
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
||||
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
|
||||
|
||||
const noopCreateType = () => {};
|
||||
|
||||
const testEntryConfig = {
|
||||
startCard: {
|
||||
title: '新建作品',
|
||||
description: '选择模板后进入对应的创作表单。',
|
||||
idleBadge: '模板 Tab',
|
||||
busyBadge: '正在开启',
|
||||
},
|
||||
typeModal: {
|
||||
title: '选择创作类型',
|
||||
description: '先选玩法类型,再进入对应创作工作台。',
|
||||
},
|
||||
creationTypes: [
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图',
|
||||
subtitle: '拼图关卡创作',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 30,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'match3d',
|
||||
title: '抓大鹅',
|
||||
subtitle: '3D 消除关卡',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/match3d.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 40,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'square-hole',
|
||||
title: '方洞',
|
||||
subtitle: '形状投放挑战',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/square-hole.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 50,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel',
|
||||
title: '视觉小说',
|
||||
subtitle: '分支叙事体验',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/visual-novel.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 60,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'airp',
|
||||
title: 'AI RPG',
|
||||
subtitle: '原生角色扮演',
|
||||
badge: '即将开放',
|
||||
imageSrc: '/creation-type-references/airp.webp',
|
||||
visible: true,
|
||||
open: false,
|
||||
sortOrder: 70,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
],
|
||||
} satisfies CreationEntryConfig;
|
||||
|
||||
const testCreationTypes = derivePlatformCreationTypes(testEntryConfig.creationTypes);
|
||||
|
||||
const originalClipboard = navigator.clipboard;
|
||||
|
||||
afterEach(() => {
|
||||
@@ -58,6 +133,8 @@ test('creation hub shows published metric growth from cached page snapshot', asy
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
onOpenPuzzleDetail={() => {}}
|
||||
/>,
|
||||
);
|
||||
@@ -99,6 +176,8 @@ test('creation hub reflects updated draft title summary and counts after rerende
|
||||
onCreateType={onCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -142,6 +221,8 @@ test('creation hub reflects updated draft title summary and counts after rerende
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -182,6 +263,8 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
onOpenPuzzleDetail={() => {}}
|
||||
/>,
|
||||
);
|
||||
@@ -238,6 +321,8 @@ test('creation hub shows puzzle point incentive and claims without opening card'
|
||||
onEnterPublished={() => {}}
|
||||
onOpenPuzzleDetail={onOpenPuzzleDetail}
|
||||
onClaimPuzzlePointIncentive={onClaimPuzzlePointIncentive}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -296,6 +381,8 @@ test('creation hub shows RPG public work code from published library entry', ()
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -318,6 +405,8 @@ test('creation hub shows delete action for persisted rpg drafts', () => {
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
onDeletePublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -359,6 +448,8 @@ test('creation hub published work delete action is available beside share withou
|
||||
onEnterPublished={() => {}}
|
||||
onOpenPuzzleDetail={onOpenPuzzleDetail}
|
||||
onDeletePuzzle={onDeletePuzzle}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -396,6 +487,8 @@ test('creation hub opens persisted rpg drafts by card click', async () => {
|
||||
openedItems.push(item);
|
||||
}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -444,6 +537,8 @@ test('creation hub published share button copies share text without opening the
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
onOpenPuzzleDetail={onOpenPuzzleDetail}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,10 +1,85 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
|
||||
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
||||
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
|
||||
|
||||
const noopCreateType = () => {};
|
||||
|
||||
const testEntryConfig = {
|
||||
startCard: {
|
||||
title: '新建作品',
|
||||
description: '选择模板后进入对应的创作表单。',
|
||||
idleBadge: '模板 Tab',
|
||||
busyBadge: '正在开启',
|
||||
},
|
||||
typeModal: {
|
||||
title: '选择创作类型',
|
||||
description: '先选玩法类型,再进入对应创作工作台。',
|
||||
},
|
||||
creationTypes: [
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图',
|
||||
subtitle: '拼图关卡创作',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 30,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'match3d',
|
||||
title: '抓大鹅',
|
||||
subtitle: '3D 消除关卡',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/match3d.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 40,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'square-hole',
|
||||
title: '方洞',
|
||||
subtitle: '形状投放挑战',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/square-hole.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 50,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel',
|
||||
title: '视觉小说',
|
||||
subtitle: '分支叙事体验',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/visual-novel.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 60,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'airp',
|
||||
title: 'AI RPG',
|
||||
subtitle: '原生角色扮演',
|
||||
badge: '即将开放',
|
||||
imageSrc: '/creation-type-references/airp.webp',
|
||||
visible: true,
|
||||
open: false,
|
||||
sortOrder: 70,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
],
|
||||
} satisfies CreationEntryConfig;
|
||||
|
||||
const testCreationTypes = derivePlatformCreationTypes(testEntryConfig.creationTypes);
|
||||
|
||||
|
||||
test('creation hub draft card renders compiled work summary fields', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
@@ -36,6 +111,8 @@ test('creation hub draft card renders compiled work summary fields', () => {
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -79,6 +156,8 @@ test('creation hub renders puzzle works in the same unified list with puzzle tag
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
onOpenPuzzleDetail={() => {}}
|
||||
/>,
|
||||
);
|
||||
@@ -123,6 +202,8 @@ test('creation hub published work spans full mobile row', () => {
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
onOpenPuzzleDetail={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -8,7 +8,11 @@ import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contr
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import type { PlatformCreationTypeId } from '../platform-entry/platformEntryCreationTypes';
|
||||
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
||||
import type {
|
||||
PlatformCreationTypeCard,
|
||||
PlatformCreationTypeId,
|
||||
} from '../platform-entry/platformEntryCreationTypes';
|
||||
import {
|
||||
buildCreationWorkShelfItems,
|
||||
type CreationWorkShelfItem,
|
||||
@@ -38,6 +42,8 @@ type CustomWorldCreationHubProps = {
|
||||
onRetry: () => void;
|
||||
createError?: string | null;
|
||||
createBusy?: boolean;
|
||||
entryConfig: CreationEntryConfig;
|
||||
creationTypes: readonly PlatformCreationTypeCard[];
|
||||
onCreateType: (type: PlatformCreationTypeId) => void;
|
||||
onOpenDraft: (item: CustomWorldWorkSummary) => void;
|
||||
onEnterPublished: (profileId: string) => void;
|
||||
@@ -134,6 +140,8 @@ export function CustomWorldCreationHub({
|
||||
onRetry,
|
||||
createError = null,
|
||||
createBusy = false,
|
||||
entryConfig,
|
||||
creationTypes,
|
||||
onCreateType,
|
||||
onOpenDraft,
|
||||
onEnterPublished,
|
||||
@@ -309,6 +317,8 @@ export function CustomWorldCreationHub({
|
||||
<CustomWorldCreationStartCard
|
||||
busy={createBusy}
|
||||
error={createError}
|
||||
entryConfig={entryConfig}
|
||||
creationTypes={creationTypes}
|
||||
onCreateType={onCreateType}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
import { NEW_WORK_ENTRY_CONFIG } from '../../config/newWorkEntryConfig';
|
||||
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
||||
import {
|
||||
getVisiblePlatformCreationTypes,
|
||||
type PlatformCreationTypeCard,
|
||||
type PlatformCreationTypeId,
|
||||
} from '../platform-entry/platformEntryCreationTypes';
|
||||
|
||||
type CustomWorldCreationStartCardProps = {
|
||||
busy?: boolean;
|
||||
error?: string | null;
|
||||
entryConfig: CreationEntryConfig;
|
||||
creationTypes: readonly PlatformCreationTypeCard[];
|
||||
onCreateType: (type: PlatformCreationTypeId) => void;
|
||||
};
|
||||
|
||||
export function CustomWorldCreationStartCard({
|
||||
busy = false,
|
||||
error = null,
|
||||
entryConfig,
|
||||
creationTypes,
|
||||
onCreateType,
|
||||
}: CustomWorldCreationStartCardProps) {
|
||||
// 创作首页首屏卡带与创作类型弹层保持同一份展示口径,
|
||||
// 避免某个玩法只在其中一个入口被隐藏而出现状态漂移。
|
||||
const visibleCreationTypes = getVisiblePlatformCreationTypes();
|
||||
const visibleCreationTypes = getVisiblePlatformCreationTypes(creationTypes);
|
||||
|
||||
return (
|
||||
// 移动端限制模块高度,模板入口改为横向滚动,避免挤占作品列表首屏空间。
|
||||
@@ -28,15 +33,15 @@ export function CustomWorldCreationStartCard({
|
||||
<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">
|
||||
{NEW_WORK_ENTRY_CONFIG.startCard.title}
|
||||
{entryConfig.startCard.title}
|
||||
</div>
|
||||
<div className="hidden text-sm leading-6 text-zinc-200/88 sm:block xl:text-xs xl:leading-5">
|
||||
{NEW_WORK_ENTRY_CONFIG.startCard.description}
|
||||
{entryConfig.startCard.description}
|
||||
</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">
|
||||
{busy
|
||||
? NEW_WORK_ENTRY_CONFIG.startCard.busyBadge
|
||||
: NEW_WORK_ENTRY_CONFIG.startCard.idleBadge}
|
||||
? entryConfig.startCard.busyBadge
|
||||
: entryConfig.startCard.idleBadge}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
import { NEW_WORK_ENTRY_CONFIG } from '../../config/newWorkEntryConfig';
|
||||
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import { getVisiblePlatformCreationTypes } from './platformEntryCreationTypes';
|
||||
import {
|
||||
getVisiblePlatformCreationTypes,
|
||||
type PlatformCreationTypeCard,
|
||||
} from './platformEntryCreationTypes';
|
||||
|
||||
export interface PlatformEntryCreationTypeModalProps {
|
||||
isOpen: boolean;
|
||||
isBusy: boolean;
|
||||
error: string | null;
|
||||
entryConfig: CreationEntryConfig;
|
||||
creationTypes: readonly PlatformCreationTypeCard[];
|
||||
onClose: () => void;
|
||||
onSelectRpg: () => void;
|
||||
onSelectBigFish: () => void;
|
||||
@@ -86,6 +91,8 @@ export function PlatformEntryCreationTypeModal({
|
||||
isOpen,
|
||||
isBusy,
|
||||
error,
|
||||
entryConfig,
|
||||
creationTypes,
|
||||
onClose,
|
||||
onSelectRpg,
|
||||
onSelectBigFish,
|
||||
@@ -101,13 +108,13 @@ export function PlatformEntryCreationTypeModal({
|
||||
|
||||
// 平台入口只渲染当前允许展示的创作类型;
|
||||
// 被隐藏的玩法仍保留既有实现与路由,不在这里删除能力本体。
|
||||
const visibleCreationTypes = getVisiblePlatformCreationTypes();
|
||||
const visibleCreationTypes = getVisiblePlatformCreationTypes(creationTypes);
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={isOpen}
|
||||
title={NEW_WORK_ENTRY_CONFIG.typeModal.title}
|
||||
description={NEW_WORK_ENTRY_CONFIG.typeModal.description}
|
||||
title={entryConfig.typeModal.title}
|
||||
description={entryConfig.typeModal.description}
|
||||
onClose={onClose}
|
||||
closeDisabled={isBusy}
|
||||
size="lg"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowRight, Loader2, Sparkles } from 'lucide-react';
|
||||
import { ArrowRight, Loader2, Sparkles } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import {
|
||||
type Dispatch,
|
||||
@@ -99,10 +99,15 @@ import {
|
||||
buildPublicWorkStagePath,
|
||||
pushAppHistoryPath,
|
||||
} from '../../routing/appPageRoutes';
|
||||
import { resolveRuntimeNotFoundRecoveryAction } from '../../routing/runtimeNotFoundRecovery';
|
||||
import {
|
||||
ApiClientError,
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||
} from '../../services/apiClient';
|
||||
import {
|
||||
fetchCreationEntryConfig,
|
||||
type CreationEntryConfig,
|
||||
} from '../../services/creationEntryConfigService';
|
||||
import {
|
||||
getPublicAuthUserByCode,
|
||||
getPublicAuthUserById,
|
||||
@@ -293,6 +298,7 @@ import {
|
||||
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
|
||||
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
|
||||
import {
|
||||
derivePlatformCreationTypes,
|
||||
getVisiblePlatformCreationTypes,
|
||||
isPlatformCreationTypeVisible,
|
||||
} from './platformEntryCreationTypes';
|
||||
@@ -875,6 +881,24 @@ function isMissingPuzzleWorkError(error: unknown) {
|
||||
);
|
||||
}
|
||||
|
||||
function maybeAlertRuntimeNotFoundAndReturnHome() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const recoveryAction = resolveRuntimeNotFoundRecoveryAction(
|
||||
window.location.pathname,
|
||||
);
|
||||
if (!recoveryAction) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 中文注释:直接 runtime 深链找不到作品时,弹窗确认后立刻回首页,避免保留空白运行态。
|
||||
window.alert('作品不存在或已下架,将返回首页。');
|
||||
pushAppHistoryPath(recoveryAction.nextPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasSeenPuzzleOnboarding() {
|
||||
if (typeof window === 'undefined') {
|
||||
return true;
|
||||
@@ -1679,7 +1703,22 @@ export function PlatformEntryFlowShellImpl({
|
||||
] = useState<string | null>(null);
|
||||
const [publishSharePayload, setPublishSharePayload] =
|
||||
useState<PublishShareModalPayload | null>(null);
|
||||
const isBigFishCreationVisible = isPlatformCreationTypeVisible('big-fish');
|
||||
const [creationEntryConfig, setCreationEntryConfig] =
|
||||
useState<CreationEntryConfig | null>(null);
|
||||
const [creationEntryConfigError, setCreationEntryConfigError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const creationEntryTypes = useMemo(
|
||||
() =>
|
||||
creationEntryConfig
|
||||
? derivePlatformCreationTypes(creationEntryConfig.creationTypes)
|
||||
: [],
|
||||
[creationEntryConfig],
|
||||
);
|
||||
const isBigFishCreationVisible = isPlatformCreationTypeVisible(
|
||||
creationEntryTypes,
|
||||
'big-fish',
|
||||
);
|
||||
const [profilePlayStats, setProfilePlayStats] =
|
||||
useState<ProfilePlayStatsResponse | null>(null);
|
||||
const [profilePlayStatsError, setProfilePlayStatsError] = useState<
|
||||
@@ -1695,6 +1734,27 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
const handledInitialPublicWorkCodeRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setCreationEntryConfigError(null);
|
||||
void fetchCreationEntryConfig()
|
||||
.then((config) => {
|
||||
if (!cancelled) {
|
||||
setCreationEntryConfig(config);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!cancelled) {
|
||||
setCreationEntryConfigError(
|
||||
error instanceof Error ? error.message : '读取创作入口配置失败。',
|
||||
);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const platformBootstrap = usePlatformEntryBootstrap({
|
||||
user: authUi?.user,
|
||||
canAccessProtectedData: authUi?.canAccessProtectedData,
|
||||
@@ -4092,7 +4152,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPublicWorkDetailError(null);
|
||||
setPlatformTab('home');
|
||||
setSelectionStage('platform');
|
||||
pushAppHistoryPath('/');
|
||||
if (!maybeAlertRuntimeNotFoundAndReturnHome()) {
|
||||
pushAppHistoryPath('/');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -5590,7 +5652,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPublicWorkDetailError(null);
|
||||
setPlatformTab('home');
|
||||
setSelectionStage('platform');
|
||||
pushAppHistoryPath('/');
|
||||
if (!maybeAlertRuntimeNotFoundAndReturnHome()) {
|
||||
pushAppHistoryPath('/');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5809,7 +5873,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPublicWorkDetailError(null);
|
||||
setPlatformTab('home');
|
||||
setSelectionStage('platform');
|
||||
pushAppHistoryPath('/');
|
||||
if (!maybeAlertRuntimeNotFoundAndReturnHome()) {
|
||||
pushAppHistoryPath('/');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7380,7 +7446,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
fallbackLabel: string,
|
||||
) => (
|
||||
<Suspense fallback={<LazyPanelFallback label={fallbackLabel} />}>
|
||||
<CustomWorldCreationHub
|
||||
{creationEntryConfig ? (
|
||||
<CustomWorldCreationHub
|
||||
mode={mode}
|
||||
items={creationHubItems}
|
||||
loading={
|
||||
@@ -7410,6 +7477,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
onRetry={() => {
|
||||
platformBootstrap.setPlatformError(null);
|
||||
setCreationEntryConfigError(null);
|
||||
void fetchCreationEntryConfig()
|
||||
.then(setCreationEntryConfig)
|
||||
.catch((error: unknown) => {
|
||||
setCreationEntryConfigError(
|
||||
error instanceof Error ? error.message : '读取创作入口配置失败。',
|
||||
);
|
||||
});
|
||||
setBigFishError(null);
|
||||
setMatch3DError(null);
|
||||
setSquareHoleError(null);
|
||||
@@ -7431,6 +7506,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
void refreshVisualNovelShelf();
|
||||
}}
|
||||
createError={
|
||||
creationEntryConfigError ??
|
||||
sessionController.creationTypeError ??
|
||||
bigFishError ??
|
||||
match3dError ??
|
||||
@@ -7440,6 +7516,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
visualNovelError
|
||||
}
|
||||
createBusy={
|
||||
!creationEntryConfig ||
|
||||
sessionController.isCreatingAgentSession ||
|
||||
isCreativeAgentBusy ||
|
||||
isCreativeAgentStreaming ||
|
||||
@@ -7450,6 +7527,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
isVisualNovelBusy ||
|
||||
isVisualNovelStreamingReply
|
||||
}
|
||||
entryConfig={creationEntryConfig}
|
||||
creationTypes={creationEntryTypes}
|
||||
onCreateType={handleCreationHubCreateType}
|
||||
onOpenDraft={(item) => {
|
||||
runProtectedAction(() => {
|
||||
@@ -7530,6 +7609,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
handleDeleteVisualNovelWork(item);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Suspense>
|
||||
);
|
||||
const creationStartContent = (
|
||||
@@ -7541,7 +7621,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
role="tablist"
|
||||
aria-label="选择模板"
|
||||
>
|
||||
{getVisiblePlatformCreationTypes().map((item) => {
|
||||
{getVisiblePlatformCreationTypes(creationEntryTypes).map((item) => {
|
||||
const selected = item.id === 'puzzle';
|
||||
const disabled =
|
||||
item.locked ||
|
||||
@@ -9117,7 +9197,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<PlatformEntryCreationTypeModal
|
||||
{creationEntryConfig ? (
|
||||
<PlatformEntryCreationTypeModal
|
||||
isOpen={showCreationTypeModal}
|
||||
isBusy={
|
||||
sessionController.isCreatingAgentSession ||
|
||||
@@ -9131,6 +9212,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
isVisualNovelStreamingReply
|
||||
}
|
||||
error={
|
||||
creationEntryConfigError ??
|
||||
bigFishError ??
|
||||
creativeAgentError ??
|
||||
match3dError ??
|
||||
@@ -9140,6 +9222,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
puzzleError ??
|
||||
sessionController.creationTypeError
|
||||
}
|
||||
entryConfig={creationEntryConfig}
|
||||
creationTypes={creationEntryTypes}
|
||||
onClose={() => {
|
||||
if (
|
||||
sessionController.isCreatingAgentSession ||
|
||||
@@ -9192,6 +9276,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<PublishShareModal
|
||||
open={Boolean(publishSharePayload)}
|
||||
payload={publishSharePayload}
|
||||
|
||||
@@ -1,69 +1,103 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { NEW_WORK_ENTRY_CONFIG } from '../../config/newWorkEntryConfig';
|
||||
import {
|
||||
getVisiblePlatformCreationTypes,
|
||||
isPlatformCreationTypeVisible,
|
||||
PLATFORM_CREATION_TYPES,
|
||||
} from './platformEntryCreationTypes';
|
||||
import { derivePlatformCreationTypes, getVisiblePlatformCreationTypes } from './platformEntryCreationTypes';
|
||||
|
||||
test('platform creation types are derived from new work entry config', () => {
|
||||
const puzzleConfig = NEW_WORK_ENTRY_CONFIG.creationTypes.find(
|
||||
(item) => item.id === 'puzzle',
|
||||
);
|
||||
const match3dConfig = NEW_WORK_ENTRY_CONFIG.creationTypes.find(
|
||||
(item) => item.id === 'match3d',
|
||||
);
|
||||
|
||||
expect(puzzleConfig).toBeTruthy();
|
||||
expect(match3dConfig).toBeTruthy();
|
||||
expect(PLATFORM_CREATION_TYPES).toContainEqual(
|
||||
expect.objectContaining({
|
||||
test('database entry config controls visibility open state and display order', () => {
|
||||
const cards = derivePlatformCreationTypes([
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: puzzleConfig?.title,
|
||||
subtitle: puzzleConfig?.subtitle,
|
||||
badge: puzzleConfig?.badge,
|
||||
imageSrc: puzzleConfig?.imageSrc,
|
||||
locked: false,
|
||||
hidden: false,
|
||||
}),
|
||||
);
|
||||
expect(PLATFORM_CREATION_TYPES).toContainEqual(
|
||||
title: '数据库拼图',
|
||||
subtitle: '由服务端配置覆盖',
|
||||
badge: '维护中',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
visible: true,
|
||||
open: false,
|
||||
sortOrder: 30,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'match3d',
|
||||
title: '抓大鹅',
|
||||
subtitle: '数据库开放',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/match3d.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'square-hole',
|
||||
title: '方洞挑战',
|
||||
subtitle: '临时隐藏',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/square-hole.webp',
|
||||
visible: false,
|
||||
open: true,
|
||||
sortOrder: 10,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(cards).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'match3d',
|
||||
title: match3dConfig?.title,
|
||||
subtitle: match3dConfig?.subtitle,
|
||||
badge: match3dConfig?.badge,
|
||||
imageSrc: match3dConfig?.imageSrc,
|
||||
locked: false,
|
||||
hidden: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('every platform creation type has a generated reference image', () => {
|
||||
expect(
|
||||
NEW_WORK_ENTRY_CONFIG.creationTypes.every((item) =>
|
||||
item.imageSrc.startsWith('/creation-type-references/'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('new work entry config controls visibility and open order', () => {
|
||||
const visibleIds = getVisiblePlatformCreationTypes().map((item) => item.id);
|
||||
|
||||
expect(isPlatformCreationTypeVisible('rpg')).toBe(false);
|
||||
expect(isPlatformCreationTypeVisible('big-fish')).toBe(false);
|
||||
expect(isPlatformCreationTypeVisible('match3d')).toBe(true);
|
||||
expect(isPlatformCreationTypeVisible('creative-agent')).toBe(false);
|
||||
expect(visibleIds).not.toContain('rpg');
|
||||
expect(visibleIds).not.toContain('big-fish');
|
||||
expect(visibleIds).not.toContain('creative-agent');
|
||||
expect(visibleIds).toEqual([
|
||||
'puzzle',
|
||||
'match3d',
|
||||
'square-hole',
|
||||
'visual-novel',
|
||||
'airp',
|
||||
expect.objectContaining({
|
||||
id: 'square-hole',
|
||||
locked: false,
|
||||
hidden: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'puzzle',
|
||||
title: '数据库拼图',
|
||||
locked: true,
|
||||
hidden: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('visible platform creation types hide invisible cards and put locked cards last', () => {
|
||||
const cards = derivePlatformCreationTypes([
|
||||
{
|
||||
id: 'hidden',
|
||||
title: '隐藏',
|
||||
subtitle: '隐藏',
|
||||
badge: '隐藏',
|
||||
imageSrc: '/creation-type-references/hidden.webp',
|
||||
visible: false,
|
||||
open: true,
|
||||
sortOrder: 1,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'locked',
|
||||
title: '锁定',
|
||||
subtitle: '锁定',
|
||||
badge: '即将开放',
|
||||
imageSrc: '/creation-type-references/locked.webp',
|
||||
visible: true,
|
||||
open: false,
|
||||
sortOrder: 2,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'open',
|
||||
title: '开放',
|
||||
subtitle: '开放',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/open.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 3,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(getVisiblePlatformCreationTypes(cards).map((item) => item.id)).toEqual([
|
||||
'open',
|
||||
'locked',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import {
|
||||
NEW_WORK_ENTRY_CONFIG,
|
||||
type NewWorkEntryCreationTypeId,
|
||||
} from '../../config/newWorkEntryConfig';
|
||||
import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService';
|
||||
|
||||
export type PlatformCreationTypeId = NewWorkEntryCreationTypeId;
|
||||
export type PlatformCreationTypeId = string;
|
||||
|
||||
export type PlatformCreationTypeCard = {
|
||||
id: PlatformCreationTypeId;
|
||||
@@ -15,40 +12,46 @@ export type PlatformCreationTypeCard = {
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 返回当前平台入口允许展示的创作类型。
|
||||
* 平台层的入口、首屏卡带与初始化请求都应基于这份结果统一判断。
|
||||
*/
|
||||
export function getVisiblePlatformCreationTypes() {
|
||||
const visibleCreationTypes = PLATFORM_CREATION_TYPES.filter(
|
||||
(item) => !item.hidden,
|
||||
);
|
||||
export function getVisiblePlatformCreationTypes(
|
||||
creationTypes: readonly PlatformCreationTypeCard[],
|
||||
) {
|
||||
const visibleCreationTypes = creationTypes.filter((item) => !item.hidden);
|
||||
|
||||
// 中文注释:可创建模板优先露出,敬请期待模板后置;两组内部沿用配置顺序。
|
||||
// 中文注释:可创建模板优先露出,敬请期待模板后置;两组内部沿用数据库排序。
|
||||
return [
|
||||
...visibleCreationTypes.filter((item) => !item.locked),
|
||||
...visibleCreationTypes.filter((item) => item.locked),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断某个创作类型当前是否仍暴露在平台入口中。
|
||||
*/
|
||||
export function isPlatformCreationTypeVisible(id: PlatformCreationTypeId) {
|
||||
return PLATFORM_CREATION_TYPES.some((item) => item.id === id && !item.hidden);
|
||||
export function isPlatformCreationTypeVisible(
|
||||
creationTypes: readonly PlatformCreationTypeCard[],
|
||||
id: PlatformCreationTypeId,
|
||||
) {
|
||||
return creationTypes.some((item) => item.id === id && !item.hidden);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创作页与类型弹层共用同一份新建作品入口配置,避免多入口文案和开放状态漂移。
|
||||
* `hidden` 只控制平台入口是否展示,不影响既有玩法链路和路由能力。
|
||||
* 创作入口卡片只做展示派生;配置事实源来自后端 API / SpacetimeDB,前端不再保留入口默认配置。
|
||||
*/
|
||||
export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] =
|
||||
NEW_WORK_ENTRY_CONFIG.creationTypes.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
badge: item.badge,
|
||||
imageSrc: item.imageSrc,
|
||||
locked: !item.open,
|
||||
hidden: !item.visible,
|
||||
}));
|
||||
export function derivePlatformCreationTypes(
|
||||
creationTypes: readonly CreationEntryTypeConfig[],
|
||||
): PlatformCreationTypeCard[] {
|
||||
const orderedCards = [...creationTypes]
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
badge: item.badge,
|
||||
imageSrc: item.imageSrc,
|
||||
locked: !item.open,
|
||||
hidden: !item.visible,
|
||||
}));
|
||||
|
||||
return [
|
||||
...orderedCards.filter((item) => !item.hidden && !item.locked),
|
||||
...orderedCards.filter((item) => item.hidden),
|
||||
...orderedCards.filter((item) => !item.hidden && item.locked),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* 新建作品入口配置。
|
||||
* 修改入口开放状态、隐藏状态和展示文案时,优先调整本文件,避免多入口文案漂移。
|
||||
*/
|
||||
export const NEW_WORK_ENTRY_CONFIG = {
|
||||
startCard: {
|
||||
title: '新建作品',
|
||||
description: '选择模板后进入对应的创作表单。',
|
||||
idleBadge: '模板 Tab',
|
||||
busyBadge: '正在开启',
|
||||
},
|
||||
typeModal: {
|
||||
title: '选择创作类型',
|
||||
description: '先选玩法类型,再进入对应创作工作台。',
|
||||
},
|
||||
creationTypes: [
|
||||
{
|
||||
id: 'rpg',
|
||||
title: '角色扮演',
|
||||
subtitle: '敬请期待',
|
||||
badge: '敬请期待',
|
||||
imageSrc: '/creation-type-references/rpg.webp',
|
||||
visible: false,
|
||||
open: true,
|
||||
},
|
||||
{
|
||||
id: 'big-fish',
|
||||
title: '大鱼吃小鱼',
|
||||
subtitle: '实时成长玩法',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/big-fish.webp',
|
||||
visible: false,
|
||||
open: true,
|
||||
},
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图',
|
||||
subtitle: '创意礼物,生活分享',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
},
|
||||
{
|
||||
id: 'match3d',
|
||||
title: '抓大鹅',
|
||||
subtitle: '经典消除玩法',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/match3d.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
},
|
||||
{
|
||||
id: 'square-hole',
|
||||
title: '方洞挑战',
|
||||
subtitle: '反直觉形状分拣',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/square-hole.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
},
|
||||
{
|
||||
id: 'airp',
|
||||
title: 'AIRP',
|
||||
subtitle: '敬请期待',
|
||||
badge: '敬请期待',
|
||||
imageSrc: '/creation-type-references/airp.webp',
|
||||
visible: true,
|
||||
open: false,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel',
|
||||
title: '视觉小说',
|
||||
subtitle: '故事分镜共创',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/visual-novel.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
},
|
||||
{
|
||||
id: 'creative-agent',
|
||||
title: '智能创作',
|
||||
subtitle: '图文生成拼图草稿',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/creative-agent.webp',
|
||||
visible: false,
|
||||
open: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export type NewWorkEntryCreationTypeId =
|
||||
(typeof NEW_WORK_ENTRY_CONFIG.creationTypes)[number]['id'];
|
||||
21
src/routing/runtimeNotFoundRecovery.test.ts
Normal file
21
src/routing/runtimeNotFoundRecovery.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { resolveRuntimeNotFoundRecoveryAction } from './runtimeNotFoundRecovery';
|
||||
|
||||
test('runtime not found recovery returns home after direct runtime route alert', () => {
|
||||
expect(resolveRuntimeNotFoundRecoveryAction('/runtime/puzzle')).toEqual({
|
||||
nextStage: 'platform',
|
||||
nextPath: '/',
|
||||
shouldAlert: true,
|
||||
});
|
||||
expect(resolveRuntimeNotFoundRecoveryAction('/runtime/puzzle/')).toEqual({
|
||||
nextStage: 'platform',
|
||||
nextPath: '/',
|
||||
shouldAlert: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime not found recovery only handles direct runtime routes', () => {
|
||||
expect(resolveRuntimeNotFoundRecoveryAction('/gallery/puzzle/detail')).toBeNull();
|
||||
expect(resolveRuntimeNotFoundRecoveryAction('/creation/puzzle/result')).toBeNull();
|
||||
});
|
||||
30
src/routing/runtimeNotFoundRecovery.ts
Normal file
30
src/routing/runtimeNotFoundRecovery.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export type RuntimeNotFoundRecoveryAction = {
|
||||
nextStage: 'platform';
|
||||
nextPath: '/';
|
||||
shouldAlert: true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 中文注释:直接打开 /runtime/<玩法>?work=<作品号> 时,如果作品不存在,
|
||||
* 弹窗关闭后必须回到首页,避免继续停留在没有运行态数据的空白页面。
|
||||
*/
|
||||
export function resolveRuntimeNotFoundRecoveryAction(
|
||||
pathname: string,
|
||||
): RuntimeNotFoundRecoveryAction | null {
|
||||
const normalizedPath = pathname.trim().toLowerCase().replace(/\/+$/u, '');
|
||||
if (
|
||||
normalizedPath === '/runtime/puzzle' ||
|
||||
normalizedPath === '/runtime/match3d' ||
|
||||
normalizedPath === '/runtime/big-fish' ||
|
||||
normalizedPath === '/runtime/square-hole' ||
|
||||
normalizedPath === '/runtime/visual-novel'
|
||||
) {
|
||||
return {
|
||||
nextStage: 'platform',
|
||||
nextPath: '/',
|
||||
shouldAlert: true,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
35
src/services/creationEntryConfigService.ts
Normal file
35
src/services/creationEntryConfigService.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { requestJson } from './apiClient';
|
||||
|
||||
export type CreationEntryTypeConfig = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
badge: string;
|
||||
imageSrc: string;
|
||||
visible: boolean;
|
||||
open: boolean;
|
||||
sortOrder: number;
|
||||
updatedAtMicros: number;
|
||||
};
|
||||
|
||||
export type CreationEntryConfig = {
|
||||
startCard: {
|
||||
title: string;
|
||||
description: string;
|
||||
idleBadge: string;
|
||||
busyBadge: string;
|
||||
};
|
||||
typeModal: {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
creationTypes: CreationEntryTypeConfig[];
|
||||
};
|
||||
|
||||
export async function fetchCreationEntryConfig(): Promise<CreationEntryConfig> {
|
||||
return requestJson<CreationEntryConfig>(
|
||||
'/api/creation-entry/config',
|
||||
{ method: 'GET' },
|
||||
'读取创作入口配置失败',
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user