7c47ad3358
沉淀 PlatformDetailTopbar 与 PlatformDetailShareActions 共享骨架 接入 RPG 世界详情与公开作品详情的返回复制分享动作组合 补充测试护栏与文档决策记录
497 lines
16 KiB
TypeScript
497 lines
16 KiB
TypeScript
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
CircleHelp,
|
|
Clock3,
|
|
Gamepad2,
|
|
GitFork,
|
|
Heart,
|
|
PencilLine,
|
|
Play,
|
|
Share2,
|
|
} from 'lucide-react';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
|
|
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
|
|
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
|
|
import { PlatformActionButton } from '../common/PlatformActionButton';
|
|
import { PlatformDetailShareActions } from '../common/PlatformDetailShareActions';
|
|
import { PlatformDetailTopbar } from '../common/PlatformDetailTopbar';
|
|
import { PlatformIconButton } from '../common/PlatformIconButton';
|
|
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
|
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
|
import { useCopyFeedback } from '../common/useCopyFeedback';
|
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
|
import {
|
|
buildPlatformWorldDisplayTags,
|
|
formatPlatformWorkDisplayName,
|
|
formatPlatformWorkDisplayTags,
|
|
formatPlatformWorldTime,
|
|
isBarkBattleGalleryEntry,
|
|
isCustomWorldGalleryEntry,
|
|
isEdutainmentGalleryEntry,
|
|
isJumpHopGalleryEntry,
|
|
isPuzzleClearGalleryEntry,
|
|
isWoodenFishGalleryEntry,
|
|
type PlatformPublicGalleryCard,
|
|
resolvePlatformPublicWorkCode,
|
|
resolvePlatformWorkAuthorDisplayName,
|
|
resolvePlatformWorldCoverSlides,
|
|
resolvePlatformWorldStats,
|
|
} from '../rpg-entry/rpgEntryWorldPresentation';
|
|
|
|
export interface PlatformWorkDetailViewProps {
|
|
entry: PlatformPublicGalleryCard;
|
|
authorSummary?: PublicUserSummary | null;
|
|
isBusy: boolean;
|
|
error?: string | null;
|
|
visibleCoverCount?: number;
|
|
onBack: () => void;
|
|
onLike: () => void;
|
|
onStart: () => void;
|
|
onRemix: () => void;
|
|
actionMode?: 'remix' | 'edit';
|
|
}
|
|
|
|
function formatCompactCount(value: number) {
|
|
if (value >= 10000) {
|
|
const normalized = value / 10000;
|
|
return `${Number.isInteger(normalized) ? normalized.toFixed(0) : normalized.toFixed(1)}万`;
|
|
}
|
|
return `${value}`;
|
|
}
|
|
|
|
function getSourceLabel(entry: PlatformPublicGalleryCard) {
|
|
if ('sourceType' in entry && entry.sourceType === 'puzzle') {
|
|
return '拼图';
|
|
}
|
|
if (isPuzzleClearGalleryEntry(entry)) {
|
|
return '拼消消';
|
|
}
|
|
if ('sourceType' in entry && entry.sourceType === 'big-fish') {
|
|
return '大鱼吃小鱼';
|
|
}
|
|
if (isJumpHopGalleryEntry(entry)) {
|
|
return '跳一跳';
|
|
}
|
|
if (isWoodenFishGalleryEntry(entry)) {
|
|
return '敲木鱼';
|
|
}
|
|
if ('sourceType' in entry && entry.sourceType === 'match3d') {
|
|
return '抓大鹅';
|
|
}
|
|
if ('sourceType' in entry && entry.sourceType === 'square-hole') {
|
|
return '方洞挑战';
|
|
}
|
|
if ('sourceType' in entry && entry.sourceType === 'visual-novel') {
|
|
return '视觉小说';
|
|
}
|
|
if (isBarkBattleGalleryEntry(entry)) {
|
|
return '汪汪声浪';
|
|
}
|
|
if (isEdutainmentGalleryEntry(entry)) {
|
|
return entry.templateName;
|
|
}
|
|
if (isCustomWorldGalleryEntry(entry)) {
|
|
return 'RPG';
|
|
}
|
|
|
|
throw new Error('未知公开作品类型。');
|
|
}
|
|
|
|
function getAuthorAvatarLabel(authorDisplayName: string) {
|
|
return Array.from(authorDisplayName.trim() || '作')[0] ?? '作';
|
|
}
|
|
|
|
const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200;
|
|
|
|
export function PlatformWorkDetailView({
|
|
entry,
|
|
authorSummary,
|
|
isBusy,
|
|
visibleCoverCount = 1,
|
|
onBack,
|
|
onLike,
|
|
onStart,
|
|
onRemix,
|
|
actionMode = 'remix',
|
|
}: PlatformWorkDetailViewProps) {
|
|
const coverSlides = useMemo(
|
|
() => resolvePlatformWorldCoverSlides(entry),
|
|
[entry],
|
|
);
|
|
const [activeCoverIndex, setActiveCoverIndex] = useState(0);
|
|
const activeCoverSlide =
|
|
coverSlides[activeCoverIndex] ?? coverSlides[0] ?? null;
|
|
const coverImage = activeCoverSlide?.imageSrc ?? '';
|
|
const unlockedCoverCount = Math.max(1, Math.floor(visibleCoverCount));
|
|
const isActiveCoverVisible = activeCoverIndex < unlockedCoverCount;
|
|
const appIconImage = coverSlides[0]?.imageSrc ?? '';
|
|
const hasCoverCarousel = coverSlides.length > 1;
|
|
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
|
|
const normalizedAuthorAvatarUrl = authorSummary?.avatarUrl?.trim() ?? '';
|
|
const resolvedAuthorDisplayName = resolvePlatformWorkAuthorDisplayName(
|
|
entry,
|
|
authorSummary,
|
|
);
|
|
const {
|
|
copyState,
|
|
copyText: copyWorkCodeText,
|
|
resetCopyState: resetWorkCodeCopyState,
|
|
} = useCopyFeedback();
|
|
const {
|
|
copyState: shareState,
|
|
copyText: copyShareText,
|
|
resetCopyState: resetShareCopyState,
|
|
} = useCopyFeedback();
|
|
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
|
const tags = useMemo(
|
|
() =>
|
|
formatPlatformWorkDisplayTags(
|
|
[getSourceLabel(entry), ...buildPlatformWorldDisplayTags(entry, 3)],
|
|
4,
|
|
),
|
|
[entry],
|
|
);
|
|
const stats = resolvePlatformWorldStats(entry);
|
|
const workActionLabel = actionMode === 'edit' ? '作品编辑' : '作品改造';
|
|
const WorkActionIcon = actionMode === 'edit' ? PencilLine : GitFork;
|
|
const statItems = [
|
|
{
|
|
label: '游玩',
|
|
value: formatCompactCount(stats.playCount),
|
|
unit: '次',
|
|
icon: Gamepad2,
|
|
tone: 'play',
|
|
},
|
|
{
|
|
label: '改造',
|
|
value: formatCompactCount(stats.remixCount),
|
|
unit: '次',
|
|
icon: GitFork,
|
|
tone: 'remix',
|
|
},
|
|
{
|
|
label: '点赞',
|
|
value: formatCompactCount(stats.likeCount),
|
|
unit: '赞',
|
|
icon: Heart,
|
|
tone: 'like',
|
|
},
|
|
{
|
|
label: '日期',
|
|
value: formatPlatformWorldTime(stats.updatedAt ?? stats.publishedAt),
|
|
icon: Clock3,
|
|
tone: 'time',
|
|
isTime: true,
|
|
},
|
|
];
|
|
|
|
useEffect(() => {
|
|
setActiveCoverIndex(0);
|
|
resetWorkCodeCopyState();
|
|
resetShareCopyState();
|
|
}, [
|
|
entry.profileId,
|
|
coverSlides.length,
|
|
resetShareCopyState,
|
|
resetWorkCodeCopyState,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
setActiveCoverIndex((current) =>
|
|
coverSlides.length > 0 ? Math.min(current, coverSlides.length - 1) : 0,
|
|
);
|
|
}, [coverSlides.length]);
|
|
|
|
useEffect(() => {
|
|
if (!hasCoverCarousel) {
|
|
return undefined;
|
|
}
|
|
|
|
const timerId = window.setInterval(() => {
|
|
setActiveCoverIndex((current) => (current + 1) % coverSlides.length);
|
|
}, PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS);
|
|
|
|
return () => {
|
|
window.clearInterval(timerId);
|
|
};
|
|
}, [coverSlides.length, hasCoverCarousel]);
|
|
|
|
const showPreviousCover = () => {
|
|
if (!hasCoverCarousel) {
|
|
return;
|
|
}
|
|
setActiveCoverIndex(
|
|
(current) => (current - 1 + coverSlides.length) % coverSlides.length,
|
|
);
|
|
};
|
|
|
|
const showNextCover = () => {
|
|
if (!hasCoverCarousel) {
|
|
return;
|
|
}
|
|
setActiveCoverIndex((current) => (current + 1) % coverSlides.length);
|
|
};
|
|
|
|
const copyPublicWorkCode = () => {
|
|
if (!publicWorkCode) {
|
|
return;
|
|
}
|
|
|
|
void copyWorkCodeText(publicWorkCode);
|
|
};
|
|
|
|
const sharePublicWork = () => {
|
|
if (!publicWorkCode) {
|
|
return;
|
|
}
|
|
|
|
const shareText = `邀请你来玩《${entry.worldName}》\n作品号:${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`;
|
|
void copyShareText(shareText);
|
|
};
|
|
|
|
return (
|
|
<div className="platform-work-detail">
|
|
<PlatformDetailTopbar
|
|
onBack={onBack}
|
|
backVariant="icon"
|
|
title={
|
|
<div className="platform-work-detail__title">
|
|
详情
|
|
</div>
|
|
}
|
|
className="platform-work-detail__topbar"
|
|
backButtonClassName="platform-work-detail__icon-button"
|
|
trailing={
|
|
<PlatformIconButton
|
|
label="分享"
|
|
className="platform-work-detail__icon-button"
|
|
onClick={sharePublicWork}
|
|
disabled={!publicWorkCode}
|
|
title="分享"
|
|
icon={<Share2 className="h-5 w-5" />}
|
|
/>
|
|
}
|
|
/>
|
|
|
|
<div className="platform-work-detail__scroll">
|
|
<section className="platform-work-detail__cover">
|
|
{coverImage ? (
|
|
<>
|
|
<ResolvedAssetImage
|
|
src={coverImage}
|
|
alt=""
|
|
aria-hidden="true"
|
|
className="platform-work-detail__cover-blur"
|
|
/>
|
|
<ResolvedAssetImage
|
|
src={coverImage}
|
|
alt={entry.worldName}
|
|
className={`platform-work-detail__cover-image${
|
|
isActiveCoverVisible
|
|
? ''
|
|
: ' platform-work-detail__cover-image--locked'
|
|
}`}
|
|
/>
|
|
{!isActiveCoverVisible ? (
|
|
<div
|
|
className="platform-work-detail__cover-lock"
|
|
aria-hidden="true"
|
|
>
|
|
<CircleHelp className="platform-work-detail__cover-lock-icon" />
|
|
</div>
|
|
) : null}
|
|
{hasCoverCarousel ? (
|
|
<>
|
|
<PlatformIconButton
|
|
label="上一张关卡图"
|
|
className="platform-work-detail__cover-nav platform-work-detail__cover-nav--prev"
|
|
onClick={showPreviousCover}
|
|
title="上一张关卡图"
|
|
icon={<ChevronLeft className="h-5 w-5" />}
|
|
/>
|
|
<PlatformIconButton
|
|
label="下一张关卡图"
|
|
className="platform-work-detail__cover-nav platform-work-detail__cover-nav--next"
|
|
onClick={showNextCover}
|
|
title="下一张关卡图"
|
|
icon={<ChevronRight className="h-5 w-5" />}
|
|
/>
|
|
<div className="platform-work-detail__cover-dots">
|
|
{coverSlides.map((slide, index) => (
|
|
<button
|
|
key={slide.id}
|
|
type="button"
|
|
className={`platform-work-detail__cover-dot${
|
|
index === activeCoverIndex
|
|
? ' platform-work-detail__cover-dot--active'
|
|
: ''
|
|
}`}
|
|
onClick={() => setActiveCoverIndex(index)}
|
|
aria-label={`查看${slide.label || `第 ${index + 1} 关`}`}
|
|
aria-current={
|
|
index === activeCoverIndex ? 'true' : undefined
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<div className="platform-work-detail__cover-fallback" />
|
|
)}
|
|
</section>
|
|
|
|
<section className="platform-work-detail__summary">
|
|
<div className="platform-work-detail__meta-row">
|
|
<div className="platform-work-detail__app-icon">
|
|
{appIconImage ? (
|
|
<ResolvedAssetImage
|
|
src={appIconImage}
|
|
alt=""
|
|
aria-hidden="true"
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
) : (
|
|
entry.worldName.slice(0, 1)
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="platform-work-detail__name">{displayName}</div>
|
|
<div className="platform-work-detail__author">
|
|
<span className="platform-work-detail__author-avatar">
|
|
{normalizedAuthorAvatarUrl ? (
|
|
<ResolvedAssetImage
|
|
src={normalizedAuthorAvatarUrl}
|
|
alt=""
|
|
aria-hidden="true"
|
|
className="platform-work-detail__author-avatar-image"
|
|
/>
|
|
) : (
|
|
<span className="platform-work-detail__author-avatar-label">
|
|
{getAuthorAvatarLabel(resolvedAuthorDisplayName)}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="platform-work-detail__author-name">
|
|
{resolvedAuthorDisplayName}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<PlatformActionButton
|
|
type="button"
|
|
tone="accentSoft"
|
|
onClick={onLike}
|
|
disabled={isBusy}
|
|
aria-label={`点赞 ${formatCompactCount(stats.likeCount)}赞`}
|
|
title="点赞"
|
|
className="platform-work-detail__like min-w-[5.2rem] flex-col gap-1 px-3 py-2.5 text-[0.8125rem] [--platform-action-accent:var(--platform-work-like-accent,#c7653d)]"
|
|
>
|
|
<Heart className="h-5 w-5 fill-current" />
|
|
点赞
|
|
</PlatformActionButton>
|
|
</div>
|
|
|
|
<div className="platform-work-detail__stats">
|
|
{statItems.map((item) => (
|
|
<div
|
|
key={item.label}
|
|
className={`platform-work-detail__stat platform-work-detail__stat--${item.tone}`}
|
|
>
|
|
<div className="platform-work-detail__stat-head">
|
|
<span className="platform-work-detail__stat-icon">
|
|
<item.icon className="h-3.5 w-3.5" />
|
|
</span>
|
|
<span className="platform-work-detail__stat-label">
|
|
{item.label}
|
|
</span>
|
|
</div>
|
|
<div
|
|
className={`platform-work-detail__stat-value${
|
|
item.isTime ? ' platform-work-detail__stat-value--time' : ''
|
|
}`}
|
|
>
|
|
<span className="platform-work-detail__stat-number">
|
|
{item.value}
|
|
</span>
|
|
{item.unit ? (
|
|
<span className="platform-work-detail__stat-unit">
|
|
{item.unit}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="platform-work-detail__body">
|
|
<div className="platform-work-detail__chips">
|
|
{tags.map((tag) => (
|
|
<PlatformPillBadge
|
|
key={tag}
|
|
tone="neutralSolid"
|
|
size="sm"
|
|
className="platform-work-detail__chip"
|
|
>
|
|
{tag}
|
|
</PlatformPillBadge>
|
|
))}
|
|
</div>
|
|
<p className="platform-work-detail__copy">{entry.summaryText}</p>
|
|
<PlatformDetailShareActions
|
|
workCode={publicWorkCode}
|
|
copyState={copyState}
|
|
onCopyWorkCode={copyPublicWorkCode}
|
|
shareState={shareState}
|
|
onShare={sharePublicWork}
|
|
shareAriaLabel={`分享作品 ${entry.worldName}`}
|
|
leading={null}
|
|
showShareAction={false}
|
|
variant="solid"
|
|
className="platform-work-detail__code"
|
|
/>
|
|
{shareState !== 'idle' ? (
|
|
<PlatformStatusMessage
|
|
tone={shareState === 'copied' ? 'success' : 'error'}
|
|
surface="platform"
|
|
size="sm"
|
|
className="platform-work-detail__toast"
|
|
>
|
|
{shareState === 'copied' ? '分享内容已复制' : '分享失败'}
|
|
</PlatformStatusMessage>
|
|
) : null}
|
|
</section>
|
|
</div>
|
|
|
|
<div className="platform-work-detail__bottom">
|
|
<PlatformActionButton
|
|
tone="secondary"
|
|
shape="pill"
|
|
size="lg"
|
|
fullWidth
|
|
className="platform-work-detail__remix"
|
|
onClick={onRemix}
|
|
disabled={isBusy}
|
|
>
|
|
<WorkActionIcon className="h-5 w-5" />
|
|
{workActionLabel}
|
|
</PlatformActionButton>
|
|
<PlatformActionButton
|
|
shape="pill"
|
|
size="lg"
|
|
fullWidth
|
|
className="platform-work-detail__start"
|
|
onClick={onStart}
|
|
disabled={isBusy}
|
|
>
|
|
<Play className="h-5 w-5 fill-current" />
|
|
启动
|
|
</PlatformActionButton>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|