This commit is contained in:
2026-04-29 20:56:59 +08:00
parent fb6f455530
commit 730f485f48
200 changed files with 9881 additions and 2221 deletions

View File

@@ -1,64 +1,240 @@
import { Copy } from 'lucide-react';
import { useState } from 'react';
import { Share2, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { copyTextToClipboard } from '../../services/clipboard';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import type { CreationWorkShelfItem } from './creationWorkShelf';
function formatUpdatedAt(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '最近更新';
}
return new Intl.DateTimeFormat('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
import {
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTag,
} from '../rpg-entry/rpgEntryWorldPresentation';
import {
type CreationWorkShelfBadgeTone,
type CreationWorkShelfItem,
type CreationWorkShelfMetric,
type CreationWorkShelfMetricId,
formatCreationMetricCount,
} from './creationWorkShelf';
type CustomWorldWorkCardProps = {
item: CreationWorkShelfItem;
previousMetricValues?: Partial<Record<CreationWorkShelfMetricId, number>>;
onOpen: () => void;
onExperience?: (() => void) | null;
onDelete?: (() => void) | null;
deleteBusy?: boolean;
};
const BADGE_TONE_CLASS: Record<
CreationWorkShelfItem['badges'][number]['tone'],
string
> = {
const BADGE_TONE_CLASS: Record<CreationWorkShelfBadgeTone, string> = {
warm: 'platform-pill--warm',
success: 'platform-pill--success',
neutral: 'platform-pill--neutral',
};
export function CustomWorldWorkCard({
item,
onOpen,
onExperience = null,
onDelete = null,
deleteBusy = false,
}: CustomWorldWorkCardProps) {
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
const METRIC_ANIMATION_DURATION_MS = 820;
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 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 copyPublicWorkCode = () => {
if (!item.publicWorkCode) {
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;
}
void copyTextToClipboard(item.publicWorkCode).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
window.setTimeout(() => setCopyState('idle'), 1400);
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,
}: CustomWorldWorkCardProps) {
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const shareResetTimerRef = useRef<number | null>(null);
const isPublished = item.status === 'published';
const displayTitle = formatPlatformWorkDisplayName(item.title);
const { cardRef, deltas, displayValues, showGrowth } =
usePublishedMetricAnimation(
isPublished ? item.metrics : EMPTY_PUBLISHED_METRICS,
previousMetricValues,
);
const copyShareText = () => {
const publicWorkCode = item.publicWorkCode?.trim();
const sharePath = item.sharePath?.trim();
if (!publicWorkCode || !sharePath) {
return;
}
const shareUrl =
typeof window === 'undefined'
? sharePath
: new URL(sharePath, window.location.origin).href;
const shareText = `邀请你来玩《${item.title}\n作品号${publicWorkCode}\n${shareUrl}`;
void copyTextToClipboard(shareText).then((copied) => {
setShareState(copied ? 'copied' : 'failed');
if (shareResetTimerRef.current !== null) {
window.clearTimeout(shareResetTimerRef.current);
}
shareResetTimerRef.current = window.setTimeout(() => {
shareResetTimerRef.current = null;
setShareState('idle');
}, 1400);
});
};
useEffect(
() => () => {
if (shareResetTimerRef.current !== null) {
window.clearTimeout(shareResetTimerRef.current);
}
},
[],
);
return (
<div
ref={cardRef}
role="button"
tabIndex={0}
aria-label={`${item.openActionLabel}${item.title}`}
@@ -71,7 +247,7 @@ export function CustomWorldWorkCard({
event.preventDefault();
onOpen();
}}
className="platform-surface platform-interactive-card relative min-h-[16rem] cursor-pointer overflow-hidden px-4 py-4 text-left sm:min-h-[15.5rem] xl:min-h-[14rem] xl:px-4 xl:py-3.5"
className={`platform-surface platform-interactive-card relative min-h-[9.5rem] cursor-pointer overflow-hidden px-2.5 py-2.5 text-left sm:min-h-[12.5rem] sm:px-4 sm:py-4 xl:min-h-[11.25rem] xl:px-4 xl:py-3.5 ${isPublished ? 'col-span-2 sm:col-span-1' : ''}`}
>
<CustomWorldCoverArtwork
imageSrc={item.coverImageSrc}
@@ -79,126 +255,127 @@ export function CustomWorldWorkCard({
fallbackLabel="封面"
renderMode={item.coverRenderMode}
characterImageSrcs={item.coverCharacterImageSrcs}
className="platform-cover-artwork absolute inset-0"
className="platform-cover-artwork absolute inset-0 opacity-70 saturate-[1.08]"
/>
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="pointer-events-none relative z-20 flex min-h-[14rem] flex-col sm:min-h-[13.5rem] xl:min-h-[12.5rem]">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-wrap gap-2">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_100%_100%,rgba(255,255,255,0.18),transparent_34%),linear-gradient(180deg,rgba(255,255,255,0.08),rgba(0,0,0,0.08))]" />
<div className="pointer-events-none relative z-20 flex min-h-[8rem] flex-col sm:min-h-[10.5rem] xl:min-h-[9.75rem]">
{!isPublished && onDelete ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : '删除'}
title={deleteBusy ? '删除中' : '删除作品'}
className="pointer-events-auto absolute right-0 top-0 z-30 grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-button-danger-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:w-8"
>
{deleteBusy ? (
<span className="text-xs leading-none"></span>
) : (
<Trash2 aria-hidden="true" className="h-3.5 w-3.5" />
)}
</button>
) : null}
{isPublished ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
copyShareText();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
disabled={!item.canShare || !item.sharePath}
title={
!item.canShare || !item.sharePath
? '暂不可分享'
: shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'
}
aria-label={
!item.canShare || !item.sharePath
? '暂不可分享'
: shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享'
}
className="pointer-events-auto absolute right-0 top-0 z-30 inline-flex h-7 min-w-7 items-center justify-center gap-1 whitespace-nowrap px-1.5 text-[var(--platform-text-soft)] transition hover:text-[var(--platform-cool-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:min-w-8"
>
{shareState === 'idle' ? (
<Share2 aria-hidden="true" className="h-3.5 w-3.5" />
) : (
<span className="text-[10px] font-semibold leading-none">
{shareState === 'copied' ? '已复制' : '复制失败'}
</span>
)}
</button>
) : null}
<div className="flex items-start justify-between gap-2 pr-12 sm:gap-3 sm:pr-14">
<div className="flex max-h-[3rem] min-w-0 flex-wrap gap-1 overflow-hidden sm:max-h-none sm:gap-2">
{item.badges.map((badge) => (
<span
key={`${item.id}-${badge.id}`}
className={`platform-pill ${BADGE_TONE_CLASS[badge.tone]} px-3 py-1 text-[10px]`}
className={`platform-pill ${BADGE_TONE_CLASS[badge.tone]} max-w-full truncate px-2 py-0.5 text-[9px] sm:px-3 sm:py-1 sm:text-[10px]`}
>
{badge.label}
{formatPlatformWorkDisplayTag(badge.label)}
</span>
))}
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="text-[11px] text-[var(--platform-text-soft)]">
{formatUpdatedAt(item.updatedAt)}
</span>
{onDelete ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : '删除'}
title={deleteBusy ? '删除中' : '删除作品'}
className="pointer-events-auto relative z-30 grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-danger)] disabled:cursor-not-allowed disabled:opacity-55"
>
{deleteBusy ? (
<span className="text-xs leading-none"></span>
) : (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path d="M3 6h18" />
<path d="M8 6V4h8v2" />
<path d="M19 6l-1 14H6L5 6" />
<path d="M10 11v5" />
<path d="M14 11v5" />
</svg>
)}
</button>
) : null}
</div>
</div>
<div className="mt-4 min-h-0 xl:mt-3">
<div className="text-2xl font-black text-[var(--platform-text-strong)] xl:text-xl">
{item.title}
<div className="mt-3 min-h-0 sm:mt-4 xl:mt-3">
<div className="line-clamp-1 break-words text-base font-black leading-tight text-[var(--platform-text-strong)] sm:text-2xl xl:text-xl">
{displayTitle}
</div>
<div className="mt-1 text-xs tracking-[0.12em] text-[var(--platform-text-soft)]">
{item.subtitle}
</div>
<div className="mt-3 line-clamp-2 text-sm leading-6 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)] xl:mt-2">
<div className="mt-2 line-clamp-2 break-words text-[11px] leading-4 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)] sm:mt-3 sm:text-sm sm:leading-6 xl:mt-2">
{item.summary}
</div>
</div>
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-end sm:justify-between xl:gap-2 xl:pt-3">
<div className="min-w-0 space-y-2">
{item.publicWorkCode ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
copyPublicWorkCode();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
className="platform-pill platform-pill--neutral pointer-events-auto relative z-30 inline-flex max-w-full items-center gap-1.5 px-3 py-1 text-[10px]"
aria-label={`复制作品号 ${item.publicWorkCode}`}
title="复制作品号"
{isPublished ? (
<div className="mt-auto grid grid-cols-3 gap-1.5 pt-3 sm:gap-2 sm:pt-4 xl:pt-3">
{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="shrink-0"></span>
<span className="min-w-0 truncate">{item.publicWorkCode}</span>
<Copy className="h-3 w-3 shrink-0" />
{copyState !== 'idle' ? (
<span className="shrink-0">
{copyState === 'copied' ? '已复制' : '复制失败'}
</span>
) : null}
</button>
) : null}
<div className="flex flex-wrap gap-2">
{item.metrics.map((metric) => (
<span
key={`${item.id}-${metric.id}`}
className={`platform-pill ${BADGE_TONE_CLASS[metric.tone ?? 'neutral']} px-3 py-1 text-[10px]`}
>
<span className="creation-work-card-stat__label">
{metric.label}
</span>
))}
</div>
<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 className="flex flex-wrap gap-2 sm:justify-end xl:gap-1.5">
{onExperience ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onExperience();
}}
className="platform-button platform-button--secondary pointer-events-auto relative z-30 min-h-0 shrink-0 rounded-full px-4 py-2 text-sm xl:px-3.5 xl:py-1.5 xl:text-xs"
>
</button>
) : null}
</div>
</div>
) : null}
</div>
</div>
);