fix: 完善作品号复制与详情返回

This commit is contained in:
2026-04-26 15:19:53 +08:00
parent 874e10383b
commit 7aabbcc10c
11 changed files with 328 additions and 101 deletions

View File

@@ -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。

View File

@@ -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(
<CustomWorldCreationHub
items={[]}
puzzleItems={[
{
workId: 'puzzle:work-1',
profileId: 'puzzle-profile-1',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
levelName: '沉钟拼图',
summary: '拼图作品会与其他创作作品一起展示。',
themeTags: ['潮雾', '沉钟'],
coverImageSrc: null,
publicationStatus: 'published',
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
playCount: 8,
publishReady: true,
},
]}
loading={false}
error={null}
onRetry={() => {}}
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();
});

View File

@@ -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 (
<div
role="button"
@@ -146,7 +154,10 @@ export function CustomWorldWorkCard({
type="button"
onClick={(event) => {
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({
<span className="shrink-0"></span>
<span className="min-w-0 truncate">{item.publicWorkCode}</span>
<Copy className="h-3 w-3 shrink-0" />
{copyState !== 'idle' ? (
<span className="shrink-0">
{copyState === 'copied' ? '已复制' : '复制失败'}
</span>
) : null}
</button>
) : null}
<div className="flex flex-wrap gap-2">

View File

@@ -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<PuzzleWorkSummary | null>(null);
const [puzzleDetailReturnTarget, setPuzzleDetailReturnTarget] =
useState<PuzzleDetailReturnTarget | null>(null);
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(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={

View File

@@ -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(
<PuzzleGalleryDetailView
item={detailItem}
onBack={vi.fn()}
onStartGame={vi.fn()}
/>,
);
await user.click(
screen.getByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }),
);
expect(writeText).toHaveBeenCalledWith('PZ-EPUBLIC1');
expect(execCommand).toHaveBeenCalledWith('copy');
expect(await screen.findByText('已复制')).toBeTruthy();
});

View File

@@ -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 (
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
@@ -42,6 +45,7 @@ export function PuzzleGalleryDetailView({
<button
type="button"
onClick={onBack}
aria-label="返回"
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-white/10 text-white/84"
>
<ArrowLeft className="h-4 w-4" />
@@ -82,7 +86,7 @@ export function PuzzleGalleryDetailView({
<span>{item.playCount} </span>
<button
type="button"
onClick={() => copyText(publicWorkCode)}
onClick={copyPublicWorkCode}
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-white/14 bg-white/10 px-3 py-1 text-sm text-amber-50/86"
aria-label={`复制作品号 ${publicWorkCode}`}
title="复制作品号"
@@ -90,6 +94,11 @@ export function PuzzleGalleryDetailView({
<span className="shrink-0"></span>
<span className="min-w-0 truncate">{publicWorkCode}</span>
<Copy className="h-3.5 w-3.5 shrink-0" />
{copyState !== 'idle' ? (
<span className="shrink-0 text-xs">
{copyState === 'copied' ? '已复制' : '复制失败'}
</span>
) : null}
</button>
</div>
</div>

View File

@@ -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(<TestWrapper withAuth />);
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();

View File

@@ -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();
});

View File

@@ -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<HTMLDivElement>) => {
if (event.target !== event.currentTarget) {
return;
}
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
onClick();
};
return (
<div
role="button"
tabIndex={0}
<button
type="button"
onClick={onClick}
onKeyDown={openCardByKeyboard}
className={`platform-surface platform-interactive-card relative flex h-[15rem] w-[min(15.25rem,78vw)] shrink-0 flex-col overflow-hidden px-3.5 py-3.5 text-left ${className ?? ''}`}
>
{coverImage ? (
@@ -342,25 +323,9 @@ function WorldCard({
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="relative z-10 flex h-full flex-col">
<div className="flex items-start justify-between gap-3">
{publicWorkCode ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
copyText(publicWorkCode);
}}
className="platform-pill platform-pill--warm flex max-w-[10rem] items-center gap-1.5 px-3 py-1"
aria-label={`复制作品号 ${publicWorkCode}`}
title="复制作品号"
>
<span className="min-w-0 truncate">{publicWorkCode}</span>
<Copy className="h-3 w-3 shrink-0" />
</button>
) : (
<span className="platform-pill platform-pill--warm max-w-[8.5rem] truncate">
{badgeLabel}
</span>
)}
<span className="platform-pill platform-pill--warm max-w-[8.5rem] truncate">
{badge}
</span>
<span className="platform-pill platform-pill--neutral px-2.5">
{metaLabel}
</span>
@@ -395,7 +360,7 @@ function WorldCard({
</div>
</div>
</div>
</div>
</button>
);
}
@@ -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({
<div className="flex items-center gap-2 text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
<span>{`${rank}`.padStart(2, '0')}</span>
<span className="truncate">
{publicWorkCode ?? describePublicGalleryCardKind(entry)}
{describePublicGalleryCardKind(entry)}
</span>
</div>
<div className="mt-2 line-clamp-1 text-lg font-semibold text-[var(--platform-text-strong)]">

View File

@@ -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 (
<div className="flex h-full min-h-0 flex-col">
@@ -99,7 +106,7 @@ export function RpgEntryWorldDetailView({
className="platform-button platform-button--ghost px-3 py-1.5 text-[11px]"
>
<ArrowLeft className="h-4 w-4" />
广
</button>
<div className="platform-pill platform-pill--neutral px-3 py-1.5 text-[11px] tracking-[0.08em]">
{entry.visibility === 'published' ? '已发布' : '草稿'}
@@ -141,11 +148,18 @@ export function RpgEntryWorldDetailView({
{publicWorkCode ? (
<button
type="button"
onClick={() => copyText(publicWorkCode)}
onClick={copyPublicWorkCode}
className="platform-pill platform-pill--neutral flex items-center gap-1 px-3"
aria-label={`复制作品号 ${publicWorkCode}`}
title="复制作品号"
>
<span> {publicWorkCode}</span>
<Copy className="h-3 w-3" />
{copyState !== 'idle' ? (
<span className="text-xs">
{copyState === 'copied' ? '已复制' : '复制失败'}
</span>
) : null}
</button>
) : null}
</div>

53
src/services/clipboard.ts Normal file
View File

@@ -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;
}