Files
Genarrative/src/components/platform-entry/PlatformProfilePlayedWorksModal.tsx
kdletters d08842b576 继续沉淀工具信息弹窗与个人中心内容骨架
新增PlatformUtilityInfoModal统一工具信息弹窗白底骨架
收口profile副弹层的摘要头列表骨架与内容行
同步更新PlatformUiKit收口计划与共享决策记录
2026-06-11 06:07:30 +08:00

268 lines
9.7 KiB
TypeScript

import { ArrowRight, Clock3 } from 'lucide-react';
import type {
ProfilePlayedWorkSummary,
ProfilePlayStatsResponse,
ProfileSaveArchiveSummary,
} from '../../../packages/shared/src/contracts/runtime';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformProfileContentRow } from '../common/PlatformProfileContentRow';
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
import { PlatformProfileSummaryHeader } from '../common/PlatformProfileSummaryHeader';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell';
import {
formatCompactPlayTime,
formatPlayedWorkId,
formatPlayedWorkType,
formatSnapshotTime,
formatTotalPlayTimeHours,
} from '../rpg-entry/rpgEntryProfileDashboardPresentation';
import { formatPlatformWorkDisplayName } from '../rpg-entry/rpgEntryWorldPresentation';
type PlatformProfilePlayedWorksModalProps = {
stats: ProfilePlayStatsResponse | null;
isLoading: boolean;
error: string | null;
saveEntries: ProfileSaveArchiveSummary[];
saveError: string | null;
isResumingSaveWorldKey: string | null;
onClose: () => void;
onOpenWork?: (work: ProfilePlayedWorkSummary) => void;
onResumeSave: (entry: ProfileSaveArchiveSummary) => void;
};
function SaveArchivePreview({
entry,
className,
}: {
entry: ProfileSaveArchiveSummary;
className: string;
}) {
return (
<div
aria-hidden="true"
className={`platform-remap-surface relative shrink-0 overflow-hidden rounded-[1.35rem] border border-white/12 bg-black/18 shadow-[var(--platform-desktop-hover-shadow)] ${className}`}
>
{entry.coverImageSrc ? (
<ResolvedAssetImage
src={entry.coverImageSrc}
alt=""
aria-hidden
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_34%),linear-gradient(145deg,rgba(255,94,125,0.92),rgba(255,150,116,0.88))]" />
)}
<div className="absolute inset-0 bg-[var(--platform-card-overlay-soft)]" />
</div>
);
}
function SaveArchiveCard({
entry,
onClick,
loading = false,
}: {
entry: ProfileSaveArchiveSummary;
onClick: () => void;
loading?: boolean;
}) {
const summaryText =
entry.summaryText || entry.subtitle || '继续推进上一次保存的故事。';
const displayName = formatPlatformWorkDisplayName(entry.worldName);
return (
<button
type="button"
onClick={onClick}
disabled={loading}
className={`platform-surface platform-surface--soft platform-interactive-card relative flex min-h-[13rem] w-full overflow-hidden p-3.5 text-left sm:min-h-[12.5rem] sm:p-4 ${loading ? 'opacity-80' : ''}`}
>
<div className="absolute inset-0 bg-[var(--platform-card-overlay-deep)]" />
<div className="relative z-10 flex h-full w-full flex-col gap-3">
<div className="flex flex-wrap justify-end gap-2">
<PlatformPillBadge
tone="darkNeutral"
size="xs"
className="font-medium text-[var(--platform-text-base)]"
>
{loading ? '恢复中' : formatSnapshotTime(entry.lastPlayedAt)}
</PlatformPillBadge>
</div>
<div className="flex min-w-0 flex-1 items-stretch gap-3 sm:gap-4">
<div className="min-w-0 flex-1">
<div className="line-clamp-2 break-words text-[1.35rem] font-black leading-tight text-[var(--platform-text-strong)] sm:text-2xl">
{displayName}
</div>
{entry.subtitle ? (
<div className="mt-1 line-clamp-2 break-words text-sm font-semibold text-[var(--platform-text-base)]">
{entry.subtitle}
</div>
) : null}
<div className="mt-2 line-clamp-3 break-words text-xs leading-5 text-[var(--platform-text-soft)] sm:text-sm">
{summaryText}
</div>
<div className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold text-zinc-200">
<span>{loading ? '正在恢复' : '继续游玩'}</span>
<ArrowRight className="h-3.5 w-3.5 shrink-0" />
</div>
</div>
<SaveArchivePreview
entry={entry}
className="aspect-square w-[6.5rem] self-start sm:w-[7.5rem]"
/>
</div>
</div>
</button>
);
}
export function PlatformProfilePlayedWorksModal({
stats,
isLoading,
error,
saveEntries,
saveError,
isResumingSaveWorldKey,
onClose,
onOpenWork,
onResumeSave,
}: PlatformProfilePlayedWorksModalProps) {
// 中文注释:个人中心“玩过”弹层同时承接“可继续”的存档列表,保持同一入口下的历史/恢复语义。
const playedWorks = stats?.playedWorks ?? [];
const hasArchiveEntries = saveEntries.length > 0;
const hasPlayedWorks = playedWorks.length > 0;
return (
<PlatformProfileSecondaryModalShell
title="玩过"
onClose={onClose}
panelClassName="relative !max-h-[min(92vh,42rem)] !max-w-[38rem] bg-white text-zinc-950 shadow-2xl !rounded-[1.35rem] sm:!rounded-[1.35rem]"
contentClassName="relative max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5"
>
<PlatformProfileSummaryHeader
kicker="PLAYED"
title="玩过"
badge={
<PlatformPillBadge
tone="profile"
icon={<Clock3 className="h-3.5 w-3.5 text-[#ff4056]" />}
>
{formatTotalPlayTimeHours(stats?.totalPlayTimeMs ?? 0)}
</PlatformPillBadge>
}
badgeClassName="mt-2"
/>
{error ? (
<PlatformStatusMessage tone="error" className="mt-4">
{error}
</PlatformStatusMessage>
) : null}
{saveError ? (
<PlatformStatusMessage tone="error" className="mt-4">
{saveError}
</PlatformStatusMessage>
) : null}
<PlatformAsyncStatePanel
isLoading={isLoading}
loadingState={
<PlatformProfileSkeletonList
count={4}
containerClassName="mt-5 space-y-3"
itemClassName="h-20"
/>
}
isEmpty={!hasArchiveEntries && !hasPlayedWorks}
emptyState={
<PlatformEmptyState
surface="subpanel"
size="inline"
tone="base"
className="mt-5 text-left"
>
</PlatformEmptyState>
}
>
<div className="mt-5 space-y-5">
{hasArchiveEntries ? (
<section>
<PlatformFieldLabel variant="section" className="mb-2 block">
</PlatformFieldLabel>
<div className="grid gap-3">
{saveEntries.map((entry) => (
<SaveArchiveCard
key={`${entry.worldKey}:played-archive`}
entry={entry}
loading={isResumingSaveWorldKey === entry.worldKey}
onClick={() => onResumeSave(entry)}
/>
))}
</div>
</section>
) : null}
{hasPlayedWorks ? (
<section>
<PlatformFieldLabel variant="section" className="mb-2 block">
</PlatformFieldLabel>
<div className="space-y-3">
{playedWorks.map((work) => (
<PlatformProfileContentRow
as="button"
key={`${work.worldKey}:${work.lastPlayedAt}`}
onClick={() => onOpenWork?.(work)}
surface="flat"
radius="sm"
padding="md"
interactive
className="w-full hover:border-[#ff4056]"
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="line-clamp-1 text-base font-black text-zinc-950">
{work.worldTitle}
</div>
{work.worldSubtitle ? (
<div className="mt-1 line-clamp-1 text-xs text-zinc-500">
{work.worldSubtitle}
</div>
) : null}
</div>
<PlatformPillBadge
tone="profileAccent"
size="xs"
className="shrink-0 border-transparent"
>
{formatPlayedWorkType(work.worldType)}
</PlatformPillBadge>
</div>
<div className="mt-3 grid gap-2 text-xs text-zinc-500 sm:grid-cols-3">
<span className="truncate"> {formatPlayedWorkId(work)}</span>
<span className="truncate">
{formatSnapshotTime(work.lastPlayedAt)}
</span>
<span className="truncate">
{formatCompactPlayTime(work.lastObservedPlayTimeMs)}
</span>
</div>
</PlatformProfileContentRow>
))}
</div>
</section>
) : null}
</div>
</PlatformAsyncStatePanel>
</PlatformProfileSecondaryModalShell>
);
}