Files
Genarrative/src/components/rpg-entry/useRpgEntryLibraryDetail.ts
T
kdletters 1ad25e30f8 收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
2026-06-10 10:24:18 +08:00

598 lines
19 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,
getRpgEntryWorldGalleryDetail,
getRpgEntryWorldLibraryDetail,
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';
import {
countCustomWorldProfileAssetSlots,
countCustomWorldProfileDetailSlots,
countCustomWorldProfileStructuredSlots,
} from './rpgProfileCompleteness';
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'
);
}
function shouldKeepSelectedDetailProfile(
selectedEntry: CustomWorldLibraryEntry<CustomWorldProfile>,
nextOwnedEntry: CustomWorldLibraryEntry<CustomWorldProfile>,
) {
if (
selectedEntry.ownerUserId !== nextOwnedEntry.ownerUserId ||
selectedEntry.profileId !== nextOwnedEntry.profileId
) {
return false;
}
const selectedDetailCount = countCustomWorldProfileDetailSlots(
selectedEntry.profile,
);
const nextDetailCount = countCustomWorldProfileDetailSlots(
nextOwnedEntry.profile,
);
const selectedAssetSlotCount = countCustomWorldProfileAssetSlots(
selectedEntry.profile,
);
const nextAssetSlotCount = countCustomWorldProfileAssetSlots(
nextOwnedEntry.profile,
);
const selectedStructuredSlotCount = countCustomWorldProfileStructuredSlots(
selectedEntry.profile,
);
const nextStructuredSlotCount = countCustomWorldProfileStructuredSlots(
nextOwnedEntry.profile,
);
const expectedRuntimeCount =
nextOwnedEntry.playableNpcCount + nextOwnedEntry.landmarkCount;
// 作品架列表只保证卡片摘要,不能在详情接口已经拿到完整运行态字段后覆盖详情。
return (
(selectedDetailCount > nextDetailCount &&
expectedRuntimeCount > nextDetailCount) ||
selectedAssetSlotCount > nextAssetSlotCount ||
selectedStructuredSlotCount > nextStructuredSlotCount
);
}
/**
* 负责平台详情、创作作品入口和结果页打开路径。
* 平台壳层只消费“打开哪个面板”的结果,不再自己拼接恢复流程细节。
*/
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) {
if (
shouldKeepSelectedDetailProfile(selectedDetailEntry, nextOwnedEntry)
) {
return;
}
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,
resetAutoSaveTrackingToIdle,
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,
refreshCustomWorldWorks,
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;
}
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;