1
This commit is contained in:
@@ -8,27 +8,23 @@
|
||||
} from 'react';
|
||||
|
||||
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
|
||||
import {
|
||||
resolveCustomWorldCampSceneImage,
|
||||
resolveCustomWorldLandmarkImageMap,
|
||||
} from '../data/customWorldVisuals';
|
||||
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
||||
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
|
||||
import {
|
||||
buildCustomWorldFoundationEntries,
|
||||
parseFoundationTagText,
|
||||
} from '../services/customWorldFoundationEntries';
|
||||
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
|
||||
import { buildCustomWorldScenePresentations } from '../services/customWorldScenePresentation';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
CustomWorldProfile,
|
||||
type Character,
|
||||
type CustomWorldProfile,
|
||||
type SceneActBlueprint,
|
||||
type SceneChapterBlueprint,
|
||||
} from '../types';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal';
|
||||
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal';
|
||||
|
||||
export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks';
|
||||
|
||||
@@ -242,45 +238,6 @@ function PendingEntityCard({
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSceneEntrySceneChapters(params: {
|
||||
sceneChapters: CustomWorldProfile['sceneChapterBlueprints'];
|
||||
sceneId: string;
|
||||
sceneName: string;
|
||||
}) {
|
||||
const sceneChapters = params.sceneChapters ?? [];
|
||||
const normalizedSceneId = params.sceneId.trim();
|
||||
const normalizedSceneName = params.sceneName.trim();
|
||||
|
||||
const directMatches = sceneChapters.filter(
|
||||
(chapter) => chapter.sceneId.trim() === normalizedSceneId,
|
||||
);
|
||||
if (directMatches.length > 0) {
|
||||
return directMatches;
|
||||
}
|
||||
|
||||
const linkedMatches = sceneChapters.filter((chapter) =>
|
||||
chapter.linkedLandmarkIds.some(
|
||||
(landmarkId) => landmarkId.trim() === normalizedSceneId,
|
||||
),
|
||||
);
|
||||
if (linkedMatches.length > 0) {
|
||||
return linkedMatches;
|
||||
}
|
||||
|
||||
return sceneChapters.filter((chapter) => {
|
||||
const chapterTitle = chapter.title.trim();
|
||||
return (
|
||||
chapterTitle === normalizedSceneName ||
|
||||
chapter.summary.includes(normalizedSceneName) ||
|
||||
chapter.acts.some(
|
||||
(act) =>
|
||||
act.title.includes(normalizedSceneName) ||
|
||||
act.summary.includes(normalizedSceneName),
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function buildSceneActParticipantText(
|
||||
act: SceneActBlueprint,
|
||||
roleById: Map<
|
||||
@@ -334,58 +291,6 @@ function buildSceneTaskDescriptionText(sceneChapters: SceneChapterBlueprint[]) {
|
||||
)[0] ?? '';
|
||||
}
|
||||
|
||||
function resolveSceneCardImage(params: {
|
||||
sceneImageSrc?: string | null;
|
||||
sceneChapters: SceneChapterBlueprint[];
|
||||
}) {
|
||||
const firstActImageSrc =
|
||||
params.sceneChapters
|
||||
.flatMap((chapter) => chapter.acts)
|
||||
.map((act) => act.backgroundImageSrc?.trim() || '')
|
||||
.find(Boolean) || '';
|
||||
|
||||
return firstActImageSrc || params.sceneImageSrc?.trim() || '';
|
||||
}
|
||||
|
||||
function collectSceneActImagePreviews(
|
||||
sceneChapters: SceneChapterBlueprint[],
|
||||
sharedSceneImageSrc?: string | null,
|
||||
) {
|
||||
const sharedImageSrc = sharedSceneImageSrc?.trim() || '';
|
||||
|
||||
return sceneChapters.flatMap((chapter) =>
|
||||
chapter.acts
|
||||
.map((act, index) => ({
|
||||
id: act.id.trim() || `${chapter.id}-act-${index}`,
|
||||
title: act.title.trim() || `第${index + 1}幕`,
|
||||
imageSrc: sharedImageSrc || act.backgroundImageSrc?.trim() || '',
|
||||
}))
|
||||
.filter((act) => act.imageSrc),
|
||||
);
|
||||
}
|
||||
|
||||
function buildFallbackSceneActImagePreviews(params: {
|
||||
sceneChapters: SceneChapterBlueprint[];
|
||||
sceneImageSrc?: string | null;
|
||||
}) {
|
||||
const sceneImageSrc = params.sceneImageSrc?.trim() || '';
|
||||
const actPreviews = collectSceneActImagePreviews(
|
||||
params.sceneChapters,
|
||||
sceneImageSrc,
|
||||
);
|
||||
|
||||
if (actPreviews.length > 0 || !sceneImageSrc) {
|
||||
return actPreviews;
|
||||
}
|
||||
|
||||
// 中文注释:旧草稿可能只把开局场景图写在 camp.imageSrc,尚未回填到每一幕;目录侧先用场景图兜底,避免开局场景看起来没有幕图片。
|
||||
return [1, 2, 3].map((actNumber) => ({
|
||||
id: `fallback-scene-act-${actNumber}`,
|
||||
title: `第${actNumber}幕`,
|
||||
imageSrc: sceneImageSrc,
|
||||
}));
|
||||
}
|
||||
|
||||
function SceneActPreviewStrip({
|
||||
acts,
|
||||
sceneName,
|
||||
@@ -574,7 +479,7 @@ function resolvePlayableRolePreviewImage(
|
||||
|
||||
function buildOpeningSceneSearchText(
|
||||
profile: CustomWorldProfile,
|
||||
campScene: ReturnType<typeof resolveCustomWorldCampScene>,
|
||||
campScene: { name: string; description: string },
|
||||
) {
|
||||
return [
|
||||
campScene.name,
|
||||
@@ -694,16 +599,8 @@ export function CustomWorldEntityCatalog({
|
||||
() => new Map(profile.landmarks.map((landmark) => [landmark.id, landmark])),
|
||||
[profile.landmarks],
|
||||
);
|
||||
const landmarkImageById = useMemo(
|
||||
() => resolveCustomWorldLandmarkImageMap(profile),
|
||||
[profile],
|
||||
);
|
||||
const resolvedCampScene = useMemo(
|
||||
() => resolveCustomWorldCampScene(profile),
|
||||
[profile],
|
||||
);
|
||||
const resolvedCampImageSrc = useMemo(
|
||||
() => resolveCustomWorldCampSceneImage(profile),
|
||||
const scenePresentations = useMemo(
|
||||
() => buildCustomWorldScenePresentations(profile),
|
||||
[profile],
|
||||
);
|
||||
const previewCharacterById = useMemo(
|
||||
@@ -749,18 +646,6 @@ export function CustomWorldEntityCatalog({
|
||||
[deferredSearch, profile.storyNpcs],
|
||||
);
|
||||
|
||||
const filteredLandmarks = useMemo(
|
||||
() =>
|
||||
profile.landmarks.filter(
|
||||
(landmark) =>
|
||||
!deferredSearch ||
|
||||
matchText(
|
||||
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
|
||||
deferredSearch,
|
||||
),
|
||||
),
|
||||
[deferredSearch, landmarkById, profile.landmarks, storyNpcById],
|
||||
);
|
||||
const structuredFoundationEntries = useMemo(
|
||||
() => buildCustomWorldFoundationEntries(profile),
|
||||
[profile],
|
||||
@@ -773,66 +658,34 @@ export function CustomWorldEntityCatalog({
|
||||
? profile.attributeSchema.slots
|
||||
: [];
|
||||
const filteredSceneEntries = useMemo(() => {
|
||||
const openingSceneChapters = resolveSceneEntrySceneChapters({
|
||||
sceneChapters: profile.sceneChapterBlueprints,
|
||||
sceneId: resolvedCampScene.id,
|
||||
sceneName: resolvedCampScene.name,
|
||||
});
|
||||
const openingSceneImageSrc = resolveSceneCardImage({
|
||||
sceneImageSrc: resolvedCampImageSrc,
|
||||
sceneChapters: openingSceneChapters,
|
||||
});
|
||||
const openingSceneEntry = {
|
||||
id: resolvedCampScene.id,
|
||||
kind: 'camp' as const,
|
||||
name: resolvedCampScene.name,
|
||||
description: resolvedCampScene.description,
|
||||
imageSrc: openingSceneImageSrc,
|
||||
sceneChapters: openingSceneChapters,
|
||||
sceneTaskDescription: buildSceneTaskDescriptionText(openingSceneChapters),
|
||||
actPreviews: buildFallbackSceneActImagePreviews({
|
||||
sceneChapters: openingSceneChapters,
|
||||
sceneImageSrc: openingSceneImageSrc,
|
||||
}),
|
||||
const openingSceneEntry = {
|
||||
...scenePresentations.camp,
|
||||
sceneTaskDescription: buildSceneTaskDescriptionText(
|
||||
scenePresentations.camp.sceneChapters,
|
||||
),
|
||||
searchText: [
|
||||
buildOpeningSceneSearchText(profile, resolvedCampScene),
|
||||
buildSceneChapterSearchText(openingSceneChapters, roleById),
|
||||
buildOpeningSceneSearchText(profile, scenePresentations.camp),
|
||||
buildSceneChapterSearchText(
|
||||
scenePresentations.camp.sceneChapters,
|
||||
roleById,
|
||||
),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
};
|
||||
const landmarkEntries = profile.landmarks.map((landmark) => {
|
||||
const sceneChapters = resolveSceneEntrySceneChapters({
|
||||
sceneChapters: profile.sceneChapterBlueprints,
|
||||
sceneId: landmark.id,
|
||||
sceneName: landmark.name,
|
||||
});
|
||||
const firstActImageSrc =
|
||||
sceneChapters
|
||||
.flatMap((chapter) => chapter.acts)
|
||||
.map((act) => act.backgroundImageSrc?.trim() || '')
|
||||
.find(Boolean) || '';
|
||||
const sceneImageSrc = resolveSceneCardImage({
|
||||
sceneImageSrc:
|
||||
firstActImageSrc || landmarkImageById.get(landmark.id) || landmark.imageSrc,
|
||||
sceneChapters,
|
||||
});
|
||||
const landmarkEntries = scenePresentations.landmarks.map((scene) => {
|
||||
const landmark = profile.landmarks.find((entry) => entry.id === scene.id);
|
||||
|
||||
return {
|
||||
id: landmark.id,
|
||||
kind: 'landmark' as const,
|
||||
name: landmark.name,
|
||||
description: landmark.description,
|
||||
imageSrc: sceneImageSrc,
|
||||
sceneChapters,
|
||||
sceneTaskDescription: buildSceneTaskDescriptionText(sceneChapters),
|
||||
actPreviews: buildFallbackSceneActImagePreviews({
|
||||
sceneChapters,
|
||||
sceneImageSrc,
|
||||
}),
|
||||
...scene,
|
||||
sceneTaskDescription: buildSceneTaskDescriptionText(
|
||||
scene.sceneChapters,
|
||||
),
|
||||
searchText: [
|
||||
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
|
||||
buildSceneChapterSearchText(sceneChapters, roleById),
|
||||
landmark
|
||||
? buildLandmarkSearchText(landmark, storyNpcById, landmarkById)
|
||||
: '',
|
||||
buildSceneChapterSearchText(scene.sceneChapters, roleById),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
@@ -856,12 +709,10 @@ export function CustomWorldEntityCatalog({
|
||||
}, [
|
||||
deferredSearch,
|
||||
landmarkById,
|
||||
landmarkImageById,
|
||||
profile,
|
||||
recentLandmarkIdSet,
|
||||
resolvedCampImageSrc,
|
||||
resolvedCampScene,
|
||||
roleById,
|
||||
scenePresentations,
|
||||
storyNpcById,
|
||||
]);
|
||||
|
||||
|
||||
@@ -5,18 +5,20 @@ import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import * as customWorldCoverAssetService from '../services/customWorldCoverAssetService';
|
||||
import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient';
|
||||
import type {
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
SceneActBlueprint,
|
||||
SceneChapterBlueprint,
|
||||
} from '../types';
|
||||
import { CustomWorldEntityCatalog } from './CustomWorldEntityCatalog';
|
||||
import {
|
||||
type RpgCreationEditorTarget,
|
||||
RpgCreationEntityEditorModal,
|
||||
} from './rpg-creation-editor/RpgCreationEntityEditorModal';
|
||||
import * as customWorldCoverAssetService from '../services/customWorldCoverAssetService';
|
||||
import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -261,6 +263,80 @@ function createProfileWithTwoLandmarks(): CustomWorldProfile {
|
||||
} as unknown as CustomWorldProfile;
|
||||
}
|
||||
|
||||
function createSceneAct(
|
||||
sceneId: string,
|
||||
index: number,
|
||||
imageSrc: string,
|
||||
): SceneActBlueprint {
|
||||
return {
|
||||
id: `${sceneId}-act-${index + 1}`,
|
||||
sceneId,
|
||||
title: `第${index + 1}幕`,
|
||||
summary: `第${index + 1}幕摘要`,
|
||||
stageCoverage: index === 0 ? ['opening'] : ['expansion'],
|
||||
backgroundPromptText: '',
|
||||
backgroundImageSrc: imageSrc,
|
||||
encounterNpcIds: ['story-1'],
|
||||
primaryNpcId: 'story-1',
|
||||
oppositeNpcId: 'story-1',
|
||||
eventDescription: `第${index + 1}幕事件`,
|
||||
linkedThreadIds: [],
|
||||
advanceRule:
|
||||
index === 0
|
||||
? 'after_primary_contact'
|
||||
: index >= 2
|
||||
? 'after_chapter_resolution'
|
||||
: 'after_active_step_complete',
|
||||
actGoal: `第${index + 1}幕目标`,
|
||||
transitionHook: '',
|
||||
};
|
||||
}
|
||||
|
||||
function createSceneChapter(
|
||||
sceneId: string,
|
||||
sceneName: string,
|
||||
imagePrefix: string,
|
||||
): SceneChapterBlueprint {
|
||||
return {
|
||||
id: `${sceneId}-chapter`,
|
||||
sceneId,
|
||||
title: sceneName,
|
||||
summary: `${sceneName}章节`,
|
||||
sceneTaskDescription: `${sceneName}任务`,
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: [sceneId],
|
||||
acts: [0, 1, 2].map((index) =>
|
||||
createSceneAct(sceneId, index, `${imagePrefix}-act-${index + 1}.png`),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function createProfileWithSceneChapters(): CustomWorldProfile {
|
||||
return {
|
||||
...createProfileWithLandmark(),
|
||||
camp: {
|
||||
id: 'custom-scene-camp',
|
||||
name: '潮灯居',
|
||||
description: '玩家最初落脚的旧灯塔内院。',
|
||||
imageSrc: '/generated-custom-world-scenes/camp-main.png',
|
||||
sceneNpcIds: ['story-1'],
|
||||
connections: [],
|
||||
},
|
||||
sceneChapterBlueprints: [
|
||||
createSceneChapter(
|
||||
'custom-scene-camp',
|
||||
'潮灯居',
|
||||
'/generated-custom-world-scenes/camp',
|
||||
),
|
||||
createSceneChapter(
|
||||
'landmark-1',
|
||||
'沉钟栈桥',
|
||||
'/generated-custom-world-scenes/landmark',
|
||||
),
|
||||
],
|
||||
} as unknown as CustomWorldProfile;
|
||||
}
|
||||
|
||||
function LandmarkEditorFlowHarness() {
|
||||
const [profile, setProfile] = useState(createProfileWithLandmark());
|
||||
const [target, setTarget] = useState<RpgCreationEditorTarget | null>({
|
||||
@@ -882,6 +958,51 @@ test('开局场景在场景配置面板中与普通场景使用同级参数并
|
||||
expect(openingSceneChapter?.linkedLandmarkIds).toContain('custom-scene-camp');
|
||||
});
|
||||
|
||||
test('开局场景列表与详情幕预览复用同一套幕级图片', async () => {
|
||||
const profile = createProfileWithSceneChapters();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<>
|
||||
<CustomWorldEntityCatalog
|
||||
profile={profile}
|
||||
previewCharacters={[]}
|
||||
activeTab="landmarks"
|
||||
onActiveTabChange={() => {}}
|
||||
onEditTarget={() => {}}
|
||||
onProfileChange={() => {}}
|
||||
onDeleteStoryNpcs={() => {}}
|
||||
onDeleteLandmarks={() => {}}
|
||||
/>
|
||||
<RpgCreationEntityEditorModal
|
||||
profile={profile}
|
||||
target={{ kind: 'camp' }}
|
||||
onClose={() => {}}
|
||||
onProfileChange={() => {}}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('img', { name: '潮灯居-第2幕' }).getAttribute('src')).toBe(
|
||||
'/generated-custom-world-scenes/camp-act-2.png',
|
||||
);
|
||||
expect(screen.getByRole('img', { name: '沉钟栈桥-第2幕' }).getAttribute('src')).toBe(
|
||||
'/generated-custom-world-scenes/landmark-act-2.png',
|
||||
);
|
||||
expect(screen.getByRole('img', { name: '第2幕幕背景' }).getAttribute('src')).toBe(
|
||||
'/generated-custom-world-scenes/camp-act-2.png',
|
||||
);
|
||||
|
||||
await user.click(within(getSceneActCard(1)).getByRole('button', { name: '配置背景' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('配置幕背景:第2幕')).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByRole('img', { name: '第2幕背景预览' }).getAttribute('src')).toBe(
|
||||
'/generated-custom-world-scenes/camp-act-2.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('普通场景世界地图会包含开局场景并高亮当前场景', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
@@ -13,10 +13,11 @@ const authMocks = vi.hoisted(() => ({
|
||||
authEntry: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
ensureStoredAccessToken: vi.fn(),
|
||||
ensureAutoAuthUser: vi.fn(),
|
||||
getAuthLoginOptions: vi.fn(),
|
||||
getCurrentAuthUser: vi.fn(),
|
||||
loginWithPhoneCode: vi.fn(),
|
||||
logoutAllAuthSessions: vi.fn(),
|
||||
logoutAuthUser: vi.fn(),
|
||||
resetPassword: vi.fn(),
|
||||
sendPhoneLoginCode: vi.fn(),
|
||||
startWechatLogin: vi.fn(),
|
||||
@@ -34,7 +35,6 @@ vi.mock('../../services/authService', () => ({
|
||||
changePassword: authMocks.changePassword,
|
||||
changePhoneNumber: vi.fn(),
|
||||
consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult,
|
||||
ensureAutoAuthUser: authMocks.ensureAutoAuthUser,
|
||||
getStoredLastLoginPhone: vi.fn(() => ''),
|
||||
getAuthAuditLogs: vi.fn(),
|
||||
getAuthLoginOptions: authMocks.getAuthLoginOptions,
|
||||
@@ -44,8 +44,8 @@ vi.mock('../../services/authService', () => ({
|
||||
getCaptchaChallengeFromError: vi.fn(() => null),
|
||||
liftAuthRiskBlock: vi.fn(),
|
||||
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
||||
logoutAllAuthSessions: vi.fn(),
|
||||
logoutAuthUser: vi.fn(),
|
||||
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
|
||||
logoutAuthUser: authMocks.logoutAuthUser,
|
||||
resetPassword: authMocks.resetPassword,
|
||||
revokeAuthSession: vi.fn(),
|
||||
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
|
||||
@@ -96,22 +96,21 @@ beforeEach(() => {
|
||||
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
|
||||
authMocks.authEntry.mockResolvedValue(mockUser);
|
||||
authMocks.changePassword.mockResolvedValue(mockUser);
|
||||
authMocks.logoutAllAuthSessions.mockResolvedValue(undefined);
|
||||
authMocks.logoutAuthUser.mockResolvedValue(undefined);
|
||||
authMocks.resetPassword.mockResolvedValue(mockUser);
|
||||
authMocks.sendPhoneLoginCode.mockResolvedValue({
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
});
|
||||
authMocks.startWechatLogin.mockResolvedValue(undefined);
|
||||
authMocks.ensureAutoAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
credentials: {
|
||||
username: 'guest_tester',
|
||||
password: 'auto_password',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
function ProtectedActionButton({ onAuthenticated }: { onAuthenticated: () => void }) {
|
||||
function ProtectedActionButton({
|
||||
onAuthenticated,
|
||||
}: {
|
||||
onAuthenticated: () => void;
|
||||
}) {
|
||||
const authUi = useAuthUi();
|
||||
|
||||
return (
|
||||
@@ -139,6 +138,27 @@ function PlatformTabStateProbe() {
|
||||
);
|
||||
}
|
||||
|
||||
function LogoutStateProbe() {
|
||||
const authUi = useAuthUi();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>当前用户:{authUi?.user?.displayName ?? '未登录'}</div>
|
||||
<div>
|
||||
私有数据:{authUi?.canAccessProtectedData ? '可读取' : '不可读取'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void authUi?.logout();
|
||||
}}
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
test('auth gate keeps platform content visible when phone login is available', async () => {
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone'],
|
||||
@@ -153,7 +173,6 @@ test('auth gate keeps platform content visible when phone login is available', a
|
||||
expect(await screen.findByText('应用内容')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '登录' })).toBeNull();
|
||||
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
|
||||
expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('auth gate waits for access token refresh before exposing restored user content', async () => {
|
||||
@@ -195,7 +214,6 @@ test('auth gate does not auto-create a guest account when dev guest switch is no
|
||||
);
|
||||
|
||||
expect(await screen.findByText('应用内容')).toBeTruthy();
|
||||
expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('auth gate opens a login modal for protected actions and resumes after login', async () => {
|
||||
@@ -220,7 +238,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi
|
||||
|
||||
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.type(within(dialog).getByLabelText('验证码'), '123456');
|
||||
await user.click(within(dialog).getByRole('button', { name: '注册/登录' }));
|
||||
await user.click(within(dialog).getByRole('button', { name: '登录' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
|
||||
@@ -276,6 +294,40 @@ test('auth state refresh keeps mounted platform content and local tab state', as
|
||||
expect(screen.getByText('当前Tab:创作')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('logout withdraws user context before backend request finishes', async () => {
|
||||
const user = userEvent.setup();
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
let resolveLogout!: () => void;
|
||||
const logoutPromise = new Promise<void>((resolve) => {
|
||||
resolveLogout = resolve;
|
||||
});
|
||||
authMocks.logoutAuthUser.mockReturnValueOnce(logoutPromise);
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<LogoutStateProbe />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
|
||||
expect(screen.getByText('私有数据:可读取')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '退出登录' }));
|
||||
|
||||
expect(await screen.findByText('当前用户:未登录')).toBeTruthy();
|
||||
expect(screen.getByText('私有数据:不可读取')).toBeTruthy();
|
||||
expect(authMocks.logoutAuthUser).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
resolveLogout();
|
||||
await logoutPromise;
|
||||
});
|
||||
});
|
||||
|
||||
test('auth gate shows sms send feedback in the login modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -312,6 +364,72 @@ test('auth gate shows sms send feedback in the login modal', async () => {
|
||||
expect(within(dialog).getByRole('button', { name: '60s' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('login modal resets draft state every time it is reopened', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone', 'password'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
||||
|
||||
const firstDialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
await user.type(within(firstDialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.click(
|
||||
within(firstDialog).getByRole('button', { name: '获取验证码' }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await within(firstDialog).findByText(
|
||||
'短信请求已提交,验证码有效期约 5 分钟。',
|
||||
),
|
||||
).toBeTruthy();
|
||||
await user.type(within(firstDialog).getByLabelText('验证码'), '123456');
|
||||
await user.click(within(firstDialog).getByRole('tab', { name: '密码登录' }));
|
||||
await user.type(within(firstDialog).getByLabelText('密码'), 'passw0rd');
|
||||
await user.click(
|
||||
within(firstDialog).getByRole('button', { name: '忘记密码' }),
|
||||
);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '重置密码' })).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '关闭登录弹窗' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '进入作品' }));
|
||||
|
||||
const reopenedDialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
expect(
|
||||
within(reopenedDialog)
|
||||
.getByRole('tab', { name: '短信登录' })
|
||||
.getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
(within(reopenedDialog).getByLabelText('手机号') as HTMLInputElement).value,
|
||||
).toBe('');
|
||||
expect(
|
||||
(within(reopenedDialog).getByLabelText('验证码') as HTMLInputElement).value,
|
||||
).toBe('');
|
||||
expect(within(reopenedDialog).queryByLabelText('密码')).toBeNull();
|
||||
expect(
|
||||
within(reopenedDialog).queryByText(
|
||||
'短信请求已提交,验证码有效期约 5 分钟。',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(reopenedDialog).getByRole('button', { name: '获取验证码' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('auth gate separates sms and password login by tabs', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -344,9 +462,9 @@ test('auth gate separates sms and password login by tabs', async () => {
|
||||
).toBe('true');
|
||||
expect(within(dialog).queryByLabelText('验证码')).toBeNull();
|
||||
|
||||
await user.type(within(dialog).getByLabelText('手机号/邮箱'), '13800000000');
|
||||
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.type(within(dialog).getByLabelText('密码'), 'passw0rd');
|
||||
await user.click(within(dialog).getByRole('button', { name: '注册/登录' }));
|
||||
await user.click(within(dialog).getByRole('button', { name: '登录' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.authEntry).toHaveBeenCalledWith('13800000000', 'passw0rd');
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
changePassword,
|
||||
changePhoneNumber,
|
||||
consumeAuthCallbackResult,
|
||||
ensureAutoAuthUser,
|
||||
getAuthAuditLogs,
|
||||
getAuthLoginOptions,
|
||||
getAuthRiskBlocks,
|
||||
@@ -42,10 +41,7 @@ import {
|
||||
startWechatLogin,
|
||||
} from '../../services/authService';
|
||||
import { AccountModal } from './AccountModal';
|
||||
import {
|
||||
AuthUiContext,
|
||||
type PlatformSettingsSection,
|
||||
} from './AuthUiContext';
|
||||
import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext';
|
||||
import { BindPhoneScreen } from './BindPhoneScreen';
|
||||
import { LoginScreen } from './LoginScreen';
|
||||
|
||||
@@ -61,11 +57,6 @@ type AuthStatus =
|
||||
| 'ready'
|
||||
| 'error';
|
||||
|
||||
const allowDevGuestAutoAuth =
|
||||
import.meta.env.DEV &&
|
||||
// 开发游客兜底必须显式开启,避免抢占正式手机号验证码登录入口。
|
||||
import.meta.env.VITE_AUTH_ALLOW_DEV_GUEST === 'true';
|
||||
|
||||
export function AuthGate({ children }: AuthGateProps) {
|
||||
const [status, setStatus] = useState<AuthStatus>('checking');
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
@@ -113,6 +104,50 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setStatus('ready');
|
||||
}, []);
|
||||
|
||||
const clearLocalAuthenticatedState = useCallback(() => {
|
||||
// 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成。
|
||||
// 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存。
|
||||
pendingProtectedActionRef.current = null;
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
setShowLoginModal(false);
|
||||
setShowSettingsModal(false);
|
||||
setInitialSettingsSection(null);
|
||||
setSessions([]);
|
||||
setAuditLogs([]);
|
||||
setRiskBlocks([]);
|
||||
setLoginCaptchaChallenge(null);
|
||||
setBindCaptchaChallenge(null);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
setError('');
|
||||
}, []);
|
||||
|
||||
const logoutCurrentSession = useCallback(async () => {
|
||||
clearLocalAuthenticatedState();
|
||||
try {
|
||||
await logoutAuthUser();
|
||||
} catch (logoutError) {
|
||||
setError(
|
||||
logoutError instanceof Error
|
||||
? logoutError.message
|
||||
: '退出登录失败,请刷新页面确认状态。',
|
||||
);
|
||||
}
|
||||
}, [clearLocalAuthenticatedState]);
|
||||
|
||||
const logoutAllSessions = useCallback(async () => {
|
||||
clearLocalAuthenticatedState();
|
||||
try {
|
||||
await logoutAllAuthSessions();
|
||||
} catch (logoutError) {
|
||||
setError(
|
||||
logoutError instanceof Error
|
||||
? logoutError.message
|
||||
: '退出全部设备失败,请刷新页面确认状态。',
|
||||
);
|
||||
}
|
||||
}, [clearLocalAuthenticatedState]);
|
||||
|
||||
const closeLoginModal = useCallback(() => {
|
||||
pendingProtectedActionRef.current = null;
|
||||
setShowLoginModal(false);
|
||||
@@ -160,37 +195,6 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
|
||||
const ensureAutoUser = async () => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('recovering');
|
||||
|
||||
try {
|
||||
const { user: nextUser } = await ensureAutoAuthUser();
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureStoredAccessToken();
|
||||
activateReadyUser(nextUser);
|
||||
setError('');
|
||||
} catch (autoAuthError) {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
setStatus('error');
|
||||
setError(
|
||||
autoAuthError instanceof Error
|
||||
? autoAuthError.message
|
||||
: '自动登录失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const hydrate = async () => {
|
||||
const loadLoginOptions = async () => {
|
||||
const options = await getAuthLoginOptions();
|
||||
@@ -209,15 +213,6 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
allowDevGuestAutoAuth &&
|
||||
options &&
|
||||
options.availableLoginMethods.length === 0
|
||||
) {
|
||||
await ensureAutoUser();
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
} catch (optionsError) {
|
||||
@@ -225,11 +220,6 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowDevGuestAutoAuth) {
|
||||
await ensureAutoUser();
|
||||
return;
|
||||
}
|
||||
|
||||
setAvailableLoginMethods([]);
|
||||
setUser(null);
|
||||
setError(
|
||||
@@ -400,10 +390,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
requireAuth,
|
||||
openSettingsModal,
|
||||
openAccountModal,
|
||||
logout: async () => {
|
||||
await logoutAuthUser();
|
||||
setShowSettingsModal(false);
|
||||
},
|
||||
logout: logoutCurrentSession,
|
||||
musicVolume: settings.musicVolume,
|
||||
setMusicVolume: settings.setMusicVolume,
|
||||
platformTheme: settings.platformTheme,
|
||||
@@ -418,6 +405,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
openSettingsModal,
|
||||
readyUser,
|
||||
requireAuth,
|
||||
logoutCurrentSession,
|
||||
status,
|
||||
settings.isHydratingSettings,
|
||||
settings.isPersistingSettings,
|
||||
@@ -431,7 +419,9 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
|
||||
if (status === 'checking' && !canKeepPlatformContentMounted) {
|
||||
return (
|
||||
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}>
|
||||
<div
|
||||
className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}
|
||||
>
|
||||
正在校验登录状态...
|
||||
</div>
|
||||
);
|
||||
@@ -439,8 +429,10 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
|
||||
if (status === 'recovering' && !canKeepPlatformContentMounted) {
|
||||
return (
|
||||
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}>
|
||||
正在自动创建或恢复账号...
|
||||
<div
|
||||
className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}
|
||||
>
|
||||
正在恢复登录状态...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -458,7 +450,11 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(phone, 'bind_phone', captcha);
|
||||
const result = await sendPhoneLoginCode(
|
||||
phone,
|
||||
'bind_phone',
|
||||
captcha,
|
||||
);
|
||||
setBindCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
@@ -494,9 +490,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
}}
|
||||
onLogout={async () => {
|
||||
await logoutAuthUser();
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
await logoutCurrentSession();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -508,7 +502,9 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
!canKeepPlatformContentMounted
|
||||
) {
|
||||
return (
|
||||
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] px-6 text-[var(--platform-text-base)]`}>
|
||||
<div
|
||||
className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] px-6 text-[var(--platform-text-base)]`}
|
||||
>
|
||||
<div className="platform-auth-card max-w-md rounded-3xl px-6 py-7 text-center">
|
||||
<div className="text-base font-medium text-[var(--platform-text-strong)]">
|
||||
登录状态异常
|
||||
@@ -551,10 +547,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
settingsError={settings.settingsError}
|
||||
onClose={() => setShowSettingsModal(false)}
|
||||
onPlatformThemeChange={settings.setPlatformTheme}
|
||||
onLogout={async () => {
|
||||
await logoutAuthUser();
|
||||
setShowSettingsModal(false);
|
||||
}}
|
||||
onLogout={logoutCurrentSession}
|
||||
onRefreshRiskBlocks={async () => {
|
||||
setLoadingRiskBlocks(true);
|
||||
try {
|
||||
@@ -614,7 +607,9 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
try {
|
||||
await revokeAuthSession(sessionId);
|
||||
setSessions((current) =>
|
||||
current.filter((session) => session.sessionId !== sessionId),
|
||||
current.filter(
|
||||
(session) => session.sessionId !== sessionId,
|
||||
),
|
||||
);
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (revokeError) {
|
||||
@@ -625,10 +620,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
);
|
||||
}
|
||||
}}
|
||||
onLogoutAll={async () => {
|
||||
await logoutAllAuthSessions();
|
||||
setShowSettingsModal(false);
|
||||
}}
|
||||
onLogoutAll={logoutAllSessions}
|
||||
changePhoneCaptchaChallenge={changePhoneCaptchaChallenge}
|
||||
onSendChangePhoneCode={async (phone, captcha) => {
|
||||
try {
|
||||
@@ -640,7 +632,8 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge = getCaptchaChallengeFromError(sendError);
|
||||
const captchaChallenge =
|
||||
getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setChangePhoneCaptchaChallenge(captchaChallenge);
|
||||
}
|
||||
@@ -653,7 +646,10 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setUser(nextUser);
|
||||
}}
|
||||
onChangePassword={async (currentPassword, newPassword) => {
|
||||
const nextUser = await changePassword(currentPassword, newPassword);
|
||||
const nextUser = await changePassword(
|
||||
currentPassword,
|
||||
newPassword,
|
||||
);
|
||||
setUser(nextUser);
|
||||
}}
|
||||
/>
|
||||
@@ -676,7 +672,8 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setLoginCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge = getCaptchaChallengeFromError(sendError);
|
||||
const captchaChallenge =
|
||||
getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setLoginCaptchaChallenge(captchaChallenge);
|
||||
}
|
||||
@@ -708,12 +705,12 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setLoggingIn(false);
|
||||
}
|
||||
}}
|
||||
onPasswordSubmit={async (username, password) => {
|
||||
onPasswordSubmit={async (phone, password) => {
|
||||
setLoggingIn(true);
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await authEntry(username, password);
|
||||
setStoredLastLoginPhone(username);
|
||||
const nextUser = await authEntry(phone, password);
|
||||
setStoredLastLoginPhone(phone);
|
||||
activateReadyUser(nextUser);
|
||||
} catch (loginError) {
|
||||
setError(
|
||||
|
||||
@@ -34,7 +34,7 @@ type LoginScreenProps = {
|
||||
expiresInSeconds: number;
|
||||
}>;
|
||||
onPhoneSubmit: (phone: string, code: string) => Promise<void>;
|
||||
onPasswordSubmit: (username: string, password: string) => Promise<void>;
|
||||
onPasswordSubmit: (phone: string, password: string) => Promise<void>;
|
||||
onResetPassword: (
|
||||
phone: string,
|
||||
code: string,
|
||||
@@ -76,12 +76,40 @@ export function LoginScreen({
|
||||
const [activeLoginTab, setActiveLoginTab] = useState<LoginTab>('phone');
|
||||
|
||||
useEffect(() => {
|
||||
if (activeLoginTab === 'phone' && !phoneLoginEnabled && passwordLoginEnabled) {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 每次重新打开弹窗都丢弃上一次未完成的表单草稿,只保留最近成功登录手机号回填。
|
||||
setIsResetPanelOpen(false);
|
||||
setPhone(getStoredLastLoginPhone());
|
||||
setPassword('');
|
||||
setCode('');
|
||||
setResetPhone('');
|
||||
setResetCode('');
|
||||
setResetPasswordValue('');
|
||||
setCaptchaAnswer('');
|
||||
setCooldownSeconds(0);
|
||||
setResetCooldownSeconds(0);
|
||||
setHint('');
|
||||
setActiveLoginTab(phoneLoginEnabled ? 'phone' : 'password');
|
||||
}, [isOpen, phoneLoginEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeLoginTab === 'phone' &&
|
||||
!phoneLoginEnabled &&
|
||||
passwordLoginEnabled
|
||||
) {
|
||||
setActiveLoginTab('password');
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeLoginTab === 'password' && !passwordLoginEnabled && phoneLoginEnabled) {
|
||||
if (
|
||||
activeLoginTab === 'password' &&
|
||||
!passwordLoginEnabled &&
|
||||
phoneLoginEnabled
|
||||
) {
|
||||
setActiveLoginTab('phone');
|
||||
}
|
||||
}, [activeLoginTab, passwordLoginEnabled, phoneLoginEnabled]);
|
||||
@@ -162,7 +190,9 @@ export function LoginScreen({
|
||||
const result = await onSendCode(resetPhone, 'reset_password');
|
||||
setResetCooldownSeconds(result.cooldownSeconds);
|
||||
}}
|
||||
onSubmit={() => onResetPassword(resetPhone, resetCode, resetPasswordValue)}
|
||||
onSubmit={() =>
|
||||
onResetPassword(resetPhone, resetCode, resetPasswordValue)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 px-5 py-5">
|
||||
@@ -196,13 +226,14 @@ export function LoginScreen({
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号/邮箱</span>
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
placeholder="手机号或邮箱"
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
@@ -222,10 +253,12 @@ export function LoginScreen({
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitDisabled || !phone.trim() || !password.trim()}
|
||||
disabled={
|
||||
submitDisabled || !phone.trim() || !password.trim()
|
||||
}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loggingIn ? '登录中' : '注册/登录'}
|
||||
{loggingIn ? '登录中' : '登录'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -257,7 +290,7 @@ export function LoginScreen({
|
||||
loggingIn={loggingIn}
|
||||
error={error}
|
||||
hint={hint}
|
||||
submitLabel="注册/登录"
|
||||
submitLabel="登录"
|
||||
enabled={phoneLoginEnabled}
|
||||
showPhoneField
|
||||
onPhoneChange={setPhone}
|
||||
@@ -279,7 +312,9 @@ export function LoginScreen({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!passwordLoginEnabled && !phoneLoginEnabled && !wechatLoginEnabled ? (
|
||||
{!passwordLoginEnabled &&
|
||||
!phoneLoginEnabled &&
|
||||
!wechatLoginEnabled ? (
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
当前登录入口暂不可用。
|
||||
</div>
|
||||
@@ -524,7 +559,9 @@ function PasswordResetPanel({
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loggingIn || !phone.trim() || !code.trim() || !password.trim()}
|
||||
disabled={
|
||||
loggingIn || !phone.trim() || !code.trim() || !password.trim()
|
||||
}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loggingIn ? '处理中' : '重置密码'}
|
||||
@@ -556,9 +593,17 @@ function WechatButton({
|
||||
}
|
||||
|
||||
function ErrorBanner({ message }: { message: string }) {
|
||||
return <div className="platform-banner platform-banner--danger text-sm">{message}</div>;
|
||||
return (
|
||||
<div className="platform-banner platform-banner--danger text-sm">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessBanner({ message }: { message: string }) {
|
||||
return <div className="platform-banner platform-banner--success text-sm">{message}</div>;
|
||||
return (
|
||||
<div className="platform-banner platform-banner--success text-sm">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
BigFishSessionSnapshotResponse,
|
||||
ExecuteBigFishActionRequest,
|
||||
} from '../../../packages/shared/src/contracts/bigFish';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type BigFishAssetStudioTarget =
|
||||
@@ -537,38 +538,37 @@ function BigFishResultErrorModal({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[160] flex items-center justify-center bg-slate-950/58 px-4 py-6 backdrop-blur-sm">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="big-fish-result-error-title"
|
||||
className="w-full max-w-sm rounded-[1.6rem] border border-red-100/80 bg-white p-5 text-slate-950 shadow-2xl"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-600">
|
||||
<Waves className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
id="big-fish-result-error-title"
|
||||
className="text-base font-black text-slate-950"
|
||||
>
|
||||
发布失败
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-slate-600">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UnifiedModal
|
||||
open
|
||||
title="发布失败"
|
||||
onClose={onClose}
|
||||
closeOnBackdrop={false}
|
||||
showCloseButton={false}
|
||||
size="sm"
|
||||
zIndexClassName="z-[160]"
|
||||
overlayClassName="bg-slate-950/58"
|
||||
panelClassName="border-red-100/80 bg-white text-slate-950 shadow-2xl"
|
||||
bodyClassName="p-5"
|
||||
footer={(
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="mt-5 inline-flex w-full items-center justify-center rounded-full bg-slate-950 px-4 py-2.5 text-sm font-bold text-white"
|
||||
className="inline-flex w-full items-center justify-center rounded-full bg-slate-950 px-4 py-2.5 text-sm font-bold text-white"
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
)}
|
||||
footerClassName="border-t-0 px-5 pb-5 pt-0"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-600">
|
||||
<Waves className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-sm leading-6 text-slate-600">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
58
src/components/common/UnifiedModal.test.tsx
Normal file
58
src/components/common/UnifiedModal.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { UnifiedModal } from './UnifiedModal';
|
||||
|
||||
test('renders an accessible platform modal', () => {
|
||||
render(
|
||||
<UnifiedModal open title="统一弹窗" onClose={() => {}} portal={false}>
|
||||
<div>窗口内容</div>
|
||||
</UnifiedModal>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '统一弹窗' })).toBeTruthy();
|
||||
expect(screen.getByText('窗口内容')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('closes through backdrop and escape', () => {
|
||||
const onClose = vi.fn();
|
||||
const { rerender } = render(
|
||||
<UnifiedModal open title="统一弹窗" onClose={onClose} portal={false}>
|
||||
<div>窗口内容</div>
|
||||
</UnifiedModal>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('dialog').parentElement as HTMLElement);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<UnifiedModal open title="统一弹窗" onClose={onClose} portal={false}>
|
||||
<div>窗口内容</div>
|
||||
</UnifiedModal>,
|
||||
);
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('respects closeDisabled for every default close path', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<UnifiedModal
|
||||
open
|
||||
title="生成中"
|
||||
onClose={onClose}
|
||||
closeDisabled
|
||||
portal={false}
|
||||
>
|
||||
<div>窗口内容</div>
|
||||
</UnifiedModal>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('dialog').parentElement as HTMLElement);
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
220
src/components/common/UnifiedModal.tsx
Normal file
220
src/components/common/UnifiedModal.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { X } from 'lucide-react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useId,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
|
||||
type UnifiedModalVariant = 'platform' | 'pixel';
|
||||
type UnifiedModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen';
|
||||
|
||||
type UnifiedModalProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
onClose: () => void;
|
||||
variant?: UnifiedModalVariant;
|
||||
size?: UnifiedModalSize;
|
||||
closeDisabled?: boolean;
|
||||
closeOnBackdrop?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
closeLabel?: string;
|
||||
portal?: boolean;
|
||||
zIndexClassName?: string;
|
||||
overlayClassName?: string;
|
||||
panelClassName?: string;
|
||||
headerClassName?: string;
|
||||
bodyClassName?: string;
|
||||
footerClassName?: string;
|
||||
panelStyle?: CSSProperties;
|
||||
};
|
||||
|
||||
const PLATFORM_SIZE_CLASS: Record<UnifiedModalSize, string> = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-xl',
|
||||
lg: 'max-w-3xl',
|
||||
xl: 'max-w-5xl',
|
||||
fullscreen: 'max-w-[min(100vw,76rem)] sm:h-[min(92vh,60rem)]',
|
||||
};
|
||||
|
||||
const PIXEL_SIZE_CLASS: Record<UnifiedModalSize, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-3xl',
|
||||
xl: 'max-w-5xl',
|
||||
fullscreen: 'max-w-[min(96vw,64rem)]',
|
||||
};
|
||||
|
||||
function joinClassNames(...classNames: Array<string | false | null | undefined>) {
|
||||
return classNames.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function getPanelStyle(
|
||||
variant: UnifiedModalVariant,
|
||||
panelStyle: CSSProperties | undefined,
|
||||
) {
|
||||
if (variant !== 'pixel') {
|
||||
return panelStyle;
|
||||
}
|
||||
|
||||
return {
|
||||
...getNineSliceStyle(UI_CHROME.modalPanel),
|
||||
...panelStyle,
|
||||
};
|
||||
}
|
||||
|
||||
function UnifiedModalContent({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
onClose,
|
||||
variant = 'platform',
|
||||
size = 'md',
|
||||
closeDisabled = false,
|
||||
closeOnBackdrop = true,
|
||||
showCloseButton = true,
|
||||
closeLabel = '关闭',
|
||||
zIndexClassName = 'z-[90]',
|
||||
overlayClassName,
|
||||
panelClassName,
|
||||
headerClassName,
|
||||
bodyClassName,
|
||||
footerClassName,
|
||||
panelStyle,
|
||||
}: Omit<UnifiedModalProps, 'portal'>) {
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || closeDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [closeDisabled, onClose, open]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPixel = variant === 'pixel';
|
||||
const sizeClassName = isPixel
|
||||
? PIXEL_SIZE_CLASS[size]
|
||||
: PLATFORM_SIZE_CLASS[size];
|
||||
|
||||
const overlayClasses = isPixel
|
||||
? 'fixed inset-0 flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4'
|
||||
: 'platform-overlay fixed inset-0 flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4';
|
||||
|
||||
const panelClasses = isPixel
|
||||
? 'pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,58rem)] w-full flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]'
|
||||
: 'platform-modal-shell flex max-h-[min(92vh,58rem)] w-full flex-col overflow-hidden rounded-t-[1.75rem] sm:rounded-[1.75rem]';
|
||||
|
||||
const headerClasses = isPixel
|
||||
? 'flex items-start justify-between gap-3 border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4'
|
||||
: 'flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5';
|
||||
|
||||
const titleClasses = isPixel
|
||||
? 'truncate text-sm font-semibold text-white'
|
||||
: 'text-base font-semibold text-[var(--platform-text-strong)]';
|
||||
|
||||
const descriptionClasses = isPixel
|
||||
? 'mt-1 text-xs leading-5 text-zinc-400'
|
||||
: 'mt-1 text-xs leading-5 text-[var(--platform-text-base)]';
|
||||
|
||||
const bodyClasses = isPixel
|
||||
? 'min-h-0 flex-1 overflow-y-auto p-4 sm:p-5'
|
||||
: 'min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-5 sm:py-5';
|
||||
|
||||
const footerClasses = isPixel
|
||||
? 'flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-4 py-3 sm:px-5 sm:py-4'
|
||||
: 'flex flex-wrap items-center justify-end gap-3 border-t border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5';
|
||||
|
||||
const closeButtonClasses = isPixel
|
||||
? 'rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-45'
|
||||
: 'platform-icon-button disabled:cursor-not-allowed disabled:opacity-45';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={joinClassNames(overlayClasses, zIndexClassName, overlayClassName)}
|
||||
onClick={(event) => {
|
||||
if (
|
||||
closeOnBackdrop &&
|
||||
!closeDisabled &&
|
||||
event.target === event.currentTarget
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={description ? descriptionId : undefined}
|
||||
className={joinClassNames(panelClasses, sizeClassName, panelClassName)}
|
||||
style={getPanelStyle(variant, panelStyle)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className={joinClassNames(headerClasses, headerClassName)}>
|
||||
<div className="min-w-0">
|
||||
<div id={titleId} className={titleClasses}>
|
||||
{title}
|
||||
</div>
|
||||
{description ? (
|
||||
<div id={descriptionId} className={descriptionClasses}>
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{showCloseButton ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={closeLabel}
|
||||
onClick={onClose}
|
||||
disabled={closeDisabled}
|
||||
className={closeButtonClasses}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={joinClassNames(bodyClasses, bodyClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className={joinClassNames(footerClasses, footerClassName)}>
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一模态窗口外壳。
|
||||
* 业务组件只传入标题、内容和操作区;遮罩、无障碍属性、Escape 与移动端布局在这里收口。
|
||||
*/
|
||||
export function UnifiedModal({ portal = true, ...props }: UnifiedModalProps) {
|
||||
if (!portal || typeof document === 'undefined') {
|
||||
return <UnifiedModalContent {...props} />;
|
||||
}
|
||||
|
||||
return createPortal(<UnifiedModalContent {...props} />, document.body);
|
||||
}
|
||||
@@ -2,12 +2,20 @@
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test } from 'vitest';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
|
||||
|
||||
const noopCreateType = () => {};
|
||||
const originalClipboard = navigator.clipboard;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: originalClipboard,
|
||||
});
|
||||
});
|
||||
|
||||
const baseDraftItem: CustomWorldWorkSummary = {
|
||||
workId: 'draft:session-1',
|
||||
@@ -72,7 +80,9 @@ test('creation hub reflects updated draft title summary and counts after rerende
|
||||
);
|
||||
|
||||
expect(screen.getByText('潮雾列岛·回潮版')).toBeTruthy();
|
||||
expect(screen.getByText('世界总卡和角色网已经继续长出了新的支线。')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('世界总卡和角色网已经继续长出了新的支线。'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('角色 5')).toBeTruthy();
|
||||
expect(screen.getByText('地点 6')).toBeTruthy();
|
||||
});
|
||||
@@ -110,10 +120,59 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to
|
||||
|
||||
expect(screen.getByText('潮雾列岛')).toBeTruthy();
|
||||
expect(screen.getByText('沉钟拼图')).toBeTruthy();
|
||||
expect(screen.getByText('PZ-PROFILE1')).toBeTruthy();
|
||||
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('我的拼图作品')).toBeNull();
|
||||
});
|
||||
|
||||
test('creation hub shows RPG public work code from published library entry', () => {
|
||||
render(
|
||||
<CustomWorldCreationHub
|
||||
items={[
|
||||
{
|
||||
...baseDraftItem,
|
||||
workId: 'published:world-public-1',
|
||||
sourceType: 'published_profile',
|
||||
status: 'published',
|
||||
title: '潮雾列岛已发布版',
|
||||
profileId: 'world-public-1',
|
||||
canResume: false,
|
||||
canEnterWorld: true,
|
||||
},
|
||||
]}
|
||||
rpgLibraryEntries={[
|
||||
{
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'world-public-1',
|
||||
publicWorkCode: 'CW-00000001',
|
||||
authorPublicUserCode: 'SY-00000001',
|
||||
profile: {} as never,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-04-20T10:00:00.000Z',
|
||||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||||
authorDisplayName: '测试玩家',
|
||||
worldName: '潮雾列岛已发布版',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '已经发布的群岛世界作品。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 3,
|
||||
landmarkCount: 4,
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('潮雾列岛已发布版')).toBeTruthy();
|
||||
expect(screen.getByText('CW-00000001')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('creation hub shows delete action for persisted rpg drafts', () => {
|
||||
render(
|
||||
<CustomWorldCreationHub
|
||||
@@ -157,7 +216,57 @@ test('creation hub opens persisted rpg drafts by card click', async () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /继续完善《可继续整理的草稿》/u }));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /继续完善《可继续整理的草稿》/u }),
|
||||
);
|
||||
|
||||
expect(openedItems).toEqual([persistedDraft]);
|
||||
});
|
||||
|
||||
test('creation hub work code copy button copies without opening the card', async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeText = vi.fn(async () => undefined);
|
||||
const onOpenPuzzleDetail = vi.fn();
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
|
||||
render(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
puzzleItems={[
|
||||
{
|
||||
workId: 'puzzle:work-1',
|
||||
profileId: 'puzzle-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '沉钟拼图',
|
||||
summary: '拼图作品会与其他创作作品一起展示。',
|
||||
themeTags: ['潮雾', '沉钟'],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
|
||||
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
|
||||
playCount: 8,
|
||||
publishReady: true,
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
onOpenPuzzleDetail={onOpenPuzzleDetail}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '复制作品号 PZ-PROFILE1' }),
|
||||
);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith('PZ-PROFILE1');
|
||||
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText('已复制')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -80,5 +80,7 @@ test('creation hub renders puzzle works in the same unified list with puzzle tag
|
||||
|
||||
expect(html).toContain('潮雾拼图');
|
||||
expect(html).toContain('拼图');
|
||||
expect(html).toContain('作品号');
|
||||
expect(html).toContain('PZ-PROFILE1');
|
||||
expect(html).not.toContain('我的拼图作品');
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
|
||||
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
|
||||
import {
|
||||
@@ -28,6 +30,7 @@ type CustomWorldCreationHubProps = {
|
||||
onDeletePublished?: ((item: CustomWorldWorkSummary) => void) | null;
|
||||
deletingWorkId?: string | null;
|
||||
onExperienceRpg?: ((item: CustomWorldWorkSummary) => void) | null;
|
||||
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||||
bigFishItems?: BigFishWorkSummary[];
|
||||
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
|
||||
onExperienceBigFish?: ((item: BigFishWorkSummary) => void) | null;
|
||||
@@ -61,6 +64,7 @@ export function CustomWorldCreationHub({
|
||||
onDeletePublished = null,
|
||||
deletingWorkId = null,
|
||||
onExperienceRpg = null,
|
||||
rpgLibraryEntries = [],
|
||||
bigFishItems = [],
|
||||
onOpenBigFishDetail,
|
||||
onExperienceBigFish = null,
|
||||
@@ -76,6 +80,7 @@ export function CustomWorldCreationHub({
|
||||
() =>
|
||||
buildCreationWorkShelfItems({
|
||||
rpgItems: items,
|
||||
rpgLibraryEntries,
|
||||
bigFishItems,
|
||||
puzzleItems,
|
||||
canDeleteRpg: Boolean(onDeletePublished),
|
||||
@@ -89,9 +94,12 @@ export function CustomWorldCreationHub({
|
||||
onDeletePublished,
|
||||
onDeletePuzzle,
|
||||
puzzleItems,
|
||||
rpgLibraryEntries,
|
||||
],
|
||||
);
|
||||
const draftCount = shelfItems.filter((entry) => entry.status === 'draft').length;
|
||||
const draftCount = shelfItems.filter(
|
||||
(entry) => entry.status === 'draft',
|
||||
).length;
|
||||
const publishedCount = shelfItems.filter(
|
||||
(entry) => entry.status === 'published',
|
||||
).length;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { Copy } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||||
import type { CreationWorkShelfItem } from './creationWorkShelf';
|
||||
|
||||
@@ -23,7 +27,10 @@ type CustomWorldWorkCardProps = {
|
||||
deleteBusy?: boolean;
|
||||
};
|
||||
|
||||
const BADGE_TONE_CLASS: Record<CreationWorkShelfItem['badges'][number]['tone'], string> = {
|
||||
const BADGE_TONE_CLASS: Record<
|
||||
CreationWorkShelfItem['badges'][number]['tone'],
|
||||
string
|
||||
> = {
|
||||
warm: 'platform-pill--warm',
|
||||
success: 'platform-pill--success',
|
||||
neutral: 'platform-pill--neutral',
|
||||
@@ -36,6 +43,20 @@ export function CustomWorldWorkCard({
|
||||
onDelete = null,
|
||||
deleteBusy = false,
|
||||
}: CustomWorldWorkCardProps) {
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||||
'idle',
|
||||
);
|
||||
const copyPublicWorkCode = () => {
|
||||
if (!item.publicWorkCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
void copyTextToClipboard(item.publicWorkCode).then((copied) => {
|
||||
setCopyState(copied ? 'copied' : 'failed');
|
||||
window.setTimeout(() => setCopyState('idle'), 1400);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
@@ -127,15 +148,41 @@ export function CustomWorldWorkCard({
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-end sm:justify-between xl:gap-2 xl:pt-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.metrics.map((metric) => (
|
||||
<span
|
||||
key={`${item.id}-${metric.id}`}
|
||||
className={`platform-pill ${BADGE_TONE_CLASS[metric.tone ?? 'neutral']} px-3 py-1 text-[10px]`}
|
||||
<div className="min-w-0 space-y-2">
|
||||
{item.publicWorkCode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
copyPublicWorkCode();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
className="platform-pill platform-pill--neutral pointer-events-auto relative z-30 inline-flex max-w-full items-center gap-1.5 px-3 py-1 text-[10px]"
|
||||
aria-label={`复制作品号 ${item.publicWorkCode}`}
|
||||
title="复制作品号"
|
||||
>
|
||||
{metric.label}
|
||||
</span>
|
||||
))}
|
||||
<span className="shrink-0">作品号</span>
|
||||
<span className="min-w-0 truncate">{item.publicWorkCode}</span>
|
||||
<Copy className="h-3 w-3 shrink-0" />
|
||||
{copyState !== 'idle' ? (
|
||||
<span className="shrink-0">
|
||||
{copyState === 'copied' ? '已复制' : '复制失败'}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.metrics.map((metric) => (
|
||||
<span
|
||||
key={`${item.id}-${metric.id}`}
|
||||
className={`platform-pill ${BADGE_TONE_CLASS[metric.tone ?? 'neutral']} px-3 py-1 text-[10px]`}
|
||||
>
|
||||
{metric.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 sm:justify-end xl:gap-1.5">
|
||||
{onExperience ? (
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type CreationWorkShelfKind = 'rpg' | 'big-fish' | 'puzzle';
|
||||
export type CreationWorkShelfStatus = 'draft' | 'published';
|
||||
@@ -44,6 +47,7 @@ export type CreationWorkShelfItem = {
|
||||
coverImageSrc: string | null;
|
||||
coverRenderMode: 'image' | 'scene_with_roles';
|
||||
coverCharacterImageSrcs: string[];
|
||||
publicWorkCode: string | null;
|
||||
typeLabel: string;
|
||||
openActionLabel: string;
|
||||
canExperience: boolean;
|
||||
@@ -55,6 +59,7 @@ export type CreationWorkShelfItem = {
|
||||
|
||||
export function buildCreationWorkShelfItems(params: {
|
||||
rpgItems: CustomWorldWorkSummary[];
|
||||
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||||
bigFishItems: BigFishWorkSummary[];
|
||||
puzzleItems: PuzzleWorkSummary[];
|
||||
canDeleteRpg?: boolean;
|
||||
@@ -63,6 +68,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
}) {
|
||||
const {
|
||||
rpgItems,
|
||||
rpgLibraryEntries = [],
|
||||
bigFishItems,
|
||||
puzzleItems,
|
||||
canDeleteRpg = false,
|
||||
@@ -71,11 +77,15 @@ export function buildCreationWorkShelfItems(params: {
|
||||
} = params;
|
||||
|
||||
return [
|
||||
...rpgItems.map((item) => mapRpgWorkToShelfItem(item, canDeleteRpg)),
|
||||
...rpgItems.map((item) =>
|
||||
mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries),
|
||||
),
|
||||
...bigFishItems.map((item) =>
|
||||
mapBigFishWorkToShelfItem(item, canDeleteBigFish),
|
||||
),
|
||||
...puzzleItems.map((item) => mapPuzzleWorkToShelfItem(item, canDeletePuzzle)),
|
||||
...puzzleItems.map((item) =>
|
||||
mapPuzzleWorkToShelfItem(item, canDeletePuzzle),
|
||||
),
|
||||
].sort(
|
||||
(left, right) =>
|
||||
getShelfItemTime(right.updatedAt) - getShelfItemTime(left.updatedAt),
|
||||
@@ -85,8 +95,12 @@ export function buildCreationWorkShelfItems(params: {
|
||||
function mapRpgWorkToShelfItem(
|
||||
item: CustomWorldWorkSummary,
|
||||
canDelete: boolean,
|
||||
libraryEntries: CustomWorldLibraryEntry<CustomWorldProfile>[],
|
||||
): CreationWorkShelfItem {
|
||||
const isDraft = item.status === 'draft';
|
||||
const libraryEntry = item.profileId
|
||||
? libraryEntries.find((entry) => entry.profileId === item.profileId)
|
||||
: null;
|
||||
const badges: CreationWorkShelfBadge[] = [
|
||||
buildStatusBadge(item.status),
|
||||
{ id: 'type', label: 'RPG', tone: 'neutral' },
|
||||
@@ -134,6 +148,10 @@ function mapRpgWorkToShelfItem(
|
||||
coverImageSrc: item.coverImageSrc ?? null,
|
||||
coverRenderMode: item.coverRenderMode ?? 'image',
|
||||
coverCharacterImageSrcs: item.coverCharacterImageSrcs ?? [],
|
||||
publicWorkCode:
|
||||
item.status === 'published'
|
||||
? (libraryEntry?.publicWorkCode ?? null)
|
||||
: null,
|
||||
typeLabel: 'RPG',
|
||||
openActionLabel: isDraft
|
||||
? item.playableNpcCount > 0 || item.landmarkCount > 0
|
||||
@@ -163,6 +181,7 @@ function mapBigFishWorkToShelfItem(
|
||||
coverImageSrc: item.coverImageSrc ?? null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
publicWorkCode: null,
|
||||
typeLabel: '大鱼',
|
||||
openActionLabel: item.status === 'draft' ? '继续创作' : '查看详情',
|
||||
canExperience: item.status === 'published',
|
||||
@@ -212,8 +231,11 @@ function mapPuzzleWorkToShelfItem(
|
||||
coverImageSrc: item.coverImageSrc ?? null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
publicWorkCode:
|
||||
status === 'published' ? buildPuzzlePublicWorkCode(item.profileId) : null,
|
||||
typeLabel: '拼图',
|
||||
openActionLabel: status === 'draft' ? '继续创作' : '查看详情',
|
||||
openActionLabel:
|
||||
status === 'published' && !item.sourceSessionId ? '查看详情' : '继续创作',
|
||||
canExperience: status === 'published',
|
||||
canDelete,
|
||||
badges: [
|
||||
@@ -233,7 +255,9 @@ function mapPuzzleWorkToShelfItem(
|
||||
};
|
||||
}
|
||||
|
||||
function buildStatusBadge(status: CreationWorkShelfStatus): CreationWorkShelfBadge {
|
||||
function buildStatusBadge(
|
||||
status: CreationWorkShelfStatus,
|
||||
): CreationWorkShelfBadge {
|
||||
return {
|
||||
id: 'status',
|
||||
label: status === 'draft' ? '草稿' : '已发布',
|
||||
|
||||
@@ -9,12 +9,15 @@ import {
|
||||
} from '../../types';
|
||||
import { GameCanvasEntityLayer } from './GameCanvasEntityLayer';
|
||||
import {
|
||||
CHARACTER_COMBAT_HP_TOP_PX,
|
||||
ENTITY_CONTAINER_REM,
|
||||
getEncounterCharacterBottomOffsetPx,
|
||||
getEncounterCharacterOpponentBottom,
|
||||
getHostileNpcSceneBottomOffsetPx,
|
||||
getMirroredStageEntityLeft,
|
||||
getNpcCombatHpTop,
|
||||
getSceneNpcVisualBottomOffsetPx,
|
||||
MONSTER_COMBAT_HP_TOP_PX,
|
||||
} from './GameCanvasShared';
|
||||
|
||||
function createCharacter(): Character {
|
||||
@@ -141,23 +144,32 @@ describe('GameCanvasEntityLayer', () => {
|
||||
const character = createCharacter();
|
||||
|
||||
expect(getEncounterCharacterOpponentBottom('18%', 68, sceneNpcEncounter, character))
|
||||
.toBe('calc(18% + 68px - 132px)');
|
||||
.toBe('calc(18% + 68px - 78px)');
|
||||
expect(getEncounterCharacterBottomOffsetPx(68, sceneNpcEncounter, character))
|
||||
.toBe(-64);
|
||||
.toBe(-10);
|
||||
});
|
||||
|
||||
it('lowers scene npc custom visuals even without character ids', () => {
|
||||
const sceneNpcEncounter = createEncounter({
|
||||
visual: {
|
||||
species: 'aquatic',
|
||||
body: '章鱼形态',
|
||||
attire: '深海服饰',
|
||||
palette: '蓝紫',
|
||||
signature: '触腕',
|
||||
race: 'elf',
|
||||
bodyColor: 'blue',
|
||||
headIndex: 0,
|
||||
hairColorIndex: 1,
|
||||
hairStyleFrame: 2,
|
||||
facialHairEnabled: false,
|
||||
facialHairColorIndex: 0,
|
||||
facialHairStyleFrame: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getSceneNpcVisualBottomOffsetPx(sceneNpcEncounter)).toBe(-132);
|
||||
expect(getSceneNpcVisualBottomOffsetPx(sceneNpcEncounter)).toBe(-78);
|
||||
});
|
||||
|
||||
it('keeps combat hp bars above character and monster silhouettes', () => {
|
||||
expect(getNpcCombatHpTop('hero', null)).toBe(CHARACTER_COMBAT_HP_TOP_PX);
|
||||
expect(getNpcCombatHpTop(null, 'monster-20')).toBe(MONSTER_COMBAT_HP_TOP_PX);
|
||||
expect(getNpcCombatHpTop(null, null)).toBe(CHARACTER_COMBAT_HP_TOP_PX);
|
||||
});
|
||||
|
||||
it('renders affinity effect on the matching hostile npc', () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
DialogueBubbleIcon,
|
||||
type GameCanvasEntitySelection,
|
||||
GENERIC_NPC_SCENE_SCALE,
|
||||
CHARACTER_COMBAT_HP_TOP_PX,
|
||||
getCompanionSlotOffset,
|
||||
getEncounterCharacterBottomOffsetPx,
|
||||
getEncounterCharacterOpponentBottom,
|
||||
@@ -172,7 +173,10 @@ export function GameCanvasEntityLayer({
|
||||
className="relative flex w-28 flex-col items-center"
|
||||
>
|
||||
{inBattle && (
|
||||
<div className="absolute -top-2 left-1/2 -translate-x-1/2">
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2"
|
||||
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
|
||||
>
|
||||
<HpBar hp={companion.hp} maxHp={companion.maxHp} tone="emerald" />
|
||||
</div>
|
||||
)}
|
||||
@@ -214,7 +218,10 @@ export function GameCanvasEntityLayer({
|
||||
>
|
||||
<div className="relative">
|
||||
{inBattle && (
|
||||
<div className="absolute -top-2 left-1/2 -translate-x-1/2">
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2"
|
||||
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
|
||||
>
|
||||
<HpBar hp={playerHp} maxHp={playerMaxHp} tone="emerald" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -72,14 +72,14 @@ export const ROLE_CHARACTER_FRAME_CLASS = 'flex h-28 w-28 items-end justify-cent
|
||||
export const ROLE_CHARACTER_SPRITE_CLASS = 'h-full w-full scale-[1.32] origin-bottom';
|
||||
export const ROLE_CHARACTER_SCENE_IMAGE_SCALE = 1.32;
|
||||
export const GENERIC_NPC_SCENE_SCALE = 1.72;
|
||||
export const SCENE_NPC_CUSTOM_VISUAL_GROUND_OFFSET_PX = 132;
|
||||
export const SCENE_NPC_CUSTOM_VISUAL_GROUND_OFFSET_PX = 78;
|
||||
const DEFAULT_IMAGE_STYLE: React.CSSProperties = {
|
||||
imageRendering: 'pixelated',
|
||||
objectPosition: 'center bottom',
|
||||
};
|
||||
export const DEFAULT_COMBAT_HP_TOP_PX = -18;
|
||||
export const CHARACTER_NPC_COMBAT_HP_TOP_PX = -2;
|
||||
export const GENERIC_NPC_COMBAT_HP_TOP_PX = 94;
|
||||
export const CHARACTER_COMBAT_HP_TOP_PX = -48;
|
||||
export const MONSTER_COMBAT_HP_TOP_PX = -44;
|
||||
export const GENERIC_NPC_COMBAT_HP_TOP_PX = -48;
|
||||
export const GENERIC_NPC_EFFECT_TARGET_OFFSET_PX = -16;
|
||||
export const HOSTILE_NPC_SCENE_INSET_PX = 28;
|
||||
export type HostileNpcSceneAnchorConfig = {
|
||||
@@ -226,8 +226,8 @@ export function getHostileNpcSceneBottomOffsetPx(
|
||||
}
|
||||
|
||||
export function getNpcCombatHpTop(characterId?: string | null, monsterPresetId?: string | null) {
|
||||
if (monsterPresetId) return DEFAULT_COMBAT_HP_TOP_PX;
|
||||
return characterId ? CHARACTER_NPC_COMBAT_HP_TOP_PX : GENERIC_NPC_COMBAT_HP_TOP_PX;
|
||||
if (monsterPresetId) return MONSTER_COMBAT_HP_TOP_PX;
|
||||
return characterId ? CHARACTER_COMBAT_HP_TOP_PX : GENERIC_NPC_COMBAT_HP_TOP_PX;
|
||||
}
|
||||
|
||||
export function getSceneEntityZIndex(bottomOffsetPx: number) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ArrowRight, X } from 'lucide-react';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import { PLATFORM_CREATION_TYPES } from './platformEntryCreationTypes';
|
||||
|
||||
export interface PlatformEntryCreationTypeModalProps {
|
||||
@@ -79,58 +80,40 @@ export function PlatformEntryCreationTypeModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="platform-overlay fixed inset-0 z-[90] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4">
|
||||
<div className="platform-modal-shell w-full max-w-3xl overflow-hidden rounded-[1.8rem]">
|
||||
<div className="bg-transparent">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
选择创作类型
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[var(--platform-text-base)]">
|
||||
先选玩法类型,再进入对应创作工作台。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isBusy}
|
||||
className="platform-icon-button disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-4 sm:px-5 sm:py-5">
|
||||
<div className="grid gap-3 sm:grid-cols-5">
|
||||
{PLATFORM_CREATION_TYPES.map((item) => (
|
||||
<CreationTypeCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
busy={isBusy}
|
||||
onSelect={() => {
|
||||
if (item.id === 'rpg') {
|
||||
onSelectRpg();
|
||||
}
|
||||
if (item.id === 'big-fish') {
|
||||
onSelectBigFish();
|
||||
}
|
||||
if (item.id === 'puzzle') {
|
||||
onSelectPuzzle();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<UnifiedModal
|
||||
open={isOpen}
|
||||
title="选择创作类型"
|
||||
description="先选玩法类型,再进入对应创作工作台。"
|
||||
onClose={onClose}
|
||||
closeDisabled={isBusy}
|
||||
size="lg"
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-5">
|
||||
{PLATFORM_CREATION_TYPES.map((item) => (
|
||||
<CreationTypeCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
busy={isBusy}
|
||||
onSelect={() => {
|
||||
if (item.id === 'rpg') {
|
||||
onSelectRpg();
|
||||
}
|
||||
if (item.id === 'big-fish') {
|
||||
onSelectBigFish();
|
||||
}
|
||||
if (item.id === 'puzzle') {
|
||||
onSelectPuzzle();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,10 @@ import {
|
||||
getPuzzleAgentSession,
|
||||
streamPuzzleAgentMessage,
|
||||
} from '../../services/puzzle-agent';
|
||||
import { getPuzzleGalleryDetail, listPuzzleGallery } from '../../services/puzzle-gallery';
|
||||
import {
|
||||
getPuzzleGalleryDetail,
|
||||
listPuzzleGallery,
|
||||
} from '../../services/puzzle-gallery';
|
||||
import { advanceLocalPuzzleNextLevel } from '../../services/puzzle-runtime';
|
||||
import {
|
||||
dragLocalPuzzlePiece,
|
||||
@@ -75,6 +78,7 @@ import {
|
||||
swapLocalPuzzlePieces,
|
||||
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
|
||||
import { deletePuzzleWork, listPuzzleWorks } from '../../services/puzzle-works';
|
||||
import { isSamePuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||
import { deleteRpgCreationAgentSession } from '../../services/rpg-creation';
|
||||
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
|
||||
import { deleteRpgEntryWorldProfile } from '../../services/rpg-entry';
|
||||
@@ -97,7 +101,10 @@ import {
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
|
||||
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
|
||||
import { PlatformEntryHomeView } from './PlatformEntryHomeView';
|
||||
import {
|
||||
PlatformEntryHomeView,
|
||||
type PlatformHomeTab,
|
||||
} from './PlatformEntryHomeView';
|
||||
import {
|
||||
buildCreationHubFallbackItems,
|
||||
normalizeAgentBackedProfile,
|
||||
@@ -115,6 +122,10 @@ type AgentResultPublishGateView = {
|
||||
publishReady: boolean;
|
||||
};
|
||||
|
||||
type PuzzleDetailReturnTarget = {
|
||||
tab: PlatformHomeTab;
|
||||
};
|
||||
|
||||
type AgentResultBlockerView = {
|
||||
code?: string;
|
||||
message: string;
|
||||
@@ -269,8 +280,7 @@ function buildAgentResultPublishGateView(
|
||||
|
||||
const blockers = fallbackBlockers
|
||||
.filter(
|
||||
(entry) =>
|
||||
!isAgentResultStructuralBlockerResolved(profile, entry.code),
|
||||
(entry) => !isAgentResultStructuralBlockerResolved(profile, entry.code),
|
||||
)
|
||||
.map((entry) => entry.message);
|
||||
|
||||
@@ -362,6 +372,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
>([]);
|
||||
const [selectedPuzzleDetail, setSelectedPuzzleDetail] =
|
||||
useState<PuzzleWorkSummary | null>(null);
|
||||
const [puzzleDetailReturnTarget, setPuzzleDetailReturnTarget] =
|
||||
useState<PuzzleDetailReturnTarget | null>(null);
|
||||
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
|
||||
const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false);
|
||||
const [puzzleGenerationState, setPuzzleGenerationState] =
|
||||
@@ -369,12 +381,15 @@ export function PlatformEntryFlowShellImpl({
|
||||
const [isPuzzleNextLevelGenerating, setIsPuzzleNextLevelGenerating] =
|
||||
useState(false);
|
||||
const [isSearchingPublicCode, setIsSearchingPublicCode] = useState(false);
|
||||
const [publicSearchError, setPublicSearchError] = useState<string | null>(null);
|
||||
const [publicSearchError, setPublicSearchError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [searchedPublicUser, setSearchedPublicUser] =
|
||||
useState<PublicUserSummary | null>(null);
|
||||
const [deletingCreationWorkId, setDeletingCreationWorkId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const hadReadableProtectedDataRef = useRef(false);
|
||||
const hasInitialAgentSession = Boolean(
|
||||
readCustomWorldAgentUiState().activeSessionId,
|
||||
);
|
||||
@@ -492,9 +507,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
agentSession: sessionController.agentSession,
|
||||
handleCustomWorldSelect,
|
||||
executePublishWorld: async () => {
|
||||
const latestSession = await autosaveCoordinator.executeAgentActionAndWait({
|
||||
action: 'publish_world',
|
||||
});
|
||||
const latestSession = await autosaveCoordinator.executeAgentActionAndWait(
|
||||
{
|
||||
action: 'publish_world',
|
||||
},
|
||||
);
|
||||
// 发布动作会在后端同步 gallery 投影;前端发布完成后立即刷新首页/分类页共用的公开作品列表。
|
||||
await Promise.allSettled([
|
||||
platformBootstrap.refreshPublishedGallery(),
|
||||
@@ -552,18 +569,15 @@ export function PlatformEntryFlowShellImpl({
|
||||
return '服务端预览';
|
||||
}, [agentResultPreview]);
|
||||
|
||||
const featuredGalleryEntries = useMemo(
|
||||
() => {
|
||||
const puzzlePublicEntries = puzzleGalleryEntries.map(
|
||||
mapPuzzleWorkToPlatformGalleryCard,
|
||||
);
|
||||
return mergePlatformPublicGalleryEntries(
|
||||
platformBootstrap.publishedGalleryEntries,
|
||||
puzzlePublicEntries,
|
||||
).slice(0, 6);
|
||||
},
|
||||
[platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries],
|
||||
);
|
||||
const featuredGalleryEntries = useMemo(() => {
|
||||
const puzzlePublicEntries = puzzleGalleryEntries.map(
|
||||
mapPuzzleWorkToPlatformGalleryCard,
|
||||
);
|
||||
return mergePlatformPublicGalleryEntries(
|
||||
platformBootstrap.publishedGalleryEntries,
|
||||
puzzlePublicEntries,
|
||||
).slice(0, 6);
|
||||
}, [platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries]);
|
||||
const latestGalleryEntries = useMemo(
|
||||
() =>
|
||||
mergePlatformPublicGalleryEntries(
|
||||
@@ -609,86 +623,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
[authUi],
|
||||
);
|
||||
|
||||
const handlePublicCodeSearch = useCallback(
|
||||
async (keyword: string) => {
|
||||
const normalizedKeyword = keyword.trim();
|
||||
if (!normalizedKeyword) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearchingPublicCode(true);
|
||||
setPublicSearchError(null);
|
||||
setSearchedPublicUser(null);
|
||||
|
||||
const upperKeyword = normalizedKeyword.toUpperCase();
|
||||
const shouldSearchUserIdFirst = /^user[_-][a-z0-9_-]+$/iu.test(normalizedKeyword);
|
||||
const shouldSearchWorkFirst =
|
||||
!shouldSearchUserIdFirst &&
|
||||
(upperKeyword.startsWith('CW') || /^\d{1,8}$/u.test(normalizedKeyword));
|
||||
const shouldSearchUserFirst =
|
||||
shouldSearchUserIdFirst || upperKeyword.startsWith('SY') || !shouldSearchWorkFirst;
|
||||
|
||||
const tryOpenGalleryEntry = async () => {
|
||||
const entry = await getRpgEntryWorldGalleryDetailByCode(normalizedKeyword);
|
||||
await detailNavigation.openGalleryDetail({
|
||||
ownerUserId: entry.ownerUserId,
|
||||
profileId: entry.profileId,
|
||||
publicWorkCode: entry.publicWorkCode,
|
||||
authorPublicUserCode: entry.authorPublicUserCode,
|
||||
visibility: 'published',
|
||||
publishedAt: entry.publishedAt,
|
||||
updatedAt: entry.updatedAt,
|
||||
authorDisplayName: entry.authorDisplayName,
|
||||
worldName: entry.worldName,
|
||||
subtitle: entry.subtitle,
|
||||
summaryText: entry.summaryText,
|
||||
coverImageSrc: entry.coverImageSrc,
|
||||
themeMode: entry.themeMode,
|
||||
playableNpcCount: entry.playableNpcCount,
|
||||
landmarkCount: entry.landmarkCount,
|
||||
} satisfies CustomWorldGalleryCard);
|
||||
};
|
||||
|
||||
try {
|
||||
if (shouldSearchUserIdFirst) {
|
||||
const user = await getPublicAuthUserById(normalizedKeyword);
|
||||
setSearchedPublicUser(user);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldSearchWorkFirst) {
|
||||
try {
|
||||
await tryOpenGalleryEntry();
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (shouldSearchUserFirst) {
|
||||
try {
|
||||
const user = await getPublicAuthUserByCode(normalizedKeyword);
|
||||
setSearchedPublicUser(user);
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!shouldSearchWorkFirst) {
|
||||
await tryOpenGalleryEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await getPublicAuthUserByCode(normalizedKeyword);
|
||||
setSearchedPublicUser(user);
|
||||
} catch (error) {
|
||||
setPublicSearchError(
|
||||
resolveRpgCreationErrorMessage(error, '未找到对应的叙世号或作品号。'),
|
||||
);
|
||||
} finally {
|
||||
setIsSearchingPublicCode(false);
|
||||
}
|
||||
},
|
||||
[detailNavigation],
|
||||
);
|
||||
|
||||
const prepareCreationLaunch = useCallback(() => {
|
||||
if (sessionController.isCreatingAgentSession) {
|
||||
return false;
|
||||
@@ -749,9 +683,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
return galleryResponse.items;
|
||||
} catch (error) {
|
||||
setPuzzleGalleryEntries([]);
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(error, '读取拼图广场失败。'),
|
||||
);
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图广场失败。'));
|
||||
return [];
|
||||
}
|
||||
}, [resolvePuzzleErrorMessage]);
|
||||
@@ -836,7 +768,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
{ session: PuzzleAgentSessionSnapshot },
|
||||
SendPuzzleAgentMessageRequest,
|
||||
PuzzleAgentActionRequest,
|
||||
{ operation: PuzzleAgentOperationRecord; session: PuzzleAgentSessionSnapshot }
|
||||
{
|
||||
operation: PuzzleAgentOperationRecord;
|
||||
session: PuzzleAgentSessionSnapshot;
|
||||
}
|
||||
>({
|
||||
client: {
|
||||
createSession: createPuzzleAgentSession,
|
||||
@@ -936,7 +871,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
const setIsPuzzleBusy = puzzleFlow.setIsBusy;
|
||||
const streamingPuzzleReplyText = puzzleFlow.streamingReplyText;
|
||||
const isStreamingPuzzleReply = puzzleFlow.isStreamingReply;
|
||||
|
||||
const resetRpgSessionViewState = sessionController.resetSessionViewState;
|
||||
const setRpgGeneratedCustomWorldProfile =
|
||||
sessionController.setGeneratedCustomWorldProfile;
|
||||
const setRpgCustomWorldError = sessionController.setCustomWorldError;
|
||||
const persistRpgAgentUiState = sessionController.persistAgentUiState;
|
||||
const resetAutoSaveTrackingToIdle =
|
||||
autosaveCoordinator.resetAutoSaveTrackingToIdle;
|
||||
const openBigFishAgentWorkspace = useCallback(async () => {
|
||||
setBigFishRun(null);
|
||||
await bigFishFlow.openWorkspace();
|
||||
@@ -948,6 +889,61 @@ export function PlatformEntryFlowShellImpl({
|
||||
await puzzleFlow.openWorkspace();
|
||||
}, [puzzleFlow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (platformBootstrap.canReadProtectedData) {
|
||||
hadReadableProtectedDataRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (authUi?.user || !hadReadableProtectedDataRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hadReadableProtectedDataRef.current = false;
|
||||
|
||||
// 创作中心只展示当前登录用户的私有作品。
|
||||
// 一旦退出登录或鉴权上下文被收回,三类作品缓存必须同步清空,不能等刷新页面。
|
||||
setShowCreationTypeModal(false);
|
||||
setSelectedDetailEntry(null);
|
||||
setBigFishWorks([]);
|
||||
setBigFishRun(null);
|
||||
setBigFishGenerationState(null);
|
||||
setBigFishError(null);
|
||||
setPuzzleOperation(null);
|
||||
setPuzzleWorks([]);
|
||||
setSelectedPuzzleDetail(null);
|
||||
setPuzzleRun(null);
|
||||
setPuzzleGenerationState(null);
|
||||
setIsPuzzleNextLevelGenerating(false);
|
||||
setPuzzleError(null);
|
||||
setDeletingCreationWorkId(null);
|
||||
resetRpgSessionViewState();
|
||||
setRpgGeneratedCustomWorldProfile(null);
|
||||
setRpgCustomWorldError(null);
|
||||
persistRpgAgentUiState(null, null);
|
||||
resetAutoSaveTrackingToIdle();
|
||||
|
||||
if (
|
||||
selectionStage !== 'platform' &&
|
||||
selectionStage !== 'detail' &&
|
||||
selectionStage !== 'puzzle-gallery-detail'
|
||||
) {
|
||||
setSelectionStage('platform');
|
||||
}
|
||||
}, [
|
||||
authUi?.user,
|
||||
platformBootstrap.canReadProtectedData,
|
||||
persistRpgAgentUiState,
|
||||
resetAutoSaveTrackingToIdle,
|
||||
resetRpgSessionViewState,
|
||||
selectionStage,
|
||||
setBigFishError,
|
||||
setPuzzleError,
|
||||
setRpgCustomWorldError,
|
||||
setRpgGeneratedCustomWorldProfile,
|
||||
setSelectionStage,
|
||||
]);
|
||||
|
||||
const handleCreationHubCreateType = useCallback(
|
||||
(type: PlatformCreationTypeId) => {
|
||||
if (type === 'airp' || type === 'visual-novel') {
|
||||
@@ -1105,11 +1101,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
|
||||
const dragPuzzlePiece = useCallback(
|
||||
(payload: {
|
||||
pieceId: string;
|
||||
targetRow: number;
|
||||
targetCol: number;
|
||||
}) => {
|
||||
(payload: { pieceId: string; targetRow: number; targetCol: number }) => {
|
||||
if (!puzzleRun || isPuzzleBusy) {
|
||||
return;
|
||||
}
|
||||
@@ -1138,7 +1130,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
const { run } = await advanceLocalPuzzleNextLevel({
|
||||
run: puzzleRun,
|
||||
sourceSessionId:
|
||||
selectedPuzzleDetail?.sourceSessionId ?? puzzleSession?.sessionId ?? null,
|
||||
selectedPuzzleDetail?.sourceSessionId ??
|
||||
puzzleSession?.sessionId ??
|
||||
null,
|
||||
});
|
||||
setPuzzleRun(run);
|
||||
} catch (error) {
|
||||
@@ -1233,7 +1227,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
(entry) => entry.profileId === work.profileId,
|
||||
);
|
||||
if (!matchedEntry) {
|
||||
platformBootstrap.setPlatformError('未找到可体验的作品,请刷新后重试。');
|
||||
platformBootstrap.setPlatformError(
|
||||
'未找到可体验的作品,请刷新后重试。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1302,10 +1298,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const deleteTask =
|
||||
work.sourceType === 'published_profile' && work.profileId
|
||||
? deleteRpgEntryWorldProfile(work.profileId).then(async (entries) => {
|
||||
platformBootstrap.setSavedCustomWorldEntries(entries);
|
||||
await platformBootstrap.refreshCustomWorldWorks().catch(() => []);
|
||||
})
|
||||
? deleteRpgEntryWorldProfile(work.profileId).then(
|
||||
async (entries) => {
|
||||
platformBootstrap.setSavedCustomWorldEntries(entries);
|
||||
await platformBootstrap
|
||||
.refreshCustomWorldWorks()
|
||||
.catch(() => []);
|
||||
},
|
||||
)
|
||||
: work.sourceType === 'agent_session' && work.sessionId
|
||||
? deleteRpgCreationAgentSession(work.sessionId).then((items) => {
|
||||
platformBootstrap.setCustomWorldWorkEntries(items);
|
||||
@@ -1386,7 +1386,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
void refreshPuzzleGallery();
|
||||
})
|
||||
.catch((error) => {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '删除拼图作品失败。'));
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(error, '删除拼图作品失败。'),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setDeletingCreationWorkId(null);
|
||||
@@ -1402,14 +1404,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
|
||||
const openPuzzleDetail = useCallback(
|
||||
async (profileId: string) => {
|
||||
async (
|
||||
profileId: string,
|
||||
returnTarget: PuzzleDetailReturnTarget = {
|
||||
tab: platformBootstrap.platformTab,
|
||||
},
|
||||
) => {
|
||||
setIsPuzzleBusy(true);
|
||||
setPuzzleError(null);
|
||||
|
||||
try {
|
||||
const { item } = await getPuzzleGalleryDetail(profileId);
|
||||
setSelectedPuzzleDetail(item);
|
||||
enterCreateTab();
|
||||
setPuzzleDetailReturnTarget(returnTarget);
|
||||
setSelectionStage('puzzle-gallery-detail');
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图详情失败。'));
|
||||
@@ -1417,7 +1424,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
},
|
||||
[enterCreateTab, resolvePuzzleErrorMessage, setSelectionStage],
|
||||
[
|
||||
platformBootstrap.platformTab,
|
||||
resolvePuzzleErrorMessage,
|
||||
setSelectionStage,
|
||||
],
|
||||
);
|
||||
|
||||
const openPuzzleDraft = useCallback(
|
||||
@@ -1425,12 +1436,139 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleOperation(null);
|
||||
setPuzzleRun(null);
|
||||
setSelectedPuzzleDetail(null);
|
||||
const restoredSession = await puzzleFlow.restoreDraft(item.sourceSessionId);
|
||||
if (!item.sourceSessionId?.trim()) {
|
||||
if (item.publicationStatus === 'published') {
|
||||
await openPuzzleDetail(item.profileId, { tab: 'create' });
|
||||
return;
|
||||
}
|
||||
|
||||
setPuzzleError('这份拼图草稿缺少会话信息,请重新开始创作。');
|
||||
return;
|
||||
}
|
||||
|
||||
const restoredSession = await puzzleFlow.restoreDraft(
|
||||
item.sourceSessionId,
|
||||
);
|
||||
if (!restoredSession) {
|
||||
await refreshPuzzleShelf().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
[puzzleFlow, refreshPuzzleShelf],
|
||||
[openPuzzleDetail, puzzleFlow, refreshPuzzleShelf, setPuzzleError],
|
||||
);
|
||||
|
||||
const handlePublicCodeSearch = useCallback(
|
||||
async (keyword: string) => {
|
||||
const normalizedKeyword = keyword.trim();
|
||||
if (!normalizedKeyword) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearchingPublicCode(true);
|
||||
setPublicSearchError(null);
|
||||
setSearchedPublicUser(null);
|
||||
|
||||
const upperKeyword = normalizedKeyword.toUpperCase();
|
||||
const shouldSearchUserIdFirst = /^user[_-][a-z0-9_-]+$/iu.test(
|
||||
normalizedKeyword,
|
||||
);
|
||||
const shouldSearchPuzzleFirst = upperKeyword.startsWith('PZ');
|
||||
const shouldSearchWorkFirst =
|
||||
!shouldSearchUserIdFirst &&
|
||||
!shouldSearchPuzzleFirst &&
|
||||
(upperKeyword.startsWith('CW') || /^\d{1,8}$/u.test(normalizedKeyword));
|
||||
const shouldSearchUserFirst =
|
||||
shouldSearchUserIdFirst ||
|
||||
upperKeyword.startsWith('SY') ||
|
||||
(!shouldSearchWorkFirst && !shouldSearchPuzzleFirst);
|
||||
|
||||
const tryOpenGalleryEntry = async () => {
|
||||
const entry =
|
||||
await getRpgEntryWorldGalleryDetailByCode(normalizedKeyword);
|
||||
await detailNavigation.openGalleryDetail({
|
||||
ownerUserId: entry.ownerUserId,
|
||||
profileId: entry.profileId,
|
||||
publicWorkCode: entry.publicWorkCode,
|
||||
authorPublicUserCode: entry.authorPublicUserCode,
|
||||
visibility: 'published',
|
||||
publishedAt: entry.publishedAt,
|
||||
updatedAt: entry.updatedAt,
|
||||
authorDisplayName: entry.authorDisplayName,
|
||||
worldName: entry.worldName,
|
||||
subtitle: entry.subtitle,
|
||||
summaryText: entry.summaryText,
|
||||
coverImageSrc: entry.coverImageSrc,
|
||||
themeMode: entry.themeMode,
|
||||
playableNpcCount: entry.playableNpcCount,
|
||||
landmarkCount: entry.landmarkCount,
|
||||
} satisfies CustomWorldGalleryCard);
|
||||
};
|
||||
const tryOpenPuzzleGalleryEntry = async () => {
|
||||
const entries =
|
||||
puzzleGalleryEntries.length > 0
|
||||
? puzzleGalleryEntries
|
||||
: await refreshPuzzleGallery();
|
||||
const matchedEntry = entries.find((entry) =>
|
||||
isSamePuzzlePublicWorkCode(normalizedKeyword, entry.profileId),
|
||||
);
|
||||
|
||||
if (!matchedEntry) {
|
||||
throw new Error('未找到拼图作品。');
|
||||
}
|
||||
|
||||
await openPuzzleDetail(matchedEntry.profileId, {
|
||||
tab: platformBootstrap.platformTab,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
if (shouldSearchUserIdFirst) {
|
||||
const user = await getPublicAuthUserById(normalizedKeyword);
|
||||
setSearchedPublicUser(user);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldSearchPuzzleFirst) {
|
||||
await tryOpenPuzzleGalleryEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldSearchWorkFirst) {
|
||||
try {
|
||||
await tryOpenGalleryEntry();
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (shouldSearchUserFirst) {
|
||||
try {
|
||||
const user = await getPublicAuthUserByCode(normalizedKeyword);
|
||||
setSearchedPublicUser(user);
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!shouldSearchWorkFirst) {
|
||||
await tryOpenGalleryEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await getPublicAuthUserByCode(normalizedKeyword);
|
||||
setSearchedPublicUser(user);
|
||||
} catch (error) {
|
||||
setPublicSearchError(
|
||||
resolveRpgCreationErrorMessage(error, '未找到对应的叙世号或作品号。'),
|
||||
);
|
||||
} finally {
|
||||
setIsSearchingPublicCode(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
detailNavigation,
|
||||
openPuzzleDetail,
|
||||
platformBootstrap.platformTab,
|
||||
puzzleGalleryEntries,
|
||||
refreshPuzzleGallery,
|
||||
],
|
||||
);
|
||||
|
||||
const openBigFishDraft = useCallback(
|
||||
@@ -1572,6 +1710,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
onExperienceRpg={(item) => {
|
||||
handleExperienceRpgWork(item);
|
||||
}}
|
||||
rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries}
|
||||
bigFishItems={bigFishWorks}
|
||||
onOpenBigFishDetail={(item) => {
|
||||
runProtectedAction(() => {
|
||||
@@ -1589,11 +1728,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
puzzleItems={puzzleWorks}
|
||||
onOpenPuzzleDetail={(item) => {
|
||||
runProtectedAction(() => {
|
||||
if (item.publicationStatus === 'draft') {
|
||||
void openPuzzleDraft(item);
|
||||
return;
|
||||
}
|
||||
void openPuzzleDetail(item.profileId);
|
||||
void openPuzzleDraft(item);
|
||||
});
|
||||
}}
|
||||
onExperiencePuzzle={(profileId) => {
|
||||
@@ -1653,7 +1788,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
onOpenCreateTypePicker={openCreationTypePicker}
|
||||
onOpenGalleryDetail={(entry) => {
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
void openPuzzleDetail(entry.profileId);
|
||||
void openPuzzleDetail(entry.profileId, {
|
||||
tab: platformBootstrap.platformTab,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1834,13 +1971,17 @@ export function PlatformEntryFlowShellImpl({
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载大鱼吃小鱼生成面板..." />}
|
||||
fallback={
|
||||
<LazyPanelFallback label="正在加载大鱼吃小鱼生成面板..." />
|
||||
}
|
||||
>
|
||||
<CustomWorldGenerationView
|
||||
settingText={
|
||||
bigFishSession?.lastAssistantReply ?? '正在整理当前玩法草稿。'
|
||||
}
|
||||
anchorEntries={buildBigFishGenerationAnchorEntries(bigFishSession)}
|
||||
anchorEntries={buildBigFishGenerationAnchorEntries(
|
||||
bigFishSession,
|
||||
)}
|
||||
progress={buildMiniGameDraftGenerationProgress(
|
||||
bigFishGenerationState,
|
||||
)}
|
||||
@@ -1851,7 +1992,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSelectionStage('big-fish-agent-workspace');
|
||||
}}
|
||||
onRetry={() => {
|
||||
void executeBigFishAction({ action: 'big_fish_compile_draft' });
|
||||
void executeBigFishAction({
|
||||
action: 'big_fish_compile_draft',
|
||||
});
|
||||
}}
|
||||
onInterrupt={undefined}
|
||||
backLabel="返回创作中心"
|
||||
@@ -1966,7 +2109,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
settingText={
|
||||
puzzleSession?.lastAssistantReply ?? '正在整理当前拼图草稿。'
|
||||
}
|
||||
anchorEntries={buildPuzzleGenerationAnchorEntries(puzzleSession)}
|
||||
anchorEntries={buildPuzzleGenerationAnchorEntries(
|
||||
puzzleSession,
|
||||
)}
|
||||
progress={buildMiniGameDraftGenerationProgress(
|
||||
puzzleGenerationState,
|
||||
)}
|
||||
@@ -2030,9 +2175,22 @@ export function PlatformEntryFlowShellImpl({
|
||||
isBusy={isPuzzleBusy}
|
||||
error={puzzleError}
|
||||
onBack={() => {
|
||||
enterCreateTab();
|
||||
platformBootstrap.setPlatformTab(
|
||||
puzzleDetailReturnTarget?.tab ?? 'home',
|
||||
);
|
||||
setPuzzleDetailReturnTarget(null);
|
||||
setSelectionStage('platform');
|
||||
}}
|
||||
onEdit={
|
||||
selectedPuzzleDetail.ownerUserId === authUi?.user?.id &&
|
||||
Boolean(selectedPuzzleDetail.sourceSessionId?.trim())
|
||||
? () => {
|
||||
runProtectedAction(() => {
|
||||
void openPuzzleDraft(selectedPuzzleDetail);
|
||||
});
|
||||
}
|
||||
: null
|
||||
}
|
||||
onStartGame={() => {
|
||||
void startPuzzleRunFromProfile(selectedPuzzleDetail.profileId);
|
||||
}}
|
||||
@@ -2211,15 +2369,17 @@ export function PlatformEntryFlowShellImpl({
|
||||
? 'generate_landmarks'
|
||||
: 'generate_characters';
|
||||
const latestSession =
|
||||
await autosaveCoordinator.executeAgentActionAndWait({
|
||||
action,
|
||||
count: 1,
|
||||
...(kind === 'playable'
|
||||
? { roleType: 'playable' as const }
|
||||
: kind === 'story'
|
||||
? { roleType: 'story' as const }
|
||||
: {}),
|
||||
});
|
||||
await autosaveCoordinator.executeAgentActionAndWait(
|
||||
{
|
||||
action,
|
||||
count: 1,
|
||||
...(kind === 'playable'
|
||||
? { roleType: 'playable' as const }
|
||||
: kind === 'story'
|
||||
? { roleType: 'story' as const }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
const latestProfile = latestSession
|
||||
? rpgCreationPreviewAdapter.buildPreviewFromSession(
|
||||
latestSession,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/* @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 { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import { PuzzleGalleryDetailView } from './PuzzleGalleryDetailView';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: () => null,
|
||||
}));
|
||||
|
||||
const originalClipboard = navigator.clipboard;
|
||||
|
||||
const detailItem = {
|
||||
workId: 'puzzle-work-public-1',
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: 'puzzle-session-1',
|
||||
authorDisplayName: '拼图玩家',
|
||||
levelName: '奇幻拼图',
|
||||
summary: '一张用于公开分享的拼图作品。',
|
||||
themeTags: ['奇幻'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T10:00:00.000Z',
|
||||
publishedAt: '2026-04-25T10:00:00.000Z',
|
||||
playCount: 7,
|
||||
publishReady: true,
|
||||
} satisfies PuzzleWorkSummary;
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: originalClipboard,
|
||||
});
|
||||
});
|
||||
|
||||
test('shows and copies puzzle public work code in detail view', async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeText = vi.fn(async () => undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
|
||||
render(
|
||||
<PuzzleGalleryDetailView
|
||||
item={detailItem}
|
||||
onBack={vi.fn()}
|
||||
onStartGame={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('作品号')).toBeTruthy();
|
||||
expect(screen.getByText('PZ-EPUBLIC1')).toBeTruthy();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }),
|
||||
);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith('PZ-EPUBLIC1');
|
||||
});
|
||||
|
||||
test('falls back to legacy selection copy when clipboard api rejects', async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeText = vi.fn(async () => {
|
||||
throw new Error('clipboard denied');
|
||||
});
|
||||
const execCommand = vi.fn(() => true);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: execCommand,
|
||||
});
|
||||
|
||||
render(
|
||||
<PuzzleGalleryDetailView
|
||||
item={detailItem}
|
||||
onBack={vi.fn()}
|
||||
onStartGame={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }),
|
||||
);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith('PZ-EPUBLIC1');
|
||||
expect(execCommand).toHaveBeenCalledWith('copy');
|
||||
expect(await screen.findByText('已复制')).toBeTruthy();
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { ArrowLeft, Play, UserRound } from 'lucide-react';
|
||||
import { ArrowLeft, Copy, Pencil, Play, UserRound } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type PuzzleGalleryDetailViewProps = {
|
||||
@@ -8,6 +11,7 @@ type PuzzleGalleryDetailViewProps = {
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onEdit?: (() => void) | null;
|
||||
onStartGame: () => void;
|
||||
};
|
||||
|
||||
@@ -20,8 +24,20 @@ export function PuzzleGalleryDetailView({
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onEdit = null,
|
||||
onStartGame,
|
||||
}: PuzzleGalleryDetailViewProps) {
|
||||
const publicWorkCode = buildPuzzlePublicWorkCode(item.profileId);
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||||
'idle',
|
||||
);
|
||||
const copyPublicWorkCode = () => {
|
||||
void copyTextToClipboard(publicWorkCode).then((copied) => {
|
||||
setCopyState(copied ? 'copied' : 'failed');
|
||||
window.setTimeout(() => setCopyState('idle'), 1400);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
|
||||
<div className="relative overflow-hidden rounded-[1.8rem] border border-amber-100/16 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.18),transparent_32%),linear-gradient(135deg,rgba(76,29,19,0.98),rgba(15,23,42,0.98))] px-4 py-4 text-white sm:px-5">
|
||||
@@ -29,19 +45,33 @@ export function PuzzleGalleryDetailView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label="返回"
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-white/10 text-white/84"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={onStartGame}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
进入第 1 关
|
||||
</button>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{onEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={onEdit}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-white/12 px-4 py-2 text-sm font-bold text-white disabled:opacity-45"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
修改作品
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={onStartGame}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
进入第 1 关
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
@@ -54,6 +84,22 @@ export function PuzzleGalleryDetailView({
|
||||
{item.authorDisplayName}
|
||||
</span>
|
||||
<span>{item.playCount} 次游玩</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyPublicWorkCode}
|
||||
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-white/14 bg-white/10 px-3 py-1 text-sm text-amber-50/86"
|
||||
aria-label={`复制作品号 ${publicWorkCode}`}
|
||||
title="复制作品号"
|
||||
>
|
||||
<span className="shrink-0">作品号</span>
|
||||
<span className="min-w-0 truncate">{publicWorkCode}</span>
|
||||
<Copy className="h-3.5 w-3.5 shrink-0" />
|
||||
{copyState !== 'idle' ? (
|
||||
<span className="shrink-0 text-xs">
|
||||
{copyState === 'copied' ? '已复制' : '复制失败'}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,14 +25,14 @@ import { buildDefaultRolePromptBundle } from '../asset-studio/customWorldRolePro
|
||||
import { buildProjectPixelStyleReferenceBoard } from '../asset-studio/projectPixelStyleReference';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { CharacterAnimator } from '../CharacterAnimator';
|
||||
import { RpgCreationRoleAnimationSection } from './RpgCreationRoleAnimationSection';
|
||||
import { RpgCreationRoleAssetStudioFooter } from './RpgCreationRoleAssetStudioFooter';
|
||||
import { RpgCreationRoleVisualSection } from './RpgCreationRoleVisualSection';
|
||||
import {
|
||||
CORE_ACTIONS,
|
||||
type CustomWorldAiActionConfig,
|
||||
type EditableCustomWorldRole,
|
||||
} from './roleAssetStudioModel';
|
||||
import { RpgCreationRoleAnimationSection } from './RpgCreationRoleAnimationSection';
|
||||
import { RpgCreationRoleAssetStudioFooter } from './RpgCreationRoleAssetStudioFooter';
|
||||
import { RpgCreationRoleVisualSection } from './RpgCreationRoleVisualSection';
|
||||
import { useRoleAnimationWorkflow } from './useRoleAnimationWorkflow';
|
||||
import { useRoleVisualCandidateWorkflow } from './useRoleVisualCandidateWorkflow';
|
||||
|
||||
@@ -867,7 +867,7 @@ export function RpgCreationRoleAssetStudioModal({
|
||||
}
|
||||
|
||||
return window.confirm(
|
||||
`${params.kindLabel}预计消耗 ${params.points} 积分。\n${params.description}`,
|
||||
`${params.kindLabel}预计消耗 ${params.points} 叙世币。\n${params.description}`,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,7 @@ import {
|
||||
listPuzzleGallery,
|
||||
} from '../../services/puzzle-gallery';
|
||||
import { listPuzzleWorks } from '../../services/puzzle-works';
|
||||
import { getRpgEntryWorldGalleryDetailByCode } from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||
import type { GameState } from '../../types';
|
||||
import {
|
||||
AuthUiContext,
|
||||
@@ -128,6 +129,10 @@ vi.mock('../../services/puzzle-gallery', () => ({
|
||||
listPuzzleGallery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/rpg-entry/rpgEntryLibraryClient', () => ({
|
||||
getRpgEntryWorldGalleryDetailByCode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/big-fish-creation', () => ({
|
||||
createBigFishCreationSession: vi.fn(),
|
||||
executeBigFishCreationAction: vi.fn(),
|
||||
@@ -548,6 +553,9 @@ beforeEach(() => {
|
||||
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
|
||||
vi.mocked(getRpgEntryWorldGalleryDetailByCode).mockRejectedValue(
|
||||
new Error('未找到公开作品'),
|
||||
);
|
||||
vi.mocked(upsertRpgWorldProfile).mockResolvedValue({
|
||||
entry: {
|
||||
ownerUserId: 'user-1',
|
||||
@@ -1020,9 +1028,7 @@ test('opening RPG agent workspace does not refetch session snapshot in a render
|
||||
expect(getRpgCreationSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test(
|
||||
'create tab opens compiled agent draft in result refinement page',
|
||||
async () => {
|
||||
test('create tab opens compiled agent draft in result refinement page', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||||
@@ -1075,9 +1081,7 @@ test(
|
||||
screen.queryByText('Agent工作区:custom-world-agent-session-1'),
|
||||
).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /返回创作/u })).toBeTruthy();
|
||||
},
|
||||
10000,
|
||||
);
|
||||
}, 10000);
|
||||
|
||||
test('create tab resumes agent workspace when draft has no compiled result yet', async () => {
|
||||
const user = userEvent.setup();
|
||||
@@ -1274,6 +1278,101 @@ test('clicking a public work while logged out routes through requireAuth', async
|
||||
expect(getRpgEntryWorldGalleryDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('creation hub clears all private work shelves immediately after logout state', async () => {
|
||||
const user = userEvent.setup();
|
||||
const loggedInAuth = createAuthValue();
|
||||
const loggedOutAuth = createAuthValue({
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: () => {},
|
||||
requireAuth: () => {},
|
||||
});
|
||||
|
||||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||||
{
|
||||
workId: 'draft:rpg-logout-cache-1',
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: 'RPG 退出缓存作品',
|
||||
subtitle: '登出后不应继续可见',
|
||||
summary: '这条 RPG 私有作品只能在登录态展示。',
|
||||
coverImageSrc: null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
updatedAt: '2026-04-25T10:00:00.000Z',
|
||||
publishedAt: null,
|
||||
stage: 'clarifying',
|
||||
stageLabel: '补齐关键锚点',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
roleVisualReadyCount: 0,
|
||||
roleAnimationReadyCount: 0,
|
||||
roleAssetSummaryLabel: null,
|
||||
sessionId: 'rpg-logout-cache-session',
|
||||
profileId: null,
|
||||
canResume: true,
|
||||
canEnterWorld: false,
|
||||
},
|
||||
]);
|
||||
vi.mocked(listBigFishWorks).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
workId: 'big-fish-logout-cache-1',
|
||||
sourceSessionId: 'big-fish-logout-cache-session',
|
||||
title: '大鱼退出缓存作品',
|
||||
subtitle: '登出后不应继续可见',
|
||||
summary: '这条大鱼私有作品只能在登录态展示。',
|
||||
coverImageSrc: null,
|
||||
status: 'draft',
|
||||
updatedAt: '2026-04-25T10:05:00.000Z',
|
||||
publishReady: false,
|
||||
levelCount: 8,
|
||||
levelMainImageReadyCount: 0,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(listPuzzleWorks).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
workId: 'puzzle-logout-cache-1',
|
||||
profileId: 'puzzle-logout-cache-profile',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-logout-cache-session',
|
||||
authorDisplayName: '测试玩家',
|
||||
levelName: '拼图退出缓存作品',
|
||||
summary: '这条拼图私有作品只能在登录态展示。',
|
||||
themeTags: ['退出态'],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-04-25T10:10:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
publishReady: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(<TestWrapper authValue={loggedInAuth} />);
|
||||
|
||||
await openCreationHub(user);
|
||||
const createPanel = getPlatformTabPanel('create');
|
||||
|
||||
expect(await within(createPanel).findByText('RPG 退出缓存作品')).toBeTruthy();
|
||||
expect(await within(createPanel).findByText('大鱼退出缓存作品')).toBeTruthy();
|
||||
expect(await within(createPanel).findByText('拼图退出缓存作品')).toBeTruthy();
|
||||
|
||||
rerender(<TestWrapper authValue={loggedOutAuth} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(createPanel).queryByText('RPG 退出缓存作品')).toBeNull();
|
||||
expect(within(createPanel).queryByText('大鱼退出缓存作品')).toBeNull();
|
||||
expect(within(createPanel).queryByText('拼图退出缓存作品')).toBeNull();
|
||||
});
|
||||
expect(within(createPanel).getByText('还没有作品')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('published puzzle works appear on home and category public shelves', async () => {
|
||||
const user = userEvent.setup();
|
||||
const publishedPuzzleWork = {
|
||||
@@ -1310,12 +1409,72 @@ test('published puzzle works appear on home and category public shelves', async
|
||||
await user.click(screen.getByRole('button', { name: '分类' }));
|
||||
|
||||
const categoryPanel = getPlatformTabPanel('category');
|
||||
expect(within(categoryPanel).getAllByText('星桥机关').length).toBeGreaterThan(0);
|
||||
expect(within(categoryPanel).getAllByText('星桥机关').length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
within(categoryPanel).getAllByRole('button', { name: /机关/u }).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('published puzzle detail returns to the source platform tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
const publishedPuzzleWork = {
|
||||
workId: 'puzzle-work-public-1',
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: null,
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '星桥机关',
|
||||
summary: '旋转碎片并接通星桥机关。',
|
||||
themeTags: ['机关', '星桥'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||||
playCount: 3,
|
||||
publishReady: true,
|
||||
} satisfies PuzzleWorkSummary;
|
||||
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [publishedPuzzleWork],
|
||||
});
|
||||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||||
item: publishedPuzzleWork,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '分类' }));
|
||||
await waitFor(() => {
|
||||
expect(document.getElementById('platform-tab-panel-category')).toBeTruthy();
|
||||
});
|
||||
const categoryPanel = getPlatformTabPanel('category');
|
||||
expect(
|
||||
within(categoryPanel).getAllByText('星桥机关').length,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
await user.click(
|
||||
within(categoryPanel).getByRole('button', {
|
||||
name: /拼图关卡.*星桥机关/u,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '进入第 1 关' }),
|
||||
).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const returnedCategoryPanel = getPlatformTabPanel('category');
|
||||
expect(returnedCategoryPanel.getAttribute('aria-hidden')).toBe('false');
|
||||
expect(
|
||||
within(returnedCategoryPanel).getAllByText('星桥机关').length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('selecting RPG creation while logged out routes through requireAuth', async () => {
|
||||
const user = userEvent.setup();
|
||||
const requireAuth = vi.fn();
|
||||
@@ -1375,8 +1534,9 @@ test('restoring an agent workspace ignores a stored session owned by another use
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.sessionStorage.getItem('genarrative.custom-world-agent-ui.v1'))
|
||||
.toBeNull();
|
||||
expect(
|
||||
window.sessionStorage.getItem('genarrative.custom-world-agent-ui.v1'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
expect(getRpgCreationSession).not.toHaveBeenCalled();
|
||||
@@ -1511,6 +1671,93 @@ test('puzzle draft card restores the bound agent session and opens the result vi
|
||||
expect(screen.queryByText('拼图玩法共创')).toBeNull();
|
||||
});
|
||||
|
||||
test('published puzzle work card restores its source session for editing', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listPuzzleWorks).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
workId: 'puzzle-work-session-1',
|
||||
profileId: 'puzzle-profile-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-session-1',
|
||||
authorDisplayName: '测试玩家',
|
||||
levelName: '雨夜猫塔',
|
||||
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
|
||||
themeTags: ['雨夜', '猫咪', '遗迹'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T12:10:00.000Z',
|
||||
publishedAt: '2026-04-25T12:10:00.000Z',
|
||||
playCount: 8,
|
||||
publishReady: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /继续创作/u })).toBeTruthy();
|
||||
await user.click(await screen.findByRole('button', { name: /继续创作/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getPuzzleAgentSession).toHaveBeenCalledWith('puzzle-session-1');
|
||||
});
|
||||
|
||||
expect(getPuzzleGalleryDetail).not.toHaveBeenCalledWith(
|
||||
'puzzle-profile-session-1',
|
||||
);
|
||||
expect(await screen.findByText('拼图结果页')).toBeTruthy();
|
||||
expect(screen.getByDisplayValue('雨夜猫塔')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('public code search opens a published puzzle by PZ code', async () => {
|
||||
const user = userEvent.setup();
|
||||
const puzzleWork: PuzzleWorkSummary = {
|
||||
workId: 'puzzle-work-public-1',
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: null,
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '雨夜猫塔',
|
||||
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
|
||||
themeTags: ['雨夜', '猫咪', '遗迹'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T12:10:00.000Z',
|
||||
publishedAt: '2026-04-25T12:10:00.000Z',
|
||||
playCount: 8,
|
||||
publishReady: true,
|
||||
};
|
||||
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [puzzleWork],
|
||||
});
|
||||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||||
item: puzzleWork,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const searchInput =
|
||||
await screen.findByPlaceholderText('输入 SY / CW / PZ 编号');
|
||||
await user.type(searchInput, 'PZ-EPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getPuzzleGalleryDetail).toHaveBeenCalledWith(
|
||||
'puzzle-profile-public-1',
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText('进入第 1 关')).toBeTruthy();
|
||||
expect(screen.getByText('雨夜猫塔')).toBeTruthy();
|
||||
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('big fish draft card restores the bound agent session and opens the result view', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -1557,11 +1804,11 @@ test('big fish draft card restores the bound agent session and opens the result
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
expect(await screen.findByText('大鱼吃小鱼工作区:big-fish-session-1')).toBeTruthy();
|
||||
expect(screen.queryByText(/大鱼吃小鱼共创/u)).toBeNull();
|
||||
expect(
|
||||
screen.getByText('我想做机械深海里微生物互相吞并进化。'),
|
||||
await screen.findByText('大鱼吃小鱼工作区:big-fish-session-1'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText(/大鱼吃小鱼共创/u)).toBeNull();
|
||||
expect(screen.getByText('我想做机械深海里微生物互相吞并进化。')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
|
||||
@@ -1760,7 +2007,9 @@ test('agent result view shows publish blocker dialog before publish action when
|
||||
sessionId === 'custom-world-agent-session-1' &&
|
||||
payload?.action === 'publish_world',
|
||||
).length;
|
||||
expect(publishWorldCallCountAfterClick).toBe(publishWorldCallCountBeforeClick);
|
||||
expect(publishWorldCallCountAfterClick).toBe(
|
||||
publishWorldCallCountBeforeClick,
|
||||
);
|
||||
});
|
||||
|
||||
test('agent draft result publishes to gallery from publish panel', async () => {
|
||||
@@ -1796,6 +2045,9 @@ test('agent draft result publishes to gallery from publish panel', async () => {
|
||||
} satisfies CustomWorldAgentSessionSnapshot;
|
||||
|
||||
let hasPublishedWorld = false;
|
||||
vi.mocked(createRpgCreationSession).mockResolvedValue({
|
||||
session: publishReadyDraftSession,
|
||||
});
|
||||
vi.mocked(getRpgCreationOperation).mockResolvedValueOnce({
|
||||
operationId: 'operation-publish-world-1',
|
||||
type: 'publish_world',
|
||||
@@ -1850,9 +2102,13 @@ test('agent draft result publishes to gallery from publish panel', async () => {
|
||||
|
||||
await openNewRpgCreation(user);
|
||||
|
||||
const actionButton = await screen.findByRole('button', {
|
||||
name: '发布',
|
||||
});
|
||||
const actionButton = await screen.findByRole(
|
||||
'button',
|
||||
{
|
||||
name: '发布',
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await user.click(actionButton);
|
||||
await user.click(await screen.findByRole('button', { name: '发布到广场' }));
|
||||
|
||||
@@ -2425,8 +2681,8 @@ test('agent draft result can open from server result preview without embedded le
|
||||
expect(await screen.findByText('世界档案')).toBeTruthy();
|
||||
expect(screen.getByText('潮雾列岛·服务端预览')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('结果页改为优先消费 session.resultPreview'),
|
||||
).toBeTruthy();
|
||||
screen.getAllByText('结果页改为优先消费 session.resultPreview').length,
|
||||
).toBeGreaterThan(0);
|
||||
},
|
||||
{ timeout: 2500 },
|
||||
);
|
||||
@@ -2756,9 +3012,7 @@ test('creation hub published work experience button enters world directly', asyn
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />,
|
||||
);
|
||||
render(<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await user.click(await screen.findByRole('button', { name: '体验' }));
|
||||
|
||||
@@ -5,7 +5,8 @@ import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import { RpgEntryHomeView } from './RpgEntryHomeView';
|
||||
import { RpgEntryHomeView, type RpgEntryHomeViewProps } from './RpgEntryHomeView';
|
||||
import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation';
|
||||
|
||||
vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||
getRpgProfileRechargeCenter: vi.fn(async () => ({
|
||||
@@ -19,15 +20,15 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||
},
|
||||
pointProducts: [
|
||||
{
|
||||
productId: 'points_10',
|
||||
title: '10积分',
|
||||
priceCents: 100,
|
||||
productId: 'points_60',
|
||||
title: '60叙世币',
|
||||
priceCents: 600,
|
||||
kind: 'points',
|
||||
pointsAmount: 10,
|
||||
bonusPoints: 19,
|
||||
pointsAmount: 60,
|
||||
bonusPoints: 60,
|
||||
durationDays: 0,
|
||||
badgeLabel: '首充送积分',
|
||||
description: '首充送19积分',
|
||||
badgeLabel: '首充双倍',
|
||||
description: '首充送60叙世币',
|
||||
tier: 'normal',
|
||||
},
|
||||
],
|
||||
@@ -47,7 +48,7 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||
],
|
||||
benefits: [
|
||||
{
|
||||
benefitName: '免积分回合数',
|
||||
benefitName: '免叙世币回合数',
|
||||
normalValue: '30',
|
||||
monthValue: '100',
|
||||
seasonValue: '100',
|
||||
@@ -60,19 +61,19 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||
createRpgProfileRechargeOrder: vi.fn(async () => ({
|
||||
order: {
|
||||
orderId: 'order-1',
|
||||
productId: 'points_10',
|
||||
productTitle: '10积分',
|
||||
productId: 'points_60',
|
||||
productTitle: '60叙世币',
|
||||
kind: 'points',
|
||||
amountCents: 100,
|
||||
amountCents: 600,
|
||||
status: 'paid',
|
||||
paymentChannel: 'mock',
|
||||
paidAt: '2026-04-25T10:00:00Z',
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
pointsDelta: 29,
|
||||
pointsDelta: 120,
|
||||
membershipExpiresAt: null,
|
||||
},
|
||||
center: {
|
||||
walletBalance: 29,
|
||||
walletBalance: 120,
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
@@ -93,6 +94,41 @@ vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: () => null,
|
||||
}));
|
||||
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
const puzzlePublicEntry = {
|
||||
sourceType: 'puzzle',
|
||||
workId: 'puzzle-work-public-1',
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
publicWorkCode: 'PZ-EPUBLIC1',
|
||||
ownerUserId: 'user-2',
|
||||
authorDisplayName: '拼图玩家',
|
||||
worldName: '奇幻拼图',
|
||||
subtitle: '拼图关卡',
|
||||
summaryText: '一张用于公开分享的拼图作品。',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['奇幻'],
|
||||
visibility: 'published',
|
||||
publishedAt: '1777110165.990127Z',
|
||||
updatedAt: '2026-04-25T10:00:00.000Z',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
function mockDesktopLayout() {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(() => ({
|
||||
matches: true,
|
||||
media: '(min-width: 1024px)',
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function renderProfileView(onRechargeSuccess = vi.fn()) {
|
||||
return render(
|
||||
<AuthUiContext.Provider
|
||||
@@ -157,8 +193,77 @@ function renderProfileView(onRechargeSuccess = vi.fn()) {
|
||||
);
|
||||
}
|
||||
|
||||
function renderLoggedOutHomeView(
|
||||
openLoginModal = vi.fn(),
|
||||
overrides: Partial<
|
||||
Pick<
|
||||
RpgEntryHomeViewProps,
|
||||
| 'featuredEntries'
|
||||
| 'latestEntries'
|
||||
| 'onOpenGalleryDetail'
|
||||
| 'onSearchPublicCode'
|
||||
>
|
||||
> = {},
|
||||
) {
|
||||
return render(
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal,
|
||||
requireAuth: vi.fn(),
|
||||
openSettingsModal: vi.fn(),
|
||||
openAccountModal: vi.fn(),
|
||||
logout: vi.fn(async () => undefined),
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: vi.fn(),
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: vi.fn(),
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
}}
|
||||
>
|
||||
<RpgEntryHomeView
|
||||
activeTab="home"
|
||||
onTabChange={vi.fn()}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
saveEntries={[]}
|
||||
saveError={null}
|
||||
featuredEntries={overrides.featuredEntries ?? []}
|
||||
latestEntries={overrides.latestEntries ?? []}
|
||||
myEntries={[]}
|
||||
historyEntries={[]}
|
||||
profileDashboard={null}
|
||||
isLoadingPlatform={false}
|
||||
isLoadingDashboard={false}
|
||||
isResumingSaveWorldKey={null}
|
||||
platformError={null}
|
||||
dashboardError={null}
|
||||
onContinueGame={vi.fn()}
|
||||
onResumeSave={vi.fn()}
|
||||
onOpenCreateWorld={vi.fn()}
|
||||
onOpenCreateTypePicker={vi.fn()}
|
||||
onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalMatchMedia,
|
||||
});
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('opens recharge modal and submits points product', async () => {
|
||||
@@ -169,9 +274,106 @@ test('opens recharge modal and submits points product', async () => {
|
||||
await user.click(screen.getByText('会员充值'));
|
||||
|
||||
expect(await screen.findByText('账户充值')).toBeTruthy();
|
||||
expect(await screen.findByText('10积分')).toBeTruthy();
|
||||
expect(await screen.findByText('60叙世币')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByText('首充送19积分'));
|
||||
await user.click(screen.getByText('首充送60叙世币'));
|
||||
|
||||
await waitFor(() => expect(onRechargeSuccess).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
test('shows a reachable login entry in logged out mobile shell', async () => {
|
||||
const user = userEvent.setup();
|
||||
const openLoginModal = vi.fn();
|
||||
|
||||
renderLoggedOutHomeView(openLoginModal);
|
||||
await user.click(screen.getByRole('button', { name: '登录' }));
|
||||
|
||||
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('mobile home search submits public work code', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchPublicCode = vi.fn();
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: vi.fn(),
|
||||
requireAuth: vi.fn(),
|
||||
openSettingsModal: vi.fn(),
|
||||
openAccountModal: vi.fn(),
|
||||
logout: vi.fn(async () => undefined),
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: vi.fn(),
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: vi.fn(),
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
}}
|
||||
>
|
||||
<RpgEntryHomeView
|
||||
activeTab="home"
|
||||
onTabChange={vi.fn()}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
saveEntries={[]}
|
||||
saveError={null}
|
||||
featuredEntries={[]}
|
||||
latestEntries={[]}
|
||||
myEntries={[]}
|
||||
historyEntries={[]}
|
||||
profileDashboard={null}
|
||||
isLoadingPlatform={false}
|
||||
isLoadingDashboard={false}
|
||||
isResumingSaveWorldKey={null}
|
||||
platformError={null}
|
||||
dashboardError={null}
|
||||
onContinueGame={vi.fn()}
|
||||
onResumeSave={vi.fn()}
|
||||
onOpenCreateWorld={vi.fn()}
|
||||
onOpenCreateTypePicker={vi.fn()}
|
||||
onOpenGalleryDetail={vi.fn()}
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={onSearchPublicCode}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('输入 SY / CW / PZ 编号');
|
||||
await user.type(searchInput, 'PZ-PROFILE1{enter}');
|
||||
|
||||
expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1');
|
||||
});
|
||||
|
||||
test('public gallery cards hide work code until detail is opened', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenGalleryDetail = vi.fn();
|
||||
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
onOpenGalleryDetail,
|
||||
});
|
||||
|
||||
expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }))
|
||||
.toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /查看作品/u }));
|
||||
|
||||
expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
|
||||
});
|
||||
|
||||
test('desktop trending list shows kind instead of work code or timestamp text', () => {
|
||||
mockDesktopLayout();
|
||||
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
});
|
||||
|
||||
expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull();
|
||||
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('1777110165.990127Z')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Copy,
|
||||
Crown,
|
||||
House,
|
||||
LogIn,
|
||||
MessageCircle,
|
||||
Pencil,
|
||||
Search,
|
||||
@@ -33,9 +34,9 @@ import type {
|
||||
PlatformBrowseHistoryEntry,
|
||||
ProfileDashboardCardKey,
|
||||
ProfileDashboardSummary,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
ProfileRechargeCenterResponse,
|
||||
ProfileRechargeProduct,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
ProfileSaveArchiveSummary,
|
||||
RedeemProfileReferralInviteCodeResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
@@ -43,8 +44,8 @@ import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapsho
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
createRpgProfileRechargeOrder,
|
||||
getRpgProfileReferralInviteCenter,
|
||||
getRpgProfileRechargeCenter,
|
||||
getRpgProfileReferralInviteCenter,
|
||||
redeemRpgProfileReferralInviteCode,
|
||||
} from '../../services/rpg-entry/rpgProfileClient';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
@@ -195,6 +196,48 @@ function SectionHeader({ title, detail }: { title: string; detail: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PublicCodeSearchBar({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
isSearching,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
isSearching: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`platform-desktop-search flex min-w-0 items-center gap-3 px-4 py-3 text-[var(--platform-text-soft)] ${className ?? ''}`}
|
||||
>
|
||||
<Search className="h-4 w-4 shrink-0" />
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="输入 SY / CW / PZ 编号"
|
||||
className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={!value.trim() || isSearching}
|
||||
className="shrink-0 text-xs font-semibold text-[var(--platform-text-soft)] disabled:opacity-50"
|
||||
>
|
||||
{isSearching ? '搜索中' : '搜索'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyShelf({ text }: { text: string }) {
|
||||
return (
|
||||
<div
|
||||
@@ -285,7 +328,9 @@ function WorldCard({
|
||||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
|
||||
<div className="relative z-10 flex h-full flex-col">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="platform-pill platform-pill--warm">{badge}</span>
|
||||
<span className="platform-pill platform-pill--warm max-w-[8.5rem] truncate">
|
||||
{badge}
|
||||
</span>
|
||||
<span className="platform-pill platform-pill--neutral px-2.5">
|
||||
{metaLabel}
|
||||
</span>
|
||||
@@ -598,7 +643,9 @@ function DesktopTrendingItem({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
<span>{`${rank}`.padStart(2, '0')}</span>
|
||||
<span>{formatPlatformWorldTime(entry.publishedAt)}</span>
|
||||
<span className="truncate">
|
||||
{describePublicGalleryCardKind(entry)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-1 text-lg font-semibold text-[var(--platform-text-strong)]">
|
||||
{entry.worldName}
|
||||
@@ -858,6 +905,14 @@ function formatRechargePrice(priceCents: number) {
|
||||
return Number.isInteger(yuan) ? `¥${yuan}` : `¥${yuan.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatMembershipDuration(days: number) {
|
||||
if (days >= 365) {
|
||||
return '365天';
|
||||
}
|
||||
|
||||
return `${days}天`;
|
||||
}
|
||||
|
||||
function AccountRechargeModal({
|
||||
center,
|
||||
activeTab,
|
||||
@@ -883,36 +938,48 @@ function AccountRechargeModal({
|
||||
: (center?.membershipProducts ?? []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/42 px-3 py-5">
|
||||
<div className="relative max-h-[min(92vh,46rem)] w-full max-w-[28rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
|
||||
<div className="relative max-h-[min(92vh,46rem)] w-full max-w-[32rem] overflow-hidden rounded-[1.35rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_34%,#f8fafc_100%)] text-zinc-950 shadow-2xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-2 z-10 flex h-8 w-8 items-center justify-center rounded-full text-[#ff4056]"
|
||||
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/78 text-[#ff4056] shadow-sm"
|
||||
aria-label="关闭账户充值"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="max-h-[min(92vh,46rem)] overflow-y-auto px-5 pb-5 pt-4">
|
||||
<div className="text-center text-2xl font-black">账户充值</div>
|
||||
<div className="mt-4 grid grid-cols-2 rounded-xl bg-zinc-100 p-1">
|
||||
<div className="max-h-[min(92vh,46rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
|
||||
<div className="pr-10">
|
||||
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
|
||||
WALLET
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-black">账户充值</div>
|
||||
<div className="mt-2 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-white/70 px-3 py-1.5 text-xs font-bold text-zinc-600">
|
||||
<Coins className="h-3.5 w-3.5 text-[#ff4056]" />
|
||||
<span>
|
||||
{center ? `${center.walletBalance}叙世币` : '叙世币账户'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 rounded-2xl bg-zinc-100/90 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange('points')}
|
||||
className={`rounded-lg px-4 py-3 text-sm font-black transition ${
|
||||
className={`rounded-xl px-4 py-3 text-sm font-black transition ${
|
||||
activeTab === 'points'
|
||||
? 'bg-white text-[#ff4056] shadow'
|
||||
? 'bg-white text-[#ff4056] shadow-sm'
|
||||
: 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
积分充值
|
||||
叙世币充值
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange('membership')}
|
||||
className={`rounded-lg px-4 py-3 text-sm font-black transition ${
|
||||
className={`rounded-xl px-4 py-3 text-sm font-black transition ${
|
||||
activeTab === 'membership'
|
||||
? 'bg-white text-[#ff4056] shadow'
|
||||
? 'bg-white text-[#ff4056] shadow-sm'
|
||||
: 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
@@ -945,20 +1012,14 @@ function AccountRechargeModal({
|
||||
key={product.productId}
|
||||
disabled={Boolean(isSubmitting)}
|
||||
onClick={() => onSelectProduct(product)}
|
||||
className="relative min-h-[8.45rem] overflow-hidden rounded-xl border border-zinc-200 bg-white text-center shadow-sm transition hover:border-[#ff4056] disabled:opacity-70"
|
||||
className="relative min-h-[8.45rem] overflow-hidden rounded-2xl border border-zinc-200 bg-white text-center shadow-sm transition hover:border-[#ff4056] disabled:opacity-70"
|
||||
>
|
||||
<div
|
||||
className={`h-8 px-2 py-1.5 text-xs font-black text-white ${
|
||||
product.productId === 'points_60'
|
||||
? 'bg-zinc-500'
|
||||
: 'bg-[#ff4056]'
|
||||
}`}
|
||||
>
|
||||
<div className="h-8 bg-[#ff4056] px-2 py-1.5 text-xs font-black text-white">
|
||||
{product.badgeLabel}
|
||||
</div>
|
||||
<div className="px-2 py-3">
|
||||
<div className="text-xl font-black">
|
||||
{product.pointsAmount}积分
|
||||
{product.pointsAmount}叙世币
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
金额:{formatRechargePrice(product.priceCents)}
|
||||
@@ -975,46 +1036,66 @@ function AccountRechargeModal({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-5 grid grid-cols-3 gap-3">
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
||||
{visibleProducts.map((product) => (
|
||||
<button
|
||||
type="button"
|
||||
key={product.productId}
|
||||
disabled={Boolean(isSubmitting)}
|
||||
onClick={() => onSelectProduct(product)}
|
||||
className="min-h-[6rem] rounded-xl border border-zinc-200 bg-zinc-50 px-2 py-4 text-center transition hover:border-[#ff4056] disabled:opacity-70"
|
||||
className="group relative min-h-[7.75rem] overflow-hidden rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-left shadow-sm transition hover:border-[#ff4056] hover:shadow-md disabled:opacity-70"
|
||||
>
|
||||
<div className="text-lg font-black">{product.title}</div>
|
||||
<div className="mt-2 text-xl font-black text-[#ff4056]">
|
||||
{formatRechargePrice(product.priceCents)}
|
||||
<div className="absolute right-0 top-0 h-16 w-16 rounded-bl-[2rem] bg-[#ff4056]/10 transition group-hover:bg-[#ff4056]/16" />
|
||||
<div className="relative">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-lg font-black">
|
||||
{product.title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs font-bold text-zinc-500">
|
||||
{formatMembershipDuration(product.durationDays)}
|
||||
</div>
|
||||
</div>
|
||||
<Crown className="h-5 w-5 shrink-0 text-[#ff4056]" />
|
||||
</div>
|
||||
<div className="mt-4 text-2xl font-black text-[#ff4056]">
|
||||
{formatRechargePrice(product.priceCents)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs font-semibold text-zinc-500">
|
||||
{isSubmitting === product.productId
|
||||
? '处理中'
|
||||
: product.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-5 overflow-hidden rounded-xl border border-zinc-200">
|
||||
<div className="mt-5 overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm">
|
||||
<div className="border-b border-zinc-200 px-4 py-3 text-sm font-black">
|
||||
用户等级特权
|
||||
</div>
|
||||
<div className="grid grid-cols-5 text-center text-sm">
|
||||
{center?.benefits.map((benefit) => (
|
||||
<div key={benefit.benefitName} className="contents">
|
||||
<div className="border-b border-zinc-100 bg-zinc-50 px-2 py-3 text-left text-zinc-600">
|
||||
{benefit.benefitName}
|
||||
<div className="overflow-x-auto">
|
||||
<div className="grid min-w-[30rem] grid-cols-5 text-center text-sm">
|
||||
{center?.benefits.map((benefit) => (
|
||||
<div key={benefit.benefitName} className="contents">
|
||||
<div className="border-b border-zinc-100 bg-zinc-50 px-2 py-3 text-left text-zinc-600">
|
||||
{benefit.benefitName}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 text-zinc-500">
|
||||
{benefit.normalValue}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-emerald-700">
|
||||
{benefit.monthValue}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-rose-500">
|
||||
{benefit.seasonValue}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-amber-600">
|
||||
{benefit.yearValue}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 text-zinc-500">
|
||||
{benefit.normalValue}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-emerald-700">
|
||||
{benefit.monthValue}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-rose-500">
|
||||
{benefit.seasonValue}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-amber-600">
|
||||
{benefit.yearValue}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -1202,6 +1283,7 @@ export function RpgEntryHomeView({
|
||||
}: RpgEntryHomeViewProps) {
|
||||
const authUi = useAuthUi();
|
||||
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
|
||||
const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
|
||||
const [isRechargeOpen, setIsRechargeOpen] = useState(false);
|
||||
const [rechargeTab, setRechargeTab] = useState<'points' | 'membership'>(
|
||||
'points',
|
||||
@@ -1368,7 +1450,7 @@ export function RpgEntryHomeView({
|
||||
setReferralCenter(response.center);
|
||||
setInviteCodeInput('');
|
||||
setReferralSuccess(
|
||||
response.inviteeRewardGranted ? '已获得30积分' : '填写成功',
|
||||
response.inviteeRewardGranted ? '已获得30叙世币' : '填写成功',
|
||||
);
|
||||
void onRechargeSuccess?.();
|
||||
})
|
||||
@@ -1387,6 +1469,14 @@ export function RpgEntryHomeView({
|
||||
|
||||
void onSearchPublicCode(keyword);
|
||||
};
|
||||
const submitMobileSearch = () => {
|
||||
const keyword = mobileSearchKeyword.trim();
|
||||
if (!keyword || !onSearchPublicCode || isSearchingPublicCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
void onSearchPublicCode(keyword);
|
||||
};
|
||||
const desktopHeroEntry =
|
||||
featuredShelf[0] ?? latestEntries[0] ?? myEntries[0] ?? null;
|
||||
const desktopHeroCover = desktopHeroEntry
|
||||
@@ -1414,6 +1504,13 @@ export function RpgEntryHomeView({
|
||||
|
||||
const mobileHomeContent: ReactNode = (
|
||||
<div className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage`}>
|
||||
<PublicCodeSearchBar
|
||||
value={mobileSearchKeyword}
|
||||
onChange={setMobileSearchKeyword}
|
||||
onSubmit={submitMobileSearch}
|
||||
isSearching={isSearchingPublicCode}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openLeadPublicEntry}
|
||||
@@ -1485,7 +1582,7 @@ export function RpgEntryHomeView({
|
||||
<WorldCard
|
||||
key={`${buildPublicGalleryCardKey(entry)}:latest`}
|
||||
entry={entry}
|
||||
badge={formatPlatformWorldTime(entry.publishedAt)}
|
||||
badge={describePublicGalleryCardKind(entry)}
|
||||
metaLabel={entry.authorDisplayName}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
/>
|
||||
@@ -2093,7 +2190,7 @@ export function RpgEntryHomeView({
|
||||
<WorldCard
|
||||
key={`${buildPublicGalleryCardKey(entry)}:desktop-latest`}
|
||||
entry={entry}
|
||||
badge={formatPlatformWorldTime(entry.publishedAt)}
|
||||
badge={describePublicGalleryCardKind(entry)}
|
||||
metaLabel={entry.authorDisplayName}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
className="h-[17rem] w-full min-w-0"
|
||||
@@ -2125,8 +2222,18 @@ export function RpgEntryHomeView({
|
||||
if (!isDesktopLayout) {
|
||||
return (
|
||||
<div className="platform-mobile-entry-shell flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
|
||||
<div className="mb-3 shrink-0 px-0.5">
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
|
||||
<RpgEntryBrandLogo />
|
||||
{!isAuthenticated ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openUserSurface}
|
||||
className="platform-button platform-button--primary shrink-0 px-3 py-2 text-xs"
|
||||
>
|
||||
<LogIn className="h-3.5 w-3.5" />
|
||||
登录
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="platform-tab-panel-stack min-w-0 flex-1">
|
||||
@@ -2141,7 +2248,7 @@ export function RpgEntryHomeView({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : visibleTabs.length === 4 ? 'grid-cols-4' : 'grid-cols-2'}`}
|
||||
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : visibleTabs.length === 4 ? 'grid-cols-4' : visibleTabs.length === 3 ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
{visibleTabs.map((tab) => (
|
||||
<PlatformTabButton
|
||||
@@ -2193,35 +2300,15 @@ export function RpgEntryHomeView({
|
||||
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-5">
|
||||
<RpgEntryBrandLogo className="shrink-0" decorative />
|
||||
<div className="platform-desktop-search flex min-w-0 max-w-[34rem] flex-1 items-center gap-3 px-4 py-3 text-[var(--platform-text-soft)]">
|
||||
<Search className="h-4 w-4 shrink-0" />
|
||||
<input
|
||||
value={desktopSearchKeyword}
|
||||
onChange={(event) =>
|
||||
setDesktopSearchKeyword(event.target.value)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
submitDesktopSearch();
|
||||
}
|
||||
}}
|
||||
placeholder="输入 SY 或 CW 编号"
|
||||
className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitDesktopSearch}
|
||||
disabled={
|
||||
!desktopSearchKeyword.trim() ||
|
||||
!onSearchPublicCode ||
|
||||
isSearchingPublicCode
|
||||
}
|
||||
className="shrink-0 text-xs font-semibold text-[var(--platform-text-soft)] disabled:opacity-50"
|
||||
>
|
||||
{isSearchingPublicCode ? '搜索中' : '搜索'}
|
||||
</button>
|
||||
</div>
|
||||
<PublicCodeSearchBar
|
||||
value={desktopSearchKeyword}
|
||||
onChange={setDesktopSearchKeyword}
|
||||
onSubmit={submitDesktopSearch}
|
||||
isSearching={
|
||||
!onSearchPublicCode || Boolean(isSearchingPublicCode)
|
||||
}
|
||||
className="max-w-[34rem] flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -2249,10 +2336,10 @@ export function RpgEntryHomeView({
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{authUi?.user?.displayName || '进入账户'}
|
||||
{authUi?.user?.displayName || '登录'}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-[var(--platform-text-soft)]">
|
||||
{authUi?.user ? publicUserCode : '登录后同步作品与进度'}
|
||||
{authUi?.user ? publicUserCode : '账号入口'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { ArrowLeft, Copy } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import {
|
||||
buildPlatformWorldTags,
|
||||
describePlatformThemeLabel,
|
||||
formatPlatformWorldTime,
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorldCoverImage,
|
||||
resolvePlatformWorldLeadPortrait,
|
||||
} from './rpgEntryWorldPresentation';
|
||||
@@ -67,6 +70,10 @@ export function RpgEntryWorldDetailView({
|
||||
}: RpgEntryWorldDetailViewProps) {
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||||
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||||
'idle',
|
||||
);
|
||||
const canStartGame = entry.visibility === 'published';
|
||||
const previewCharacters = buildCustomWorldPlayableCharacters(
|
||||
entry.profile,
|
||||
@@ -79,6 +86,16 @@ export function RpgEntryWorldDetailView({
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, 3);
|
||||
const copyPublicWorkCode = () => {
|
||||
if (!publicWorkCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
void copyTextToClipboard(publicWorkCode).then((copied) => {
|
||||
setCopyState(copied ? 'copied' : 'failed');
|
||||
window.setTimeout(() => setCopyState('idle'), 1400);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
@@ -89,7 +106,7 @@ export function RpgEntryWorldDetailView({
|
||||
className="platform-button platform-button--ghost px-3 py-1.5 text-[11px]"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回广场
|
||||
返回
|
||||
</button>
|
||||
<div className="platform-pill platform-pill--neutral px-3 py-1.5 text-[11px] tracking-[0.08em]">
|
||||
{entry.visibility === 'published' ? '已发布' : '草稿'}
|
||||
@@ -128,6 +145,23 @@ export function RpgEntryWorldDetailView({
|
||||
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
|
||||
: '仅自己可见'}
|
||||
</span>
|
||||
{publicWorkCode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyPublicWorkCode}
|
||||
className="platform-pill platform-pill--neutral flex items-center gap-1 px-3"
|
||||
aria-label={`复制作品号 ${publicWorkCode}`}
|
||||
title="复制作品号"
|
||||
>
|
||||
<span>作品号 {publicWorkCode}</span>
|
||||
<Copy className="h-3 w-3" />
|
||||
{copyState !== 'idle' ? (
|
||||
<span className="text-xs">
|
||||
{copyState === 'copied' ? '已复制' : '复制失败'}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-4 text-3xl font-black text-white">
|
||||
{entry.worldName}
|
||||
|
||||
@@ -50,6 +50,7 @@ export function createFailedRpgEntryAgentOperation(params: {
|
||||
phaseDetail: params.error,
|
||||
progress: 0,
|
||||
error: params.error,
|
||||
startedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
|
||||
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type PlatformWorldCardLike =
|
||||
@@ -16,6 +17,7 @@ export type PlatformPuzzleGalleryCard = {
|
||||
sourceType: 'puzzle';
|
||||
workId: string;
|
||||
profileId: string;
|
||||
publicWorkCode: string;
|
||||
ownerUserId: string;
|
||||
authorDisplayName: string;
|
||||
worldName: string;
|
||||
@@ -51,6 +53,7 @@ export function mapPuzzleWorkToPlatformGalleryCard(
|
||||
sourceType: 'puzzle',
|
||||
workId: work.workId,
|
||||
profileId: work.profileId,
|
||||
publicWorkCode: buildPuzzlePublicWorkCode(work.profileId),
|
||||
ownerUserId: work.ownerUserId,
|
||||
authorDisplayName: work.authorDisplayName,
|
||||
worldName: work.levelName,
|
||||
@@ -122,6 +125,16 @@ export function formatPlatformWorldTime(value: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
export function resolvePlatformPublicWorkCode(
|
||||
entry: PlatformWorldCardLike,
|
||||
): string | null {
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
export function describePlatformThemeLabel(
|
||||
themeMode: CustomWorldGalleryCard['themeMode'],
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user