feat: move creation entry config to database

This commit is contained in:
2026-05-11 11:23:24 +08:00
parent 7f2461313e
commit 793d82cccd
37 changed files with 1458 additions and 204 deletions

View File

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

View File

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

View File

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

View File

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