Files
Genarrative/src/components/custom-world-home/CustomWorldWorkCard.tsx
kdletters c5763fdf25 重构作品分享链路
统一发布分享弹窗为作品分享卡片

支持下载分享卡与小程序九宫切图保存

小程序复制链接改为可直达作品详情的 web-view 路径

修复本地 dev Rust 构建绕过损坏 sccache

补充分享链路与 dev 启动文档和测试
2026-06-11 21:32:29 +08:00

799 lines
25 KiB
TypeScript

import {
BadgeCheck,
CircleAlert,
Clock3,
Loader2,
Share2,
Trash2,
} from 'lucide-react';
import {
type CSSProperties,
default as React,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
type TouchEvent as ReactTouchEvent,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import {
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTag,
} from '../rpg-entry/rpgEntryWorldPresentation';
import {
CREATION_WORK_KIND_FALLBACK_COVER,
type CreationWorkShelfBadgeTone,
type CreationWorkShelfItem,
type CreationWorkShelfMetric,
type CreationWorkShelfMetricId,
formatCreationMetricCount,
formatCreationPointIncentiveTotal,
} from './creationWorkShelf';
type CustomWorldWorkCardProps = {
item: CreationWorkShelfItem;
previousMetricValues?: Partial<Record<CreationWorkShelfMetricId, number>>;
onOpen: () => void;
onDelete?: (() => void) | null;
deleteBusy?: boolean;
onShare?: (() => void) | null;
onClaimPointIncentive?: (() => void) | null;
pointIncentiveBusy?: boolean;
};
const BADGE_TONE_CLASS: Record<CreationWorkShelfBadgeTone, string> = {
warm: 'platform-pill--warm',
success: 'platform-pill--success',
neutral: 'platform-pill--neutral',
};
const METRIC_ANIMATION_DURATION_MS = 820;
const SWIPE_ACTION_WIDTH_PX = 76;
const SWIPE_REVEAL_THRESHOLD_PX = 42;
const SWIPE_DIRECTION_LOCK_PX = 8;
const EMPTY_PUBLISHED_METRICS: CreationWorkShelfMetric[] = [];
function easeOutCubic(progress: number) {
return 1 - (1 - progress) ** 3;
}
function resolveMetricStartValue(
metric: CreationWorkShelfMetric,
previousMetricValues?: Partial<Record<CreationWorkShelfMetricId, number>>,
) {
const previousValue = previousMetricValues?.[metric.id];
if (previousValue === undefined || previousValue >= metric.value) {
return metric.value;
}
return Math.max(0, Math.floor(previousValue));
}
function buildMetricValueMap(
metrics: CreationWorkShelfMetric[],
resolveValue: (metric: CreationWorkShelfMetric) => number,
) {
return Object.fromEntries(
metrics.map((metric) => [metric.id, resolveValue(metric)]),
) as Record<CreationWorkShelfMetricId, number>;
}
function shouldAnimatePublishedMetrics() {
if (typeof window === 'undefined') {
return false;
}
return !window.navigator.userAgent.toLowerCase().includes('jsdom');
}
function clampSwipeOffset(value: number, revealWidth: number) {
return Math.min(0, Math.max(-revealWidth, value));
}
function usePublishedMetricAnimation(
metrics: CreationWorkShelfMetric[],
previousMetricValues?: Partial<Record<CreationWorkShelfMetricId, number>>,
) {
const cardRef = useRef<HTMLDivElement | null>(null);
const [hasEnteredView, setHasEnteredView] = useState(false);
const startValues = useMemo(
() =>
buildMetricValueMap(metrics, (metric) =>
resolveMetricStartValue(metric, previousMetricValues),
),
[metrics, previousMetricValues],
);
const endValues = useMemo(
() => buildMetricValueMap(metrics, (metric) => metric.value),
[metrics],
);
const deltas = useMemo(
() =>
buildMetricValueMap(metrics, (metric) =>
Math.max(0, metric.value - startValues[metric.id]),
),
[metrics, startValues],
);
const hasGrowth = useMemo(
() => Object.values(deltas).some((delta) => delta > 0),
[deltas],
);
const [displayValues, setDisplayValues] = useState(endValues);
const [showGrowth, setShowGrowth] = useState(false);
useEffect(() => {
setShowGrowth(false);
setHasEnteredView(false);
setDisplayValues(hasGrowth ? startValues : endValues);
}, [endValues, hasGrowth, startValues]);
useEffect(() => {
const element = cardRef.current;
if (!element || !hasGrowth) {
setHasEnteredView(true);
return;
}
if (typeof window === 'undefined' || !('IntersectionObserver' in window)) {
setHasEnteredView(true);
return;
}
// 中文注释:指标增长只在卡片进入视口后启动,避免列表刷新时离屏卡片提前播放。
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setHasEnteredView(true);
observer.disconnect();
}
},
{ rootMargin: '0px 0px -10% 0px', threshold: 0.28 },
);
observer.observe(element);
return () => observer.disconnect();
}, [hasGrowth]);
useEffect(() => {
if (!hasEnteredView) {
return;
}
if (!hasGrowth || !shouldAnimatePublishedMetrics()) {
setDisplayValues(endValues);
if (hasGrowth) {
setShowGrowth(true);
}
return;
}
if (typeof window === 'undefined') {
setDisplayValues(endValues);
setShowGrowth(true);
return;
}
let animationFrameId = 0;
const startTime = window.performance.now();
const tick = (now: number) => {
const progress = Math.min(
1,
(now - startTime) / METRIC_ANIMATION_DURATION_MS,
);
const easedProgress = easeOutCubic(progress);
setDisplayValues(
buildMetricValueMap(metrics, (metric) => {
const startValue = startValues[metric.id];
const endValue = endValues[metric.id];
return Math.round(
startValue + (endValue - startValue) * easedProgress,
);
}),
);
if (progress < 1) {
animationFrameId = window.requestAnimationFrame(tick);
return;
}
setDisplayValues(endValues);
setShowGrowth(true);
};
animationFrameId = window.requestAnimationFrame(tick);
return () => {
window.cancelAnimationFrame(animationFrameId);
};
}, [endValues, hasEnteredView, hasGrowth, metrics, startValues]);
return { cardRef, deltas, displayValues, showGrowth };
}
export function CustomWorldWorkCard({
item,
previousMetricValues,
onOpen,
onDelete = null,
deleteBusy = false,
onShare = null,
onClaimPointIncentive = null,
pointIncentiveBusy = false,
}: CustomWorldWorkCardProps) {
const suppressOpenResetTimerRef = useRef<number | null>(null);
const suppressOpenRef = useRef(false);
const swipeGestureRef = useRef<{
pointerId: number;
startX: number;
startY: number;
startOffset: number;
isDragging: boolean;
} | null>(null);
const lastSwipeOffsetRef = useRef(0);
const [isSwipeDragging, setIsSwipeDragging] = useState(false);
const [isSwipeActionRevealed, setIsSwipeActionRevealed] = useState(false);
const [swipeOffset, setSwipeOffset] = useState(0);
const isPublished = item.status === 'published';
const canUseShareAction =
isPublished && item.canShare && Boolean(item.sharePath) && Boolean(onShare);
const swipeActionCount = onDelete ? 1 : 0;
const swipeRevealWidth = swipeActionCount * SWIPE_ACTION_WIDTH_PX;
const canClaimPointIncentive =
Boolean(onClaimPointIncentive) &&
(item.pointIncentive?.claimablePoints ?? 0) > 0;
const displayTitle = formatPlatformWorkDisplayName(item.title);
const fallbackCoverImageSrc = CREATION_WORK_KIND_FALLBACK_COVER[item.kind];
const { cardRef, deltas, displayValues, showGrowth } =
usePublishedMetricAnimation(
isPublished ? item.metrics : EMPTY_PUBLISHED_METRICS,
previousMetricValues,
);
const coverFadeStyle = {
WebkitMaskImage:
'linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.04) 18%, rgba(0, 0, 0, 0.18) 42%, rgba(0, 0, 0, 0.48) 70%, rgba(0, 0, 0, 0.72) 100%)',
maskImage:
'linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.04) 18%, rgba(0, 0, 0, 0.18) 42%, rgba(0, 0, 0, 0.48) 70%, rgba(0, 0, 0, 0.72) 100%)',
} as CSSProperties;
const currentSwipeOffset = isSwipeDragging
? swipeOffset
: isSwipeActionRevealed
? -swipeRevealWidth
: 0;
const cardSurfaceStyle = {
'--creation-work-card-swipe-offset': `${currentSwipeOffset}px`,
'--creation-work-card-cover-fallback': `url(${fallbackCoverImageSrc})`,
} as CSSProperties;
const swipeShellStyle = {
'--creation-work-card-action-opacity': `${
swipeRevealWidth > 0
? Math.min(1, Math.abs(currentSwipeOffset) / swipeRevealWidth)
: 0
}`,
} as CSSProperties;
useEffect(() => {
return () => {
if (suppressOpenResetTimerRef.current !== null) {
window.clearTimeout(suppressOpenResetTimerRef.current);
}
};
}, []);
const closeSwipeActions = () => {
setIsSwipeActionRevealed(false);
setSwipeOffset(0);
lastSwipeOffsetRef.current = 0;
};
const revealSwipeActions = () => {
if (swipeRevealWidth <= 0) {
closeSwipeActions();
return;
}
setIsSwipeActionRevealed(true);
setSwipeOffset(-swipeRevealWidth);
lastSwipeOffsetRef.current = -swipeRevealWidth;
};
const updateSwipeOffset = (
gesture: NonNullable<typeof swipeGestureRef.current>,
clientX: number,
clientY: number,
preventDefault: () => void,
) => {
const deltaX = clientX - gesture.startX;
const deltaY = clientY - gesture.startY;
if (!gesture.isDragging) {
if (
Math.abs(deltaX) < SWIPE_DIRECTION_LOCK_PX &&
Math.abs(deltaY) < SWIPE_DIRECTION_LOCK_PX
) {
return;
}
if (Math.abs(deltaY) > Math.abs(deltaX)) {
swipeGestureRef.current = null;
return;
}
gesture.isDragging = true;
setIsSwipeDragging(true);
}
// 中文注释:横向手势只移动卡片表层,删除动作保持在底层,避免列表滚动时误触。
preventDefault();
suppressOpenRef.current = true;
const nextOffset = clampSwipeOffset(
gesture.startOffset + deltaX,
swipeRevealWidth,
);
lastSwipeOffsetRef.current = nextOffset;
setSwipeOffset(nextOffset);
};
const finishSwipeGesture = (wasDragging: boolean) => {
setIsSwipeDragging(false);
if (!wasDragging) {
return;
}
const shouldReveal =
lastSwipeOffsetRef.current <=
-Math.min(SWIPE_REVEAL_THRESHOLD_PX, swipeRevealWidth * 0.45);
if (shouldReveal) {
revealSwipeActions();
} else {
closeSwipeActions();
}
suppressOpenRef.current = true;
scheduleOpenSuppressReset();
};
const scheduleOpenSuppressReset = () => {
if (typeof window === 'undefined') {
return;
}
if (suppressOpenResetTimerRef.current !== null) {
window.clearTimeout(suppressOpenResetTimerRef.current);
}
suppressOpenResetTimerRef.current = window.setTimeout(() => {
suppressOpenResetTimerRef.current = null;
suppressOpenRef.current = false;
}, 260);
};
useEffect(() => {
if (swipeActionCount > 0) {
return;
}
closeSwipeActions();
}, [swipeActionCount]);
const beginSwipeGesture = (event: ReactPointerEvent<HTMLDivElement>) => {
if (swipeRevealWidth <= 0) {
return;
}
if (event.pointerType === 'mouse' && event.button !== 0) {
return;
}
swipeGestureRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
startOffset: isSwipeActionRevealed ? -swipeRevealWidth : 0,
isDragging: false,
};
event.currentTarget.setPointerCapture?.(event.pointerId);
};
const updateSwipeGesture = (event: ReactPointerEvent<HTMLDivElement>) => {
const gesture = swipeGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) {
return;
}
updateSwipeOffset(gesture, event.clientX, event.clientY, () =>
event.preventDefault(),
);
};
const endSwipeGesture = (event: ReactPointerEvent<HTMLDivElement>) => {
const gesture = swipeGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) {
return;
}
event.currentTarget.releasePointerCapture?.(event.pointerId);
swipeGestureRef.current = null;
finishSwipeGesture(gesture.isDragging);
};
const cancelSwipeGesture = (event: ReactPointerEvent<HTMLDivElement>) => {
const gesture = swipeGestureRef.current;
if (gesture?.pointerId === event.pointerId) {
event.currentTarget.releasePointerCapture?.(event.pointerId);
}
swipeGestureRef.current = null;
setIsSwipeDragging(false);
if (isSwipeActionRevealed) {
revealSwipeActions();
} else {
closeSwipeActions();
}
};
const beginTouchSwipeGesture = (event: ReactTouchEvent<HTMLDivElement>) => {
if (swipeRevealWidth <= 0) {
return;
}
const touch = event.touches[0];
if (!touch) {
return;
}
swipeGestureRef.current = {
pointerId: -1,
startX: touch.clientX,
startY: touch.clientY,
startOffset: isSwipeActionRevealed ? -swipeRevealWidth : 0,
isDragging: false,
};
};
const updateTouchSwipeGesture = (event: ReactTouchEvent<HTMLDivElement>) => {
const gesture = swipeGestureRef.current;
const touch = event.touches[0];
if (!gesture || gesture.pointerId !== -1 || !touch) {
return;
}
updateSwipeOffset(gesture, touch.clientX, touch.clientY, () =>
event.preventDefault(),
);
};
const endTouchSwipeGesture = () => {
const gesture = swipeGestureRef.current;
if (!gesture || gesture.pointerId !== -1) {
return;
}
swipeGestureRef.current = null;
finishSwipeGesture(gesture.isDragging);
};
const cancelTouchSwipeGesture = () => {
const gesture = swipeGestureRef.current;
if (!gesture || gesture.pointerId !== -1) {
return;
}
swipeGestureRef.current = null;
setIsSwipeDragging(false);
if (isSwipeActionRevealed) {
revealSwipeActions();
} else {
closeSwipeActions();
}
};
const handleCardOpen = () => {
if (isSwipeActionRevealed) {
closeSwipeActions();
return;
}
onOpen();
};
const handleCardKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (
(event.key === 'ArrowLeft' ||
event.key === 'ContextMenu' ||
(event.shiftKey && event.key === 'F10')) &&
swipeRevealWidth > 0
) {
event.preventDefault();
revealSwipeActions();
return;
}
if (event.key === 'Escape' && isSwipeActionRevealed) {
event.preventDefault();
closeSwipeActions();
return;
}
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
handleCardOpen();
};
return (
<div
ref={cardRef}
style={swipeShellStyle}
className={`creation-work-card-shell ${
isSwipeDragging || isSwipeActionRevealed
? 'creation-work-card-shell--actions-visible'
: ''
}`}
>
{swipeActionCount > 0 ? (
<div
aria-hidden={!isSwipeActionRevealed}
className="creation-work-card__swipe-underlay"
>
<div className="creation-work-card__swipe-actions">
{onDelete ? (
<button
type="button"
tabIndex={isSwipeActionRevealed ? 0 : -1}
onClick={(event) => {
event.stopPropagation();
suppressOpenRef.current = false;
closeSwipeActions();
onDelete();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : '删除'}
title={deleteBusy ? '删除中' : '删除作品'}
className="creation-work-card__swipe-button creation-work-card__swipe-button--danger"
>
{deleteBusy ? (
<span className="text-xs leading-none">...</span>
) : (
<Trash2 aria-hidden="true" className="h-4 w-4" />
)}
</button>
) : null}
</div>
</div>
) : null}
<div
role="button"
tabIndex={0}
aria-label={`${item.openActionLabel}${item.title}${item.isGenerating ? ',生成中' : ''}`}
onClick={(event) => {
if (suppressOpenRef.current) {
event.preventDefault();
suppressOpenRef.current = false;
return;
}
handleCardOpen();
}}
onKeyDown={handleCardKeyDown}
onPointerDown={beginSwipeGesture}
onPointerMove={updateSwipeGesture}
onPointerUp={endSwipeGesture}
onPointerCancel={cancelSwipeGesture}
onTouchStart={beginTouchSwipeGesture}
onTouchMove={updateTouchSwipeGesture}
onTouchEnd={endTouchSwipeGesture}
onTouchCancel={cancelTouchSwipeGesture}
onContextMenu={(event) => {
if (swipeRevealWidth <= 0) {
return;
}
event.preventDefault();
revealSwipeActions();
}}
style={cardSurfaceStyle}
className={`creation-work-card platform-category-game-item platform-interactive-card cursor-pointer overflow-hidden text-left ${isPublished ? 'creation-work-card--published' : 'creation-work-card--draft'} ${item.isGenerating ? 'creation-work-card--generating' : ''} ${isSwipeDragging ? 'creation-work-card--swiping' : ''}`}
>
<div className="creation-work-card__body platform-category-game-item__body">
<div className="creation-work-card__title-row platform-category-game-item__title-row">
<div className="creation-work-card__title-lockup">
<span
aria-label={
item.isGenerating
? '生成中'
: item.status === 'published'
? '已发布'
: '草稿'
}
className={`creation-work-card__state-mark creation-work-card__state-mark--${
item.isGenerating ? 'generating' : item.status
}`}
>
{item.isGenerating ? (
<Loader2 aria-hidden="true" className="h-3.5 w-3.5" />
) : item.status === 'published' ? (
<BadgeCheck aria-hidden="true" className="h-3.5 w-3.5" />
) : (
<Clock3 aria-hidden="true" className="h-3.5 w-3.5" />
)}
</span>
<span className="creation-work-card__title platform-category-game-item__title">
{displayTitle}
</span>
</div>
{canUseShareAction ? (
<div className="creation-work-card__quick-actions">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
suppressOpenRef.current = false;
closeSwipeActions();
onShare?.();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
onPointerDown={(event) => {
event.stopPropagation();
}}
onTouchStart={(event) => {
event.stopPropagation();
}}
title="分享作品"
aria-label="分享"
className="creation-work-card__quick-action-button"
>
<Share2 aria-hidden="true" className="h-4 w-4" />
</button>
</div>
) : null}
</div>
<div className="creation-work-card__meta platform-category-game-item__meta">
{item.badges.slice(1).map((badge) => (
<span
key={`${item.id}-${badge.id}`}
className={`creation-work-card__badge platform-pill ${BADGE_TONE_CLASS[badge.tone]}`}
>
{formatPlatformWorkDisplayTag(badge.label)}
</span>
))}
</div>
<div className="creation-work-card__summary platform-category-game-item__summary">
{item.summary}
</div>
{item.hasGenerationFailure ? (
<div
aria-label={item.generationFailureSummary ?? '生成失败'}
className="creation-work-card__failure-status"
>
<CircleAlert aria-hidden="true" className="h-3.5 w-3.5" />
<span>{item.generationFailureSummary ?? '生成失败'}</span>
</div>
) : null}
{isPublished ? (
<div className="creation-work-card__published-info">
{item.pointIncentive ? (
<div className="creation-work-card-incentive">
<div
aria-label={`积分激励总数 ${formatCreationPointIncentiveTotal(item.pointIncentive.totalPoints)} 泥点`}
className="creation-work-card-incentive__metric"
>
<span className="creation-work-card-incentive__label">
</span>
<span className="creation-work-card-incentive__value">
{formatCreationPointIncentiveTotal(
item.pointIncentive.totalPoints,
)}
</span>
</div>
<div
aria-label={`待领取积分 ${item.pointIncentive.claimablePoints} 泥点`}
className="creation-work-card-incentive__metric"
>
<span className="creation-work-card-incentive__label">
</span>
<span className="creation-work-card-incentive__value">
{formatCreationMetricCount(
item.pointIncentive.claimablePoints,
)}
</span>
</div>
<button
type="button"
disabled={!canClaimPointIncentive || pointIncentiveBusy}
onClick={(event) => {
event.stopPropagation();
onClaimPointIncentive?.();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
className="creation-work-card-incentive__button"
>
{pointIncentiveBusy ? '领取中' : '领取积分'}
</button>
</div>
) : null}
<div className="creation-work-card__metrics">
{item.metrics.map((metric) => (
<div
key={`${item.id}-${metric.id}`}
aria-label={`${metric.label} ${displayValues[metric.id] ?? metric.value}${metric.unit}`}
className={`creation-work-card-stat creation-work-card-stat--${metric.tone}`}
>
<span className="creation-work-card-stat__label">
{metric.label}
</span>
<span className="creation-work-card-stat__value">
<span className="creation-work-card-stat__number">
{formatCreationMetricCount(
displayValues[metric.id] ?? metric.value,
)}
</span>
<span className="creation-work-card-stat__unit">
{metric.unit}
</span>
</span>
{showGrowth && deltas[metric.id] > 0 ? (
<span className="creation-work-card-stat__growth">
<span aria-hidden="true"></span>
{formatCreationMetricCount(deltas[metric.id])}
</span>
) : null}
</div>
))}
</div>
</div>
) : null}
</div>
<div
className="creation-work-card__side-cover"
style={coverFadeStyle}
aria-hidden="true"
>
<div className="creation-work-card__side-cover-inner">
<CustomWorldCoverArtwork
imageSrc={item.coverImageSrc}
fallbackImageSrc={fallbackCoverImageSrc}
title={item.title}
fallbackLabel="封面"
renderMode={item.coverRenderMode}
characterImageSrcs={item.coverCharacterImageSrcs}
className="absolute inset-0"
/>
</div>
</div>
{item.hasUnreadUpdate ? (
<span
aria-label="新生成完成"
className="creation-work-card__unread-dot"
/>
) : null}
{item.isGenerating ? (
<div
className="creation-work-card__generating-mask"
aria-hidden="true"
>
<span className="creation-work-card__spinner" />
<span>...</span>
</div>
) : null}
</div>
</div>
);
}