feat(canvas): preview generated task images in app
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
# Task: Add enlarged image preview to AI Design task results
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260915-design-image-preview-b24997a6
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260915-design-image-preview-b24997a6-design-image-preview
|
||||
- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260915-design-image-preview-b24997a6
|
||||
- Base commit: 80e52496d2696c3b579c911ee7c07f9588b368a6
|
||||
- Owner: codex
|
||||
- Status: Ready for Integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Add an in-app enlarged image viewer to generated images in AI Design task history, on desktop and compact layouts. Preserve the existing download action.
|
||||
- Own DesignPlanHistory, its new image-preview component, focused unit/Electron coverage, README, and this record.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Concurrent Gate Passed: bundled check/start succeeded and status confirmed the exact task, feature mode, branch, worktree and base above. Main contains three pre-existing untracked task records, so work is isolated from main.
|
||||
- Planning Gate Passed: read agent entry, own task, memory index, positioning, integrated state, decision index, architecture, domain/success criteria, ADR-007, evidence/reflection/commitment/stale indexes and peer task scopes. Most registered peers are completed packaging/integration or historical reviews; active known work is unrelated, except an old Enter-key E2E test in a retired file. Placeholder peer scopes remain unknown, with no concrete semantic dependency on this feature.
|
||||
- Positioning contains placeholders; use README, executable code and accepted V2 rules. Asset URL resolution remains through the existing Main-owned Host API helper. No contract, generation, billing, provider, persistence or global theme change.
|
||||
- User prohibits subagents without consent; implementation and verification are performed directly.
|
||||
- Implement a persistent enlarge icon beside download, a light modal that fits the complete image, optional native-size scrolling, load/error state, and accessible close/Escape behavior. Reset preview across Workspace switches.
|
||||
- Verify selected-asset correctness, absence of download side effects, failures and dismissal with focused tests; verify rendered dimensions and desktop/compact interaction in Electron, then typecheck/lint/build for renderer integration.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Generated image thumbnails now expose a persistent enlarge button beside the existing download button in task history. Desktop rail and compact conversation history share the same implementation.
|
||||
- The light in-app dialog uses the existing canonical Asset content URL, initially fits the complete image, and supports native-size scrolling, dimensions, loading/error feedback, close button, Escape, and focus restoration. Viewing never calls the download endpoint. Video actions remain unchanged.
|
||||
- Workspace changes remount history content to dismiss the old preview; reopening resets the viewer and permits retry after a load error. README reflects the new behavior.
|
||||
- Feature complete in the isolated task branch; no main merge, push, package or installed-client update performed.
|
||||
|
||||
## Verification
|
||||
|
||||
- Exact pinned pnpm 10.33.4; frozen-lockfile install succeeded without lockfile changes.
|
||||
- `pnpm run typecheck`: passed.
|
||||
- `pnpm exec vitest run tests/unit/design-image-preview.test.tsx tests/unit/image-canvas-page.test.tsx tests/unit/image-workspace-api.test.ts --maxWorkers=4`: 31 passed, including five new preview cases. Covers selected result, image-only action, fit/native mode, reopening, close/Escape/focus, independent download, URL/image failures, and Workspace changes.
|
||||
- Scoped ESLint over both changed product files and both test files: passed with no warnings or errors.
|
||||
- `pnpm run build:vite`: all Renderer/Main/Preload/utility targets passed. Existing Browserslist age, chunk-size and mixed-import warnings remain; none prevented compilation.
|
||||
- `pnpm exec playwright test tests/e2e/image-workspace-v2.spec.ts -g 'keeps conversation'`: 1 passed on Windows Electron. Existing V2 edit/confirmation flow remains covered; completed task preview verifies a 1600×2400 fixture, fit behavior, exact native dimensions and scrolling, Escape/focus restoration, 375px compact bounds and close action, with zero download requests.
|
||||
- Electron uses deterministic local Host API/media fixtures; no paid generation or production asset access is claimed. The full unrelated unit suite was not run.
|
||||
- Task Documentation Gate: reviewed changes against recorded base; `git diff --check` and the bundled task-aware documentation drift check passed.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Integrate this feature branch and rebuild the desktop client to deliver the button to installed users.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- None. This is a local presentation improvement within the accepted V2 Asset and Main-network boundaries; README documents the user-visible behavior without changing canonical architecture or domain rules.
|
||||
@@ -13,7 +13,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智
|
||||
|
||||
Agents 侧栏的「渠道」统一管理个人微信账号。每个账号由唯一扫码微信身份连接,可以在发布智能体前先扫码,之后选择或更换目标智能体;多个微信账号可以使用同一个已发布智能体,切换目标不需要重新扫码。账号可单独启用、暂停、重新连接或断开,不提供联系人邀请或授权调用者管理。Agent 的「使用与分享」只展示已关联渠道并提供定位入口。选中账号的二级详情保留运行/投递活动、失败文件补发和微信对话;对话可审批、停止、新开一段并保存实际产物,不同账号的会话相互隔离。自动任务可明确选择把结果发送到已经开始对话的微信账号,暂停保留目标;渠道触发的聊天等应计费模型调用仍由智能体创建者支付,费用统计包含微信渠道。渠道需要配套版本的 AgentBus Core/微信 Adapter、Yuxi 和 Works Square,桌面用户无需填写服务凭据。Main 保管操作恢复记录,重开界面不会自动重发绑定或执行请求;验证码不写入恢复记录,已移除的联系人权限操作记录只能丢弃。用户与应用分享不属于微信渠道账号管理范围。
|
||||
|
||||
- `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace,并在桌面设计模式下以 256px 宽度常驻展开;中央沿用 AI 编程的安静对话画布、自然消息流和底部悬浮输入器,AI 整理出的制作方案作为对话内的轻量可编辑稿持续更新;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。
|
||||
- `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace,并在桌面设计模式下以 256px 宽度常驻展开;中央沿用 AI 编程的安静对话画布、自然消息流和底部悬浮输入器,AI 整理出的制作方案作为对话内的轻量可编辑稿持续更新;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。任务中的已生成图片可通过“放大查看图片”按钮直接打开大图,支持适应窗口、原始尺寸及 Esc 关闭,无需先下载。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。
|
||||
- `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。
|
||||
|
||||
应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main,再由 Main 调用 Works Square;成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与四个模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。
|
||||
|
||||
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="保存作品到电脑"
|
||||
|
||||
@@ -145,7 +145,7 @@ test.describe('AI Design V2 workspace', () => {
|
||||
assistantMessage: '明白了:你想做一张适合小红书的便携咖啡机图片。',
|
||||
}],
|
||||
tasks: [] as Array<Record<string, unknown>>,
|
||||
assets: [],
|
||||
assets: [] as Array<Record<string, unknown>>,
|
||||
};
|
||||
const respond = (json: unknown) => ({
|
||||
ok: true,
|
||||
@@ -162,9 +162,29 @@ test.describe('AI Design V2 workspace', () => {
|
||||
const e2eGlobal = globalThis as typeof globalThis & {
|
||||
__designE2eAuthReady?: boolean;
|
||||
__designE2eProfileReady?: boolean;
|
||||
__designE2eCompleteTask?: () => void;
|
||||
__designE2eDownloadCount?: number;
|
||||
};
|
||||
e2eGlobal.__designE2eAuthReady = false;
|
||||
e2eGlobal.__designE2eProfileReady = false;
|
||||
e2eGlobal.__designE2eDownloadCount = 0;
|
||||
e2eGlobal.__designE2eCompleteTask = () => {
|
||||
workspace.tasks[0] = {
|
||||
...workspace.tasks[0],
|
||||
status: 'succeeded',
|
||||
taskRevision: 2,
|
||||
progress: { stage: 'completed', completedRequiredSteps: 1, totalRequiredSteps: 1 },
|
||||
resultAssetIds: ['result-1'],
|
||||
nextActions: ['view_result'],
|
||||
};
|
||||
workspace.assets = [{
|
||||
assetId: 'result-1', workspaceId: 'workspace-1', role: 'generated',
|
||||
mediaType: 'image', mimeType: 'image/svg+xml', width: 1600, height: 2400,
|
||||
durationMilliseconds: null, generationTaskId: 'task-1',
|
||||
createdAt: '2026-08-30T10:02:00.000Z',
|
||||
contentPath: '/api/works/image-workspace/workspaces/workspace-1/assets/result-1/content',
|
||||
}];
|
||||
};
|
||||
|
||||
ipcMain.removeHandler('hostapi:fetch');
|
||||
ipcMain.handle('hostapi:fetch', async (_event, request: {
|
||||
@@ -173,6 +193,10 @@ test.describe('AI Design V2 workspace', () => {
|
||||
body?: string | null;
|
||||
}) => {
|
||||
const path = request.path ?? '';
|
||||
if (path.endsWith('/assets/result-1/download')) {
|
||||
e2eGlobal.__designE2eDownloadCount! += 1;
|
||||
return envelope({ status: 'saved' });
|
||||
}
|
||||
if (path === '/api/auth/session/sync') {
|
||||
return respond({ success: true, session: e2eSession });
|
||||
}
|
||||
@@ -465,6 +489,54 @@ test.describe('AI Design V2 workspace', () => {
|
||||
await expect(page.getByText('等待制作')).toBeVisible();
|
||||
await expect(page.getByTestId('youth-creation-card')).toHaveCount(0);
|
||||
await expect(page.getByText('1200', { exact: true })).toHaveCount(0);
|
||||
|
||||
// Complete the existing task without a paid provider or native download.
|
||||
await page.route('**/assets/result-1/content?*', (route) => route.fulfill({
|
||||
contentType: 'image/svg+xml',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="2400"><rect width="1600" height="2400" fill="#f4efe5"/><rect x="80" y="80" width="1440" height="2240" rx="80" fill="#d6e5dc"/><circle cx="800" cy="1050" r="450" fill="#47665b"/><text x="800" y="1850" text-anchor="middle" font-size="120" fill="#203b30">DESIGN PREVIEW</text></svg>',
|
||||
}));
|
||||
await app.evaluate(() => {
|
||||
(globalThis as typeof globalThis & { __designE2eCompleteTask?: () => void }).__designE2eCompleteTask?.();
|
||||
});
|
||||
await page.reload();
|
||||
const historyTask = page.getByTestId('design-plan-history-task-1');
|
||||
await expect(historyTask).toContainText('已完成');
|
||||
await historyTask.locator('summary').click();
|
||||
const previewButton = historyTask.getByRole('button', { name: '放大查看图片' });
|
||||
await previewButton.click();
|
||||
const previewDialog = page.getByRole('dialog', { name: '图片预览' });
|
||||
const previewImage = previewDialog.getByRole('img', { name: 'AI 作品大图' });
|
||||
await expect(previewDialog).toBeVisible();
|
||||
await expect(previewDialog.getByRole('button', { name: '原始尺寸' })).toBeEnabled();
|
||||
expect(await previewImage.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1600);
|
||||
await expect(previewImage).toHaveCSS('object-fit', 'contain');
|
||||
const previewBounds = await previewDialog.boundingBox();
|
||||
expect(previewBounds!.height).toBeGreaterThan(400);
|
||||
expect(previewBounds!.width).toBeGreaterThan(800);
|
||||
|
||||
await previewDialog.getByRole('button', { name: '原始尺寸' }).click();
|
||||
expect((await previewImage.boundingBox())!.width).toBe(1600);
|
||||
expect((await previewImage.boundingBox())!.height).toBe(2400);
|
||||
const scrollArea = previewDialog.getByTestId('design-image-preview-viewport');
|
||||
expect(await scrollArea.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
|
||||
await previewDialog.getByRole('button', { name: '适应窗口' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(previewDialog).toHaveCount(0);
|
||||
await expect(previewButton).toBeFocused();
|
||||
|
||||
await page.setViewportSize({ width: 375, height: 800 });
|
||||
const compactTask = page.getByTestId('image-workspace-conversation').getByTestId('design-plan-history-task-1');
|
||||
await compactTask.locator('summary').click();
|
||||
await compactTask.getByRole('button', { name: '放大查看图片' }).click();
|
||||
await expect(previewDialog.getByRole('button', { name: '原始尺寸' })).toBeEnabled();
|
||||
const compactBounds = await previewDialog.boundingBox();
|
||||
expect(compactBounds!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(compactBounds!.x + compactBounds!.width).toBeLessThanOrEqual(375);
|
||||
await previewDialog.getByRole('button', { name: '关闭图片预览' }).click();
|
||||
await expect(previewDialog).toHaveCount(0);
|
||||
expect(await app.evaluate(() => (
|
||||
globalThis as typeof globalThis & { __designE2eDownloadCount?: number }
|
||||
).__designE2eDownloadCount)).toBe(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
|
||||
126
tests/unit/design-image-preview.test.tsx
Normal file
126
tests/unit/design-image-preview.test.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resolveImageWorkspaceAssetUrl, saveImageWorkspaceAsset } from '@/lib/image-workspace';
|
||||
import { DesignHistoryRail } from '@/pages/ImageCanvas/DesignHistoryRail';
|
||||
import type { DesignAsset } from '../../shared/image-workspace';
|
||||
import { designQuoteFixture, designWorkspaceFixture } from '../fixtures/design-workspace-v2';
|
||||
|
||||
vi.mock('@/lib/image-workspace', () => ({
|
||||
resolveImageWorkspaceAssetUrl: vi.fn(),
|
||||
saveImageWorkspaceAsset: vi.fn().mockResolvedValue({ status: 'saved' }),
|
||||
}));
|
||||
|
||||
function assetFixture(assetId: string, mediaType: DesignAsset['mediaType'] = 'image'): DesignAsset {
|
||||
return {
|
||||
assetId, workspaceId: 'workspace-1', role: 'generated', mediaType,
|
||||
mimeType: mediaType === 'image' ? 'image/png' : 'video/mp4',
|
||||
width: 1600, height: 2400, durationMilliseconds: null,
|
||||
generationTaskId: 'task-1', createdAt: '2026-09-15T06:00:00.000Z',
|
||||
contentPath: `/api/works/image-workspace/workspaces/workspace-1/assets/${assetId}/content`,
|
||||
};
|
||||
}
|
||||
|
||||
function completedWorkspace(assets = [assetFixture('image-1')]) {
|
||||
const quote = designQuoteFixture();
|
||||
return designWorkspaceFixture({
|
||||
assets,
|
||||
tasks: [{
|
||||
taskId: 'task-1', quoteId: quote.quoteId, status: 'succeeded', taskRevision: 2,
|
||||
specificationRevision: quote.specificationRevision,
|
||||
specificationRevisionId: quote.specificationRevisionId,
|
||||
specificationDigest: quote.specificationDigest,
|
||||
compilerVersion: quote.compilerVersion, medium: 'image',
|
||||
outputSummary: { ...quote.outputSummary, outputCount: assets.length },
|
||||
maximumCustomerChargeAtoms: 1200,
|
||||
customerBilling: { quotedAtoms: 1200, heldAtoms: 0, chargedAtoms: 1200, refundedAtoms: 0, pendingResolutionAtoms: 0 },
|
||||
customerHoldExpiresAt: quote.expiresAt, resolutionDeadlineAt: null,
|
||||
progress: { stage: 'completed', completedRequiredSteps: 1, totalRequiredSteps: 1 },
|
||||
executionHealth: 'normal', cancellation: { state: 'unavailable', outcome: null },
|
||||
issues: [], resultAssetIds: assets.map((asset) => asset.assetId), nextActions: ['view_result'],
|
||||
createdAt: '2026-09-15T06:00:00.000Z',
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
function expandTask() {
|
||||
screen.getByTestId('design-plan-history-task-1').setAttribute('open', '');
|
||||
}
|
||||
|
||||
describe('AI Design task image preview', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(resolveImageWorkspaceAssetUrl).mockImplementation(async (path) => `http://localhost${path}`);
|
||||
});
|
||||
|
||||
it('opens the selected result without downloading and restores fit mode when reopened', async () => {
|
||||
const workspace = completedWorkspace([assetFixture('image-1'), assetFixture('image-2'), assetFixture('video-1', 'video')]);
|
||||
render(<DesignHistoryRail workspace={workspace} />);
|
||||
expandTask();
|
||||
const buttons = screen.getAllByRole('button', { name: '放大查看图片' });
|
||||
expect(buttons).toHaveLength(2);
|
||||
expect(screen.getAllByRole('button', { name: '保存作品到电脑' })).toHaveLength(3);
|
||||
|
||||
fireEvent.click(buttons[1]);
|
||||
const dialog = screen.getByRole('dialog', { name: '图片预览' });
|
||||
const image = await within(dialog).findByRole('img', { name: 'AI 作品大图' });
|
||||
expect(image).toHaveAttribute('src', `http://localhost${workspace.assets[1].contentPath}`);
|
||||
expect(image).toHaveClass('object-contain');
|
||||
expect(within(dialog).getByRole('status')).toHaveTextContent('正在加载图片');
|
||||
fireEvent.load(image);
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '原始尺寸' }));
|
||||
expect(image).toHaveClass('max-w-none');
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '适应窗口' }));
|
||||
expect(image).toHaveClass('object-contain');
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '原始尺寸' }));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '关闭图片预览' }));
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(buttons[0]);
|
||||
const reopenedImage = await screen.findByRole('img', { name: 'AI 作品大图' });
|
||||
expect(reopenedImage).toHaveAttribute('src', `http://localhost${workspace.assets[0].contentPath}`);
|
||||
expect(reopenedImage).toHaveClass('object-contain');
|
||||
expect(saveImageWorkspaceAsset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes with Escape and keeps the existing download action independent', async () => {
|
||||
const workspace = completedWorkspace();
|
||||
render(<DesignHistoryRail workspace={workspace} />);
|
||||
expandTask();
|
||||
const trigger = screen.getByRole('button', { name: '放大查看图片' });
|
||||
fireEvent.click(trigger);
|
||||
await screen.findByRole('img', { name: 'AI 作品大图' });
|
||||
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(trigger).toHaveFocus();
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存作品到电脑' }));
|
||||
expect(saveImageWorkspaceAsset).toHaveBeenCalledExactlyOnceWith(workspace.assets[0]);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(['url', 'image'] as const)('shows a recoverable message for %s loading failures', async (failure) => {
|
||||
render(<DesignHistoryRail workspace={completedWorkspace()} />);
|
||||
expandTask();
|
||||
if (failure === 'url') {
|
||||
vi.mocked(resolveImageWorkspaceAssetUrl).mockRejectedValueOnce(new Error('unavailable'));
|
||||
}
|
||||
fireEvent.click(screen.getByRole('button', { name: '放大查看图片' }));
|
||||
if (failure === 'image') fireEvent.error(await screen.findByRole('img', { name: 'AI 作品大图' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('图片暂时无法加载,请关闭后重试');
|
||||
expect(screen.getByRole('button', { name: '原始尺寸' })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭图片预览' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '放大查看图片' }));
|
||||
fireEvent.load(await screen.findByRole('img', { name: 'AI 作品大图' }));
|
||||
expect(screen.getByRole('button', { name: '原始尺寸' })).toBeEnabled();
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('removes the open preview when switching design projects', async () => {
|
||||
const workspace = completedWorkspace();
|
||||
const { rerender } = render(<DesignHistoryRail workspace={workspace} />);
|
||||
expandTask();
|
||||
fireEvent.click(screen.getByRole('button', { name: '放大查看图片' }));
|
||||
await screen.findByRole('img', { name: 'AI 作品大图' });
|
||||
rerender(<DesignHistoryRail workspace={{ ...workspace, workspace: { ...workspace.workspace, workspaceId: 'workspace-2' } }} />);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user