Files
makelore/src/pages/ImageCanvas/DesignProductionPanel.tsx
T

476 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react';
import {
AlertTriangle,
Check,
Clock3,
Download,
Film,
ImageIcon,
Loader2,
RefreshCw,
Upload,
WandSparkles,
} from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
resolveImageWorkspaceAssetUrl,
saveImageWorkspaceAsset,
uploadImageWorkspaceAsset,
} from '@/lib/image-workspace';
import { cn } from '@/lib/utils';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import type {
DesignAsset,
DesignGenerationTask,
DesignWorkspace,
} from '../../../shared/image-workspace';
import {
youthCompilationIssueMessage,
youthTaskIssueMessage,
} from './youth-issue-copy';
const TASK_STATUS_LABELS: Record<DesignGenerationTask['status'], string> = {
queued: '正在准备',
running: '正在制作',
succeeded: '完成了',
failed: '未完成',
cancelled: '已取消',
};
const TASK_STAGE_LABELS: Record<DesignGenerationTask['progress']['stage'], string> = {
queued: '正在准备画面',
preparing: '正在准备画面',
submitting: '正在准备画面',
generating: '正在制作',
composing: '正在制作',
validating: '正在检查结果',
finalizing: '正在检查结果',
completed: '完成了',
failed: '这次没有完成',
cancelled: '已取消',
};
function formatExpiry(value: string): string {
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return value;
return new Date(timestamp).toLocaleString([], {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function AssetPreview({ asset }: { asset: DesignAsset }) {
const [resolution, setResolution] = useState<{
contentPath: string;
source: string | null;
failed: boolean;
}>(() => ({ contentPath: asset.contentPath, source: null, failed: false }));
const currentResolution = resolution.contentPath === asset.contentPath
? resolution
: { contentPath: asset.contentPath, source: null, failed: false };
useEffect(() => {
let active = true;
void resolveImageWorkspaceAssetUrl(asset.contentPath)
.then((url) => {
if (active) setResolution({ contentPath: asset.contentPath, source: url, failed: false });
})
.catch(() => {
if (active) setResolution({ contentPath: asset.contentPath, source: null, failed: true });
});
return () => {
active = false;
};
}, [asset.contentPath]);
if (!currentResolution.source || currentResolution.failed) {
return (
<div className="flex aspect-square items-center justify-center bg-surface-subtle text-muted-foreground">
{asset.mediaType === 'video'
? <Film className="h-6 w-6" />
: <ImageIcon className="h-6 w-6" />}
</div>
);
}
if (asset.mediaType === 'video') {
return (
<video
src={currentResolution.source}
className="aspect-square w-full bg-surface-subtle object-cover"
controls
preload="metadata"
/>
);
}
return (
<img
src={currentResolution.source}
alt={asset.role === 'uploaded' ? '我上传的图片' : 'AI 完成的作品'}
className="aspect-square w-full bg-surface-subtle object-cover"
loading="lazy"
/>
);
}
export function DesignQuotePanel({ workspace }: { workspace: DesignWorkspace }) {
const requestQuote = useImageWorkspaceStore((state) => state.requestQuote);
const confirmGeneration = useImageWorkspaceStore((state) => state.confirmGeneration);
const quoteBlockers = useImageWorkspaceStore((state) => state.quoteBlockers);
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
const offeredQuotes = workspace.form.activeQuotes.filter((quote) => quote.status === 'offered');
const quote = offeredQuotes[0] ?? null;
const requesting = Object.values(pendingOperations).some(
(operation) => operation.status === 'submitting' && operation.command.kind === 'request_quote',
);
const confirming = Object.values(pendingOperations).some(
(operation) => operation.status === 'submitting'
&& operation.command.kind === 'confirm_generation',
);
if (!quote) {
return (
<section className="rounded-2xl border border-brand/20 bg-brand/[0.035] p-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="max-w-xl">
<div className="flex items-center gap-2">
<WandSparkles className="h-4 w-4 text-brand" />
<h3 className="text-sm font-semibold text-foreground">看看制作方案</h3>
</div>
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
我们会先告诉你要用多少设计点,确认后才开始制作。
</p>
</div>
<Button
type="button"
className="min-h-11 shrink-0 rounded-xl"
disabled={requesting}
onClick={() => {
void requestQuote().catch(() => {
toast.error('制作方案还没准备好,请稍后再试');
});
}}
>
{requesting
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
: <WandSparkles className="mr-2 h-4 w-4" />}
准备制作方案
</Button>
</div>
{quoteBlockers.length > 0 && (
<div className="mt-4 rounded-xl border border-destructive/20 bg-destructive/[0.04] p-3">
<p className="text-xs font-medium text-destructive">开始前还需要</p>
<ul className="mt-2 space-y-1.5 text-xs leading-5 text-foreground">
{quoteBlockers.map((blocker) => (
<li key={`${blocker.code}-${blocker.path ?? ''}`}>
{youthCompilationIssueMessage(blocker)}
</li>
))}
</ul>
</div>
)}
</section>
);
}
return (
<section
id="design-offered-quote"
data-testid="design-offered-quote"
className="rounded-2xl border border-brand/25 bg-background p-4 shadow-sm"
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold text-foreground">制作方案准备好了</h3>
</div>
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
开始后会按现在的想法制作;如果再改想法,需要重新准备方案。
</p>
</div>
<div className="text-left sm:text-right">
<p className="text-2xl font-semibold tabular-nums text-foreground">
{quote.quotedDesignPoints}
</p>
<p className="text-xs text-muted-foreground">需要的设计点</p>
</div>
</div>
<div className="mt-4 grid gap-3 rounded-xl bg-surface-subtle/75 p-3 sm:grid-cols-3">
<div>
<p className="text-[11px] text-muted-foreground">作品形状</p>
<p className="mt-1 text-sm font-medium text-foreground">{quote.outputSummary.aspectRatio}</p>
</div>
<div>
<p className="text-[11px] text-muted-foreground">作品数量</p>
<p className="mt-1 text-sm font-medium text-foreground">{quote.outputSummary.outputCount} 个</p>
</div>
<div>
<p className="text-[11px] text-muted-foreground">方案保留到</p>
<p className="mt-1 text-sm font-medium text-foreground">{formatExpiry(quote.expiresAt)}</p>
</div>
</div>
{quote.warnings.length > 0 && (
<div className="mt-3 space-y-1.5 rounded-xl border border-amber-200 bg-amber-50/60 p-3 text-xs leading-5 text-amber-900">
{quote.warnings.map((warning) => (
<p key={`${warning.code}-${warning.path ?? ''}`} className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{youthCompilationIssueMessage(warning)}
</p>
))}
</div>
)}
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
开始后可以离开这个页面,作品会继续制作。
</p>
<Button
type="button"
className="min-h-11 shrink-0 rounded-xl"
disabled={confirming}
onClick={() => {
void confirmGeneration(quote.quoteId).catch(() => {
toast.error('这次没有开始制作,请稍后再试');
});
}}
>
{confirming
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
: <Check className="mr-2 h-4 w-4" />}
花 {quote.quotedDesignPoints} 设计点开始制作
</Button>
</div>
</section>
);
}
export function DesignPendingOperationsPanel() {
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
const retryOperation = useImageWorkspaceStore((state) => state.retryOperation);
const unknown = Object.values(pendingOperations).filter((operation) => operation.status === 'unknown');
if (unknown.length === 0) return null;
return (
<section className="rounded-2xl border border-amber-200 bg-amber-50/55 p-4">
<div className="flex items-start gap-2.5">
<Clock3 className="mt-0.5 h-4 w-4 shrink-0 text-amber-700" />
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-amber-950">还在确认刚才的操作</h3>
<p className="mt-1 text-xs leading-5 text-amber-900/80">
可以放心查看原来的结果,不会重复制作或重复扣费。
</p>
<div className="mt-3 space-y-2">
{unknown.map((operation) => (
<div key={operation.id} className="flex items-center justify-between gap-3 rounded-xl bg-background/80 px-3 py-2">
<div className="min-w-0">
<p className="truncate text-xs font-medium text-foreground">{operation.label}</p>
<p className="mt-0.5 text-[11px] text-muted-foreground">网络可能刚刚中断</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 shrink-0 rounded-xl"
onClick={() => {
void retryOperation(operation.id).catch(() => {
toast.error('暂时没能查到结果,请稍后再试');
});
}}
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
查看原来的结果
</Button>
</div>
))}
</div>
</div>
</div>
</section>
);
}
export function DesignTaskPanel({ tasks }: { tasks: DesignGenerationTask[] }) {
if (tasks.length === 0) return null;
return (
<section className="space-y-3">
<div>
<h3 className="text-sm font-semibold text-foreground">作品进度</h3>
<p className="mt-1 text-xs text-muted-foreground">关闭页面也没关系,作品会继续制作。</p>
</div>
<div className="grid gap-3 lg:grid-cols-2">
{tasks.map((task) => {
const active = task.status === 'queued' || task.status === 'running';
return (
<article key={task.taskId} className="rounded-2xl border border-border/70 bg-background p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
{task.medium === 'video'
? <Film className="h-4 w-4 text-brand" />
: <ImageIcon className="h-4 w-4 text-brand" />}
<h4 className="text-sm font-medium text-foreground">
{task.medium === 'video' ? '我的视频' : '我的图片'}
</h4>
</div>
<p className="mt-1 text-[11px] text-muted-foreground">
{task.outputSummary.aspectRatio} · {task.outputSummary.outputCount} 个作品
</p>
</div>
<Badge
variant={task.status === 'succeeded'
? 'success'
: task.status === 'failed'
? 'destructive'
: active
? 'warning'
: 'secondary'}
>
{TASK_STATUS_LABELS[task.status]}
</Badge>
</div>
<div className="mt-4 flex items-center justify-between rounded-xl bg-surface-subtle/75 px-3 py-2.5">
<span className="text-xs text-muted-foreground">{TASK_STAGE_LABELS[task.progress.stage]}</span>
<span className="text-xs tabular-nums text-foreground">
{task.progress.completedRequiredSteps}/{task.progress.totalRequiredSteps}
</span>
</div>
{task.executionHealth !== 'normal' && (
<p className="mt-3 rounded-xl bg-amber-50/70 p-3 text-xs leading-5 text-amber-950">
平台正在处理,不会重复制作或重复扣费。
</p>
)}
{task.issues.length > 0 && (
<div className="mt-3 space-y-1 rounded-xl bg-destructive/[0.04] p-3 text-xs leading-5 text-destructive">
{task.issues.map((issue) => (
<p key={issue.code}>{youthTaskIssueMessage(issue)}</p>
))}
</div>
)}
</article>
);
})}
</div>
</section>
);
}
export function DesignAssetsPanel({ workspace }: { workspace: DesignWorkspace }) {
const refreshWorkspace = useImageWorkspaceStore((state) => state.refreshWorkspace);
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const assets = useMemo(
() => [...workspace.assets].sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)),
[workspace.assets],
);
const upload = async (file: File) => {
setUploading(true);
try {
await uploadImageWorkspaceAsset(workspace.workspace.workspaceId, file);
await refreshWorkspace();
toast.success('图片已上传');
} catch {
toast.error('图片没有上传成功,请再试一次');
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = '';
}
};
return (
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-foreground">我的图片和作品</h3>
<p className="mt-1 text-xs text-muted-foreground">上传图片后,可以告诉 AI 想保留和改变什么。</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 rounded-xl"
disabled={uploading}
onClick={() => inputRef.current?.click()}
>
{uploading
? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
: <Upload className="mr-1.5 h-3.5 w-3.5" />}
上传图片
</Button>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void upload(file);
}}
/>
</div>
{assets.length === 0 ? (
<button
type="button"
className="flex w-full items-center justify-center gap-2 rounded-2xl border border-dashed border-border px-4 py-8 text-sm text-muted-foreground transition-colors hover:border-brand/35 hover:bg-brand/[0.03]"
onClick={() => inputRef.current?.click()}
>
<Upload className="h-4 w-4" />
上传第一张图片
</button>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-4">
{assets.map((asset) => (
<article key={asset.assetId} className="group/asset overflow-hidden rounded-2xl border border-border/70 bg-background">
<AssetPreview asset={asset} />
<div className="flex items-center justify-between gap-2 px-3 py-2.5">
<div className="min-w-0">
<p className="truncate text-xs font-medium text-foreground">
{asset.role === 'uploaded' ? '我的图片' : '完成的作品'}
</p>
<p className="mt-0.5 truncate text-[10px] text-muted-foreground">
{asset.width} × {asset.height}
</p>
</div>
<button
type="button"
aria-label="保存作品到电脑"
className={cn(
'flex h-11 w-11 items-center justify-center rounded-xl text-muted-foreground transition-colors',
'hover:bg-surface-subtle hover:text-foreground',
)}
onClick={() => {
void saveImageWorkspaceAsset(asset).catch(() => {
toast.error('作品没有保存成功,请再试一次');
});
}}
>
<Download className="h-3.5 w-3.5" />
</button>
</div>
</article>
))}
</div>
)}
</section>
);
}
export function DesignProductionPanel({ workspace }: { workspace: DesignWorkspace }) {
return (
<div className="space-y-6">
<DesignPendingOperationsPanel />
<DesignQuotePanel workspace={workspace} />
<DesignTaskPanel tasks={workspace.tasks} />
<DesignAssetsPanel workspace={workspace} />
</div>
);
}