diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index c7527ffa..37c52002 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -52,7 +52,7 @@ 1. 草稿页作品卡对齐发现页列表卡风格:左侧信息,右侧封面图,移动端单列,桌面两到三列。 2. 草稿页顶部 `全部 / 草稿 / 已发布` 筛选与发现页 `推荐 / 今日 / 分类 / 排行` 频道标签复用同一选中 / 未选中视觉,即 `platform-mobile-home-channel` 与 `platform-mobile-home-channel--active`,不再使用旧 `platform-tab` 胶囊样式。 -3. 草稿页与底部导航的未读提示点统一使用平台暖棕色点和暖棕光晕,不再使用红点或红色 glow;草稿 Tab 作品架卡片无论草稿 / 已发布都不外露作者信息;已发布作品卡右上角直接显示无边框分享 icon。删除等破坏性动作在作品卡上也要直接开放统一 `actions.delete` 入口,左滑、长按和键盘左箭头仅作为打开同一操作层的辅助交互;所有玩法草稿和已发布列表项都必须通过该统一接口接入删除确认、删除中状态和列表刷新,不允许只给拼图保留专属滑动删除分支。 +3. 草稿页与底部导航的未读提示点统一使用平台暖棕色点和暖棕光晕,不再使用红点或红色 glow;草稿 Tab 作品架卡片无论草稿 / 已发布都不外露作者信息;已发布作品卡右上角直接显示带底色的分享 icon,并统一唤起发布分享弹窗 `PublishShareModal`,不在卡片内部单独复制分享文案。删除等破坏性动作在作品卡上也要直接开放统一 `actions.delete` 入口,左滑、长按和键盘左箭头仅作为打开同一操作层的辅助交互;所有玩法草稿和已发布列表项都必须通过该统一接口接入删除确认、删除中状态和列表刷新,不允许只给拼图保留专属滑动删除分支。 4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。 5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。 6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 优先使用后端 session 的 `updatedAt`,没有 session 时再使用作品摘要 `updatedAt`,不得因重新进入页面从 0 秒重新计时。 diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 61e0c605..650a6d6e 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -1043,14 +1043,10 @@ test('creation hub opens persisted rpg drafts by card click', async () => { expect(openedItems).toEqual([persistedDraft]); }); -test('creation hub published share icon copies share text without opening the card', async () => { +test('creation hub published share icon opens unified share payload without opening the card', async () => { const user = userEvent.setup(); - const writeText = vi.fn(async () => undefined); const onOpenPuzzleDetail = vi.fn(); - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText }, - }); + const onShareWork = vi.fn(); render( {}} onEnterPublished={() => {}} onOpenPuzzleDetail={onOpenPuzzleDetail} + onShareWork={onShareWork} entryConfig={testEntryConfig} creationTypes={testCreationTypes} />, @@ -1092,19 +1089,12 @@ test('creation hub published share icon copies share text without opening the ca await user.click(shareButton); - expect(writeText).toHaveBeenCalledWith( - expect.stringContaining('邀请你来玩《沉钟拼图》'), - ); - expect(writeText).toHaveBeenCalledWith( - expect.stringContaining('作品号:PZ-PROFILE1'), - ); - expect(writeText).toHaveBeenCalledWith( - expect.stringContaining('/gallery/puzzle/detail?work=PZ-PROFILE1'), - ); + expect(onShareWork).toHaveBeenCalledWith({ + title: '沉钟拼图', + publicWorkCode: 'PZ-PROFILE1', + stage: 'puzzle-gallery-detail', + }); expect(onOpenPuzzleDetail).not.toHaveBeenCalled(); - expect( - await screen.findByRole('button', { name: '分享内容已复制' }), - ).toBeTruthy(); }); test('creation hub published share icon is shown directly on the card header', () => { diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index 47066baa..aaea5cb7 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -12,8 +12,10 @@ import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; +import { resolveSelectionStageFromPath } from '../../routing/appPageRoutes'; import type { CreationEntryConfig } from '../../services/creationEntryConfigService'; import type { CustomWorldProfile } from '../../types'; +import type { PublishShareModalPayload } from '../common/publishShareModalModel'; import type { PlatformCreationTypeCard, PlatformCreationTypeId, @@ -99,6 +101,7 @@ type CustomWorldCreationHubProps = { item: CreationWorkShelfItem, ) => CreationWorkShelfRuntimeState | null; onOpenShelfItem?: (item: CreationWorkShelfItem) => void; + onShareWork?: ((payload: PublishShareModalPayload) => void) | null; // 中文注释:底部加号入口可传入后端作品架摘要,用于推导最近使用过的模板。 recentWorkItems?: CreationWorkShelfItem[]; mode?: 'full' | 'start-only' | 'works-only'; @@ -167,6 +170,41 @@ function writeWorkMetricSnapshot(items: CreationWorkShelfItem[]) { } } +function resolveShelfShareStage( + sharePath: string, +): PublishShareModalPayload['stage'] | null { + let pathname = ''; + try { + pathname = new URL(sharePath, 'https://genarrative.local').pathname; + } catch { + pathname = sharePath.split(/[?#]/u)[0] ?? ''; + } + + const stage = resolveSelectionStageFromPath(pathname); + return stage === 'platform' ? null : stage; +} + +function buildCreationWorkShelfSharePayload( + item: CreationWorkShelfItem, +): PublishShareModalPayload | null { + const publicWorkCode = item.publicWorkCode?.trim(); + const sharePath = item.sharePath?.trim(); + if (!publicWorkCode || !sharePath) { + return null; + } + + const stage = resolveShelfShareStage(sharePath); + if (!stage) { + return null; + } + + return { + title: item.title, + publicWorkCode, + stage, + }; +} + /** 渲染底部加号创作入口页与草稿作品架,最近创作复用最近使用过的模板入口。 */ export function CustomWorldCreationHub({ items, @@ -216,6 +254,7 @@ export function CustomWorldCreationHub({ onDeleteVisualNovel = null, getWorkState, onOpenShelfItem, + onShareWork = null, recentWorkItems: recentWorkSourceItems, mode = 'full', }: CustomWorldCreationHubProps) { @@ -406,6 +445,17 @@ export function CustomWorldCreationHub({ return item.actions.delete ?? null; } + function buildShareAction(item: CreationWorkShelfItem) { + const payload = buildCreationWorkShelfSharePayload(item); + if (!payload) { + return null; + } + + return () => { + onShareWork?.(payload); + }; + } + function buildPointIncentiveAction(item: CreationWorkShelfItem) { return item.actions.claimPointIncentive ?? null; } @@ -481,6 +531,7 @@ export function CustomWorldCreationHub({ }} onDelete={buildDeleteAction(item)} deleteBusy={deletingWorkId === item.id} + onShare={buildShareAction(item)} onClaimPointIncentive={buildPointIncentiveAction(item)} pointIncentiveBusy={ item.source.kind === 'puzzle' && diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx index 1c4da359..72ac97c0 100644 --- a/src/components/custom-world-home/CustomWorldWorkCard.tsx +++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx @@ -18,7 +18,6 @@ import { useState, } from 'react'; -import { copyTextToClipboard } from '../../services/clipboard'; import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork'; import { formatPlatformWorkDisplayName, @@ -40,6 +39,7 @@ type CustomWorldWorkCardProps = { onOpen: () => void; onDelete?: (() => void) | null; deleteBusy?: boolean; + onShare?: (() => void) | null; onClaimPointIncentive?: (() => void) | null; pointIncentiveBusy?: boolean; }; @@ -231,13 +231,10 @@ export function CustomWorldWorkCard({ onOpen, onDelete = null, deleteBusy = false, + onShare = null, onClaimPointIncentive = null, pointIncentiveBusy = false, }: CustomWorldWorkCardProps) { - const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>( - 'idle', - ); - const shareResetTimerRef = useRef(null); const suppressOpenResetTimerRef = useRef(null); const suppressOpenRef = useRef(false); const swipeGestureRef = useRef<{ @@ -253,7 +250,7 @@ export function CustomWorldWorkCard({ const [swipeOffset, setSwipeOffset] = useState(0); const isPublished = item.status === 'published'; const canUseShareAction = - isPublished && item.canShare && Boolean(item.sharePath); + isPublished && item.canShare && Boolean(item.sharePath) && Boolean(onShare); const swipeActionCount = onDelete ? 1 : 0; const swipeRevealWidth = swipeActionCount * SWIPE_ACTION_WIDTH_PX; const canClaimPointIncentive = @@ -289,34 +286,8 @@ export function CustomWorldWorkCard({ }`, } as CSSProperties; - const copyShareText = () => { - const publicWorkCode = item.publicWorkCode?.trim(); - const sharePath = item.sharePath?.trim(); - if (!publicWorkCode || !sharePath) { - return; - } - - const shareUrl = - typeof window === 'undefined' - ? sharePath - : new URL(sharePath, window.location.origin).href; - const shareText = `邀请你来玩《${item.title}》\n作品号:${publicWorkCode}\n${shareUrl}`; - void copyTextToClipboard(shareText).then((copied) => { - setShareState(copied ? 'copied' : 'failed'); - if (shareResetTimerRef.current !== null) { - window.clearTimeout(shareResetTimerRef.current); - } - shareResetTimerRef.current = window.setTimeout(() => { - shareResetTimerRef.current = null; - setShareState('idle'); - }, 1400); - }); - }; useEffect(() => { return () => { - if (shareResetTimerRef.current !== null) { - window.clearTimeout(shareResetTimerRef.current); - } if (suppressOpenResetTimerRef.current !== null) { window.clearTimeout(suppressOpenResetTimerRef.current); } @@ -677,7 +648,7 @@ export function CustomWorldWorkCard({ event.stopPropagation(); suppressOpenRef.current = false; closeSwipeActions(); - copyShareText(); + onShare?.(); }} onKeyDown={(event) => { event.stopPropagation(); @@ -688,20 +659,8 @@ export function CustomWorldWorkCard({ onTouchStart={(event) => { event.stopPropagation(); }} - title={ - shareState === 'copied' - ? '已复制' - : shareState === 'failed' - ? '复制失败' - : '分享作品' - } - aria-label={ - shareState === 'copied' - ? '分享内容已复制' - : shareState === 'failed' - ? '分享内容复制失败' - : '分享' - } + title="分享作品" + aria-label="分享" className="creation-work-card__quick-action-button" >