476 lines
18 KiB
TypeScript
476 lines
18 KiB
TypeScript
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>
|
||
);
|
||
}
|