This commit is contained in:
2026-04-18 13:05:29 +08:00
parent 09d4c0c31b
commit 5032701c38
77 changed files with 8538 additions and 2413 deletions

View File

@@ -36,7 +36,7 @@ import {
resolvePlatformWorldLeadPortrait,
} from './platformWorldPresentation';
export type PlatformHomeTab = 'home' | 'create' | 'discover' | 'profile';
export type PlatformHomeTab = 'home' | 'create' | 'profile';
function SectionHeader({ title, detail }: { title: string; detail: string }) {
return (
@@ -443,8 +443,6 @@ export function PlatformHomeView({
const tabIcons = {
home: "/Icons/Admurin's Pixel Items/Admurin's Pixel Items/Miscellaneous/Singles/192_RustyTrinket_House.png",
create: '/Icons/01_Scroll.png',
discover:
"/Icons/Admurin's Pixel Items/Admurin's Pixel Items/Miscellaneous/Singles/321_Compass.png",
profile: '/UI/Icon_Eq_Head.png',
} as const;
const recentPlayItems = savedSnapshot
@@ -599,49 +597,6 @@ export function PlatformHomeView({
);
}
if (activeTab === 'discover') {
content = (
<div className="space-y-4 pb-2">
<section
className="pixel-nine-slice"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 18,
paddingY: 16,
})}
>
<div className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[10px] tracking-[0.2em] text-zinc-100">
DISCOVER
</div>
<div className="mt-4 text-3xl font-black text-white"></div>
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-300">
便
</div>
</section>
<section>
<SectionHeader title="最近上新" detail="先看广场里的新内容" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取推荐内容..." />
) : latestEntries.length > 0 ? (
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
{latestEntries.map((entry: CustomWorldGalleryCard) => (
<WorldCard
key={`${entry.ownerUserId}:${entry.profileId}:discover`}
entry={entry}
badge={formatPlatformWorldTime(entry.publishedAt)}
metaLabel={describePlatformThemeLabel(entry.themeMode)}
onClick={() => onOpenGalleryDetail(entry)}
/>
))}
</div>
) : (
<EmptyShelf text="发现频道暂时还没有可展示的内容。" />
)}
</section>
</div>
);
}
if (activeTab === 'profile') {
content = (
<div className="space-y-4 pb-2">
@@ -918,7 +873,7 @@ export function PlatformHomeView({
))}
</div>
) : (
<EmptyShelf text="你最近还没有浏览过作品详情,去首页或发现逛一逛吧。" />
<EmptyShelf text="你最近还没有浏览过作品详情,去首页逛一逛吧。" />
)}
</section>
@@ -988,7 +943,7 @@ export function PlatformHomeView({
className="mt-4 border-t border-white/5 pt-3"
style={{ paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)' }}
>
<div className="grid h-14 grid-cols-4 gap-1 rounded-[1.2rem] bg-black/18 px-1 py-1">
<div className="grid h-14 grid-cols-3 gap-1 rounded-[1.2rem] bg-black/18 px-1 py-1">
<PlatformTabButton
active={activeTab === 'home'}
label="首页"
@@ -1001,12 +956,6 @@ export function PlatformHomeView({
iconSrc={tabIcons.create}
onClick={() => onTabChange('create')}
/>
<PlatformTabButton
active={activeTab === 'discover'}
label="发现"
iconSrc={tabIcons.discover}
onClick={() => onTabChange('discover')}
/>
<PlatformTabButton
active={activeTab === 'profile'}
label="我的"

View File

@@ -48,6 +48,7 @@ export function PlatformWorldDetailView({
onStartGame,
onContinueEdit,
onPublish,
onDelete,
onUnpublish,
}: {
entry: CustomWorldLibraryEntry<CustomWorldProfile>;
@@ -57,17 +58,21 @@ export function PlatformWorldDetailView({
onStartGame: () => void;
onContinueEdit?: (() => void) | null;
onPublish?: (() => void) | null;
onDelete?: (() => void) | null;
onUnpublish?: (() => void) | null;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
const previewCharacters = buildCustomWorldPlayableCharacters(entry.profile).slice(
0,
3,
);
const previewCharacters = buildCustomWorldPlayableCharacters(
entry.profile,
).slice(0, 3);
const previewLandmarks = entry.profile.landmarks.slice(0, 3);
const tags = [
...new Set(buildPlatformWorldTags(entry).map((tag) => tag.trim()).filter(Boolean)),
...new Set(
buildPlatformWorldTags(entry)
.map((tag) => tag.trim())
.filter(Boolean),
),
].slice(0, 3);
return (
@@ -89,7 +94,10 @@ export function PlatformWorldDetailView({
<div className="space-y-4 pb-2">
<div
className="pixel-nine-slice relative overflow-hidden"
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 18, paddingY: 16 })}
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 18,
paddingY: 16,
})}
>
{coverImage ? (
<img
@@ -150,7 +158,10 @@ export function PlatformWorldDetailView({
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<div
className="pixel-nine-slice"
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 16, paddingY: 14 })}
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
@@ -160,13 +171,17 @@ export function PlatformWorldDetailView({
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
</div>
<div className="mt-2 text-lg font-bold">{entry.playableNpcCount}</div>
<div className="mt-2 text-lg font-bold">
{entry.playableNpcCount}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
</div>
<div className="mt-2 text-lg font-bold">{entry.landmarkCount}</div>
<div className="mt-2 text-lg font-bold">
{entry.landmarkCount}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
@@ -231,7 +246,10 @@ export function PlatformWorldDetailView({
<div
className="pixel-nine-slice"
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 16, paddingY: 14 })}
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
@@ -265,6 +283,14 @@ export function PlatformWorldDetailView({
disabled={isMutating}
/>
) : null}
{onDelete ? (
<ActionButton
label="删除作品"
onClick={onDelete}
tone="danger"
disabled={isMutating}
/>
) : null}
</div>
{error ? (
<div className="mt-4 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">

View File

@@ -11,10 +11,12 @@ import {
executeCustomWorldAgentAction,
getCustomWorldAgentOperation,
getCustomWorldAgentSession,
streamCustomWorldAgentMessage,
} from '../../services/aiService';
import type { AuthUser } from '../../services/authService';
import {
clearProfileBrowseHistory,
deleteCustomWorldProfile,
getProfileDashboard,
listCustomWorldGallery,
listCustomWorldLibrary,
@@ -35,11 +37,12 @@ vi.mock('../../services/aiService', () => ({
generateCustomWorldProfile: vi.fn(),
getCustomWorldAgentOperation: vi.fn(),
getCustomWorldAgentSession: vi.fn(),
sendCustomWorldAgentMessage: vi.fn(),
streamCustomWorldAgentMessage: vi.fn(),
}));
vi.mock('../../services/storageService', () => ({
clearProfileBrowseHistory: vi.fn(),
deleteCustomWorldProfile: vi.fn(),
getCustomWorldGalleryDetail: vi.fn(),
getProfileDashboard: vi.fn(),
listCustomWorldGallery: vi.fn(),
@@ -78,6 +81,53 @@ vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({
const mockSession: CustomWorldAgentSessionSnapshot = {
sessionId: 'custom-world-agent-session-1',
currentTurn: 0,
anchorContent: {
worldPromise: {
hook: '被海雾吞没的旧航路群岛。',
differentiator: '灯塔与禁航令共同决定谁能穿过死潮。',
desiredExperience: '压抑、潮湿、悬疑',
},
playerFantasy: {
playerRole: '玩家是被迫返乡的守灯人继承者。',
corePursuit: '查清沉船夜与假航灯的关系。',
fearOfLoss: '失去家族最后一条可信航线。',
},
themeBoundary: {
toneKeywords: ['压抑', '悬疑'],
aestheticDirectives: ['潮湿群岛', '冷雾港口'],
forbiddenDirectives: ['轻喜冒险'],
},
playerEntryPoint: {
openingIdentity: '返乡守灯人继承者',
openingProblem: '回港首夜撞见禁航区假航灯重亮',
entryMotivation: '阻止更多船只误入死潮',
},
coreConflict: {
surfaceConflicts: ['守灯会与航运公会争夺航路解释权'],
hiddenCrisis: '有人在借假航灯持续清洗旧案证据',
firstTouchedConflict: '玩家返乡当夜就被卷进封航冲突',
},
keyRelationships: [
{
pairs: '玩家 vs 沈砺',
relationshipType: '旧友互疑',
secretOrCost: '他知道沉船夜的另一半真相',
},
],
hiddenLines: {
hiddenTruths: ['沉船夜与假航灯骗局属于同一操盘链条'],
misdirectionHints: ['表面像海雾自然失控'],
revealPacing: '先见异常,再见旧案,再见操盘者',
},
iconicElements: {
iconicMotifs: ['假航灯', '沉钟回响'],
institutionsOrArtifacts: ['旧灯塔', '禁航碑'],
hardRules: ['错误航灯会把船引进必死水域'],
},
},
progressPercent: 0,
lastAssistantReply: '先告诉我你想做一个怎样的 RPG 世界。',
stage: 'clarifying',
focusCardId: null,
creatorIntent: {},
@@ -180,6 +230,7 @@ beforeEach(() => {
vi.mocked(listProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]);
vi.mocked(upsertCustomWorldProfile).mockResolvedValue({
entry: {
ownerUserId: 'user-1',
@@ -226,6 +277,7 @@ beforeEach(() => {
error: null,
});
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(mockSession);
vi.mocked(streamCustomWorldAgentMessage).mockResolvedValue(mockSession);
});
test('create tab opens game type modal, keeps AIRP and visual novel locked, and enters agent workspace for RPG', async () => {
@@ -284,6 +336,10 @@ test('starting draft generation leaves the agent workspace and shows the generat
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
expect(screen.queryByText(/Agent/u)).toBeNull();
expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0);
expect(screen.getByText('当前锚点信息')).toBeTruthy();
expect(screen.getByText('世界承诺')).toBeTruthy();
expect(screen.getByText(/穿/u)).toBeTruthy();
expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull();
});
test('existing draft sessions enter the legacy result layout directly', async () => {
@@ -448,6 +504,60 @@ test('profile tab loads server browse history and can clear it after confirmatio
});
expect(
screen.getByText('你最近还没有浏览过作品详情,去首页或发现逛一逛吧。'),
screen.getByText('你最近还没有浏览过作品详情,去首页逛一逛吧。'),
).toBeTruthy();
});
test('owned world detail can delete a work and return to the create tab list', async () => {
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(true);
vi.mocked(listCustomWorldLibrary).mockResolvedValue([
{
ownerUserId: 'user-1',
profileId: 'world-delete-1',
profile: {
id: 'world-delete-1',
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '用于测试删除流程的作品。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清旧案。',
majorFactions: ['守灯会'],
coreConflicts: ['雾潮正在逼近港口'],
playableNpcs: [],
storyNpcs: [],
landmarks: [],
} as never,
visibility: 'draft',
publishedAt: null,
updatedAt: '2026-04-16T12:00:00.000Z',
authorDisplayName: '测试玩家',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '用于测试删除流程的作品。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 0,
landmarkCount: 0,
},
]);
vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]);
render(<TestWrapper />);
await user.click(screen.getByRole('button', { name: '创作' }));
await user.click(await screen.findByText('潮雾列岛'));
await user.click(await screen.findByRole('button', { name: '删除作品' }));
await waitFor(() => {
expect(deleteCustomWorldProfile).toHaveBeenCalledWith('world-delete-1');
});
await waitFor(() => {
expect(screen.queryByRole('button', { name: '删除作品' })).toBeNull();
});
expect(
screen.getByText('你还没有保存任何自定义世界,先创建一个草稿开始吧。'),
).toBeTruthy();
});

View File

@@ -10,6 +10,7 @@ import {
} from 'react';
import type {
CustomWorldAgentMessage,
CustomWorldAgentActionRequest,
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
@@ -27,10 +28,11 @@ import {
executeCustomWorldAgentAction,
getCustomWorldAgentOperation,
getCustomWorldAgentSession,
sendCustomWorldAgentMessage,
streamCustomWorldAgentMessage,
} from '../../services/aiService';
import { buildCustomWorldProfileFromAgentDraft } from '../../services/customWorldAgentDraftResult';
import {
buildAgentDraftFoundationAnchorEntries,
buildAgentDraftFoundationGenerationProgress,
buildAgentDraftFoundationSettingText,
isDraftFoundationOperation,
@@ -55,6 +57,7 @@ import {
} from '../../services/platformBrowseHistory';
import {
clearProfileBrowseHistory,
deleteCustomWorldProfile,
getCustomWorldGalleryDetail,
getProfileDashboard,
listCustomWorldGallery,
@@ -66,10 +69,7 @@ import {
upsertCustomWorldProfile,
upsertProfileBrowseHistory,
} from '../../services/storageService';
import {
type CustomWorldProfile,
type GameState,
} from '../../types';
import { type CustomWorldProfile, type GameState } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformCreationTypeModal } from './PlatformCreationTypeModal';
import { type PlatformHomeTab, PlatformHomeView } from './PlatformHomeView';
@@ -141,6 +141,16 @@ function createFailedAgentOperation(params: {
};
}
function buildOptimisticAgentMessage(
payload: Pick<CustomWorldAgentMessage, 'id' | 'role' | 'kind' | 'text'>,
): CustomWorldAgentMessage {
return {
...payload,
createdAt: new Date().toISOString(),
relatedOperationId: null,
};
}
function buildAgentSeedTextFromProfile(profile: CustomWorldProfile) {
return (
buildCustomWorldCreatorIntentGenerationText(profile.creatorIntent).trim() ||
@@ -215,6 +225,8 @@ export function PreGameSelectionFlow({
useState<CustomWorldAgentSessionSnapshot | null>(null);
const [agentOperation, setAgentOperation] =
useState<CustomWorldAgentOperationRecord | null>(null);
const [streamingAgentReplyText, setStreamingAgentReplyText] = useState('');
const [isStreamingAgentReply, setIsStreamingAgentReply] = useState(false);
const [isLoadingAgentSession, setIsLoadingAgentSession] = useState(false);
const [customWorldError, setCustomWorldError] = useState<string | null>(null);
const [platformError, setPlatformError] = useState<string | null>(null);
@@ -457,6 +469,8 @@ export function PreGameSelectionFlow({
if (!activeAgentSessionId) {
setAgentSession(null);
setIsLoadingAgentSession(false);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
return;
}
@@ -479,6 +493,8 @@ export function PreGameSelectionFlow({
);
setAgentSession(null);
setAgentOperation(null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
persistAgentUiState(null, null);
setPlatformTab('create');
setSelectionStage('platform');
@@ -636,6 +652,10 @@ export function PreGameSelectionFlow({
() => buildAgentDraftFoundationSettingText(agentSession),
[agentSession],
);
const agentDraftAnchorPreviewEntries = useMemo(
() => buildAgentDraftFoundationAnchorEntries(agentSession),
[agentSession],
);
const agentDraftResultProfile = useMemo(
() => buildCustomWorldProfileFromAgentDraft(agentSession),
[agentSession],
@@ -794,23 +814,63 @@ export function PreGameSelectionFlow({
return;
}
const optimisticUserMessage = buildOptimisticAgentMessage({
id: payload.clientMessageId,
role: 'user',
kind: 'chat',
text: payload.text.trim(),
});
setAgentOperation(null);
persistAgentUiState(activeAgentSessionId, null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(true);
setAgentSession((current) =>
current
? {
...current,
messages: [...current.messages, optimisticUserMessage],
updatedAt: optimisticUserMessage.createdAt,
}
: current,
);
try {
const { operation } = await sendCustomWorldAgentMessage(
const nextSession = await streamCustomWorldAgentMessage(
activeAgentSessionId,
payload,
{
onUpdate: (text) => {
setStreamingAgentReplyText(text);
},
},
);
setAgentOperation(operation);
persistAgentUiState(activeAgentSessionId, operation.operationId);
setAgentSession(nextSession);
setAgentOperation(null);
setStreamingAgentReplyText('');
} catch (error) {
const errorMessage = resolveErrorMessage(error, '发送共创消息失败。');
setAgentOperation(
createFailedAgentOperation({
type: 'process_message',
phaseLabel: '发送消息失败',
error: errorMessage,
}),
setAgentSession((current) =>
current
? {
...current,
messages: [
...current.messages,
buildOptimisticAgentMessage({
id: `message-error-${Date.now()}`,
role: 'assistant',
kind: 'warning',
text: errorMessage,
}),
],
updatedAt: new Date().toISOString(),
}
: current,
);
setStreamingAgentReplyText('');
persistAgentUiState(activeAgentSessionId, null);
} finally {
setIsStreamingAgentReply(false);
}
};
@@ -858,6 +918,8 @@ export function PreGameSelectionFlow({
const leaveAgentWorkspace = () => {
setPlatformTab('create');
setAgentOperation(null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
setGeneratedCustomWorldProfile(null);
setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle');
@@ -1058,11 +1120,7 @@ export function PreGameSelectionFlow({
customWorldAutoSaveTimeoutRef.current = null;
}
};
}, [
generatedCustomWorldProfile,
saveGeneratedCustomWorld,
selectionStage,
]);
}, [generatedCustomWorldProfile, saveGeneratedCustomWorld, selectionStage]);
const openSavedCustomWorldEditor = (
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
@@ -1070,7 +1128,8 @@ export function PreGameSelectionFlow({
setSelectedDetailEntry(entry);
const normalizedProfile = normalizeAgentBackedProfile(entry.profile);
setGeneratedCustomWorldProfile(normalizedProfile);
lastAutoSavedProfileSignatureRef.current = JSON.stringify(normalizedProfile);
lastAutoSavedProfileSignatureRef.current =
JSON.stringify(normalizedProfile);
setCustomWorldAutoSaveState('saved');
setCustomWorldAutoSaveError(null);
setCustomWorldError(null);
@@ -1129,6 +1188,36 @@ export function PreGameSelectionFlow({
}
};
const handleDeleteSelectedWorld = async () => {
if (!selectedDetailEntry || isMutatingDetail) {
return;
}
const confirmed = window.confirm(
`确认删除作品《${selectedDetailEntry.worldName}》吗?删除后会从你的作品列表和公开广场中移除。`,
);
if (!confirmed) {
return;
}
setIsMutatingDetail(true);
setDetailError(null);
try {
const entries = await deleteCustomWorldProfile(
selectedDetailEntry.profileId,
);
setSavedCustomWorldEntries(entries);
setSelectedDetailEntry(null);
setPlatformTab('create');
setSelectionStage('platform');
setPublishedGalleryEntries(await listCustomWorldGallery());
} catch (error) {
setDetailError(resolveErrorMessage(error, '删除自定义世界失败。'));
} finally {
setIsMutatingDetail(false);
}
};
const isSelectedWorldOwned = Boolean(
selectedDetailEntry &&
savedCustomWorldEntries.some(
@@ -1228,6 +1317,9 @@ export function PreGameSelectionFlow({
? handleUnpublishSelectedWorld
: null
}
onDelete={
isSelectedWorldOwned ? handleDeleteSelectedWorld : null
}
/>
)}
</motion.div>
@@ -1250,13 +1342,9 @@ export function PreGameSelectionFlow({
<CustomWorldAgentWorkspace
session={agentSession}
activeOperation={agentOperation}
streamingReplyText={streamingAgentReplyText}
isStreamingReply={isStreamingAgentReply}
onBack={leaveAgentWorkspace}
onRefresh={() => {
if (!activeAgentSessionId) {
return;
}
void syncAgentSessionSnapshot(activeAgentSessionId);
}}
onSubmitMessage={(payload) => {
void submitAgentMessage(payload);
}}
@@ -1290,6 +1378,7 @@ export function PreGameSelectionFlow({
>
<CustomWorldGenerationView
settingText={activeGenerationSettingText}
anchorEntries={agentDraftAnchorPreviewEntries}
progress={activeGenerationProgress}
isGenerating={isActiveGenerationRunning}
error={activeGenerationError}
@@ -1300,10 +1389,10 @@ export function PreGameSelectionFlow({
backLabel="返回工作区"
settingActionLabel="回到工作区"
retryLabel="重新生成草稿"
settingTitle="当前共创设定"
settingTitle="当前锚点信息"
settingDescription={
isAgentDraftGenerationView
? '这批锚点会被整理成第一版世界底稿与草稿卡。'
? '将按当前八锚点结构编译第一版世界底稿与草稿卡。'
: undefined
}
progressTitle={
@@ -1385,7 +1474,6 @@ export function PreGameSelectionFlow({
void openRpgAgentWorkspace();
}}
/>
</>
);
}