feat: preview image task inputs in admin

This commit is contained in:
QiuSW
2026-07-20 08:58:34 +08:00
parent 2e222a2aef
commit 8eec0e3db7
9 changed files with 382 additions and 5 deletions
+1
View File
@@ -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
View File
@@ -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",