Files
Genarrative/src/components/platform-entry/PlatformWorkDetailView.tsx
高物 3931442249 Enforce Genarrative play-type SOP and update docs
Rewrite Genarrative play-type integration guidance across .codex and .hermes to define a platform-level SOP: default to form/image workbench, unify single-image asset slots (CreativeImageInputPanel), standardize series-material sheet->cut->transparent->OSS pipeline, and forbid copying legacy chat/agent workflows as the default. Add decision-log entry freezing the SOP and a pitfalls note warning against direct reuse of old play tools. Update CONTEXT.md and docs/README.md, add a new PRD file, and apply related small server-side changes (module-auth, spacetime-client mappers and runtime) to align back-end code with the new contracts and flows.
2026-05-20 12:12:00 +08:00

463 lines
14 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 { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
import { copyTextToClipboard } from '../../services/clipboard';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import {
buildPlatformWorldDisplayTags,
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTags,
formatPlatformWorldTime,
isEdutainmentGalleryEntry,
isJumpHopGalleryEntry,
type PlatformPublicGalleryCard,
resolvePlatformPublicWorkCode,
resolvePlatformWorldCoverSlides,
resolvePlatformWorldStats,
} from '../rpg-entry/rpgEntryWorldPresentation';
export interface PlatformWorkDetailViewProps {
entry: PlatformPublicGalleryCard;
authorAvatarUrl?: string | null;
authorDisplayName?: string | 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 ('sourceType' in entry && entry.sourceType === 'big-fish') {
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 (isJumpHopGalleryEntry(entry)) {
return '跳一跳';
}
if (isEdutainmentGalleryEntry(entry)) {
return entry.templateName;
}
return 'RPG';
}
function getAuthorAvatarLabel(authorDisplayName: string) {
return Array.from(authorDisplayName.trim() || '作')[0] ?? '作';
}
const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200;
export function PlatformWorkDetailView({
entry,
authorAvatarUrl,
authorDisplayName,
isBusy,
error,
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 = authorAvatarUrl?.trim() ?? '';
const resolvedAuthorDisplayName =
authorDisplayName?.trim() || entry.authorDisplayName;
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
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);
}, [entry.profileId, coverSlides.length]);
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 copyTextToClipboard(publicWorkCode).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
window.setTimeout(() => setCopyState('idle'), 1400);
});
};
const sharePublicWork = () => {
if (!publicWorkCode) {
return;
}
const shareText = `邀请你来玩《${entry.worldName}\n作品号${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`;
void copyTextToClipboard(shareText).then((copied) => {
setShareState(copied ? 'copied' : 'failed');
window.setTimeout(() => setShareState('idle'), 1400);
});
};
return (
<div className="platform-work-detail">
<div className="platform-work-detail__topbar">
<button
type="button"
className="platform-work-detail__icon-button"
onClick={onBack}
aria-label="返回"
title="返回"
>
<ArrowLeft className="h-6 w-6" />
</button>
<div className="platform-work-detail__title"></div>
<button
type="button"
className="platform-work-detail__icon-button"
onClick={sharePublicWork}
disabled={!publicWorkCode}
aria-label="分享"
title="分享"
>
<Share2 className="h-5 w-5" />
</button>
</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 ? (
<>
<button
type="button"
className="platform-work-detail__cover-nav platform-work-detail__cover-nav--prev"
onClick={showPreviousCover}
aria-label="上一张关卡图"
title="上一张关卡图"
>
<ChevronLeft className="h-5 w-5" />
</button>
<button
type="button"
className="platform-work-detail__cover-nav platform-work-detail__cover-nav--next"
onClick={showNextCover}
aria-label="下一张关卡图"
title="下一张关卡图"
>
<ChevronRight className="h-5 w-5" />
</button>
<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 ? (
<button
type="button"
className="platform-work-detail__code"
onClick={copyPublicWorkCode}
>
<Copy className="h-4 w-4" />
<span>{publicWorkCode}</span>
{copyState !== 'idle' ? (
<span>{copyState === 'copied' ? '已复制' : '复制失败'}</span>
) : null}
</button>
) : null}
{shareState !== 'idle' ? (
<div className="platform-work-detail__toast">
{shareState === 'copied' ? '分享内容已复制' : '分享失败'}
</div>
) : null}
{error ? (
<div className="platform-work-detail__error">{error}</div>
) : null}
</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>
);
}