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` 行为,不新增页面或新系统。
|
1. 移动端首页在 Logo 下方提供紧凑搜索条,复用现有 `onSearchPublicCode` 行为,不新增页面或新系统。
|
||||||
2. 广场作品卡的辅助 badge 优先展示作品号,点击作品号只复制,不打开详情;没有公开作品号时展示作品类型,不再用发布时间充当主 badge。
|
2. 首页、分类、趋势等公开外部列表不直接展示作品号,卡片 badge 展示推荐、分类或作品类型,不再用发布时间充当主 badge。
|
||||||
3. RPG 与拼图详情页在已发布作品的辅助信息里展示作品号,并提供复制动作。
|
3. RPG 与拼图详情页在已发布作品的辅助信息里展示作品号,并提供复制动作。
|
||||||
4. 创作页作品卡在已发布作品上展示作品号:RPG 使用后端 `publicWorkCode`;拼图当前没有独立公开号时,使用 `PZ-` + `profileId` 后 8 位作为前端展示与复制标识,后续若补后端拼图公开号再替换来源。
|
4. 创作页作品卡在已发布作品上展示作品号:RPG 使用后端 `publicWorkCode`;拼图当前没有独立公开号时,使用 `PZ-` + `profileId` 后 8 位作为前端展示与复制标识,后续若补后端拼图公开号再替换来源。
|
||||||
5. 所有入口保持轻量 UI,不写规则说明文案,不改变发布、下架、进入游戏的后端语义。
|
5. 作品号复制统一使用兼容复制工具:优先 Clipboard API,权限失败时降级到隐藏文本框选区复制,并在按钮内短暂显示复制结果。
|
||||||
|
6. 作品详情返回必须恢复打开详情前的平台来源 Tab;从分类进入回分类,从首页进入回首页,从创作中心进入回创作中心。
|
||||||
|
7. 所有入口保持轻量 UI,不写规则说明文案,不改变发布、下架、进入游戏的后端语义。
|
||||||
|
|
||||||
## 验收
|
## 验收
|
||||||
|
|
||||||
1. 399px 竖屏首页能直接看到并使用搜索入口。
|
1. 399px 竖屏首页能直接看到并使用搜索入口。
|
||||||
2. 首页公开作品卡左上角不再出现发布时间样式的疑似作品号。
|
2. 首页公开作品卡左上角不再出现发布时间样式的疑似作品号,也不直接显示作品号。
|
||||||
3. RPG 详情页能看到 `作品号 CW...` 并可复制,拼图详情页能看到 `作品号 PZ...` 并可复制。
|
3. RPG 详情页能看到 `作品号 CW...` 并可复制,拼图详情页能看到 `作品号 PZ...` 并可复制。
|
||||||
4. 创作页“我的作品”已发布卡能看到作品号,拼图卡不会只显示作者和游玩数。
|
4. 创作页“我的作品”已发布卡能看到作品号,拼图卡不会只显示作者和游玩数。
|
||||||
5. 桌面右侧趋势列表只显示排序和作品号或作品类型,不再显示 `1777110165.990127Z` 这类原始时间字符串。
|
5. 桌面右侧趋势列表只显示排序和作品类型,不再显示 `1777110165.990127Z` 这类原始时间字符串,也不直接显示作品号。
|
||||||
|
6. 在内嵌浏览器 Clipboard API 拒绝写入时,详情页与创作中心作品号复制仍能通过降级路径完成,并显示 `已复制` 或 `复制失败`。
|
||||||
|
7. 打开拼图详情后点击返回,不再固定跳到创作中心,而是回到打开详情前的平台 Tab。
|
||||||
|
|||||||
@@ -2,12 +2,20 @@
|
|||||||
|
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
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 type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||||
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
|
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
|
||||||
|
|
||||||
const noopCreateType = () => {};
|
const noopCreateType = () => {};
|
||||||
|
const originalClipboard = navigator.clipboard;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
configurable: true,
|
||||||
|
value: originalClipboard,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const baseDraftItem: CustomWorldWorkSummary = {
|
const baseDraftItem: CustomWorldWorkSummary = {
|
||||||
workId: 'draft:session-1',
|
workId: 'draft:session-1',
|
||||||
@@ -214,3 +222,51 @@ test('creation hub opens persisted rpg drafts by card click', async () => {
|
|||||||
|
|
||||||
expect(openedItems).toEqual([persistedDraft]);
|
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 { Copy } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { copyTextToClipboard } from '../../services/clipboard';
|
||||||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||||||
import type { CreationWorkShelfItem } from './creationWorkShelf';
|
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) {
|
function formatUpdatedAt(value: string) {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) {
|
if (Number.isNaN(date.getTime())) {
|
||||||
@@ -49,6 +43,20 @@ export function CustomWorldWorkCard({
|
|||||||
onDelete = null,
|
onDelete = null,
|
||||||
deleteBusy = false,
|
deleteBusy = false,
|
||||||
}: CustomWorldWorkCardProps) {
|
}: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
@@ -146,7 +154,10 @@ export function CustomWorldWorkCard({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
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]"
|
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}`}
|
aria-label={`复制作品号 ${item.publicWorkCode}`}
|
||||||
@@ -155,6 +166,11 @@ export function CustomWorldWorkCard({
|
|||||||
<span className="shrink-0">作品号</span>
|
<span className="shrink-0">作品号</span>
|
||||||
<span className="min-w-0 truncate">{item.publicWorkCode}</span>
|
<span className="min-w-0 truncate">{item.publicWorkCode}</span>
|
||||||
<Copy className="h-3 w-3 shrink-0" />
|
<Copy className="h-3 w-3 shrink-0" />
|
||||||
|
{copyState !== 'idle' ? (
|
||||||
|
<span className="shrink-0">
|
||||||
|
{copyState === 'copied' ? '已复制' : '复制失败'}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -101,7 +101,10 @@ import {
|
|||||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||||
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
|
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
|
||||||
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
|
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
|
||||||
import { PlatformEntryHomeView } from './PlatformEntryHomeView';
|
import {
|
||||||
|
PlatformEntryHomeView,
|
||||||
|
type PlatformHomeTab,
|
||||||
|
} from './PlatformEntryHomeView';
|
||||||
import {
|
import {
|
||||||
buildCreationHubFallbackItems,
|
buildCreationHubFallbackItems,
|
||||||
normalizeAgentBackedProfile,
|
normalizeAgentBackedProfile,
|
||||||
@@ -119,6 +122,10 @@ type AgentResultPublishGateView = {
|
|||||||
publishReady: boolean;
|
publishReady: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PuzzleDetailReturnTarget = {
|
||||||
|
tab: PlatformHomeTab;
|
||||||
|
};
|
||||||
|
|
||||||
type AgentResultBlockerView = {
|
type AgentResultBlockerView = {
|
||||||
code?: string;
|
code?: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -363,6 +370,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
>([]);
|
>([]);
|
||||||
const [selectedPuzzleDetail, setSelectedPuzzleDetail] =
|
const [selectedPuzzleDetail, setSelectedPuzzleDetail] =
|
||||||
useState<PuzzleWorkSummary | null>(null);
|
useState<PuzzleWorkSummary | null>(null);
|
||||||
|
const [puzzleDetailReturnTarget, setPuzzleDetailReturnTarget] =
|
||||||
|
useState<PuzzleDetailReturnTarget | null>(null);
|
||||||
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
|
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
|
||||||
const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false);
|
const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false);
|
||||||
const [puzzleGenerationState, setPuzzleGenerationState] =
|
const [puzzleGenerationState, setPuzzleGenerationState] =
|
||||||
@@ -1393,14 +1402,19 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const openPuzzleDetail = useCallback(
|
const openPuzzleDetail = useCallback(
|
||||||
async (profileId: string) => {
|
async (
|
||||||
|
profileId: string,
|
||||||
|
returnTarget: PuzzleDetailReturnTarget = {
|
||||||
|
tab: platformBootstrap.platformTab,
|
||||||
|
},
|
||||||
|
) => {
|
||||||
setIsPuzzleBusy(true);
|
setIsPuzzleBusy(true);
|
||||||
setPuzzleError(null);
|
setPuzzleError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { item } = await getPuzzleGalleryDetail(profileId);
|
const { item } = await getPuzzleGalleryDetail(profileId);
|
||||||
setSelectedPuzzleDetail(item);
|
setSelectedPuzzleDetail(item);
|
||||||
enterCreateTab();
|
setPuzzleDetailReturnTarget(returnTarget);
|
||||||
setSelectionStage('puzzle-gallery-detail');
|
setSelectionStage('puzzle-gallery-detail');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图详情失败。'));
|
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图详情失败。'));
|
||||||
@@ -1408,7 +1422,11 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setIsPuzzleBusy(false);
|
setIsPuzzleBusy(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[enterCreateTab, resolvePuzzleErrorMessage, setSelectionStage],
|
[
|
||||||
|
platformBootstrap.platformTab,
|
||||||
|
resolvePuzzleErrorMessage,
|
||||||
|
setSelectionStage,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openPuzzleDraft = useCallback(
|
const openPuzzleDraft = useCallback(
|
||||||
@@ -1418,7 +1436,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setSelectedPuzzleDetail(null);
|
setSelectedPuzzleDetail(null);
|
||||||
if (!item.sourceSessionId?.trim()) {
|
if (!item.sourceSessionId?.trim()) {
|
||||||
if (item.publicationStatus === 'published') {
|
if (item.publicationStatus === 'published') {
|
||||||
await openPuzzleDetail(item.profileId);
|
await openPuzzleDetail(item.profileId, { tab: 'create' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1495,7 +1513,9 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
throw new Error('未找到拼图作品。');
|
throw new Error('未找到拼图作品。');
|
||||||
}
|
}
|
||||||
|
|
||||||
await openPuzzleDetail(matchedEntry.profileId);
|
await openPuzzleDetail(matchedEntry.profileId, {
|
||||||
|
tab: platformBootstrap.platformTab,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1543,6 +1563,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
[
|
[
|
||||||
detailNavigation,
|
detailNavigation,
|
||||||
openPuzzleDetail,
|
openPuzzleDetail,
|
||||||
|
platformBootstrap.platformTab,
|
||||||
puzzleGalleryEntries,
|
puzzleGalleryEntries,
|
||||||
refreshPuzzleGallery,
|
refreshPuzzleGallery,
|
||||||
],
|
],
|
||||||
@@ -1765,7 +1786,9 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
onOpenCreateTypePicker={openCreationTypePicker}
|
onOpenCreateTypePicker={openCreationTypePicker}
|
||||||
onOpenGalleryDetail={(entry) => {
|
onOpenGalleryDetail={(entry) => {
|
||||||
if (isPuzzleGalleryEntry(entry)) {
|
if (isPuzzleGalleryEntry(entry)) {
|
||||||
void openPuzzleDetail(entry.profileId);
|
void openPuzzleDetail(entry.profileId, {
|
||||||
|
tab: platformBootstrap.platformTab,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2150,7 +2173,10 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
isBusy={isPuzzleBusy}
|
isBusy={isPuzzleBusy}
|
||||||
error={puzzleError}
|
error={puzzleError}
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
enterCreateTab();
|
platformBootstrap.setPlatformTab(
|
||||||
|
puzzleDetailReturnTarget?.tab ?? 'home',
|
||||||
|
);
|
||||||
|
setPuzzleDetailReturnTarget(null);
|
||||||
setSelectionStage('platform');
|
setSelectionStage('platform');
|
||||||
}}
|
}}
|
||||||
onEdit={
|
onEdit={
|
||||||
|
|||||||
@@ -64,3 +64,35 @@ test('shows and copies puzzle public work code in detail view', async () => {
|
|||||||
|
|
||||||
expect(writeText).toHaveBeenCalledWith('PZ-EPUBLIC1');
|
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 { ArrowLeft, Copy, Pencil, Play, UserRound } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||||
|
import { copyTextToClipboard } from '../../services/clipboard';
|
||||||
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
|
|
||||||
@@ -13,14 +15,6 @@ type PuzzleGalleryDetailViewProps = {
|
|||||||
onStartGame: () => void;
|
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,
|
onStartGame,
|
||||||
}: PuzzleGalleryDetailViewProps) {
|
}: PuzzleGalleryDetailViewProps) {
|
||||||
const publicWorkCode = buildPuzzlePublicWorkCode(item.profileId);
|
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 (
|
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">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onBack}
|
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"
|
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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
@@ -82,7 +86,7 @@ export function PuzzleGalleryDetailView({
|
|||||||
<span>{item.playCount} 次游玩</span>
|
<span>{item.playCount} 次游玩</span>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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}`}
|
aria-label={`复制作品号 ${publicWorkCode}`}
|
||||||
title="复制作品号"
|
title="复制作品号"
|
||||||
@@ -90,6 +94,11 @@ export function PuzzleGalleryDetailView({
|
|||||||
<span className="shrink-0">作品号</span>
|
<span className="shrink-0">作品号</span>
|
||||||
<span className="min-w-0 truncate">{publicWorkCode}</span>
|
<span className="min-w-0 truncate">{publicWorkCode}</span>
|
||||||
<Copy className="h-3.5 w-3.5 shrink-0" />
|
<Copy className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
{copyState !== 'idle' ? (
|
||||||
|
<span className="shrink-0 text-xs">
|
||||||
|
{copyState === 'copied' ? '已复制' : '复制失败'}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1444,6 +1444,64 @@ test('published puzzle works appear on home and category public shelves', async
|
|||||||
).toBeGreaterThan(0);
|
).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 () => {
|
test('selecting RPG creation while logged out routes through requireAuth', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const requireAuth = vi.fn();
|
const requireAuth = vi.fn();
|
||||||
|
|||||||
@@ -95,8 +95,6 @@ vi.mock('../ResolvedAssetImage', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const originalMatchMedia = window.matchMedia;
|
const originalMatchMedia = window.matchMedia;
|
||||||
const originalClipboard = navigator.clipboard;
|
|
||||||
|
|
||||||
const puzzlePublicEntry = {
|
const puzzlePublicEntry = {
|
||||||
sourceType: 'puzzle',
|
sourceType: 'puzzle',
|
||||||
workId: 'puzzle-work-public-1',
|
workId: 'puzzle-work-public-1',
|
||||||
@@ -264,7 +262,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: originalClipboard,
|
value: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -350,35 +348,32 @@ test('mobile home search submits public work code', async () => {
|
|||||||
expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1');
|
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 user = userEvent.setup();
|
||||||
const writeText = vi.fn(async () => undefined);
|
|
||||||
const onOpenGalleryDetail = vi.fn();
|
const onOpenGalleryDetail = vi.fn();
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
|
||||||
configurable: true,
|
|
||||||
value: { writeText },
|
|
||||||
});
|
|
||||||
|
|
||||||
renderLoggedOutHomeView(vi.fn(), {
|
renderLoggedOutHomeView(vi.fn(), {
|
||||||
latestEntries: [puzzlePublicEntry],
|
latestEntries: [puzzlePublicEntry],
|
||||||
onOpenGalleryDetail,
|
onOpenGalleryDetail,
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.click(
|
expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull();
|
||||||
screen.getByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }),
|
expect(screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }))
|
||||||
);
|
.toBeNull();
|
||||||
|
|
||||||
expect(writeText).toHaveBeenCalledWith('PZ-EPUBLIC1');
|
await user.click(screen.getByRole('button', { name: /查看作品/u }));
|
||||||
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
|
||||||
|
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();
|
mockDesktopLayout();
|
||||||
|
|
||||||
renderLoggedOutHomeView(vi.fn(), {
|
renderLoggedOutHomeView(vi.fn(), {
|
||||||
latestEntries: [puzzlePublicEntry],
|
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();
|
expect(screen.queryByText('1777110165.990127Z')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
type ComponentType,
|
type ComponentType,
|
||||||
type KeyboardEvent,
|
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -54,7 +53,6 @@ import {
|
|||||||
describePlatformThemeLabel,
|
describePlatformThemeLabel,
|
||||||
formatPlatformWorldTime,
|
formatPlatformWorldTime,
|
||||||
isPuzzleGalleryEntry,
|
isPuzzleGalleryEntry,
|
||||||
resolvePlatformPublicWorkCode,
|
|
||||||
type PlatformPublicGalleryCard,
|
type PlatformPublicGalleryCard,
|
||||||
type PlatformWorldCardLike,
|
type PlatformWorldCardLike,
|
||||||
resolvePlatformWorldCoverImage,
|
resolvePlatformWorldCoverImage,
|
||||||
@@ -293,8 +291,6 @@ function WorldCard({
|
|||||||
}) {
|
}) {
|
||||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||||||
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
|
|
||||||
const badgeLabel = publicWorkCode ?? badge;
|
|
||||||
const tags = [
|
const tags = [
|
||||||
...new Set(
|
...new Set(
|
||||||
buildPlatformWorldTags(entry)
|
buildPlatformWorldTags(entry)
|
||||||
@@ -303,25 +299,10 @@ function WorldCard({
|
|||||||
),
|
),
|
||||||
].slice(0, 3);
|
].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 (
|
return (
|
||||||
<div
|
<button
|
||||||
role="button"
|
type="button"
|
||||||
tabIndex={0}
|
|
||||||
onClick={onClick}
|
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 ?? ''}`}
|
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 ? (
|
{coverImage ? (
|
||||||
@@ -342,25 +323,9 @@ function WorldCard({
|
|||||||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
|
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
|
||||||
<div className="relative z-10 flex h-full flex-col">
|
<div className="relative z-10 flex h-full flex-col">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
{publicWorkCode ? (
|
<span className="platform-pill platform-pill--warm max-w-[8.5rem] truncate">
|
||||||
<button
|
{badge}
|
||||||
type="button"
|
</span>
|
||||||
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--neutral px-2.5">
|
<span className="platform-pill platform-pill--neutral px-2.5">
|
||||||
{metaLabel}
|
{metaLabel}
|
||||||
</span>
|
</span>
|
||||||
@@ -395,7 +360,7 @@ function WorldCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,7 +616,6 @@ function DesktopTrendingItem({
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||||
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
|
|
||||||
const tags = buildPlatformWorldTags(entry).filter(Boolean).slice(0, 2);
|
const tags = buildPlatformWorldTags(entry).filter(Boolean).slice(0, 2);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -675,7 +639,7 @@ function DesktopTrendingItem({
|
|||||||
<div className="flex items-center gap-2 text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
|
<div className="flex items-center gap-2 text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||||
<span>{`${rank}`.padStart(2, '0')}</span>
|
<span>{`${rank}`.padStart(2, '0')}</span>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{publicWorkCode ?? describePublicGalleryCardKind(entry)}
|
{describePublicGalleryCardKind(entry)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 line-clamp-1 text-lg font-semibold text-[var(--platform-text-strong)]">
|
<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 { ArrowLeft, Copy } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||||
|
import { copyTextToClipboard } from '../../services/clipboard';
|
||||||
import type { CustomWorldProfile } from '../../types';
|
import type { CustomWorldProfile } from '../../types';
|
||||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
import {
|
import {
|
||||||
@@ -25,14 +27,6 @@ export interface RpgEntryWorldDetailViewProps {
|
|||||||
onUnpublish?: (() => void) | null;
|
onUnpublish?: (() => void) | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyText(value: string) {
|
|
||||||
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void navigator.clipboard.writeText(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActionButton({
|
function ActionButton({
|
||||||
label,
|
label,
|
||||||
onClick,
|
onClick,
|
||||||
@@ -77,6 +71,9 @@ export function RpgEntryWorldDetailView({
|
|||||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||||||
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
|
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
|
||||||
|
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||||||
|
'idle',
|
||||||
|
);
|
||||||
const canStartGame = entry.visibility === 'published';
|
const canStartGame = entry.visibility === 'published';
|
||||||
const previewCharacters = buildCustomWorldPlayableCharacters(
|
const previewCharacters = buildCustomWorldPlayableCharacters(
|
||||||
entry.profile,
|
entry.profile,
|
||||||
@@ -89,6 +86,16 @@ export function RpgEntryWorldDetailView({
|
|||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
),
|
),
|
||||||
].slice(0, 3);
|
].slice(0, 3);
|
||||||
|
const copyPublicWorkCode = () => {
|
||||||
|
if (!publicWorkCode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void copyTextToClipboard(publicWorkCode).then((copied) => {
|
||||||
|
setCopyState(copied ? 'copied' : 'failed');
|
||||||
|
window.setTimeout(() => setCopyState('idle'), 1400);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
<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]"
|
className="platform-button platform-button--ghost px-3 py-1.5 text-[11px]"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
返回广场
|
返回
|
||||||
</button>
|
</button>
|
||||||
<div className="platform-pill platform-pill--neutral px-3 py-1.5 text-[11px] tracking-[0.08em]">
|
<div className="platform-pill platform-pill--neutral px-3 py-1.5 text-[11px] tracking-[0.08em]">
|
||||||
{entry.visibility === 'published' ? '已发布' : '草稿'}
|
{entry.visibility === 'published' ? '已发布' : '草稿'}
|
||||||
@@ -141,11 +148,18 @@ export function RpgEntryWorldDetailView({
|
|||||||
{publicWorkCode ? (
|
{publicWorkCode ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => copyText(publicWorkCode)}
|
onClick={copyPublicWorkCode}
|
||||||
className="platform-pill platform-pill--neutral flex items-center gap-1 px-3"
|
className="platform-pill platform-pill--neutral flex items-center gap-1 px-3"
|
||||||
|
aria-label={`复制作品号 ${publicWorkCode}`}
|
||||||
|
title="复制作品号"
|
||||||
>
|
>
|
||||||
<span>作品号 {publicWorkCode}</span>
|
<span>作品号 {publicWorkCode}</span>
|
||||||
<Copy className="h-3 w-3" />
|
<Copy className="h-3 w-3" />
|
||||||
|
{copyState !== 'idle' ? (
|
||||||
|
<span className="text-xs">
|
||||||
|
{copyState === 'copied' ? '已复制' : '复制失败'}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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