553 lines
18 KiB
TypeScript
553 lines
18 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
||
|
||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
|
||
import type {
|
||
CustomWorldGalleryCard,
|
||
CustomWorldLibraryEntry,
|
||
PlatformBrowseHistoryWriteEntry,
|
||
} from '../../../packages/shared/src/contracts/runtime';
|
||
import {
|
||
buildPublicWorkDetailPath,
|
||
pushAppHistoryPath,
|
||
} from '../../routing/appPageRoutes';
|
||
import { ApiClientError } from '../../services/apiClient';
|
||
import {
|
||
deleteRpgEntryWorldProfile,
|
||
getRpgEntryWorldLibraryDetail,
|
||
getRpgEntryWorldGalleryDetail,
|
||
listRpgEntryWorldLibrary,
|
||
publishRpgEntryWorldProfile,
|
||
unpublishRpgEntryWorldProfile,
|
||
} from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||
import type { CustomWorldProfile } from '../../types';
|
||
import { resolveRpgEntryErrorMessage } from './rpgEntryShared';
|
||
import type {
|
||
CustomWorldAutoSaveState,
|
||
CustomWorldGenerationViewSource,
|
||
CustomWorldResultViewSource,
|
||
SelectionStage,
|
||
} from './rpgEntryTypes';
|
||
|
||
type UseRpgEntryLibraryDetailParams = {
|
||
userId: string | null | undefined;
|
||
selectedDetailEntry: CustomWorldLibraryEntry<CustomWorldProfile> | null;
|
||
setSelectedDetailEntry: (
|
||
entry:
|
||
| CustomWorldLibraryEntry<CustomWorldProfile>
|
||
| null
|
||
| ((
|
||
current: CustomWorldLibraryEntry<CustomWorldProfile> | null,
|
||
) => CustomWorldLibraryEntry<CustomWorldProfile> | null),
|
||
) => void;
|
||
savedCustomWorldEntries: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||
setSavedCustomWorldEntries: (
|
||
entries: CustomWorldLibraryEntry<CustomWorldProfile>[],
|
||
) => void;
|
||
setGeneratedCustomWorldProfile: (profile: CustomWorldProfile | null) => void;
|
||
setCustomWorldError: (error: string | null) => void;
|
||
setCustomWorldAutoSaveError: (error: string | null) => void;
|
||
setCustomWorldAutoSaveState: (state: CustomWorldAutoSaveState) => void;
|
||
setCustomWorldGenerationViewSource: (
|
||
source: CustomWorldGenerationViewSource,
|
||
) => void;
|
||
setCustomWorldResultViewSource: (source: CustomWorldResultViewSource) => void;
|
||
setSelectionStage: (stage: SelectionStage) => void;
|
||
setPlatformTabToCreate: () => void;
|
||
setPlatformTabToDraft: () => void;
|
||
setPlatformError: (error: string | null) => void;
|
||
appendBrowseHistoryEntry: (
|
||
entry: PlatformBrowseHistoryWriteEntry,
|
||
) => Promise<void>;
|
||
refreshCustomWorldWorks: () => Promise<unknown>;
|
||
refreshPublishedGallery: () => Promise<unknown>;
|
||
persistAgentUiState: (
|
||
sessionId: string | null,
|
||
operationId: string | null,
|
||
generationSource?: 'agent-draft-foundation' | null,
|
||
) => void;
|
||
syncAgentCreationResultView: (
|
||
sessionId: string,
|
||
) => Promise<RpgCreationResultView | null>;
|
||
buildDraftResultProfile: (
|
||
view: RpgCreationResultView | null,
|
||
) => CustomWorldProfile | null;
|
||
suppressAgentDraftResultAutoOpen: () => void;
|
||
releaseAgentDraftResultAutoOpenSuppression: () => void;
|
||
resetAutoSaveTrackingToIdle: () => void;
|
||
markAutoSavedProfile: (profile: CustomWorldProfile) => void;
|
||
};
|
||
|
||
function isMissingRpgEntryAgentSessionError(error: unknown) {
|
||
return (
|
||
error instanceof ApiClientError &&
|
||
error.status === 404 &&
|
||
error.code === 'NOT_FOUND'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 负责平台详情、创作作品入口和结果页打开路径。
|
||
* 平台壳层只消费“打开哪个面板”的结果,不再自己拼接恢复流程细节。
|
||
*/
|
||
export function useRpgEntryLibraryDetail(
|
||
params: UseRpgEntryLibraryDetailParams,
|
||
) {
|
||
const {
|
||
userId,
|
||
selectedDetailEntry,
|
||
setSelectedDetailEntry,
|
||
savedCustomWorldEntries,
|
||
setSavedCustomWorldEntries,
|
||
setGeneratedCustomWorldProfile,
|
||
setCustomWorldError,
|
||
setCustomWorldAutoSaveError,
|
||
setCustomWorldAutoSaveState,
|
||
setCustomWorldGenerationViewSource,
|
||
setCustomWorldResultViewSource,
|
||
setSelectionStage,
|
||
setPlatformTabToCreate,
|
||
setPlatformTabToDraft,
|
||
setPlatformError,
|
||
appendBrowseHistoryEntry,
|
||
refreshCustomWorldWorks,
|
||
refreshPublishedGallery,
|
||
persistAgentUiState,
|
||
syncAgentCreationResultView,
|
||
buildDraftResultProfile,
|
||
suppressAgentDraftResultAutoOpen,
|
||
releaseAgentDraftResultAutoOpenSuppression,
|
||
resetAutoSaveTrackingToIdle,
|
||
markAutoSavedProfile,
|
||
} = params;
|
||
|
||
const [detailError, setDetailError] = useState<string | null>(null);
|
||
const [isDetailLoading, setIsDetailLoading] = useState(false);
|
||
const [isMutatingDetail, setIsMutatingDetail] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!selectedDetailEntry) {
|
||
return;
|
||
}
|
||
|
||
const nextOwnedEntry = savedCustomWorldEntries.find(
|
||
(entry) =>
|
||
entry.ownerUserId === selectedDetailEntry.ownerUserId &&
|
||
entry.profileId === selectedDetailEntry.profileId,
|
||
);
|
||
if (nextOwnedEntry && nextOwnedEntry !== selectedDetailEntry) {
|
||
setSelectedDetailEntry(nextOwnedEntry);
|
||
}
|
||
}, [savedCustomWorldEntries, selectedDetailEntry, setSelectedDetailEntry]);
|
||
|
||
const openLibraryDetail = useCallback(
|
||
async (entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
|
||
if (entry.visibility === 'published') {
|
||
void appendBrowseHistoryEntry({
|
||
ownerUserId: entry.ownerUserId,
|
||
profileId: entry.profileId,
|
||
worldName: entry.worldName,
|
||
subtitle: entry.subtitle,
|
||
summaryText: entry.summaryText,
|
||
coverImageSrc: entry.coverImageSrc,
|
||
themeMode: entry.themeMode,
|
||
authorDisplayName: entry.authorDisplayName,
|
||
});
|
||
}
|
||
setSelectedDetailEntry(entry);
|
||
setDetailError(null);
|
||
setSelectionStage('detail');
|
||
if (entry.publicWorkCode?.trim()) {
|
||
pushAppHistoryPath(buildPublicWorkDetailPath(entry.publicWorkCode));
|
||
}
|
||
|
||
if (!userId || entry.ownerUserId !== userId) {
|
||
return;
|
||
}
|
||
|
||
setIsDetailLoading(true);
|
||
try {
|
||
const detailEntry = await getRpgEntryWorldLibraryDetail(entry.profileId);
|
||
setSelectedDetailEntry(detailEntry);
|
||
} catch (error) {
|
||
setDetailError(
|
||
resolveRpgEntryErrorMessage(error, '读取作品详情失败。'),
|
||
);
|
||
} finally {
|
||
setIsDetailLoading(false);
|
||
}
|
||
},
|
||
[
|
||
appendBrowseHistoryEntry,
|
||
setSelectedDetailEntry,
|
||
setSelectionStage,
|
||
userId,
|
||
],
|
||
);
|
||
|
||
const loadGalleryDetailEntry = useCallback(
|
||
async (entry: CustomWorldGalleryCard) => {
|
||
const detailEntry = await getRpgEntryWorldGalleryDetail(
|
||
entry.ownerUserId,
|
||
entry.profileId,
|
||
);
|
||
void appendBrowseHistoryEntry({
|
||
ownerUserId: detailEntry.ownerUserId,
|
||
profileId: detailEntry.profileId,
|
||
worldName: detailEntry.worldName,
|
||
subtitle: detailEntry.subtitle,
|
||
summaryText: detailEntry.summaryText,
|
||
coverImageSrc: detailEntry.coverImageSrc,
|
||
themeMode: detailEntry.themeMode,
|
||
authorDisplayName: detailEntry.authorDisplayName,
|
||
});
|
||
return detailEntry;
|
||
},
|
||
[appendBrowseHistoryEntry],
|
||
);
|
||
|
||
const openGalleryDetail = useCallback(
|
||
async (entry: CustomWorldGalleryCard) => {
|
||
setSelectionStage('detail');
|
||
setIsDetailLoading(true);
|
||
setDetailError(null);
|
||
|
||
try {
|
||
const detailEntry = await loadGalleryDetailEntry(entry);
|
||
setSelectedDetailEntry(detailEntry);
|
||
if (detailEntry.publicWorkCode?.trim()) {
|
||
pushAppHistoryPath(
|
||
buildPublicWorkDetailPath(detailEntry.publicWorkCode),
|
||
);
|
||
}
|
||
} catch (error) {
|
||
setSelectedDetailEntry(null);
|
||
setDetailError(
|
||
resolveRpgEntryErrorMessage(error, '读取作品详情失败。'),
|
||
);
|
||
} finally {
|
||
setIsDetailLoading(false);
|
||
}
|
||
},
|
||
[
|
||
loadGalleryDetailEntry,
|
||
setSelectedDetailEntry,
|
||
setSelectionStage,
|
||
],
|
||
);
|
||
|
||
const openSavedCustomWorldEditor = useCallback(
|
||
async (entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
|
||
try {
|
||
const detailEntry =
|
||
userId && entry.ownerUserId === userId
|
||
? await getRpgEntryWorldLibraryDetail(entry.profileId)
|
||
: entry;
|
||
setSelectedDetailEntry(detailEntry);
|
||
resetAutoSaveTrackingToIdle();
|
||
setCustomWorldGenerationViewSource(null);
|
||
setCustomWorldResultViewSource(null);
|
||
setCustomWorldError(null);
|
||
setGeneratedCustomWorldProfile(null);
|
||
setGeneratedCustomWorldProfile(detailEntry.profile);
|
||
markAutoSavedProfile(detailEntry.profile);
|
||
setCustomWorldAutoSaveState('saved');
|
||
setCustomWorldAutoSaveError(null);
|
||
setCustomWorldError(null);
|
||
setCustomWorldGenerationViewSource(null);
|
||
setCustomWorldResultViewSource('saved-profile');
|
||
setSelectionStage('custom-world-result');
|
||
} catch (error) {
|
||
setDetailError(
|
||
resolveRpgEntryErrorMessage(error, '读取作品详情失败。'),
|
||
);
|
||
}
|
||
},
|
||
[
|
||
markAutoSavedProfile,
|
||
setCustomWorldAutoSaveError,
|
||
setCustomWorldAutoSaveState,
|
||
setCustomWorldError,
|
||
setCustomWorldGenerationViewSource,
|
||
setCustomWorldResultViewSource,
|
||
setGeneratedCustomWorldProfile,
|
||
setSelectedDetailEntry,
|
||
setSelectionStage,
|
||
userId,
|
||
],
|
||
);
|
||
|
||
const handleOpenCreationWork = useCallback(
|
||
async (work: CustomWorldWorkSummary) => {
|
||
if (work.status === 'draft' && work.sessionId) {
|
||
setCustomWorldError(null);
|
||
setCustomWorldAutoSaveError(null);
|
||
setCustomWorldAutoSaveState('idle');
|
||
setGeneratedCustomWorldProfile(null);
|
||
setCustomWorldGenerationViewSource(null);
|
||
setCustomWorldResultViewSource(null);
|
||
resetAutoSaveTrackingToIdle();
|
||
|
||
try {
|
||
const resultView = await syncAgentCreationResultView(work.sessionId);
|
||
const nextProfile = buildDraftResultProfile(resultView);
|
||
|
||
if (resultView?.targetStage === 'custom-world-generating') {
|
||
// 生成过程中失败的草稿要回到生成过程页承接错误处理,避免误回 Agent 对话。
|
||
suppressAgentDraftResultAutoOpen();
|
||
persistAgentUiState(
|
||
work.sessionId,
|
||
null,
|
||
resultView.generationViewSource ?? 'agent-draft-foundation',
|
||
);
|
||
setGeneratedCustomWorldProfile(null);
|
||
setCustomWorldGenerationViewSource(
|
||
resultView.generationViewSource ?? 'agent-draft-foundation',
|
||
);
|
||
setCustomWorldResultViewSource(null);
|
||
setPlatformTabToCreate();
|
||
setSelectionStage('custom-world-generating');
|
||
return;
|
||
}
|
||
|
||
if (resultView?.targetStage === 'agent-workspace') {
|
||
// 还没有服务端草稿真相源时只能恢复 Agent,对象数量等摘要字段不能决定结果页入口。
|
||
suppressAgentDraftResultAutoOpen();
|
||
persistAgentUiState(work.sessionId, null);
|
||
setGeneratedCustomWorldProfile(null);
|
||
setCustomWorldResultViewSource(null);
|
||
setPlatformTabToCreate();
|
||
setSelectionStage('agent-workspace');
|
||
return;
|
||
}
|
||
|
||
releaseAgentDraftResultAutoOpenSuppression();
|
||
if (!nextProfile) {
|
||
persistAgentUiState(work.sessionId, null, 'agent-draft-foundation');
|
||
setPlatformError(
|
||
'当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。',
|
||
);
|
||
setPlatformTabToCreate();
|
||
setCustomWorldGenerationViewSource('agent-draft-foundation');
|
||
setSelectionStage('custom-world-generating');
|
||
return;
|
||
}
|
||
|
||
persistAgentUiState(work.sessionId, null);
|
||
setGeneratedCustomWorldProfile(nextProfile);
|
||
setCustomWorldResultViewSource(
|
||
resultView?.resultViewSource ?? 'agent-draft',
|
||
);
|
||
setPlatformTabToCreate();
|
||
setSelectionStage(resultView?.targetStage ?? 'custom-world-result');
|
||
return;
|
||
} catch (error) {
|
||
if (isMissingRpgEntryAgentSessionError(error)) {
|
||
// 失效会话不能继续保留在恢复状态里,否则刷新后会重复命中同一个坏 session。
|
||
persistAgentUiState(null, null);
|
||
setGeneratedCustomWorldProfile(null);
|
||
setCustomWorldResultViewSource(null);
|
||
await refreshCustomWorldWorks().catch(() => []);
|
||
setPlatformError(
|
||
'这份共创草稿已失效,已为你返回草稿列表,请重新开始创作。',
|
||
);
|
||
} else {
|
||
setPlatformError(
|
||
resolveRpgEntryErrorMessage(error, '读取创作草稿失败。'),
|
||
);
|
||
}
|
||
|
||
setPlatformTabToDraft();
|
||
setSelectionStage('platform');
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (!work.profileId) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
let matchedEntry = savedCustomWorldEntries.find(
|
||
(entry) => entry.profileId === work.profileId,
|
||
);
|
||
|
||
if (!matchedEntry && userId) {
|
||
const latestLibraryEntries = await listRpgEntryWorldLibrary();
|
||
setSavedCustomWorldEntries(latestLibraryEntries);
|
||
matchedEntry = latestLibraryEntries.find(
|
||
(entry) => entry.profileId === work.profileId,
|
||
);
|
||
}
|
||
|
||
if (matchedEntry) {
|
||
void openLibraryDetail(matchedEntry);
|
||
return;
|
||
}
|
||
|
||
setPlatformError('未找到对应作品,请刷新后重试。');
|
||
} catch (error) {
|
||
setPlatformError(
|
||
resolveRpgEntryErrorMessage(error, '读取作品详情失败。'),
|
||
);
|
||
}
|
||
},
|
||
[
|
||
buildDraftResultProfile,
|
||
openLibraryDetail,
|
||
persistAgentUiState,
|
||
releaseAgentDraftResultAutoOpenSuppression,
|
||
resetAutoSaveTrackingToIdle,
|
||
savedCustomWorldEntries,
|
||
setCustomWorldAutoSaveError,
|
||
setCustomWorldAutoSaveState,
|
||
setCustomWorldError,
|
||
setCustomWorldGenerationViewSource,
|
||
setCustomWorldResultViewSource,
|
||
setGeneratedCustomWorldProfile,
|
||
setPlatformError,
|
||
setPlatformTabToCreate,
|
||
setPlatformTabToDraft,
|
||
setSavedCustomWorldEntries,
|
||
setSelectionStage,
|
||
suppressAgentDraftResultAutoOpen,
|
||
syncAgentCreationResultView,
|
||
userId,
|
||
],
|
||
);
|
||
|
||
const handlePublishSelectedWorld = useCallback(async () => {
|
||
if (!selectedDetailEntry || isMutatingDetail) {
|
||
return;
|
||
}
|
||
|
||
setIsMutatingDetail(true);
|
||
setDetailError(null);
|
||
try {
|
||
const mutation = await publishRpgEntryWorldProfile(
|
||
selectedDetailEntry.profileId,
|
||
);
|
||
setSavedCustomWorldEntries(mutation.entries);
|
||
await refreshCustomWorldWorks().catch(() => []);
|
||
setSelectedDetailEntry(mutation.entry);
|
||
await refreshPublishedGallery().catch(() => []);
|
||
} catch (error) {
|
||
setDetailError(
|
||
resolveRpgEntryErrorMessage(error, '发布自定义世界失败。'),
|
||
);
|
||
} finally {
|
||
setIsMutatingDetail(false);
|
||
}
|
||
}, [
|
||
isMutatingDetail,
|
||
refreshCustomWorldWorks,
|
||
refreshPublishedGallery,
|
||
selectedDetailEntry,
|
||
setSavedCustomWorldEntries,
|
||
setSelectedDetailEntry,
|
||
]);
|
||
|
||
const handleUnpublishSelectedWorld = useCallback(async () => {
|
||
if (!selectedDetailEntry || isMutatingDetail) {
|
||
return;
|
||
}
|
||
|
||
setIsMutatingDetail(true);
|
||
setDetailError(null);
|
||
try {
|
||
const mutation = await unpublishRpgEntryWorldProfile(
|
||
selectedDetailEntry.profileId,
|
||
);
|
||
setSavedCustomWorldEntries(mutation.entries);
|
||
await refreshCustomWorldWorks().catch(() => []);
|
||
setSelectedDetailEntry(mutation.entry);
|
||
await refreshPublishedGallery().catch(() => []);
|
||
} catch (error) {
|
||
setDetailError(
|
||
resolveRpgEntryErrorMessage(error, '下架自定义世界失败。'),
|
||
);
|
||
} finally {
|
||
setIsMutatingDetail(false);
|
||
}
|
||
}, [
|
||
isMutatingDetail,
|
||
refreshCustomWorldWorks,
|
||
refreshPublishedGallery,
|
||
selectedDetailEntry,
|
||
setSavedCustomWorldEntries,
|
||
setSelectedDetailEntry,
|
||
]);
|
||
|
||
const handleDeleteSelectedWorld = useCallback(async () => {
|
||
if (!selectedDetailEntry || isMutatingDetail) {
|
||
return;
|
||
}
|
||
|
||
const confirmed = window.confirm(
|
||
`确认删除作品《${selectedDetailEntry.worldName}》吗?删除后会从你的作品列表和公开广场中移除。`,
|
||
);
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
|
||
setIsMutatingDetail(true);
|
||
setDetailError(null);
|
||
try {
|
||
const entries = await deleteRpgEntryWorldProfile(
|
||
selectedDetailEntry.profileId,
|
||
);
|
||
setSavedCustomWorldEntries(entries);
|
||
await refreshCustomWorldWorks().catch(() => []);
|
||
setSelectedDetailEntry(null);
|
||
setPlatformTabToCreate();
|
||
setSelectionStage('platform');
|
||
await refreshPublishedGallery().catch(() => []);
|
||
} catch (error) {
|
||
setDetailError(
|
||
resolveRpgEntryErrorMessage(error, '删除自定义世界失败。'),
|
||
);
|
||
} finally {
|
||
setIsMutatingDetail(false);
|
||
}
|
||
}, [
|
||
isMutatingDetail,
|
||
refreshCustomWorldWorks,
|
||
refreshPublishedGallery,
|
||
selectedDetailEntry,
|
||
setPlatformTabToCreate,
|
||
setSavedCustomWorldEntries,
|
||
setSelectedDetailEntry,
|
||
setSelectionStage,
|
||
]);
|
||
|
||
const isSelectedWorldOwned = Boolean(
|
||
selectedDetailEntry &&
|
||
savedCustomWorldEntries.some(
|
||
(entry) =>
|
||
entry.ownerUserId === selectedDetailEntry.ownerUserId &&
|
||
entry.profileId === selectedDetailEntry.profileId,
|
||
),
|
||
);
|
||
|
||
return {
|
||
detailError,
|
||
setDetailError,
|
||
isDetailLoading,
|
||
isMutatingDetail,
|
||
isSelectedWorldOwned,
|
||
openLibraryDetail,
|
||
openGalleryDetail,
|
||
loadGalleryDetailEntry,
|
||
openSavedCustomWorldEditor,
|
||
handleOpenCreationWork,
|
||
handlePublishSelectedWorld,
|
||
handleUnpublishSelectedWorld,
|
||
handleDeleteSelectedWorld,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 兼容旧创作链命名,避免并行工作包在本轮迁移后断开导入。
|
||
*/
|
||
export const useRpgCreationDetailNavigation = useRpgEntryLibraryDetail;
|