Files
Genarrative/src/components/platform-entry/PlatformWorkDetailView.tsx
kdletters 7977a19d24 收口作品详情图标按钮
将作品详情顶部返回和分享入口迁移到 PlatformIconButton

将作品详情封面轮播图标入口迁移到 PlatformIconButton

补充作品详情公共图标按钮断言

更新 PlatformUiKit 文档和 Hermes 决策记录
2026-06-10 13:34:02 +08:00

479 lines
15 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 {
ArrowLeft,
ChevronLeft,
ChevronRight,
CircleHelp,
Clock3,
Copy,
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 { CopyCodeButton } from '../common/CopyCodeButton';
import { CopyFeedbackMessage } from '../common/CopyFeedbackMessage';
import { PlatformIconButton } from '../common/PlatformIconButton';
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">
<div className="platform-work-detail__topbar">
<PlatformIconButton
label="返回"
className="platform-work-detail__icon-button"
onClick={onBack}
title="返回"
icon={<ArrowLeft className="h-6 w-6" />}
/>
<div className="platform-work-detail__title"></div>
<PlatformIconButton
label="分享"
className="platform-work-detail__icon-button"
onClick={sharePublicWork}
disabled={!publicWorkCode}
title="分享"
icon={<Share2 className="h-5 w-5" />}
/>
</div>
<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>
<button
type="button"
className="platform-work-detail__like"
onClick={onLike}
disabled={isBusy}
aria-label={`点赞 ${formatCompactCount(stats.likeCount)}`}
title="点赞"
>
<Heart className="h-5 w-5 fill-current" />
</button>
</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) => (
<span key={tag} className="platform-work-detail__chip">
{tag}
</span>
))}
</div>
<p className="platform-work-detail__copy">{entry.summaryText}</p>
{publicWorkCode ? (
<CopyCodeButton
state={copyState}
code={publicWorkCode}
codeLabel={null}
accessibleLabel={publicWorkCode}
title="复制作品号"
className="platform-work-detail__code"
onClick={copyPublicWorkCode}
idleIcon={<Copy className="h-4 w-4" />}
copiedIcon={<Copy className="h-4 w-4" />}
/>
) : null}
<CopyFeedbackMessage
state={shareState}
className="platform-work-detail__toast"
copiedLabel="分享内容已复制"
failedLabel="分享失败"
/>
</section>
</div>
<div className="platform-work-detail__bottom">
<button
type="button"
className="platform-work-detail__remix"
onClick={onRemix}
disabled={isBusy}
>
<WorkActionIcon className="h-5 w-5" />
{workActionLabel}
</button>
<button
type="button"
className="platform-work-detail__start"
onClick={onStart}
disabled={isBusy}
>
<Play className="h-5 w-5 fill-current" />
</button>
</div>
</div>
);
}