This commit is contained in:
2026-05-10 22:20:54 +08:00
parent d6219f1a0c
commit 192accd796
92 changed files with 7045 additions and 1559 deletions

View File

@@ -9,6 +9,7 @@ import {
Coins,
Compass,
Copy,
GitFork,
Gamepad2,
Heart,
LogIn,
@@ -16,10 +17,12 @@ import {
Pencil,
Plus,
Search,
Share2,
Settings,
SlidersHorizontal,
Sparkles,
Star,
ThumbsUp,
Ticket,
UserPlus,
UserRound,
@@ -54,6 +57,7 @@ import type {
RedeemProfileRewardCodeResponse,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
import type { AuthUser } from '../../services/authService';
import {
getPublicAuthUserByCode,
@@ -86,6 +90,7 @@ import {
isVisualNovelGalleryEntry,
type PlatformPublicGalleryCard,
type PlatformWorldCardLike,
resolvePlatformPublicWorkCode,
resolvePlatformWorldCoverImage,
resolvePlatformWorldCoverSlides,
resolvePlatformWorldLeadPortrait,
@@ -126,6 +131,8 @@ export interface RpgEntryHomeViewProps {
recommendRuntimeError?: string | null;
onSelectNextRecommendEntry?: () => void;
onSelectPreviousRecommendEntry?: () => void;
onLikeRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
onRemixRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
onOpenLibraryDetail: (
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
) => void;
@@ -772,19 +779,27 @@ function RecommendSwipeCard({
authorAvatarUrl,
isActive,
visual,
shareState,
onDragPointerDown,
onDragPointerMove,
onDragPointerUp,
onDragPointerCancel,
onLike,
onShare,
onRemix,
}: {
entry: PlatformPublicGalleryCard;
authorAvatarUrl?: string | null;
isActive: boolean;
visual: ReactNode;
shareState?: 'idle' | 'copied' | 'failed';
onDragPointerDown?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
onLike?: () => void;
onShare?: () => void;
onRemix?: () => void;
}) {
return (
<div
@@ -797,10 +812,14 @@ function RecommendSwipeCard({
entry={entry}
authorAvatarUrl={authorAvatarUrl}
isActive={isActive}
shareState={shareState}
onDragPointerDown={onDragPointerDown}
onDragPointerMove={onDragPointerMove}
onDragPointerUp={onDragPointerUp}
onDragPointerCancel={onDragPointerCancel}
onLike={onLike}
onShare={onShare}
onRemix={onRemix}
/>
</div>
</div>
@@ -814,6 +833,10 @@ function RecommendRuntimeMeta({
onDragPointerMove,
onDragPointerUp,
onDragPointerCancel,
shareState = 'idle',
onLike,
onShare,
onRemix,
isActive = true,
}: {
entry: PlatformPublicGalleryCard;
@@ -822,20 +845,21 @@ function RecommendRuntimeMeta({
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
shareState?: 'idle' | 'copied' | 'failed';
onLike?: () => void;
onShare?: () => void;
onRemix?: () => void;
isActive?: boolean;
}) {
const playCount = getPlatformWorldPlayCount(entry);
const remixCount = getPlatformWorldRemixCount(entry);
const likeCount = getPlatformWorldLikeCount(entry);
const remixCount = getPlatformWorldRemixCount(entry);
const authorName = entry.authorDisplayName.trim() || '玩家';
const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName);
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const statItems = [
{ label: '游玩', value: playCount, icon: Gamepad2 },
{ label: '点赞', value: likeCount, icon: Heart },
{ label: '改造', value: remixCount, icon: MessageCircle },
];
const stopActionPointer = (event: PointerEvent<HTMLButtonElement>) => {
event.stopPropagation();
};
return (
<section
@@ -854,19 +878,6 @@ function RecommendRuntimeMeta({
onPointerUp={onDragPointerUp}
onPointerCancel={onDragPointerCancel}
>
<div className="platform-recommend-work-meta__stats">
{statItems.map(({ label, value, icon: Icon }) => (
<span
key={label}
className="platform-recommend-work-meta__stat"
aria-label={`${label} ${formatCompactCount(value)}`}
>
<Icon className="h-4 w-4" aria-hidden="true" />
<span>{formatCompactCount(value)}</span>
</span>
))}
</div>
<div className="platform-recommend-work-meta__row">
<div
className="platform-recommend-work-meta__identity"
@@ -894,6 +905,62 @@ function RecommendRuntimeMeta({
</span>
</span>
</div>
<div className="platform-recommend-work-meta__actions">
<button
type="button"
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon platform-recommend-work-meta__action--like"
onPointerDown={stopActionPointer}
onClick={(event) => {
event.stopPropagation();
onLike?.();
}}
disabled={!isActive || !onLike}
aria-label={`点赞 ${formatCompactCount(likeCount)}`}
title="点赞"
>
<ThumbsUp className="h-5 w-5" aria-hidden="true" />
</button>
<span
className="platform-recommend-work-meta__like-count"
aria-label={`${formatCompactCount(likeCount)} 个赞`}
>
{formatCompactCount(likeCount)}
</span>
<button
type="button"
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon"
onPointerDown={stopActionPointer}
onClick={(event) => {
event.stopPropagation();
onShare?.();
}}
disabled={!isActive || !onShare}
aria-label={
shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享'
}
title="分享"
>
<Share2 className="h-5 w-5" aria-hidden="true" />
</button>
<button
type="button"
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon platform-recommend-work-meta__action--remix"
onPointerDown={stopActionPointer}
onClick={(event) => {
event.stopPropagation();
onRemix?.();
}}
disabled={!isActive || !onRemix}
aria-label={`改造 ${formatCompactCount(remixCount)}`}
title="改造"
>
<GitFork className="h-5 w-5" aria-hidden="true" />
</button>
</div>
</div>
</section>
);
@@ -2977,6 +3044,8 @@ export function RpgEntryHomeView({
recommendRuntimeError = null,
onSelectNextRecommendEntry,
onSelectPreviousRecommendEntry,
onLikeRecommendEntry,
onRemixRecommendEntry,
onOpenLibraryDetail,
onDeleteLibraryEntry,
deletingLibraryEntryId = null,
@@ -3863,6 +3932,10 @@ export function RpgEntryHomeView({
const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0);
const [recommendDragCommitDirection, setRecommendDragCommitDirection] =
useState<1 | -1 | null>(null);
const [recommendShareState, setRecommendShareState] = useState<
'idle' | 'copied' | 'failed'
>('idle');
const recommendShareResetTimerRef = useRef<number | null>(null);
const recommendCardStageRef = useRef<HTMLDivElement | null>(null);
const recommendDragStartRef = useRef<{
pointerId: number;
@@ -4005,6 +4078,36 @@ export function RpgEntryHomeView({
onSelectNextRecommendEntry,
recommendedFeedEntries.length,
]);
useEffect(
() => () => {
if (recommendShareResetTimerRef.current !== null) {
window.clearTimeout(recommendShareResetTimerRef.current);
}
},
[],
);
useEffect(() => {
setRecommendShareState('idle');
}, [activeRecommendEntryKey]);
const shareRecommendEntry = useCallback((entry: PlatformPublicGalleryCard) => {
const publicWorkCode = resolvePlatformPublicWorkCode(entry)?.trim();
if (!publicWorkCode) {
setRecommendShareState('failed');
return;
}
const shareText = `邀请你来玩《${entry.worldName}\n作品号${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`;
void copyTextToClipboard(shareText).then((copied) => {
setRecommendShareState(copied ? 'copied' : 'failed');
if (recommendShareResetTimerRef.current !== null) {
window.clearTimeout(recommendShareResetTimerRef.current);
}
recommendShareResetTimerRef.current = window.setTimeout(() => {
recommendShareResetTimerRef.current = null;
setRecommendShareState('idle');
}, 1400);
});
}, []);
const openActiveRecommendEntry = useCallback(() => {
if (!activeRecommendEntry) {
return;
@@ -4168,6 +4271,10 @@ export function RpgEntryHomeView({
onDragPointerMove={moveRecommendDrag}
onDragPointerUp={endRecommendDrag}
onDragPointerCancel={cancelRecommendDrag}
shareState={recommendShareState}
onLike={() => onLikeRecommendEntry?.(activeRecommendEntry)}
onShare={() => shareRecommendEntry(activeRecommendEntry)}
onRemix={() => onRemixRecommendEntry?.(activeRecommendEntry)}
/>
</div>