feat: preview image task inputs in admin
This commit is contained in:
@@ -13,6 +13,7 @@ class ImageGenerationTaskInputInline(admin.TabularInline):
|
|||||||
|
|
||||||
@admin.register(ImageGenerationTask)
|
@admin.register(ImageGenerationTask)
|
||||||
class ImageGenerationTaskAdmin(admin.ModelAdmin):
|
class ImageGenerationTaskAdmin(admin.ModelAdmin):
|
||||||
|
change_form_template = "admin/api/imagegenerationtask/change_form.html"
|
||||||
inlines = (ImageGenerationTaskInputInline,)
|
inlines = (ImageGenerationTaskInputInline,)
|
||||||
list_display = (
|
list_display = (
|
||||||
"task_id",
|
"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 import admin
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
from django.urls import path
|
from django.urls import path, reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
@@ -696,6 +697,111 @@ class ClientLatestReleaseApiTests(TestCase):
|
|||||||
self.assertIn("size_bytes", download_fields)
|
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(
|
@override_settings(
|
||||||
PAYMENT_CALLBACK_MODE="mock",
|
PAYMENT_CALLBACK_MODE="mock",
|
||||||
PAYMENT_MOCK_CALLBACK_SECRET="test-payment-callback-secret",
|
PAYMENT_MOCK_CALLBACK_SECRET="test-payment-callback-secret",
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
|
|
||||||
当前项目处于:**Phase 6 增强任务推进期**。Phase 2 计费核心已完成到 T-204;Phase 3 已完成 T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口、T-304 充值回调、T-305 扫码充值下单 + 轮询与 T-306 对外 API 安全加固;Phase 4 已完成 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化;Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档;Phase 6 已完成 T-601「可用别名发现」、T-602「django-admin 中文化第 1-3 层」、T-603「django-admin 字段级中文化」、T-604「中文敏感词本地过滤」、T-605「免邮箱验证策略落地」、T-606「公开首页 + 客户端下载入口」、T-607「桌面端最新版本检查接口」、T-608「新用户注册赠送试用点数(当前 10 点)」、T-609「桌面端版本检查接口增加强制更新标记」、T-610「首页导入模板下载入口」、T-611「用户端品牌名统一为虾皮圈」、T-612「生图同步接口止血」、T-613「抽生成核心 service」、T-614「生图异步任务化接口」与 T-615「旧同步生图接口遥测 / 弃用口径」。生图慢 / 504 / 客户端超时已拆为 T-612~T-615 并完成:T-612 已做同步接口上游硬截止止血,T-613 已抽共享生成 core,T-614 已新增异步提交 / 轮询接口与 DB worker,T-615 已给旧同步 / 新异步提交路径接入结构化日志并形成旧同步接口退出条件。生产侧仍需补真实支付回调到账闭环;邮件服务仅用于后续密码找回/通知等邮件能力,不阻塞注册登录。
|
当前项目处于:**Phase 6 增强任务推进期**。Phase 2 计费核心已完成到 T-204;Phase 3 已完成 T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口、T-304 充值回调、T-305 扫码充值下单 + 轮询与 T-306 对外 API 安全加固;Phase 4 已完成 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化;Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档;Phase 6 已完成 T-601「可用别名发现」、T-602「django-admin 中文化第 1-3 层」、T-603「django-admin 字段级中文化」、T-604「中文敏感词本地过滤」、T-605「免邮箱验证策略落地」、T-606「公开首页 + 客户端下载入口」、T-607「桌面端最新版本检查接口」、T-608「新用户注册赠送试用点数(当前 10 点)」、T-609「桌面端版本检查接口增加强制更新标记」、T-610「首页导入模板下载入口」、T-611「用户端品牌名统一为虾皮圈」、T-612「生图同步接口止血」、T-613「抽生成核心 service」、T-614「生图异步任务化接口」与 T-615「旧同步生图接口遥测 / 弃用口径」。生图慢 / 504 / 客户端超时已拆为 T-612~T-615 并完成:T-612 已做同步接口上游硬截止止血,T-613 已抽共享生成 core,T-614 已新增异步提交 / 轮询接口与 DB worker,T-615 已给旧同步 / 新异步提交路径接入结构化日志并形成旧同步接口退出条件。生产侧仍需补真实支付回调到账闭环;邮件服务仅用于后续密码找回/通知等邮件能力,不阻塞注册登录。
|
||||||
|
|
||||||
T-616~T-620 也已完成:生图失败自动重试、客户端版本文件校验元数据、多图理解 `vision` 操作与同步接口,以及单图 / 多图图生图均已落地。T-620 保持旧单图字段兼容,并固定第一张为主图、后续为参考图;生产启用多图理解前仍需在 admin 配置视觉模型、默认别名和计费规则。
|
T-616~T-620 与 T-622 也已完成:生图失败自动重试、客户端版本文件校验元数据、多图理解 `vision` 操作与同步接口、单图 / 多图图生图,以及 django-admin 图片生成任务详情页的输入图 / 结果图缩略预览与双击大图查看。T-620 保持旧单图字段兼容,并固定第一张为主图、后续为参考图;T-622 不改接口或账务。生产启用多图理解前仍需在 admin 配置视觉模型、默认别名和计费规则。
|
||||||
|
|
||||||
优先路径:
|
优先路径:
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,7 @@
|
|||||||
| T-618 | 客户端发布版本后台必填文件校验元数据 | T-617 | 把 django-admin 里 `DownloadRelease` 的 `sha256` 和 `size_bytes` 改为必填,避免运营发布客户端安装包时漏填校验元数据,导致桌面端无法完整校验下载文件。**范围**:本任务只要求 admin 后台保存时必填;为兼容历史数据和现有 API 合约,第一步不直接把数据库字段改成 `NOT NULL`,`size_bytes` 仍允许旧记录为空,API 暂保持可返回 `null`;若后续要数据库级强约束,需先单独回填全量历史发布记录再做迁移。**实现**:`DownloadReleaseAdmin` 已挂专用 `ModelForm`,将 `sha256`、`size_bytes` 设为 required,并保留 `sha256` 64 位十六进制校验、`size_bytes >= 1` 校验;admin 新增 / 编辑保存时未填会显示字段级错误,不写库。**生产数据**:上线后必须补齐当前 `windows` 发布版本的 `size_bytes`,再验证 `/api/v1/client/releases/latest?platform=windows` 返回正整数 `release.size_bytes`;已有 `sha256` 继续保留并核对。**兼容性**:不删除既有 API 字段,不改变无当前版本时的 `release:null`;非 admin 的历史数据、迁移和只读接口不应因空值直接 500。**文档**:同步 `api.md`、`04-architecture.md`、`routes.md`、`current-state.md` 和 `progress.md` 的发布校验口径。**测试**:已覆盖 admin 表单缺 `sha256` / 缺 `size_bytes` 拒绝保存、合法 `sha256 + size_bytes` 可保存、API 仍能读取历史空值记录、当前版本补齐后接口返回正整数;`check` / `makemigrations --check` / 目标测试通过并在 `../progress.md` 留证据 | DONE |
|
| T-618 | 客户端发布版本后台必填文件校验元数据 | T-617 | 把 django-admin 里 `DownloadRelease` 的 `sha256` 和 `size_bytes` 改为必填,避免运营发布客户端安装包时漏填校验元数据,导致桌面端无法完整校验下载文件。**范围**:本任务只要求 admin 后台保存时必填;为兼容历史数据和现有 API 合约,第一步不直接把数据库字段改成 `NOT NULL`,`size_bytes` 仍允许旧记录为空,API 暂保持可返回 `null`;若后续要数据库级强约束,需先单独回填全量历史发布记录再做迁移。**实现**:`DownloadReleaseAdmin` 已挂专用 `ModelForm`,将 `sha256`、`size_bytes` 设为 required,并保留 `sha256` 64 位十六进制校验、`size_bytes >= 1` 校验;admin 新增 / 编辑保存时未填会显示字段级错误,不写库。**生产数据**:上线后必须补齐当前 `windows` 发布版本的 `size_bytes`,再验证 `/api/v1/client/releases/latest?platform=windows` 返回正整数 `release.size_bytes`;已有 `sha256` 继续保留并核对。**兼容性**:不删除既有 API 字段,不改变无当前版本时的 `release:null`;非 admin 的历史数据、迁移和只读接口不应因空值直接 500。**文档**:同步 `api.md`、`04-architecture.md`、`routes.md`、`current-state.md` 和 `progress.md` 的发布校验口径。**测试**:已覆盖 admin 表单缺 `sha256` / 缺 `size_bytes` 拒绝保存、合法 `sha256 + size_bytes` 可保存、API 仍能读取历史空值记录、当前版本补齐后接口返回正整数;`check` / `makemigrations --check` / 目标测试通过并在 `../progress.md` 留证据 | DONE |
|
||||||
| T-619 | 多张图片理解并返回文字 | T-613, T-601, T-306 | 新增独立的多模态图片理解能力,不复用“生成标题”语义。**操作与别名**:在 `ModelAlias.OperationType`、`CallRecord.OperationType` 和别名解析中新增 `vision`;建议首个数据库别名为 `vision-standard`,但不得在接口中暴露具体供应商模型名。`vision` 别名指向的 `AiModel` 及 Provider 必须同时具备 `vision` 与文字输出能力,图片生成 / 编辑专用 Provider 不得误接。**新增接口**:`POST /api/v1/analyze/images`,继承 `ExternalApiView`、使用 API Key 鉴权和生成限流,同步返回 `{text, alias, model_used, points_cost, points_balance, call_id}`;不改变 `/api/v1/generate/title`、`/api/v1/generate/image` 及异步生图接口。**请求结构**:`prompt` 必填,`model` 可选,`images` 为有序列表且至少 1 张;每项必须且只能提供一个 `image_url` 或 `image_base64`,允许 URL/base64 混合。新增 `VISION_MAX_IMAGES=8`、`VISION_MAX_IMAGE_BYTES=10485760`、`VISION_MAX_TOTAL_BYTES=33554432` 三个可配置上限,分别控制单次图片数量、单图解码后字节数和总字节数;超限、空列表、格式错误或同项双来源返回 `400 bad_request`,不扣点、不调上游。**安全时序**:serializer 后先按 T-604 审核 prompt,再下载 URL / 解码 base64;每个 URL 必须复用 T-306 的协议、公网地址、重定向逐跳校验、超时和响应大小保护,不另写弱化下载器。第一版只审核文字 prompt,不声称已做图片内容审核。**Provider**:复用 `apps/ai/providers` 现有 HTTP 适配层,增加向 Chat Completions / Gemini 发送多张图片并保序的兼容能力;不得复制第二套上游调用实现。现有单图标题 / 生图调用签名与行为保持兼容。**计费与留痕**:复用 T-613 共享生成 core 与 billing 的预扣 / 成功确认 / 幂等退点;第一版按 `operation_type=vision + alias` 的默认 `PricingRule` 对一次请求固定扣点,不按图片张数重复扣费,图片数量上限用于控制成本;上游失败必须退点。`CallRecord` 记录 `vision`、别名、实际模型、点数、耗时和结果摘要,不保存输入图片、base64、provider raw 或完整上游响应。**目录与运营配置**:`GET /api/v1/models` 和 portal 可用模型页按 T-601 既有语义列出 active、能力匹配的 `vision` 别名并显示 `requires_image=true`;有定价时返回 `priced`,缺定价时仍返回 `unpriced`,不得改变现有目录兼容行为。代码部署 / migrate 后由运营在 admin 配置支持视觉理解的 `AiModel`、`vision-standard` 默认别名和计费规则,不通过数据迁移写入真实上游配置或密钥。**范围边界**:本任务只做同步文字结果,不做流式输出、异步 vision task、OCR 专用接口、结构化 JSON schema、图片内容审核,也不为 OCR / 商品识别 / 图片对比各建别名;这些用途先由 prompt 表达,只有底层模型或价格确实不同时再增加别名。**文档**:实现时同步 `02-requirements.md`、`04-architecture.md`、`api.md`、`routes.md`、`env.md`、`current-state.md` 和 `progress.md`。**测试**:覆盖单图 / 多图 / 混合来源及顺序、默认 / 指定别名、模型与 Provider 能力拒绝、图片数量与大小限制、SSRF、敏感词先拦截、成功仅扣一次、上游失败仅退一次、调用记录不保存图片 / raw、模型目录安全字段、旧标题 / 生图接口回归;`makemigrations` / `migrate` / `check` / 目标测试 / `init` 通过并在 `../progress.md` 留证据 | DONE |
|
| T-619 | 多张图片理解并返回文字 | T-613, T-601, T-306 | 新增独立的多模态图片理解能力,不复用“生成标题”语义。**操作与别名**:在 `ModelAlias.OperationType`、`CallRecord.OperationType` 和别名解析中新增 `vision`;建议首个数据库别名为 `vision-standard`,但不得在接口中暴露具体供应商模型名。`vision` 别名指向的 `AiModel` 及 Provider 必须同时具备 `vision` 与文字输出能力,图片生成 / 编辑专用 Provider 不得误接。**新增接口**:`POST /api/v1/analyze/images`,继承 `ExternalApiView`、使用 API Key 鉴权和生成限流,同步返回 `{text, alias, model_used, points_cost, points_balance, call_id}`;不改变 `/api/v1/generate/title`、`/api/v1/generate/image` 及异步生图接口。**请求结构**:`prompt` 必填,`model` 可选,`images` 为有序列表且至少 1 张;每项必须且只能提供一个 `image_url` 或 `image_base64`,允许 URL/base64 混合。新增 `VISION_MAX_IMAGES=8`、`VISION_MAX_IMAGE_BYTES=10485760`、`VISION_MAX_TOTAL_BYTES=33554432` 三个可配置上限,分别控制单次图片数量、单图解码后字节数和总字节数;超限、空列表、格式错误或同项双来源返回 `400 bad_request`,不扣点、不调上游。**安全时序**:serializer 后先按 T-604 审核 prompt,再下载 URL / 解码 base64;每个 URL 必须复用 T-306 的协议、公网地址、重定向逐跳校验、超时和响应大小保护,不另写弱化下载器。第一版只审核文字 prompt,不声称已做图片内容审核。**Provider**:复用 `apps/ai/providers` 现有 HTTP 适配层,增加向 Chat Completions / Gemini 发送多张图片并保序的兼容能力;不得复制第二套上游调用实现。现有单图标题 / 生图调用签名与行为保持兼容。**计费与留痕**:复用 T-613 共享生成 core 与 billing 的预扣 / 成功确认 / 幂等退点;第一版按 `operation_type=vision + alias` 的默认 `PricingRule` 对一次请求固定扣点,不按图片张数重复扣费,图片数量上限用于控制成本;上游失败必须退点。`CallRecord` 记录 `vision`、别名、实际模型、点数、耗时和结果摘要,不保存输入图片、base64、provider raw 或完整上游响应。**目录与运营配置**:`GET /api/v1/models` 和 portal 可用模型页按 T-601 既有语义列出 active、能力匹配的 `vision` 别名并显示 `requires_image=true`;有定价时返回 `priced`,缺定价时仍返回 `unpriced`,不得改变现有目录兼容行为。代码部署 / migrate 后由运营在 admin 配置支持视觉理解的 `AiModel`、`vision-standard` 默认别名和计费规则,不通过数据迁移写入真实上游配置或密钥。**范围边界**:本任务只做同步文字结果,不做流式输出、异步 vision task、OCR 专用接口、结构化 JSON schema、图片内容审核,也不为 OCR / 商品识别 / 图片对比各建别名;这些用途先由 prompt 表达,只有底层模型或价格确实不同时再增加别名。**文档**:实现时同步 `02-requirements.md`、`04-architecture.md`、`api.md`、`routes.md`、`env.md`、`current-state.md` 和 `progress.md`。**测试**:覆盖单图 / 多图 / 混合来源及顺序、默认 / 指定别名、模型与 Provider 能力拒绝、图片数量与大小限制、SSRF、敏感词先拦截、成功仅扣一次、上游失败仅退一次、调用记录不保存图片 / raw、模型目录安全字段、旧标题 / 生图接口回归;`makemigrations` / `migrate` / `check` / 目标测试 / `init` 通过并在 `../progress.md` 留证据 | DONE |
|
||||||
| T-620 | 图生图支持单图 / 多图主图与参考图 | T-613, T-614, T-616, T-619 | 已完成:同步 / 异步图生图支持有序 `images`,旧单图字段继续兼容且禁止混用;服务端固定注入首图主商品、后续参考图规则。新增 `IMAGE_MAX_INPUT_IMAGES` / `IMAGE_MAX_INPUT_IMAGE_BYTES` / `IMAGE_MAX_INPUT_TOTAL_BYTES`,图片 URL 同时受新单图限制与既有 `IMAGE_URL_MAX_BYTES` 的较小值保护。Provider 已验证 Chat 多段、JSON `image_urls` 与 `images_edits` 重复 multipart `image` 的顺序传递;异步任务新增 `ImageGenerationTaskInput` 有序输入表,旧 `input_image` 读取保持兼容。已通过 `check`、迁移一致性、13 条 Provider 测试、序列化器边界校验与 6 条目标 API 测试(同步多图 / URL+base64 混合 / 互斥输入 / 超限 / 异步恢复 / URL 上限);完整 `GenerateApiTests` 曾跑出 53 条且发现并修复 URL 上限回归,远程 MySQL 测试库后续整类重跑超过 5 分钟未完成。此前已使用真实中转站 `gpt-image-2` 对两张图片重复 multipart `image` 调用获得 HTTP 200 与图片结果,确认上游多文件传输契约。详见 `progress.md`。 | DONE |
|
| T-620 | 图生图支持单图 / 多图主图与参考图 | T-613, T-614, T-616, T-619 | 已完成:同步 / 异步图生图支持有序 `images`,旧单图字段继续兼容且禁止混用;服务端固定注入首图主商品、后续参考图规则。新增 `IMAGE_MAX_INPUT_IMAGES` / `IMAGE_MAX_INPUT_IMAGE_BYTES` / `IMAGE_MAX_INPUT_TOTAL_BYTES`,图片 URL 同时受新单图限制与既有 `IMAGE_URL_MAX_BYTES` 的较小值保护。Provider 已验证 Chat 多段、JSON `image_urls` 与 `images_edits` 重复 multipart `image` 的顺序传递;异步任务新增 `ImageGenerationTaskInput` 有序输入表,旧 `input_image` 读取保持兼容。已通过 `check`、迁移一致性、13 条 Provider 测试、序列化器边界校验与 6 条目标 API 测试(同步多图 / URL+base64 混合 / 互斥输入 / 超限 / 异步恢复 / URL 上限);完整 `GenerateApiTests` 曾跑出 53 条且发现并修复 URL 上限回归,远程 MySQL 测试库后续整类重跑超过 5 分钟未完成。此前已使用真实中转站 `gpt-image-2` 对两张图片重复 multipart `image` 调用获得 HTTP 200 与图片结果,确认上游多文件传输契约。详见 `progress.md`。 | DONE |
|
||||||
|
| T-622 | 图片生成任务后台图片缩略预览 | T-614, T-620 | 已完成:django-admin 的 `ImageGenerationTask` 详情页新增输入图 / 结果图缩略画廊;输入按 `ordinal` 标为主图和参考图,兼容历史 `input_image` 单图;成功任务以 `result_url` 展示生成结果。双击缩略图弹出后台 `<dialog>` 大图预览,支持关闭按钮、遮罩和 `Escape`;图片加载失败显示降级提示。未改 `CallRecord`、对外 API、计费、worker、迁移、媒体访问策略或列表页查询。已通过 4 条目标 admin 测试、`check`、迁移一致性、静态资源发现和 `init.ps1`。 | DONE |
|
||||||
|
|
||||||
## 里程碑
|
## 里程碑
|
||||||
|
|
||||||
@@ -119,6 +120,7 @@
|
|||||||
- M14:客户端发布版本后台必填校验元数据(T-618)。
|
- M14:客户端发布版本后台必填校验元数据(T-618)。
|
||||||
- M15:多张图片理解并返回文字(T-619)。
|
- M15:多张图片理解并返回文字(T-619)。
|
||||||
- M16:图生图支持单图 / 多图主图与参考图(T-620)。
|
- M16:图生图支持单图 / 多图主图与参考图(T-620)。
|
||||||
|
- M17:图片生成任务后台图片缩略预览(T-622)。
|
||||||
|
|
||||||
## 待办池(Backlog)
|
## 待办池(Backlog)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+13
@@ -2026,3 +2026,16 @@
|
|||||||
- 安全:仍复用既有 `SignupBonusGrant(user UNIQUE)` 数据库级幂等标记和 billing 事务写账;此次只调整额度,不改变注册接入、钱包锁、流水或 API 契约。
|
- 安全:仍复用既有 `SignupBonusGrant(user UNIQUE)` 数据库级幂等标记和 billing 事务写账;此次只调整额度,不改变注册接入、钱包锁、流水或 API 契约。
|
||||||
- 本地验证:`C:/Python312/python.exe manage.py migrate --noinput` 已应用 `billing.0009_signup_bonus_default_ten`;`manage.py check` 0 issues;`makemigrations --check --dry-run` 无待生成迁移;`compileall` 通过。定向 MySQL 测试 5 条通过:服务默认发放 / 已有钱包累加 / 并发仅发一次 / 注册 adapter 写余额与流水 / 首页匿名文案。测试仅出现既有 allauth 条件唯一约束在 MySQL 上不创建的 `models.W036` 警告。
|
- 本地验证:`C:/Python312/python.exe manage.py migrate --noinput` 已应用 `billing.0009_signup_bonus_default_ten`;`manage.py check` 0 issues;`makemigrations --check --dry-run` 无待生成迁移;`compileall` 通过。定向 MySQL 测试 5 条通过:服务默认发放 / 已有钱包累加 / 并发仅发一次 / 注册 adapter 写余额与流水 / 首页匿名文案。测试仅出现既有 allauth 条件唯一约束在 MySQL 上不创建的 `models.W036` 警告。
|
||||||
- 线上部署:已部署代码提交 `83fa541` 至 `185.216.248.75:/www/wwwroot/cmhub`,同步时保留 `.env`、媒体、静态文件和部署配置,并创建服务器侧配置/媒体备份。部署前确认无 `queued` / `running` 图片任务;`cmhub-web`、`cmhub-generate` 及 66 个 `cmhub-image-worker@*` 均已重启并为 `active`,无 cmhub / Nginx failed unit。线上已应用 `billing.0009_signup_bonus_default_ten`,迁移记录为已登记,`manage.py check` 通过;运行时代码和模型字段默认值均为 10,HTTPS 首页返回 200。
|
- 线上部署:已部署代码提交 `83fa541` 至 `185.216.248.75:/www/wwwroot/cmhub`,同步时保留 `.env`、媒体、静态文件和部署配置,并创建服务器侧配置/媒体备份。部署前确认无 `queued` / `running` 图片任务;`cmhub-web`、`cmhub-generate` 及 66 个 `cmhub-image-worker@*` 均已重启并为 `active`,无 cmhub / Nginx failed unit。线上已应用 `billing.0009_signup_bonus_default_ten`,迁移记录为已登记,`manage.py check` 通过;运行时代码和模型字段默认值均为 10,HTTPS 首页返回 200。
|
||||||
|
|
||||||
|
## 2026-07-20 文档登记:T-622 图片生成任务后台图片缩略预览
|
||||||
|
|
||||||
|
- 状态:TODO,仅登记任务,未改代码、数据库、接口、计费、worker 或线上服务。
|
||||||
|
- 范围:在 django-admin 的 `ImageGenerationTask` 详情页复用已存的 `ImageGenerationTaskInput`(主图与参考图)及 `result_url`,展示稳定尺寸缩略图;双击后以后台模态窗口查看大图。
|
||||||
|
- 边界:不改 `CallRecord`,不为旧同步生图补建任务或补存输入图,不新增公开接口或媒体访问策略;列表页不加载缩略图,避免扩大查询和带宽开销。
|
||||||
|
|
||||||
|
## 2026-07-20 完成:T-622 图片生成任务后台图片缩略预览
|
||||||
|
|
||||||
|
- 实现:`ImageGenerationTaskAdmin` 使用专用 change form 模板,仅在详情页渲染缩略画廊。多图输入按 `ordinal` 标识第 1 张主图及后续参考图;无子输入时回退展示历史 `input_image`;成功任务有 `result_url` 时展示生成结果。缩略图双击打开原生 `<dialog>` 大图预览,支持关闭按钮、点击遮罩和 `Escape`;缩略图或大图加载失败均有前端降级提示。
|
||||||
|
- 边界:未修改模型、迁移、`CallRecord`、API 契约、计费、worker 或媒体访问策略;任务列表不渲染缩略图。新增静态 CSS / JS 仅由任务详情模板加载。
|
||||||
|
- 验证:`C:/Python312/python.exe manage.py check` 通过;`manage.py test apps.api.tests.ImageGenerationTaskAdminTests --keepdb --noinput --verbosity 2` 通过,4 tests OK(多图+结果、历史单图、无图片任务、非 staff);`makemigrations --check --dry-run` 无待生成迁移;`findstatic` 找到 CSS / JS;`./init.ps1` 通过;`git diff --check` 通过。目标测试仅有既有 allauth MySQL 条件唯一约束 `models.W036` 警告。
|
||||||
|
- 线上部署:当前未提交的 T-622 工作区代码已同步到 `185.216.248.75:/www/wwwroot/cmhub`,同步保留 `.env`、媒体、静态文件与部署配置,并创建部署前配置/静态文件备份。线上 `manage.py check` 通过,`collectstatic --noinput` 新复制 7 个静态文件,`cmhub-web` 重启后为 `active`;`cmhub-generate` 未重启,未影响 66 个生图 worker。已确认任务详情模板及 `staticfiles/api/admin/image-task-gallery.css`、`image-task-gallery.js` 存在。
|
||||||
|
|||||||
Reference in New Issue
Block a user