diff --git a/docs/technical/PUBLIC_WORK_CODE_MOBILE_SHARE_ENTRY_FIX_2026-04-25.md b/docs/technical/PUBLIC_WORK_CODE_MOBILE_SHARE_ENTRY_FIX_2026-04-25.md new file mode 100644 index 00000000..aea71e9d --- /dev/null +++ b/docs/technical/PUBLIC_WORK_CODE_MOBILE_SHARE_ENTRY_FIX_2026-04-25.md @@ -0,0 +1,21 @@ +# 公开作品号移动端分享入口修复 2026-04-25 + +## 背景 + +公开编号设计已要求广场作品卡和详情页展示 `CW / PZ` 作品号,并支持通过首页搜索入口打开公开作品。但当前移动端首页只有桌面端顶部搜索框,竖屏无法输入 `SY / CW / PZ` 编号;同时首页“最新发布”和桌面趋势卡片把发布时间放在显眼 badge 位置,异常时间字符串会被误认为作品号;创作页“我的作品”卡只展示作者和游玩数,没有可复制、可搜索的公开作品号。 + +## 落地规则 + +1. 移动端首页在 Logo 下方提供紧凑搜索条,复用现有 `onSearchPublicCode` 行为,不新增页面或新系统。 +2. 广场作品卡的辅助 badge 优先展示作品号,点击作品号只复制,不打开详情;没有公开作品号时展示作品类型,不再用发布时间充当主 badge。 +3. RPG 与拼图详情页在已发布作品的辅助信息里展示作品号,并提供复制动作。 +4. 创作页作品卡在已发布作品上展示作品号:RPG 使用后端 `publicWorkCode`;拼图当前没有独立公开号时,使用 `PZ-` + `profileId` 后 8 位作为前端展示与复制标识,后续若补后端拼图公开号再替换来源。 +5. 所有入口保持轻量 UI,不写规则说明文案,不改变发布、下架、进入游戏的后端语义。 + +## 验收 + +1. 399px 竖屏首页能直接看到并使用搜索入口。 +2. 首页公开作品卡左上角不再出现发布时间样式的疑似作品号。 +3. RPG 详情页能看到 `作品号 CW...` 并可复制,拼图详情页能看到 `作品号 PZ...` 并可复制。 +4. 创作页“我的作品”已发布卡能看到作品号,拼图卡不会只显示作者和游玩数。 +5. 桌面右侧趋势列表只显示排序和作品号或作品类型,不再显示 `1777110165.990127Z` 这类原始时间字符串。 diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index dd4ee315..31a6ae81 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -72,7 +72,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 +112,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( + {}} + 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( { />, ); - await user.click(screen.getByRole('button', { name: /继续完善《可继续整理的草稿》/u })); + await user.click( + screen.getByRole('button', { name: /继续完善《可继续整理的草稿》/u }), + ); expect(openedItems).toEqual([persistedDraft]); }); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx index 1cedd873..d3ce2b3a 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx @@ -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('我的拼图作品'); }); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index 17f948d7..aa10a066 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -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[]; 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; diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx index 281ab54c..c4c14c86 100644 --- a/src/components/custom-world-home/CustomWorldWorkCard.tsx +++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx @@ -1,6 +1,16 @@ +import { Copy } from 'lucide-react'; + import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork'; import type { CreationWorkShelfItem } from './creationWorkShelf'; +function copyText(value: string) { + if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) { + return; + } + + void navigator.clipboard.writeText(value); +} + function formatUpdatedAt(value: string) { const date = new Date(value); if (Number.isNaN(date.getTime())) { @@ -23,7 +33,10 @@ type CustomWorldWorkCardProps = { deleteBusy?: boolean; }; -const BADGE_TONE_CLASS: Record = { +const BADGE_TONE_CLASS: Record< + CreationWorkShelfItem['badges'][number]['tone'], + string +> = { warm: 'platform-pill--warm', success: 'platform-pill--success', neutral: 'platform-pill--neutral', @@ -127,15 +140,33 @@ export function CustomWorldWorkCard({
-
- {item.metrics.map((metric) => ( - + {item.publicWorkCode ? ( + + ) : null} +
+ {item.metrics.map((metric) => ( + + {metric.label} + + ))} +
{onExperience ? ( diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index 46ef2aa4..6a0c2c3b 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -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[]; 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[], ): 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' ? '草稿' : '已发布', diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 0756721b..8cb0b1bf 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -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'; @@ -267,8 +271,7 @@ function buildAgentResultPublishGateView( const blockers = fallbackBlockers .filter( - (entry) => - !isAgentResultStructuralBlockerResolved(profile, entry.code), + (entry) => !isAgentResultStructuralBlockerResolved(profile, entry.code), ) .map((entry) => entry.message); @@ -367,7 +370,9 @@ export function PlatformEntryFlowShellImpl({ const [isPuzzleNextLevelGenerating, setIsPuzzleNextLevelGenerating] = useState(false); const [isSearchingPublicCode, setIsSearchingPublicCode] = useState(false); - const [publicSearchError, setPublicSearchError] = useState(null); + const [publicSearchError, setPublicSearchError] = useState( + null, + ); const [searchedPublicUser, setSearchedPublicUser] = useState(null); const [deletingCreationWorkId, setDeletingCreationWorkId] = useState< @@ -491,9 +496,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(), @@ -551,18 +558,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( @@ -608,86 +612,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; @@ -748,9 +672,7 @@ export function PlatformEntryFlowShellImpl({ return galleryResponse.items; } catch (error) { setPuzzleGalleryEntries([]); - setPuzzleError( - resolvePuzzleErrorMessage(error, '读取拼图广场失败。'), - ); + setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图广场失败。')); return []; } }, [resolvePuzzleErrorMessage]); @@ -835,7 +757,10 @@ export function PlatformEntryFlowShellImpl({ { session: PuzzleAgentSessionSnapshot }, SendPuzzleAgentMessageRequest, PuzzleAgentActionRequest, - { operation: PuzzleAgentOperationRecord; session: PuzzleAgentSessionSnapshot } + { + operation: PuzzleAgentOperationRecord; + session: PuzzleAgentSessionSnapshot; + } >({ client: { createSession: createPuzzleAgentSession, @@ -1165,11 +1090,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; } @@ -1198,7 +1119,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) { @@ -1293,7 +1216,9 @@ export function PlatformEntryFlowShellImpl({ (entry) => entry.profileId === work.profileId, ); if (!matchedEntry) { - platformBootstrap.setPlatformError('未找到可体验的作品,请刷新后重试。'); + platformBootstrap.setPlatformError( + '未找到可体验的作品,请刷新后重试。', + ); return; } @@ -1362,10 +1287,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); @@ -1446,7 +1375,9 @@ export function PlatformEntryFlowShellImpl({ void refreshPuzzleGallery(); }) .catch((error) => { - setPuzzleError(resolvePuzzleErrorMessage(error, '删除拼图作品失败。')); + setPuzzleError( + resolvePuzzleErrorMessage(error, '删除拼图作品失败。'), + ); }) .finally(() => { setDeletingCreationWorkId(null); @@ -1485,12 +1416,136 @@ 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); + 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); + }; + + 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, + puzzleGalleryEntries, + refreshPuzzleGallery, + ], ); const openBigFishDraft = useCallback( @@ -1632,6 +1687,7 @@ export function PlatformEntryFlowShellImpl({ onExperienceRpg={(item) => { handleExperienceRpgWork(item); }} + rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries} bigFishItems={bigFishWorks} onOpenBigFishDetail={(item) => { runProtectedAction(() => { @@ -1649,11 +1705,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) => { @@ -1894,13 +1946,17 @@ export function PlatformEntryFlowShellImpl({ className="flex h-full min-h-0 flex-col" > } + fallback={ + + } > { - void executeBigFishAction({ action: 'big_fish_compile_draft' }); + void executeBigFishAction({ + action: 'big_fish_compile_draft', + }); }} onInterrupt={undefined} backLabel="返回创作中心" @@ -2026,7 +2084,9 @@ export function PlatformEntryFlowShellImpl({ settingText={ puzzleSession?.lastAssistantReply ?? '正在整理当前拼图草稿。' } - anchorEntries={buildPuzzleGenerationAnchorEntries(puzzleSession)} + anchorEntries={buildPuzzleGenerationAnchorEntries( + puzzleSession, + )} progress={buildMiniGameDraftGenerationProgress( puzzleGenerationState, )} @@ -2093,6 +2153,16 @@ export function PlatformEntryFlowShellImpl({ enterCreateTab(); setSelectionStage('platform'); }} + onEdit={ + selectedPuzzleDetail.ownerUserId === authUi?.user?.id && + Boolean(selectedPuzzleDetail.sourceSessionId?.trim()) + ? () => { + runProtectedAction(() => { + void openPuzzleDraft(selectedPuzzleDetail); + }); + } + : null + } onStartGame={() => { void startPuzzleRunFromProfile(selectedPuzzleDetail.profileId); }} @@ -2271,15 +2341,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, diff --git a/src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx b/src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx new file mode 100644 index 00000000..4e1d5ca1 --- /dev/null +++ b/src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx @@ -0,0 +1,66 @@ +/* @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( + , + ); + + expect(screen.getByText('作品号')).toBeTruthy(); + expect(screen.getByText('PZ-EPUBLIC1')).toBeTruthy(); + + await user.click( + screen.getByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }), + ); + + expect(writeText).toHaveBeenCalledWith('PZ-EPUBLIC1'); +}); diff --git a/src/components/puzzle-gallery/PuzzleGalleryDetailView.tsx b/src/components/puzzle-gallery/PuzzleGalleryDetailView.tsx index ced32cd6..9afcb5ae 100644 --- a/src/components/puzzle-gallery/PuzzleGalleryDetailView.tsx +++ b/src/components/puzzle-gallery/PuzzleGalleryDetailView.tsx @@ -1,6 +1,7 @@ -import { ArrowLeft, Play, UserRound } from 'lucide-react'; +import { ArrowLeft, Copy, Pencil, Play, UserRound } from 'lucide-react'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; type PuzzleGalleryDetailViewProps = { @@ -8,9 +9,18 @@ type PuzzleGalleryDetailViewProps = { isBusy?: boolean; error?: string | null; onBack: () => void; + onEdit?: (() => void) | null; onStartGame: () => void; }; +function copyText(value: string) { + if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) { + return; + } + + void navigator.clipboard.writeText(value); +} + /** * 拼图广场详情页。 * 展示最小信息并提供进入游戏动作,不扩展评论、收藏等非本轮需求。 @@ -20,8 +30,11 @@ export function PuzzleGalleryDetailView({ isBusy = false, error = null, onBack, + onEdit = null, onStartGame, }: PuzzleGalleryDetailViewProps) { + const publicWorkCode = buildPuzzlePublicWorkCode(item.profileId); + return (
@@ -33,15 +46,28 @@ export function PuzzleGalleryDetailView({ > - +
+ {onEdit ? ( + + ) : null} + +
@@ -54,6 +80,17 @@ export function PuzzleGalleryDetailView({ {item.authorDisplayName} {item.playCount} 次游玩 +
diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 050403c5..237352b6 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -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(), @@ -575,6 +580,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', @@ -1047,9 +1055,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([ @@ -1102,9 +1108,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(); @@ -1432,7 +1436,9 @@ 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); @@ -1497,8 +1503,9 @@ test('restoring an agent workspace ignores a stored session owned by another use render(); 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(); @@ -1633,6 +1640,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(); + + 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(); + + 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(); @@ -1679,11 +1773,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 () => { @@ -1882,7 +1976,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 () => { @@ -1918,6 +2014,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', @@ -1972,9 +2071,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: '发布到广场' })); @@ -2569,8 +2672,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 }, ); @@ -2900,9 +3003,7 @@ test('creation hub published work experience button enters world directly', asyn }, ]); - render( - , - ); + render(); await openCreationHub(user); await user.click(await screen.findByRole('button', { name: '体验' })); diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 8613fecf..e4caed65 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -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 () => ({ @@ -93,6 +94,43 @@ vi.mock('../ResolvedAssetImage', () => ({ ResolvedAssetImage: () => null, })); +const originalMatchMedia = window.matchMedia; +const originalClipboard = navigator.clipboard; + +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( + > = {}, +) { return render( + + , + ); +} + +afterEach(() => { + vi.clearAllMocks(); + Object.defineProperty(window, 'matchMedia', { + configurable: true, + writable: true, + value: originalMatchMedia, + }); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: originalClipboard, + }); +}); + +test('opens recharge modal and submits points product', async () => { + const user = userEvent.setup(); + const onRechargeSuccess = vi.fn(); + + renderProfileView(onRechargeSuccess); + await user.click(screen.getByText('会员充值')); + + expect(await screen.findByText('账户充值')).toBeTruthy(); + expect(await screen.findByText('10积分')).toBeTruthy(); + + await user.click(screen.getByText('首充送19积分')); + + 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( + undefined), + musicVolume: 0.42, + setMusicVolume: vi.fn(), + platformTheme: 'light', + setPlatformTheme: vi.fn(), + isHydratingSettings: false, + isPersistingSettings: false, + settingsError: null, + }} > , ); -} -afterEach(() => { - vi.clearAllMocks(); + const searchInput = screen.getByPlaceholderText('输入 SY / CW / PZ 编号'); + await user.type(searchInput, 'PZ-PROFILE1{enter}'); + + expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1'); }); -test('opens recharge modal and submits points product', async () => { +test('public work code badge copies without opening gallery detail', async () => { const user = userEvent.setup(); - const onRechargeSuccess = vi.fn(); + const writeText = vi.fn(async () => undefined); + const onOpenGalleryDetail = vi.fn(); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); - renderProfileView(onRechargeSuccess); - await user.click(screen.getByText('会员充值')); + renderLoggedOutHomeView(vi.fn(), { + latestEntries: [puzzlePublicEntry], + onOpenGalleryDetail, + }); - expect(await screen.findByText('账户充值')).toBeTruthy(); - expect(await screen.findByText('10积分')).toBeTruthy(); + await user.click( + screen.getByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }), + ); - await user.click(screen.getByText('首充送19积分')); - - await waitFor(() => expect(onRechargeSuccess).toHaveBeenCalledTimes(1)); + expect(writeText).toHaveBeenCalledWith('PZ-EPUBLIC1'); + expect(onOpenGalleryDetail).not.toHaveBeenCalled(); }); -test('shows a reachable login entry in logged out mobile shell', async () => { - const user = userEvent.setup(); - const openLoginModal = vi.fn(); +test('desktop trending list shows public code instead of timestamp text', () => { + mockDesktopLayout(); - renderLoggedOutHomeView(openLoginModal); - await user.click(screen.getByRole('button', { name: '登录' })); + renderLoggedOutHomeView(vi.fn(), { + latestEntries: [puzzlePublicEntry], + }); - expect(openLoginModal).toHaveBeenCalledTimes(1); + expect(screen.getAllByText('PZ-EPUBLIC1').length).toBeGreaterThan(0); + expect(screen.queryByText('1777110165.990127Z')).toBeNull(); }); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 46104f65..c3765077 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -23,6 +23,7 @@ import { } from 'lucide-react'; import { type ComponentType, + type KeyboardEvent, type ReactNode, useEffect, useMemo, @@ -53,6 +54,7 @@ import { describePlatformThemeLabel, formatPlatformWorldTime, isPuzzleGalleryEntry, + resolvePlatformPublicWorkCode, type PlatformPublicGalleryCard, type PlatformWorldCardLike, resolvePlatformWorldCoverImage, @@ -191,6 +193,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 ( +
+ + 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)]" + /> + +
+ ); +} + function EmptyShelf({ text }: { text: string }) { return (
) => { + if (event.target !== event.currentTarget) { + return; + } + + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + + event.preventDefault(); + onClick(); + }; + return ( - + ) : ( + + {badgeLabel} + + )} {metaLabel} @@ -316,7 +395,7 @@ function WorldCard({
- + ); } @@ -572,6 +651,7 @@ function DesktopTrendingItem({ onClick: () => void; }) { const coverImage = resolvePlatformWorldCoverImage(entry); + const publicWorkCode = resolvePlatformPublicWorkCode(entry); const tags = buildPlatformWorldTags(entry).filter(Boolean).slice(0, 2); return ( @@ -594,7 +674,9 @@ function DesktopTrendingItem({
{`${rank}`.padStart(2, '0')} - {formatPlatformWorldTime(entry.publishedAt)} + + {publicWorkCode ?? describePublicGalleryCardKind(entry)} +
{entry.worldName} @@ -1050,6 +1132,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', @@ -1171,6 +1254,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 @@ -1198,6 +1289,13 @@ export function RpgEntryHomeView({ const mobileHomeContent: ReactNode = (
+ + -
+
diff --git a/src/components/rpg-entry/RpgEntryWorldDetailView.tsx b/src/components/rpg-entry/RpgEntryWorldDetailView.tsx index 6a9b9a95..ae8c0ae2 100644 --- a/src/components/rpg-entry/RpgEntryWorldDetailView.tsx +++ b/src/components/rpg-entry/RpgEntryWorldDetailView.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft } from 'lucide-react'; +import { ArrowLeft, Copy } from 'lucide-react'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; @@ -8,6 +8,7 @@ import { buildPlatformWorldTags, describePlatformThemeLabel, formatPlatformWorldTime, + resolvePlatformPublicWorkCode, resolvePlatformWorldCoverImage, resolvePlatformWorldLeadPortrait, } from './rpgEntryWorldPresentation'; @@ -24,6 +25,14 @@ export interface RpgEntryWorldDetailViewProps { onUnpublish?: (() => void) | null; } +function copyText(value: string) { + if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) { + return; + } + + void navigator.clipboard.writeText(value); +} + function ActionButton({ label, onClick, @@ -67,6 +76,7 @@ export function RpgEntryWorldDetailView({ }: RpgEntryWorldDetailViewProps) { const coverImage = resolvePlatformWorldCoverImage(entry); const leadPortrait = resolvePlatformWorldLeadPortrait(entry); + const publicWorkCode = resolvePlatformPublicWorkCode(entry); const canStartGame = entry.visibility === 'published'; const previewCharacters = buildCustomWorldPlayableCharacters( entry.profile, @@ -128,6 +138,16 @@ export function RpgEntryWorldDetailView({ ? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}` : '仅自己可见'} + {publicWorkCode ? ( + + ) : null}
{entry.worldName} diff --git a/src/components/rpg-entry/rpgEntryWorldPresentation.ts b/src/components/rpg-entry/rpgEntryWorldPresentation.ts index a933b92f..8450e1b8 100644 --- a/src/components/rpg-entry/rpgEntryWorldPresentation.ts +++ b/src/components/rpg-entry/rpgEntryWorldPresentation.ts @@ -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'], ) { diff --git a/src/services/publicWorkCode.ts b/src/services/publicWorkCode.ts new file mode 100644 index 00000000..04f1b6ab --- /dev/null +++ b/src/services/publicWorkCode.ts @@ -0,0 +1,24 @@ +export function normalizePublicCodeText(value: string) { + return value + .trim() + .replace(/[^a-zA-Z0-9]/gu, '') + .toUpperCase(); +} + +export function buildPuzzlePublicWorkCode(profileId: string) { + const normalized = normalizePublicCodeText(profileId); + const fallback = normalized || '00000000'; + const suffix = fallback.slice(-8).padStart(8, '0'); + + return `PZ-${suffix}`; +} + +export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) { + const normalizedKeyword = normalizePublicCodeText(keyword); + + return ( + normalizedKeyword === + normalizePublicCodeText(buildPuzzlePublicWorkCode(profileId)) || + normalizedKeyword === normalizePublicCodeText(profileId) + ); +} diff --git a/src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts b/src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts index 62daa143..723b145e 100644 --- a/src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts +++ b/src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts @@ -127,14 +127,12 @@ test('buildRpgCreationPreviewFromResultPreview normalizes server preview envelop expect(profile?.settingText).toBe('被海雾吞没的旧航路群岛'); }); -test('buildRpgCreationPreviewFromSession reads draftProfile directly', () => { +test('buildRpgCreationPreviewFromSession prefers server result preview', () => { const profile = buildRpgCreationPreviewFromSession(sessionWithPreview); - expect(profile?.name).toBe('只作为 fallback 的本地草稿名'); - expect(profile?.name).not.toBe('服务端结果预览'); - expect(profile?.playableNpcs[0]?.imageSrc).toBe( - '/generated-characters/draft-playable-1/portrait.png', - ); + expect(profile?.name).toBe('服务端结果预览'); + expect(profile?.summary).toBe('结果页应该优先消费 session.resultPreview。'); + expect(profile?.id).toBe('preview-profile-1'); }); test('buildRpgCreationPreviewFromSession does not require resultPreview', () => { diff --git a/src/services/rpg-creation/rpgCreationPreviewAdapter.ts b/src/services/rpg-creation/rpgCreationPreviewAdapter.ts index a6a8c33b..16222541 100644 --- a/src/services/rpg-creation/rpgCreationPreviewAdapter.ts +++ b/src/services/rpg-creation/rpgCreationPreviewAdapter.ts @@ -3,24 +3,26 @@ import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary import type { CustomWorldProfile } from '../../types'; export function buildCustomWorldProfileFromResultPreview( - resultPreview: CustomWorldAgentSessionSnapshot['resultPreview'] | null | undefined, + resultPreview: + | CustomWorldAgentSessionSnapshot['resultPreview'] + | null + | undefined, ): CustomWorldProfile | null { return normalizeCustomWorldProfileRecord(resultPreview?.preview ?? null); } -/** - * RPG 运行时直接读取 Agent session 的 draftProfile。 - * resultPreview 只作为质量/发布信息外壳,不再参与进入游戏 profile 的数据转换。 - */ export function buildCustomWorldProfileFromAgentSession( session: CustomWorldAgentSessionSnapshot | null | undefined, ): CustomWorldProfile | null { - return normalizeCustomWorldProfileRecord(session?.draftProfile ?? null); + return ( + buildCustomWorldProfileFromResultPreview(session?.resultPreview) ?? + normalizeCustomWorldProfileRecord(session?.draftProfile ?? null) + ); } /** * 这是工作包 A 提供的新命名兼容层。 - * 主入口保持命名稳定,但数据来源已经收敛为 draftProfile 单一真相源。 + * 主入口保持命名稳定,优先消费服务端 resultPreview,缺失时回退到 draftProfile。 */ export const rpgCreationPreviewAdapter = { buildPreviewFromSession: buildCustomWorldProfileFromAgentSession,