feat(canvas): preview generated task images in app
This commit is contained in:
103
src/pages/ImageCanvas/DesignImagePreview.tsx
Normal file
103
src/pages/ImageCanvas/DesignImagePreview.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ImageIcon, Loader2, Maximize2, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { resolveImageWorkspaceAssetUrl } from '@/lib/image-workspace';
|
||||
import type { DesignAsset } from '../../../shared/image-workspace';
|
||||
|
||||
function ImagePreviewContent({ asset }: { asset: DesignAsset }) {
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [originalSize, setOriginalSize] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void resolveImageWorkspaceAssetUrl(asset.contentPath)
|
||||
.then((url) => { if (active) setSource(url); })
|
||||
.catch(() => { if (active) setFailed(true); });
|
||||
return () => { active = false; };
|
||||
}, [asset.contentPath]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="flex items-center justify-between gap-3 border-b border-border/70 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="text-base">图片预览</DialogTitle>
|
||||
<DialogDescription className="mt-1 text-xs">
|
||||
{asset.width} × {asset.height}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!loaded || failed}
|
||||
onClick={() => setOriginalSize((value) => !value)}
|
||||
>
|
||||
{originalSize ? '适应窗口' : '原始尺寸'}
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="ghost" size="icon" aria-label="关闭图片预览">
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</header>
|
||||
<div className="relative min-h-0 overflow-auto bg-surface-subtle/50" data-testid="design-image-preview-viewport">
|
||||
{failed ? (
|
||||
<div role="alert" className="flex h-full flex-col items-center justify-center gap-3 p-6 text-sm text-muted-foreground">
|
||||
<ImageIcon className="h-8 w-8" aria-hidden="true" />
|
||||
图片暂时无法加载,请关闭后重试。
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!loaded && (
|
||||
<div role="status" className="absolute inset-0 flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" aria-hidden="true" />
|
||||
正在加载图片…
|
||||
</div>
|
||||
)}
|
||||
{source && (
|
||||
<img
|
||||
src={source}
|
||||
alt="AI 作品大图"
|
||||
className={originalSize ? 'block max-w-none' : 'h-full w-full object-contain'}
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function DesignImagePreviewButton({ asset }: { asset: DesignAsset }) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="放大查看图片"
|
||||
title="放大查看图片"
|
||||
className="absolute bottom-1 left-1 flex h-7 w-7 items-center justify-center rounded-lg bg-background/90 text-muted-foreground shadow-sm hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="h-[85vh] max-w-[min(94vw,100rem)] grid-rows-[auto_minmax(0,1fr)] gap-0 overflow-hidden p-0">
|
||||
<ImagePreviewContent key={asset.contentPath} asset={asset} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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> = {
|
||||
@@ -41,7 +42,7 @@ export function DesignPlanHistory({ workspace }: { workspace: DesignWorkspace })
|
||||
if (tasks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-testid="design-plan-history">
|
||||
<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)
|
||||
@@ -111,6 +112,7 @@ export function DesignPlanHistory({ workspace }: { workspace: DesignWorkspace })
|
||||
{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="保存作品到电脑"
|
||||
|
||||
Reference in New Issue
Block a user