feat: preview image task inputs in admin
This commit is contained in:
@@ -13,6 +13,7 @@ class ImageGenerationTaskInputInline(admin.TabularInline):
|
||||
|
||||
@admin.register(ImageGenerationTask)
|
||||
class ImageGenerationTaskAdmin(admin.ModelAdmin):
|
||||
change_form_template = "admin/api/imagegenerationtask/change_form.html"
|
||||
inlines = (ImageGenerationTaskInputInline,)
|
||||
list_display = (
|
||||
"task_id",
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
.image-task-gallery {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.image-task-gallery__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(144px, 1fr));
|
||||
gap: 12px;
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.image-task-gallery__item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.image-task-gallery__trigger {
|
||||
display: grid;
|
||||
width: 144px;
|
||||
height: 144px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
cursor: zoom-in;
|
||||
background: #f6f7f8;
|
||||
border: 1px solid var(--hairline-color, #d8dadd);
|
||||
border-radius: 6px;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.image-task-gallery__trigger:focus-visible,
|
||||
.image-task-preview-dialog__close:focus-visible {
|
||||
outline: 3px solid var(--focus-color, #417690);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.image-task-gallery__trigger img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.image-task-gallery__trigger.is-unavailable img {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.image-task-gallery__unavailable {
|
||||
padding: 12px;
|
||||
color: var(--body-quiet-color, #666);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.image-task-gallery__item figcaption {
|
||||
margin-top: 6px;
|
||||
color: var(--body-quiet-color, #666);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.image-task-preview-dialog {
|
||||
width: min(960px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 32px);
|
||||
padding: 16px;
|
||||
color: var(--body-fg, #333);
|
||||
background: var(--body-bg, #fff);
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 16px 48px rgb(0 0 0 / 30%);
|
||||
}
|
||||
|
||||
.image-task-preview-dialog::backdrop {
|
||||
background: rgb(0 0 0 / 55%);
|
||||
}
|
||||
|
||||
.image-task-preview-dialog__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.image-task-preview-dialog__header h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.image-task-preview-dialog__close {
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
padding: 4px 8px;
|
||||
color: var(--button-fg, #fff);
|
||||
cursor: pointer;
|
||||
background: var(--button-bg, #417690);
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.image-task-preview-dialog__image {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 136px);
|
||||
margin: 0 auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.image-task-preview-dialog__error {
|
||||
margin: 0;
|
||||
color: var(--error-fg, #ba2121);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function markImageUnavailable(image) {
|
||||
const trigger = image.closest(".image-task-gallery__trigger");
|
||||
if (!trigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
trigger.classList.add("is-unavailable");
|
||||
const message = trigger.querySelector(".image-task-gallery__unavailable");
|
||||
if (message) {
|
||||
message.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const dialog = document.querySelector("[data-image-preview-dialog]");
|
||||
if (!dialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previewImage = dialog.querySelector("[data-image-preview-image]");
|
||||
const previewLabel = dialog.querySelector("[data-image-preview-label]");
|
||||
const previewError = dialog.querySelector("[data-image-preview-error]");
|
||||
const closeButton = dialog.querySelector("[data-image-preview-close]");
|
||||
|
||||
document.querySelectorAll(".image-task-gallery__trigger img").forEach(function (image) {
|
||||
image.addEventListener("error", function () {
|
||||
markImageUnavailable(image);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-image-preview]").forEach(function (trigger) {
|
||||
trigger.addEventListener("dblclick", function () {
|
||||
previewLabel.textContent = trigger.dataset.imageLabel || "图片预览";
|
||||
previewImage.alt = trigger.dataset.imageLabel || "图片预览";
|
||||
previewError.hidden = true;
|
||||
previewImage.hidden = false;
|
||||
previewImage.src = trigger.dataset.imageUrl;
|
||||
dialog.showModal();
|
||||
});
|
||||
});
|
||||
|
||||
previewImage.addEventListener("error", function () {
|
||||
previewImage.hidden = true;
|
||||
previewError.hidden = false;
|
||||
});
|
||||
|
||||
closeButton.addEventListener("click", function () {
|
||||
dialog.close();
|
||||
});
|
||||
|
||||
dialog.addEventListener("click", function (event) {
|
||||
if (event.target === dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,85 @@
|
||||
{% extends "admin/change_form.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block extrastyle %}
|
||||
{{ block.super }}
|
||||
<link rel="stylesheet" href="{% static 'api/admin/image-task-gallery.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block after_field_sets %}
|
||||
{{ block.super }}
|
||||
{% if original %}
|
||||
{% with input_images=original.input_images.all %}
|
||||
{% if input_images or original.input_image or original.status == "succeeded" and original.result_url %}
|
||||
<section class="image-task-gallery" aria-labelledby="image-task-gallery-title">
|
||||
<h2 id="image-task-gallery-title">图片预览</h2>
|
||||
<div class="image-task-gallery__grid">
|
||||
{% for input_image in input_images %}
|
||||
<figure class="image-task-gallery__item">
|
||||
<button
|
||||
type="button"
|
||||
class="image-task-gallery__trigger"
|
||||
data-image-preview
|
||||
data-image-url="{{ input_image.image.url }}"
|
||||
data-image-label="{% if forloop.first %}主图{% else %}参考图 {{ forloop.counter0 }}{% endif %}"
|
||||
title="双击查看大图"
|
||||
>
|
||||
<img src="{{ input_image.image.url }}" alt="{% if forloop.first %}主图{% else %}参考图 {{ forloop.counter0 }}{% endif %}" loading="lazy">
|
||||
<span class="image-task-gallery__unavailable" hidden>图片无法加载</span>
|
||||
</button>
|
||||
<figcaption>{% if forloop.first %}主图{% else %}参考图 {{ forloop.counter0 }}{% endif %}</figcaption>
|
||||
</figure>
|
||||
{% empty %}
|
||||
{% if original.input_image %}
|
||||
<figure class="image-task-gallery__item">
|
||||
<button
|
||||
type="button"
|
||||
class="image-task-gallery__trigger"
|
||||
data-image-preview
|
||||
data-image-url="{{ original.input_image.url }}"
|
||||
data-image-label="主图"
|
||||
title="双击查看大图"
|
||||
>
|
||||
<img src="{{ original.input_image.url }}" alt="主图" loading="lazy">
|
||||
<span class="image-task-gallery__unavailable" hidden>图片无法加载</span>
|
||||
</button>
|
||||
<figcaption>主图</figcaption>
|
||||
</figure>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if original.status == "succeeded" and original.result_url %}
|
||||
<figure class="image-task-gallery__item">
|
||||
<button
|
||||
type="button"
|
||||
class="image-task-gallery__trigger"
|
||||
data-image-preview
|
||||
data-image-url="{{ original.result_url }}"
|
||||
data-image-label="生成结果"
|
||||
title="双击查看大图"
|
||||
>
|
||||
<img src="{{ original.result_url }}" alt="生成结果" loading="lazy">
|
||||
<span class="image-task-gallery__unavailable" hidden>图片无法加载</span>
|
||||
</button>
|
||||
<figcaption>生成结果</figcaption>
|
||||
</figure>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<dialog class="image-task-preview-dialog" data-image-preview-dialog aria-labelledby="image-task-preview-title">
|
||||
<div class="image-task-preview-dialog__header">
|
||||
<h2 id="image-task-preview-title" data-image-preview-label>图片预览</h2>
|
||||
<button type="button" class="image-task-preview-dialog__close" data-image-preview-close aria-label="关闭图片预览">关闭</button>
|
||||
</div>
|
||||
<img class="image-task-preview-dialog__image" data-image-preview-image alt="">
|
||||
<p class="image-task-preview-dialog__error" data-image-preview-error hidden>图片无法加载。</p>
|
||||
</dialog>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_change_form_document_ready %}
|
||||
{{ block.super }}
|
||||
<script src="{% static 'api/admin/image-task-gallery.js' %}" defer></script>
|
||||
{% endblock %}
|
||||
+107
-1
@@ -14,9 +14,10 @@ from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.cache import cache
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import path
|
||||
from django.urls import path, reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.test import APIClient
|
||||
@@ -696,6 +697,111 @@ class ClientLatestReleaseApiTests(TestCase):
|
||||
self.assertIn("size_bytes", download_fields)
|
||||
|
||||
|
||||
class ImageGenerationTaskAdminTests(TestCase):
|
||||
def setUp(self):
|
||||
self.media_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.media_dir.cleanup)
|
||||
self.media_override = override_settings(MEDIA_ROOT=self.media_dir.name)
|
||||
self.media_override.enable()
|
||||
self.addCleanup(self.media_override.disable)
|
||||
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
user_model = get_user_model()
|
||||
self.admin_user = user_model.objects.create_superuser(
|
||||
username=f"image-task-admin-{suffix}",
|
||||
email=f"image-task-admin-{suffix}@example.com",
|
||||
password="test-password",
|
||||
)
|
||||
self.user = user_model.objects.create_user(
|
||||
username=f"image-task-user-{suffix}",
|
||||
email=f"image-task-user-{suffix}@example.com",
|
||||
password="test-password",
|
||||
)
|
||||
self.api_key, _raw_key = ApiKey.create_for_user(self.user, name="image-task-admin-test")
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
def create_task(self, *, status=ImageGenerationTask.Status.SUCCEEDED, result_url=""):
|
||||
call_record = CallRecord.objects.create(
|
||||
user=self.user,
|
||||
api_key=self.api_key,
|
||||
operation_type=CallRecord.OperationType.IMAGE,
|
||||
alias="image-standard",
|
||||
model_used="test-image-model",
|
||||
resolution="1K",
|
||||
prompt="生成商品主图",
|
||||
points_cost=10,
|
||||
status=CallRecord.Status.SUCCESS,
|
||||
)
|
||||
return ImageGenerationTask.objects.create(
|
||||
user=self.user,
|
||||
api_key=self.api_key,
|
||||
call_record=call_record,
|
||||
status=status,
|
||||
request_hash=uuid.uuid4().hex + uuid.uuid4().hex,
|
||||
result_url=result_url,
|
||||
points_balance_after_charge=90,
|
||||
)
|
||||
|
||||
def change_url(self, task):
|
||||
return reverse("admin:api_imagegenerationtask_change", args=(task.pk,))
|
||||
|
||||
def add_input_image(self, task, *, ordinal, filename):
|
||||
task_input = ImageGenerationTaskInput(
|
||||
task=task,
|
||||
ordinal=ordinal,
|
||||
mime_type="image/png",
|
||||
filename=filename,
|
||||
)
|
||||
task_input.image.save(filename, ContentFile(b"test-image"), save=True)
|
||||
return task_input
|
||||
|
||||
def test_change_view_shows_ordered_input_and_result_thumbnails_with_modal_preview(self):
|
||||
task = self.create_task(result_url="https://images.example.test/generated.png")
|
||||
main = self.add_input_image(task, ordinal=0, filename="main.png")
|
||||
reference = self.add_input_image(task, ordinal=1, filename="reference.png")
|
||||
|
||||
response = self.client.get(self.change_url(task))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "图片预览")
|
||||
self.assertContains(response, "主图")
|
||||
self.assertContains(response, "参考图 1")
|
||||
self.assertContains(response, "生成结果")
|
||||
self.assertContains(response, main.image.url)
|
||||
self.assertContains(response, reference.image.url)
|
||||
self.assertContains(response, task.result_url)
|
||||
self.assertContains(response, "data-image-preview-dialog")
|
||||
self.assertContains(response, "image-task-gallery.js")
|
||||
self.assertContains(response, "双击查看大图")
|
||||
|
||||
def test_change_view_uses_legacy_single_input_image_as_main_image(self):
|
||||
task = self.create_task()
|
||||
task.input_image.save("legacy-main.png", ContentFile(b"legacy-image"), save=True)
|
||||
|
||||
response = self.client.get(self.change_url(task))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "主图")
|
||||
self.assertContains(response, task.input_image.url)
|
||||
self.assertNotContains(response, "参考图 1")
|
||||
|
||||
def test_change_view_handles_task_without_images_or_result(self):
|
||||
task = self.create_task(status=ImageGenerationTask.Status.QUEUED)
|
||||
|
||||
response = self.client.get(self.change_url(task))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, 'id="image-task-gallery-title"')
|
||||
|
||||
def test_change_view_requires_staff_access(self):
|
||||
task = self.create_task()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.get(self.change_url(task))
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
|
||||
@override_settings(
|
||||
PAYMENT_CALLBACK_MODE="mock",
|
||||
PAYMENT_MOCK_CALLBACK_SECRET="test-payment-callback-secret",
|
||||
|
||||
Reference in New Issue
Block a user