Files
makelore/src/pages/ImageCanvas/DesignPlanHistory.tsx

141 lines
6.6 KiB
TypeScript

import { ChevronDown, Clock3, Download, Film, ImageIcon, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { saveImageWorkspaceAsset } from '@/lib/image-workspace';
import { cn } from '@/lib/utils';
import type { DesignGenerationTask, DesignWorkspace } from '../../../shared/image-workspace';
import { DesignAssetThumbnail } from './DesignAssetThumbnail';
import { DesignImagePreviewButton } from './DesignImagePreview';
import { youthTaskIssueMessage } from './youth-issue-copy';
const STATUS_LABELS: Record<DesignGenerationTask['status'], string> = {
queued: '等待制作',
running: '制作中',
succeeded: '已完成',
failed: '未完成',
cancelled: '已取消',
};
function formatTime(value: string): string {
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return '';
const date = new Date(timestamp);
const now = new Date();
return date.toDateString() === now.toDateString()
? `今天 ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
: date.toLocaleDateString([], { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function taskDescription(task: DesignGenerationTask): string {
const parts = [
task.medium === 'video' ? '视频' : '图片',
task.outputSummary.aspectRatio,
];
if (task.outputSummary.durationSeconds) parts.push(`${task.outputSummary.durationSeconds}`);
parts.push(`${task.outputSummary.outputCount}`);
return parts.join(' · ');
}
export function DesignPlanHistory({ workspace }: { workspace: DesignWorkspace }) {
const tasks = [...workspace.tasks].sort(
(left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt),
);
if (tasks.length === 0) return null;
return (
<div key={workspace.workspace.workspaceId} className="space-y-2" data-testid="design-plan-history">
{tasks.map((task) => {
const resultAssets = task.resultAssetIds
.map((assetId) => workspace.assets.find((asset) => asset.assetId === assetId) ?? null)
.filter((asset): asset is NonNullable<typeof asset> => Boolean(asset));
const preview = resultAssets[0]
?? workspace.assets.find((asset) => asset.generationTaskId === task.taskId)
?? null;
const active = task.status === 'queued' || task.status === 'running';
return (
<details
key={task.taskId}
data-testid={`design-plan-history-${task.taskId}`}
className="group overflow-hidden rounded-xl border border-border/70 bg-background"
>
<summary className="flex min-h-14 cursor-pointer list-none items-center gap-3 px-3 py-2.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand/30 [&::-webkit-details-marker]:hidden">
<DesignAssetThumbnail asset={preview} className="h-9 w-9 rounded-lg" />
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="text-xs font-semibold text-foreground"></span>
<span className={cn(
'inline-flex items-center gap-1 text-[11px] font-medium',
task.status === 'succeeded'
? 'text-emerald-700'
: task.status === 'failed'
? 'text-destructive'
: active
? 'text-brand'
: 'text-muted-foreground',
)}>
{active && <Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />}
{STATUS_LABELS[task.status]}
</span>
</span>
<span className="mt-0.5 block truncate text-[11px] text-muted-foreground">
{taskDescription(task)} · {Math.ceil(task.maximumCustomerChargeAtoms / 100)}
</span>
</span>
<span className="hidden shrink-0 text-[11px] text-muted-foreground sm:inline">
{formatTime(task.createdAt)}
</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" aria-hidden="true" />
</summary>
<div className="border-t border-border/60 bg-surface-subtle/35 px-3 py-3">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
{task.medium === 'video'
? <Film className="h-3.5 w-3.5" />
: <ImageIcon className="h-3.5 w-3.5" />}
{taskDescription(task)}
</span>
<span className="inline-flex items-center gap-1.5">
<Clock3 className="h-3.5 w-3.5" />
{task.progress.completedRequiredSteps}/{task.progress.totalRequiredSteps}
</span>
</div>
{task.issues.length > 0 && (
<div className="mt-3 space-y-1 rounded-lg bg-destructive/[0.04] px-3 py-2 text-xs leading-5 text-destructive">
{task.issues.map((issue) => <p key={issue.code}>{youthTaskIssueMessage(issue)}</p>)}
</div>
)}
{resultAssets.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{resultAssets.map((asset) => (
<div key={asset.assetId} className="group/result relative">
<DesignAssetThumbnail asset={asset} className="h-20 w-20 rounded-xl" />
{asset.mediaType === 'image' && <DesignImagePreviewButton asset={asset} />}
<button
type="button"
aria-label="保存作品到电脑"
title="保存作品到电脑"
className="absolute bottom-1 right-1 flex h-7 w-7 items-center justify-center rounded-lg bg-background/90 text-muted-foreground shadow-sm hover:text-foreground"
onClick={(event) => {
event.preventDefault();
void saveImageWorkspaceAsset(asset).catch(() => {
toast.error('作品没有保存成功,请再试一次');
});
}}
>
<Download className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
</div>
</details>
);
})}
</div>
);
}