diff --git a/apps/web/src/project-pages.css b/apps/web/src/project-pages.css
index 1f8e744..35e9986 100644
--- a/apps/web/src/project-pages.css
+++ b/apps/web/src/project-pages.css
@@ -592,15 +592,26 @@
height: 18px;
}
-.project-placeholder {
+.project-placeholder,
+.project-preview {
display: grid;
height: 154px;
- grid-template-columns: repeat(4, 1fr);
overflow: hidden;
border-bottom: 1px solid #a5a59f;
background: #d8d8d3;
}
+.project-placeholder {
+ grid-template-columns: repeat(4, 1fr);
+}
+
+.project-preview img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
.project-placeholder span {
display: grid;
place-items: end center;
@@ -935,9 +946,9 @@
font-size: 19px;
}
-.project-current > .project-placeholder {
- height: auto;
- min-height: 480px;
+.project-current > .project-placeholder,
+.project-current > .project-preview {
+ height: 480px;
border: 1px solid #73736d;
}
@@ -945,6 +956,10 @@
font-size: 80px;
}
+.project-current > .project-preview img {
+ object-fit: contain;
+}
+
.project-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -1013,7 +1028,8 @@
padding: 6px;
}
-.project-history li .project-placeholder {
+.project-history li .project-placeholder,
+.project-history li .project-preview {
height: 88px;
border: 0;
}
@@ -1439,8 +1455,9 @@
flex-direction: column;
}
- .project-current > .project-placeholder {
- min-height: 360px;
+ .project-current > .project-placeholder,
+ .project-current > .project-preview {
+ height: 360px;
}
.local-only-footer {
diff --git a/apps/web/src/project-pages.tsx b/apps/web/src/project-pages.tsx
index af4fc19..835a0d6 100644
--- a/apps/web/src/project-pages.tsx
+++ b/apps/web/src/project-pages.tsx
@@ -231,6 +231,33 @@ function ProjectPlaceholder({ ratio, status }: { ratio: Ratio; status: ProjectSt
);
}
+function ProjectPreview({ alt, imageId, loading = "lazy", projectId, ratio, status }: {
+ alt: string;
+ imageId: string | null;
+ loading?: "eager" | "lazy";
+ projectId: string;
+ ratio: Ratio;
+ status: ProjectStatus;
+}) {
+ const [loadFailed, setLoadFailed] = useState(false);
+
+ useEffect(() => setLoadFailed(false), [imageId, projectId]);
+
+ if (!imageId || loadFailed) return ;
+
+ return (
+
+
![{alt}]()
setLoadFailed(true)}
+ src={`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(imageId)}`}
+ />
+
+ );
+}
+
export function WorkspacePage() {
const promptId = useId();
const [session, setSession] = useState();
@@ -568,7 +595,13 @@ function ProjectCard({ activeLimitReached, busy, onPurge, onRestore, onSelect, o
/>
) : null}
-
+
{project.name}
{project.status === "failed_empty" ? "生成失败" : project.status === "trashed" ? "回收站" : "项目"}
{project.successful_image_count} 张成功图 · {project.ratio}
@@ -953,7 +986,14 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
当前底图
{project.pixel_width ?? 1080} × {project.pixel_height ?? 1440}
-
+
{conflicted || !project.current_image_id ?
:
进入编辑器}
@@ -970,7 +1010,13 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
{project.images.toReversed().map((image, index) => (
-
-
+
生成结果 {project.images.length - index}下载原始图
))}
diff --git a/tests/e2e/projects-workspace.spec.ts b/tests/e2e/projects-workspace.spec.ts
index bb9b351..59d13f2 100644
--- a/tests/e2e/projects-workspace.spec.ts
+++ b/tests/e2e/projects-workspace.spec.ts
@@ -33,6 +33,10 @@ function routeSession(page: Page) {
}));
}
+function generatedImageSvg(label: string) {
+ return ``;
+}
+
async function captureEvidence(page: Page, caseId: string, name: string) {
const root = process.env.DADA_EVIDENCE_DIR_PROJECTS;
if (!root) return;
@@ -75,6 +79,12 @@ test("TDD-WP2-PROJ-005 limits failed-empty selection on the project list", async
],
}), contentType: "application/json", status: 200,
}));
+ await page.route(`**/api/v1/private-assets/projects/${successId}/images/*`, (route) => route.fulfill({
+ body: generatedImageSvg("城市工作室"),
+ contentType: "image/svg+xml",
+ headers: { "Content-Disposition": "attachment; filename=\"dada-original.png\"" },
+ status: 200,
+ }));
let batchPayload: unknown;
await page.route("**/api/v1/projects/failed-empty/trash", async (route) => {
batchPayload = route.request().postDataJSON();
@@ -86,6 +96,12 @@ test("TDD-WP2-PROJ-005 limits failed-empty selection on the project list", async
await expect(page.getByRole("heading", { name: "项目" })).toBeVisible();
await expect(page.getByText("2 / 20 active")).toBeVisible();
+ const projectPreview = page.getByRole("img", { name: "城市工作室预览图" });
+ await expect(projectPreview).toHaveAttribute(
+ "src",
+ `/api/v1/private-assets/projects/${successId}/images/00000000-0000-4000-8000-000000000213`,
+ );
+ await expect(projectPreview).toHaveCSS("object-fit", "cover");
await expect(page.getByLabel("选择失败草稿:失败草稿")).toBeVisible();
await expect(page.getByLabel("选择失败草稿:城市工作室")).toHaveCount(0);
await page.getByLabel("选择失败草稿:失败草稿").check();
@@ -99,9 +115,10 @@ test("TDD-WP2-PROJ-005 limits failed-empty selection on the project list", async
test("TDD-WP2-PROJ-001 keeps ratio fixed and blocks an eleventh image in project detail", async ({ page }) => {
await routeSession(page);
const projectId = "00000000-0000-4000-8000-000000000221";
+ const currentImageId = "00000000-0000-4000-8000-000000000222";
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
body: JSON.stringify({
- created_at: "2026-07-28T08:00:00.000Z", current_image_id: "00000000-0000-4000-8000-000000000222",
+ created_at: "2026-07-28T08:00:00.000Z", current_image_id: currentImageId,
draft_prompt: "城市工作室", generations: [], images: Array.from({ length: 10 }, (_, index) => ({
created_at: `2026-07-28T08:${String(index).padStart(2, "0")}:00.000Z`,
generation_id: `00000000-0000-4000-8000-${String(223 + index).padStart(12, "0")}`,
@@ -111,11 +128,25 @@ test("TDD-WP2-PROJ-001 keeps ratio fixed and blocks an eleventh image in project
status: "active", successful_image_count: 10, updated_at: "2026-07-28T08:10:00.000Z",
}), contentType: "application/json", status: 200,
}));
+ await page.route(`**/api/v1/private-assets/projects/${projectId}/images/*`, (route) => route.fulfill({
+ body: generatedImageSvg("生成结果"),
+ contentType: "image/svg+xml",
+ headers: { "Content-Disposition": "attachment; filename=\"dada-original.png\"" },
+ status: 200,
+ }));
await page.goto(`${webUrl}/app/projects/${projectId}`);
await expect(page.getByRole("heading", { name: "城市工作室" })).toBeVisible();
await expect(page.getByText("固定比例 3:4")).toBeVisible();
await expect(page.getByText("10 / 10 张成功图")).toBeVisible();
+ const currentImage = page.getByRole("img", { name: "城市工作室当前底图" });
+ await expect(currentImage).toHaveAttribute(
+ "src",
+ `/api/v1/private-assets/projects/${projectId}/images/${currentImageId}`,
+ );
+ await expect(currentImage).toHaveAttribute("loading", "eager");
+ await expect(currentImage).toHaveCSS("object-fit", "contain");
+ await expect(page.getByRole("img", { name: "生成结果 10" })).toBeVisible();
await expect(page.getByRole("button", { name: "继续生成" })).toBeDisabled();
await expect(page.getByText("请先删除一张非当前底图的历史图")).toBeVisible();
await expect(page.getByRole("radio")).toHaveCount(0);