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 index aea71e9d..e36dafc2 100644 --- 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 @@ -2,20 +2,24 @@ ## 背景 -公开编号设计已要求广场作品卡和详情页展示 `CW / PZ` 作品号,并支持通过首页搜索入口打开公开作品。但当前移动端首页只有桌面端顶部搜索框,竖屏无法输入 `SY / CW / PZ` 编号;同时首页“最新发布”和桌面趋势卡片把发布时间放在显眼 badge 位置,异常时间字符串会被误认为作品号;创作页“我的作品”卡只展示作者和游玩数,没有可复制、可搜索的公开作品号。 +公开编号设计已要求详情页和创作中心展示 `CW / PZ` 作品号,并支持通过首页搜索入口打开公开作品。但当前移动端首页只有桌面端顶部搜索框,竖屏无法输入 `SY / CW / PZ` 编号;同时首页“最新发布”和桌面趋势卡片把发布时间放在显眼 badge 位置,异常时间字符串会被误认为作品号;创作页“我的作品”卡只展示作者和游玩数,没有可复制、可搜索的公开作品号。 ## 落地规则 1. 移动端首页在 Logo 下方提供紧凑搜索条,复用现有 `onSearchPublicCode` 行为,不新增页面或新系统。 -2. 广场作品卡的辅助 badge 优先展示作品号,点击作品号只复制,不打开详情;没有公开作品号时展示作品类型,不再用发布时间充当主 badge。 +2. 首页、分类、趋势等公开外部列表不直接展示作品号,卡片 badge 展示推荐、分类或作品类型,不再用发布时间充当主 badge。 3. RPG 与拼图详情页在已发布作品的辅助信息里展示作品号,并提供复制动作。 4. 创作页作品卡在已发布作品上展示作品号:RPG 使用后端 `publicWorkCode`;拼图当前没有独立公开号时,使用 `PZ-` + `profileId` 后 8 位作为前端展示与复制标识,后续若补后端拼图公开号再替换来源。 -5. 所有入口保持轻量 UI,不写规则说明文案,不改变发布、下架、进入游戏的后端语义。 +5. 作品号复制统一使用兼容复制工具:优先 Clipboard API,权限失败时降级到隐藏文本框选区复制,并在按钮内短暂显示复制结果。 +6. 作品详情返回必须恢复打开详情前的平台来源 Tab;从分类进入回分类,从首页进入回首页,从创作中心进入回创作中心。 +7. 所有入口保持轻量 UI,不写规则说明文案,不改变发布、下架、进入游戏的后端语义。 ## 验收 1. 399px 竖屏首页能直接看到并使用搜索入口。 -2. 首页公开作品卡左上角不再出现发布时间样式的疑似作品号。 +2. 首页公开作品卡左上角不再出现发布时间样式的疑似作品号,也不直接显示作品号。 3. RPG 详情页能看到 `作品号 CW...` 并可复制,拼图详情页能看到 `作品号 PZ...` 并可复制。 4. 创作页“我的作品”已发布卡能看到作品号,拼图卡不会只显示作者和游玩数。 -5. 桌面右侧趋势列表只显示排序和作品号或作品类型,不再显示 `1777110165.990127Z` 这类原始时间字符串。 +5. 桌面右侧趋势列表只显示排序和作品类型,不再显示 `1777110165.990127Z` 这类原始时间字符串,也不直接显示作品号。 +6. 在内嵌浏览器 Clipboard API 拒绝写入时,详情页与创作中心作品号复制仍能通过降级路径完成,并显示 `已复制` 或 `复制失败`。 +7. 打开拼图详情后点击返回,不再固定跳到创作中心,而是回到打开详情前的平台 Tab。 diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 31a6ae81..614cd559 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -2,12 +2,20 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { expect, test } from 'vitest'; +import { afterEach, expect, test, vi } from 'vitest'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; import { CustomWorldCreationHub } from './CustomWorldCreationHub'; const noopCreateType = () => {}; +const originalClipboard = navigator.clipboard; + +afterEach(() => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: originalClipboard, + }); +}); const baseDraftItem: CustomWorldWorkSummary = { workId: 'draft:session-1', @@ -214,3 +222,51 @@ test('creation hub opens persisted rpg drafts by card click', async () => { expect(openedItems).toEqual([persistedDraft]); }); + +test('creation hub work code copy button copies 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 }, + }); + + render( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + onOpenPuzzleDetail={onOpenPuzzleDetail} + />, + ); + + await user.click( + screen.getByRole('button', { name: '复制作品号 PZ-PROFILE1' }), + ); + + expect(writeText).toHaveBeenCalledWith('PZ-PROFILE1'); + expect(onOpenPuzzleDetail).not.toHaveBeenCalled(); + expect(await screen.findByText('已复制')).toBeTruthy(); +}); diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx index c4c14c86..f04fa6cc 100644 --- a/src/components/custom-world-home/CustomWorldWorkCard.tsx +++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx @@ -1,16 +1,10 @@ import { Copy } from 'lucide-react'; +import { useState } from 'react'; +import { copyTextToClipboard } from '../../services/clipboard'; 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())) { @@ -49,6 +43,20 @@ export function CustomWorldWorkCard({ onDelete = null, deleteBusy = false, }: CustomWorldWorkCardProps) { + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>( + 'idle', + ); + const copyPublicWorkCode = () => { + if (!item.publicWorkCode) { + return; + } + + void copyTextToClipboard(item.publicWorkCode).then((copied) => { + setCopyState(copied ? 'copied' : 'failed'); + window.setTimeout(() => setCopyState('idle'), 1400); + }); + }; + return (
{ event.stopPropagation(); - copyText(item.publicWorkCode ?? ''); + copyPublicWorkCode(); + }} + onKeyDown={(event) => { + event.stopPropagation(); }} className="platform-pill platform-pill--neutral pointer-events-auto relative z-30 inline-flex max-w-full items-center gap-1.5 px-3 py-1 text-[10px]" aria-label={`复制作品号 ${item.publicWorkCode}`} @@ -155,6 +166,11 @@ export function CustomWorldWorkCard({ 作品号 {item.publicWorkCode} + {copyState !== 'idle' ? ( + + {copyState === 'copied' ? '已复制' : '复制失败'} + + ) : null} ) : null}
diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 8cb0b1bf..1fd9925b 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -101,7 +101,10 @@ import { } from '../rpg-entry/rpgEntryWorldPresentation'; import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal'; import type { PlatformCreationTypeId } from './platformEntryCreationTypes'; -import { PlatformEntryHomeView } from './PlatformEntryHomeView'; +import { + PlatformEntryHomeView, + type PlatformHomeTab, +} from './PlatformEntryHomeView'; import { buildCreationHubFallbackItems, normalizeAgentBackedProfile, @@ -119,6 +122,10 @@ type AgentResultPublishGateView = { publishReady: boolean; }; +type PuzzleDetailReturnTarget = { + tab: PlatformHomeTab; +}; + type AgentResultBlockerView = { code?: string; message: string; @@ -363,6 +370,8 @@ export function PlatformEntryFlowShellImpl({ >([]); const [selectedPuzzleDetail, setSelectedPuzzleDetail] = useState(null); + const [puzzleDetailReturnTarget, setPuzzleDetailReturnTarget] = + useState(null); const [puzzleRun, setPuzzleRun] = useState(null); const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false); const [puzzleGenerationState, setPuzzleGenerationState] = @@ -1393,14 +1402,19 @@ export function PlatformEntryFlowShellImpl({ ); const openPuzzleDetail = useCallback( - async (profileId: string) => { + async ( + profileId: string, + returnTarget: PuzzleDetailReturnTarget = { + tab: platformBootstrap.platformTab, + }, + ) => { setIsPuzzleBusy(true); setPuzzleError(null); try { const { item } = await getPuzzleGalleryDetail(profileId); setSelectedPuzzleDetail(item); - enterCreateTab(); + setPuzzleDetailReturnTarget(returnTarget); setSelectionStage('puzzle-gallery-detail'); } catch (error) { setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图详情失败。')); @@ -1408,7 +1422,11 @@ export function PlatformEntryFlowShellImpl({ setIsPuzzleBusy(false); } }, - [enterCreateTab, resolvePuzzleErrorMessage, setSelectionStage], + [ + platformBootstrap.platformTab, + resolvePuzzleErrorMessage, + setSelectionStage, + ], ); const openPuzzleDraft = useCallback( @@ -1418,7 +1436,7 @@ export function PlatformEntryFlowShellImpl({ setSelectedPuzzleDetail(null); if (!item.sourceSessionId?.trim()) { if (item.publicationStatus === 'published') { - await openPuzzleDetail(item.profileId); + await openPuzzleDetail(item.profileId, { tab: 'create' }); return; } @@ -1495,7 +1513,9 @@ export function PlatformEntryFlowShellImpl({ throw new Error('未找到拼图作品。'); } - await openPuzzleDetail(matchedEntry.profileId); + await openPuzzleDetail(matchedEntry.profileId, { + tab: platformBootstrap.platformTab, + }); }; try { @@ -1543,6 +1563,7 @@ export function PlatformEntryFlowShellImpl({ [ detailNavigation, openPuzzleDetail, + platformBootstrap.platformTab, puzzleGalleryEntries, refreshPuzzleGallery, ], @@ -1765,7 +1786,9 @@ export function PlatformEntryFlowShellImpl({ onOpenCreateTypePicker={openCreationTypePicker} onOpenGalleryDetail={(entry) => { if (isPuzzleGalleryEntry(entry)) { - void openPuzzleDetail(entry.profileId); + void openPuzzleDetail(entry.profileId, { + tab: platformBootstrap.platformTab, + }); return; } @@ -2150,7 +2173,10 @@ export function PlatformEntryFlowShellImpl({ isBusy={isPuzzleBusy} error={puzzleError} onBack={() => { - enterCreateTab(); + platformBootstrap.setPlatformTab( + puzzleDetailReturnTarget?.tab ?? 'home', + ); + setPuzzleDetailReturnTarget(null); setSelectionStage('platform'); }} onEdit={ diff --git a/src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx b/src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx index 4e1d5ca1..ee194b50 100644 --- a/src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx +++ b/src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx @@ -64,3 +64,35 @@ test('shows and copies puzzle public work code in detail view', async () => { expect(writeText).toHaveBeenCalledWith('PZ-EPUBLIC1'); }); + +test('falls back to legacy selection copy when clipboard api rejects', async () => { + const user = userEvent.setup(); + const writeText = vi.fn(async () => { + throw new Error('clipboard denied'); + }); + const execCommand = vi.fn(() => true); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: execCommand, + }); + + render( + , + ); + + await user.click( + screen.getByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }), + ); + + expect(writeText).toHaveBeenCalledWith('PZ-EPUBLIC1'); + expect(execCommand).toHaveBeenCalledWith('copy'); + expect(await screen.findByText('已复制')).toBeTruthy(); +}); diff --git a/src/components/puzzle-gallery/PuzzleGalleryDetailView.tsx b/src/components/puzzle-gallery/PuzzleGalleryDetailView.tsx index 9afcb5ae..458d98d2 100644 --- a/src/components/puzzle-gallery/PuzzleGalleryDetailView.tsx +++ b/src/components/puzzle-gallery/PuzzleGalleryDetailView.tsx @@ -1,6 +1,8 @@ import { ArrowLeft, Copy, Pencil, Play, UserRound } from 'lucide-react'; +import { useState } from 'react'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import { copyTextToClipboard } from '../../services/clipboard'; import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; @@ -13,14 +15,6 @@ type PuzzleGalleryDetailViewProps = { onStartGame: () => void; }; -function copyText(value: string) { - if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) { - return; - } - - void navigator.clipboard.writeText(value); -} - /** * 拼图广场详情页。 * 展示最小信息并提供进入游戏动作,不扩展评论、收藏等非本轮需求。 @@ -34,6 +28,15 @@ export function PuzzleGalleryDetailView({ onStartGame, }: PuzzleGalleryDetailViewProps) { const publicWorkCode = buildPuzzlePublicWorkCode(item.profileId); + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>( + 'idle', + ); + const copyPublicWorkCode = () => { + void copyTextToClipboard(publicWorkCode).then((copied) => { + setCopyState(copied ? 'copied' : 'failed'); + window.setTimeout(() => setCopyState('idle'), 1400); + }); + }; return (
@@ -42,6 +45,7 @@ export function PuzzleGalleryDetailView({
diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 237352b6..0ce3b96f 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -1444,6 +1444,64 @@ test('published puzzle works appear on home and category public shelves', async ).toBeGreaterThan(0); }); +test('published puzzle detail returns to the source platform tab', async () => { + const user = userEvent.setup(); + const publishedPuzzleWork = { + 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-25T09:00:00.000Z', + publishedAt: '2026-04-25T09:00:00.000Z', + playCount: 3, + publishReady: true, + } satisfies PuzzleWorkSummary; + + vi.mocked(listPuzzleGallery).mockResolvedValue({ + items: [publishedPuzzleWork], + }); + vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({ + item: publishedPuzzleWork, + }); + + render(); + + await user.click(await screen.findByRole('button', { name: '分类' })); + await waitFor(() => { + expect(document.getElementById('platform-tab-panel-category')).toBeTruthy(); + }); + const categoryPanel = getPlatformTabPanel('category'); + expect( + within(categoryPanel).getAllByText('星桥机关').length, + ).toBeGreaterThan(0); + + await user.click( + within(categoryPanel).getByRole('button', { + name: /拼图关卡.*星桥机关/u, + }), + ); + expect( + await screen.findByRole('button', { name: '进入第 1 关' }), + ).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: '返回' })); + + await waitFor(() => { + const returnedCategoryPanel = getPlatformTabPanel('category'); + expect(returnedCategoryPanel.getAttribute('aria-hidden')).toBe('false'); + expect( + within(returnedCategoryPanel).getAllByText('星桥机关').length, + ).toBeGreaterThan(0); + }); +}); + test('selecting RPG creation while logged out routes through requireAuth', async () => { const user = userEvent.setup(); const requireAuth = vi.fn(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index e4caed65..de8c445a 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -95,8 +95,6 @@ vi.mock('../ResolvedAssetImage', () => ({ })); const originalMatchMedia = window.matchMedia; -const originalClipboard = navigator.clipboard; - const puzzlePublicEntry = { sourceType: 'puzzle', workId: 'puzzle-work-public-1', @@ -264,7 +262,7 @@ afterEach(() => { }); Object.defineProperty(navigator, 'clipboard', { configurable: true, - value: originalClipboard, + value: undefined, }); }); @@ -350,35 +348,32 @@ test('mobile home search submits public work code', async () => { expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1'); }); -test('public work code badge copies without opening gallery detail', async () => { +test('public gallery cards hide work code until detail is opened', async () => { const user = userEvent.setup(); - const writeText = vi.fn(async () => undefined); const onOpenGalleryDetail = vi.fn(); - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText }, - }); renderLoggedOutHomeView(vi.fn(), { latestEntries: [puzzlePublicEntry], onOpenGalleryDetail, }); - await user.click( - screen.getByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }), - ); + expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull(); + expect(screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' })) + .toBeNull(); - expect(writeText).toHaveBeenCalledWith('PZ-EPUBLIC1'); - expect(onOpenGalleryDetail).not.toHaveBeenCalled(); + await user.click(screen.getByRole('button', { name: /查看作品/u })); + + expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry); }); -test('desktop trending list shows public code instead of timestamp text', () => { +test('desktop trending list shows kind instead of work code or timestamp text', () => { mockDesktopLayout(); renderLoggedOutHomeView(vi.fn(), { latestEntries: [puzzlePublicEntry], }); - expect(screen.getAllByText('PZ-EPUBLIC1').length).toBeGreaterThan(0); + expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull(); + expect(screen.getAllByText('拼图').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 c3765077..b55a0e50 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -23,7 +23,6 @@ import { } from 'lucide-react'; import { type ComponentType, - type KeyboardEvent, type ReactNode, useEffect, useMemo, @@ -54,7 +53,6 @@ import { describePlatformThemeLabel, formatPlatformWorldTime, isPuzzleGalleryEntry, - resolvePlatformPublicWorkCode, type PlatformPublicGalleryCard, type PlatformWorldCardLike, resolvePlatformWorldCoverImage, @@ -293,8 +291,6 @@ function WorldCard({ }) { const coverImage = resolvePlatformWorldCoverImage(entry); const leadPortrait = resolvePlatformWorldLeadPortrait(entry); - const publicWorkCode = resolvePlatformPublicWorkCode(entry); - const badgeLabel = publicWorkCode ?? badge; const tags = [ ...new Set( buildPlatformWorldTags(entry) @@ -303,25 +299,10 @@ function WorldCard({ ), ].slice(0, 3); - const openCardByKeyboard = (event: KeyboardEvent) => { - if (event.target !== event.currentTarget) { - return; - } - - if (event.key !== 'Enter' && event.key !== ' ') { - return; - } - - event.preventDefault(); - onClick(); - }; - return ( -
{coverImage ? ( @@ -342,25 +323,9 @@ function WorldCard({
- {publicWorkCode ? ( - - ) : ( - - {badgeLabel} - - )} + + {badge} + {metaLabel} @@ -395,7 +360,7 @@ function WorldCard({
-
+ ); } @@ -651,7 +616,6 @@ function DesktopTrendingItem({ onClick: () => void; }) { const coverImage = resolvePlatformWorldCoverImage(entry); - const publicWorkCode = resolvePlatformPublicWorkCode(entry); const tags = buildPlatformWorldTags(entry).filter(Boolean).slice(0, 2); return ( @@ -675,7 +639,7 @@ function DesktopTrendingItem({
{`${rank}`.padStart(2, '0')} - {publicWorkCode ?? describePublicGalleryCardKind(entry)} + {describePublicGalleryCardKind(entry)}
diff --git a/src/components/rpg-entry/RpgEntryWorldDetailView.tsx b/src/components/rpg-entry/RpgEntryWorldDetailView.tsx index ae8c0ae2..7226a2ef 100644 --- a/src/components/rpg-entry/RpgEntryWorldDetailView.tsx +++ b/src/components/rpg-entry/RpgEntryWorldDetailView.tsx @@ -1,7 +1,9 @@ import { ArrowLeft, Copy } from 'lucide-react'; +import { useState } from 'react'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; +import { copyTextToClipboard } from '../../services/clipboard'; import type { CustomWorldProfile } from '../../types'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { @@ -25,14 +27,6 @@ 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, @@ -77,6 +71,9 @@ export function RpgEntryWorldDetailView({ const coverImage = resolvePlatformWorldCoverImage(entry); const leadPortrait = resolvePlatformWorldLeadPortrait(entry); const publicWorkCode = resolvePlatformPublicWorkCode(entry); + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>( + 'idle', + ); const canStartGame = entry.visibility === 'published'; const previewCharacters = buildCustomWorldPlayableCharacters( entry.profile, @@ -89,6 +86,16 @@ export function RpgEntryWorldDetailView({ .filter(Boolean), ), ].slice(0, 3); + const copyPublicWorkCode = () => { + if (!publicWorkCode) { + return; + } + + void copyTextToClipboard(publicWorkCode).then((copied) => { + setCopyState(copied ? 'copied' : 'failed'); + window.setTimeout(() => setCopyState('idle'), 1400); + }); + }; return (
@@ -99,7 +106,7 @@ export function RpgEntryWorldDetailView({ className="platform-button platform-button--ghost px-3 py-1.5 text-[11px]" > - 返回广场 + 返回
{entry.visibility === 'published' ? '已发布' : '草稿'} @@ -141,11 +148,18 @@ export function RpgEntryWorldDetailView({ {publicWorkCode ? ( ) : null}
diff --git a/src/services/clipboard.ts b/src/services/clipboard.ts new file mode 100644 index 00000000..3ba44864 --- /dev/null +++ b/src/services/clipboard.ts @@ -0,0 +1,53 @@ +export async function copyTextToClipboard(value: string) { + const text = value.trim(); + if (!text) { + return false; + } + + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // 部分内嵌浏览器会暴露 Clipboard API,但会因权限上下文拒绝写入,继续走兼容路径。 + } + } + + if (typeof document === 'undefined') { + return false; + } + + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', 'true'); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '0'; + document.body.appendChild(textarea); + + const selection = document.getSelection(); + const selectedRange = + selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + + textarea.focus(); + textarea.select(); + + let copied = false; + try { + copied = + typeof document.execCommand === 'function' && + document.execCommand('copy'); + } catch { + copied = false; + } finally { + document.body.removeChild(textarea); + if (selection) { + selection.removeAllRanges(); + if (selectedRange) { + selection.addRange(selectedRange); + } + } + } + + return copied; +}