Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1713607572 |
@@ -592,15 +592,26 @@
|
|||||||
height: 18px;
|
height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-placeholder {
|
.project-placeholder,
|
||||||
|
.project-preview {
|
||||||
display: grid;
|
display: grid;
|
||||||
height: 154px;
|
height: 154px;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-bottom: 1px solid #a5a59f;
|
border-bottom: 1px solid #a5a59f;
|
||||||
background: #d8d8d3;
|
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 {
|
.project-placeholder span {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: end center;
|
place-items: end center;
|
||||||
@@ -935,9 +946,9 @@
|
|||||||
font-size: 19px;
|
font-size: 19px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-current > .project-placeholder {
|
.project-current > .project-placeholder,
|
||||||
height: auto;
|
.project-current > .project-preview {
|
||||||
min-height: 480px;
|
height: 480px;
|
||||||
border: 1px solid #73736d;
|
border: 1px solid #73736d;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -945,6 +956,10 @@
|
|||||||
font-size: 80px;
|
font-size: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-current > .project-preview img {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.project-actions {
|
.project-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -1013,7 +1028,8 @@
|
|||||||
padding: 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-history li .project-placeholder {
|
.project-history li .project-placeholder,
|
||||||
|
.project-history li .project-preview {
|
||||||
height: 88px;
|
height: 88px;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
@@ -1439,8 +1455,9 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-current > .project-placeholder {
|
.project-current > .project-placeholder,
|
||||||
min-height: 360px;
|
.project-current > .project-preview {
|
||||||
|
height: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.local-only-footer {
|
.local-only-footer {
|
||||||
|
|||||||
@@ -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 <ProjectPlaceholder ratio={ratio} status={status} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="project-preview" data-ratio={ratio} data-status={status}>
|
||||||
|
<img
|
||||||
|
alt={alt}
|
||||||
|
decoding="async"
|
||||||
|
loading={loading}
|
||||||
|
onError={() => setLoadFailed(true)}
|
||||||
|
src={`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(imageId)}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function WorkspacePage() {
|
export function WorkspacePage() {
|
||||||
const promptId = useId();
|
const promptId = useId();
|
||||||
const [session, setSession] = useState<SessionPayload>();
|
const [session, setSession] = useState<SessionPayload>();
|
||||||
@@ -568,7 +595,13 @@ function ProjectCard({ activeLimitReached, busy, onPurge, onRestore, onSelect, o
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
<ProjectPlaceholder ratio={project.ratio} status={project.status} />
|
<ProjectPreview
|
||||||
|
alt={`${project.name}预览图`}
|
||||||
|
imageId={project.current_image_id}
|
||||||
|
projectId={project.project_id}
|
||||||
|
ratio={project.ratio}
|
||||||
|
status={project.status}
|
||||||
|
/>
|
||||||
<div className="project-card-body">
|
<div className="project-card-body">
|
||||||
<div><h3 title={project.name}>{project.name}</h3><span>{project.status === "failed_empty" ? "生成失败" : project.status === "trashed" ? "回收站" : "项目"}</span></div>
|
<div><h3 title={project.name}>{project.name}</h3><span>{project.status === "failed_empty" ? "生成失败" : project.status === "trashed" ? "回收站" : "项目"}</span></div>
|
||||||
<p>{project.successful_image_count} 张成功图 · {project.ratio}</p>
|
<p>{project.successful_image_count} 张成功图 · {project.ratio}</p>
|
||||||
@@ -953,7 +986,14 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
|
|||||||
<div className="project-detail-grid">
|
<div className="project-detail-grid">
|
||||||
<section className="project-current" aria-labelledby="current-image-title">
|
<section className="project-current" aria-labelledby="current-image-title">
|
||||||
<header><h2 id="current-image-title">当前底图</h2><span>{project.pixel_width ?? 1080} × {project.pixel_height ?? 1440}</span></header>
|
<header><h2 id="current-image-title">当前底图</h2><span>{project.pixel_width ?? 1080} × {project.pixel_height ?? 1440}</span></header>
|
||||||
<ProjectPlaceholder ratio={project.ratio} status={project.status} />
|
<ProjectPreview
|
||||||
|
alt={`${project.name}当前底图`}
|
||||||
|
imageId={project.current_image_id}
|
||||||
|
loading="eager"
|
||||||
|
projectId={project.project_id}
|
||||||
|
ratio={project.ratio}
|
||||||
|
status={project.status}
|
||||||
|
/>
|
||||||
<div className="project-actions">
|
<div className="project-actions">
|
||||||
<button disabled={conflicted || atHistoryLimit} onClick={() => window.location.assign(`/app?continue=${project.project_id}`)} type="button">继续生成</button>
|
<button disabled={conflicted || atHistoryLimit} onClick={() => window.location.assign(`/app?continue=${project.project_id}`)} type="button">继续生成</button>
|
||||||
{conflicted || !project.current_image_id ? <button disabled type="button">进入编辑器</button> : <a href={`/app/projects/${project.project_id}/editor`}>进入编辑器</a>}
|
{conflicted || !project.current_image_id ? <button disabled type="button">进入编辑器</button> : <a href={`/app/projects/${project.project_id}/editor`}>进入编辑器</a>}
|
||||||
@@ -970,7 +1010,13 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
|
|||||||
<ol>
|
<ol>
|
||||||
{project.images.toReversed().map((image, index) => (
|
{project.images.toReversed().map((image, index) => (
|
||||||
<li key={image.image_id} data-current={image.image_id === project.current_image_id}>
|
<li key={image.image_id} data-current={image.image_id === project.current_image_id}>
|
||||||
<ProjectPlaceholder ratio={project.ratio} status="active" />
|
<ProjectPreview
|
||||||
|
alt={`生成结果 ${project.images.length - index}`}
|
||||||
|
imageId={image.image_id}
|
||||||
|
projectId={project.project_id}
|
||||||
|
ratio={project.ratio}
|
||||||
|
status="active"
|
||||||
|
/>
|
||||||
<div><strong>生成结果 {project.images.length - index}</strong><time dateTime={image.created_at}>{formatUpdatedAt(image.created_at)}</time><a href={`/api/v1/private-assets/projects/${project.project_id}/images/${image.image_id}`}>下载原始图</a></div>
|
<div><strong>生成结果 {project.images.length - index}</strong><time dateTime={image.created_at}>{formatUpdatedAt(image.created_at)}</time><a href={`/api/v1/private-assets/projects/${project.project_id}/images/${image.image_id}`}>下载原始图</a></div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ function routeSession(page: Page) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generatedImageSvg(label: string) {
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400"><rect width="300" height="400" fill="#d9f24f"/><text x="150" y="210" text-anchor="middle">${label}</text></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
async function captureEvidence(page: Page, caseId: string, name: string) {
|
async function captureEvidence(page: Page, caseId: string, name: string) {
|
||||||
const root = process.env.DADA_EVIDENCE_DIR_PROJECTS;
|
const root = process.env.DADA_EVIDENCE_DIR_PROJECTS;
|
||||||
if (!root) return;
|
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,
|
}), 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;
|
let batchPayload: unknown;
|
||||||
await page.route("**/api/v1/projects/failed-empty/trash", async (route) => {
|
await page.route("**/api/v1/projects/failed-empty/trash", async (route) => {
|
||||||
batchPayload = route.request().postDataJSON();
|
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.getByRole("heading", { name: "项目" })).toBeVisible();
|
||||||
await expect(page.getByText("2 / 20 active")).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("选择失败草稿:失败草稿")).toBeVisible();
|
||||||
await expect(page.getByLabel("选择失败草稿:城市工作室")).toHaveCount(0);
|
await expect(page.getByLabel("选择失败草稿:城市工作室")).toHaveCount(0);
|
||||||
await page.getByLabel("选择失败草稿:失败草稿").check();
|
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 }) => {
|
test("TDD-WP2-PROJ-001 keeps ratio fixed and blocks an eleventh image in project detail", async ({ page }) => {
|
||||||
await routeSession(page);
|
await routeSession(page);
|
||||||
const projectId = "00000000-0000-4000-8000-000000000221";
|
const projectId = "00000000-0000-4000-8000-000000000221";
|
||||||
|
const currentImageId = "00000000-0000-4000-8000-000000000222";
|
||||||
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
|
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
|
||||||
body: JSON.stringify({
|
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) => ({
|
draft_prompt: "城市工作室", generations: [], images: Array.from({ length: 10 }, (_, index) => ({
|
||||||
created_at: `2026-07-28T08:${String(index).padStart(2, "0")}:00.000Z`,
|
created_at: `2026-07-28T08:${String(index).padStart(2, "0")}:00.000Z`,
|
||||||
generation_id: `00000000-0000-4000-8000-${String(223 + index).padStart(12, "0")}`,
|
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",
|
status: "active", successful_image_count: 10, updated_at: "2026-07-28T08:10:00.000Z",
|
||||||
}), contentType: "application/json", status: 200,
|
}), 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 page.goto(`${webUrl}/app/projects/${projectId}`);
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "城市工作室" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "城市工作室" })).toBeVisible();
|
||||||
await expect(page.getByText("固定比例 3:4")).toBeVisible();
|
await expect(page.getByText("固定比例 3:4")).toBeVisible();
|
||||||
await expect(page.getByText("10 / 10 张成功图")).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.getByRole("button", { name: "继续生成" })).toBeDisabled();
|
||||||
await expect(page.getByText("请先删除一张非当前底图的历史图")).toBeVisible();
|
await expect(page.getByText("请先删除一张非当前底图的历史图")).toBeVisible();
|
||||||
await expect(page.getByRole("radio")).toHaveCount(0);
|
await expect(page.getByRole("radio")).toHaveCount(0);
|
||||||
|
|||||||
Reference in New Issue
Block a user