Files
Genarrative/src/components/platform-entry/PlatformWorkDetailView.tsx
kdletters 7c47ad3358 继续收口详情页头部动作组合
沉淀 PlatformDetailTopbar 与 PlatformDetailShareActions 共享骨架
接入 RPG 世界详情与公开作品详情的返回复制分享动作组合
补充测试护栏与文档决策记录
2026-06-11 06:32:20 +08:00

497 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}