统一发布分享弹窗为作品分享卡片 支持下载分享卡与小程序九宫切图保存 小程序复制链接改为可直达作品详情的 web-view 路径 修复本地 dev Rust 构建绕过损坏 sccache 补充分享链路与 dev 启动文档和测试
799 lines
25 KiB
TypeScript
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>
|
|
);
|
|
}
|