fix: 完善作品号复制与详情返回
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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
53
src/services/clipboard.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user