Files
Genarrative/src/components/visual-novel-runtime/VisualNovelSavePanel.tsx
T
kdletters 7f8400fd3a 继续扩展共享异步状态壳
将 PlatformAsyncStatePanel 扩展到公共素材选择网格

将 PlatformAsyncStatePanel 扩展到视觉小说存档面板与账号安全子区块

将兑换码弹窗提交动作改为使用标准 profile modal footer

补充对应测试并更新 PlatformUiKit 收口计划与共享决策记录
2026-06-11 04:11:32 +08:00

123 lines
4.0 KiB
TypeScript

import { Bookmark, Loader2, Play } from 'lucide-react';
import type { ProfileSaveArchiveSummary } from '../../../packages/shared/src/contracts/runtime';
import type { VisualNovelRunSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformIconBadge } from '../common/PlatformIconBadge';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
type VisualNovelSavePanelProps = {
run: VisualNovelRunSnapshot;
saveArchives?: ProfileSaveArchiveSummary[];
isSaving?: boolean;
isLoadingArchives?: boolean;
resumingWorldKey?: string | null;
onSaveRun?: () => void;
onResumeSaveArchive?: (worldKey: string) => void;
};
function formatArchiveTime(value: string) {
const timestamp = new Date(value).getTime();
if (Number.isNaN(timestamp)) {
return value;
}
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(timestamp);
}
export function VisualNovelSavePanel({
run,
saveArchives = [],
isSaving = false,
isLoadingArchives = false,
resumingWorldKey = null,
onSaveRun,
onResumeSaveArchive,
}: VisualNovelSavePanelProps) {
return (
<div className="space-y-3">
<PlatformActionButton
disabled={!onSaveRun || isSaving}
onClick={onSaveRun}
shape="pill"
fullWidth
className="min-h-12"
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Bookmark className="h-4 w-4" />
)}
<span>{isSaving ? '保存中' : '保存'}</span>
</PlatformActionButton>
<PlatformAsyncStatePanel
isLoading={isLoadingArchives}
loadingState={
<PlatformEmptyState surface="subpanel" size="inline">
读取中
</PlatformEmptyState>
}
isEmpty={saveArchives.length === 0}
emptyState={
<PlatformEmptyState surface="subpanel" size="inline">
暂无存档
</PlatformEmptyState>
}
>
<div className="grid gap-3">
{saveArchives.map((entry) => {
const isResuming = resumingWorldKey === entry.worldKey;
return (
<PlatformSubpanel
key={entry.worldKey}
as="button"
type="button"
disabled={isResuming || !onResumeSaveArchive}
onClick={() => onResumeSaveArchive?.(entry.worldKey)}
surface="flat"
radius="sm"
padding="sm"
interactive
className="flex min-h-20 items-center gap-3"
>
<PlatformIconBadge
size="md"
shape="rounded"
icon={
isResuming ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)
}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-black text-[var(--platform-text-strong)]">
{entry.worldName || run.profileId}
</span>
<span className="mt-1 block truncate text-xs font-semibold text-[var(--platform-text-soft)]">
{entry.subtitle || entry.summaryText || run.runId}
</span>
<span className="mt-1 block text-[11px] font-bold text-[var(--platform-text-soft)]">
{formatArchiveTime(entry.lastPlayedAt)}
</span>
</span>
</PlatformSubpanel>
);
})}
</div>
</PlatformAsyncStatePanel>
</div>
);
}
export default VisualNovelSavePanel;