A8多套打包+M4归档+R5多图:存量功能备份
A8 多套交付包(packaging_task.py): - 修复交付包只打第1条混乱note的bug,按ImageCandidate.strategy分A/B/C组 - 每组生独立note_0N夹(6图+文案.txt),同seq留最新去重,老数据兼容 - task74端到端验:3套各6图,独立agent7项交叉验证全过 M4 归档(tasks.py/exports.py/前端): - list_tasks加date_from/date_to/product_id筛选+product_name批量填(防N+1) - 新增exports.py:产品JSON导出+标杆CSV导出(UTF-8 BOM) - 前端HistoryFilters日期/产品筛选+产品列+打回原因红banner - response.py加raise_param_error;独立agent验A1/A2/A9通过 R5 产品多图(product_images.py/020迁移/前端): - product_images表+5端点(上传/列/改场景/设主图/删图) - 生图按ROLE_SCENE_PREFERENCE选对应场景图,回落primary - 前端ProductImageManager多图画廊 R6 账号config拆页(settings/): - 配置页按角色拆/settings(运营+组长+admin)+/config(仅admin) - Key只显末4位不显余额(守红线) 核销表对齐真实代码状态:D1改稿框/M7裂变/E12评图分纠偏为已完成(曾漏回写) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
47
backend/alembic/versions/020_product_images_table.py
Normal file
47
backend/alembic/versions/020_product_images_table.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""create product_images table
|
||||||
|
|
||||||
|
Revision ID: 020
|
||||||
|
Revises: 019
|
||||||
|
Create Date: 2026-06-18
|
||||||
|
|
||||||
|
R5 产品多图:一产品多张参考图,每张标 scene 场景类型(primary/scene/
|
||||||
|
texture/ingredient/model),生图按分镜 role 选用对应图,取不到回落主图。
|
||||||
|
product.image_path 仍保留当主图(向后兼容,零破坏)。
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "020"
|
||||||
|
down_revision = "019"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_SCENES = ("primary", "scene", "texture", "ingredient", "model")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
"product_images",
|
||||||
|
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column(
|
||||||
|
"product_id", sa.BigInteger,
|
||||||
|
sa.ForeignKey("products.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("path", sa.String(512), nullable=False, comment="绝对路径,worker直接open"),
|
||||||
|
sa.Column(
|
||||||
|
"scene", sa.Enum(*_SCENES, name="productimagescene"),
|
||||||
|
nullable=False, server_default="primary",
|
||||||
|
comment="场景类型:白底主图/使用场景/质地/成分/上脸",
|
||||||
|
),
|
||||||
|
sa.Column("is_primary", sa.Boolean, nullable=False, server_default=sa.false(),
|
||||||
|
comment="主图(同步 product.image_path)"),
|
||||||
|
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
sa.Column("created_at", sa.DateTime, server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("idx_product_images_product_id", "product_images", ["product_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index("idx_product_images_product_id", table_name="product_images")
|
||||||
|
op.drop_table("product_images")
|
||||||
100
backend/app/api/v1/exports.py
Normal file
100
backend/app/api/v1/exports.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""
|
||||||
|
app/api/v1/exports.py — 客户原始输入物导出(数据归属红线)
|
||||||
|
产品/标杆属客户 client_data,可导出(JSON/CSV);飞轮偏好属平台不在此列。
|
||||||
|
require_write_permission:仅 workspace 成员可导出本工作区数据。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.middleware.workspace_guard import CurrentUser, require_write_permission
|
||||||
|
from app.models.product import BenchmarkNote, Product
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(tags=["exports"])
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_response(rows: list[dict], fieldnames: list[str], filename: str) -> StreamingResponse:
|
||||||
|
"""通用 CSV 流式响应。加 UTF-8 BOM 防 Windows Excel 中文乱码。"""
|
||||||
|
buf = io.StringIO()
|
||||||
|
buf.write("") # BOM
|
||||||
|
writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore")
|
||||||
|
writer.writeheader()
|
||||||
|
for r in rows:
|
||||||
|
writer.writerow(r)
|
||||||
|
buf.seek(0)
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([buf.getvalue()]),
|
||||||
|
media_type="text/csv; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/products/export")
|
||||||
|
def export_products(
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""产品档案导出(JSON)。selling_points 还原为数组,非 JSON 字符串。"""
|
||||||
|
rows = (
|
||||||
|
db.query(Product)
|
||||||
|
.filter(Product.workspace_id == current_user.workspace_id, Product.is_active == True)
|
||||||
|
.order_by(Product.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
"id": p.id,
|
||||||
|
"name": p.name,
|
||||||
|
"category": p.category,
|
||||||
|
"selling_points": json.loads(p.selling_points) if p.selling_points else [],
|
||||||
|
"brand_keyword": p.brand_keyword,
|
||||||
|
"target_audience": p.target_audience,
|
||||||
|
"style_tone": p.style_tone,
|
||||||
|
"created_at": p.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
for p in rows
|
||||||
|
]
|
||||||
|
# 直接返数组(非包 ok 信封):导出供下载/二次处理,前端按 blob 存盘。
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/benchmarks/export")
|
||||||
|
def export_benchmarks(
|
||||||
|
format: str = Query(default="json", pattern="^(json|csv)$"),
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""标杆笔记导出(JSON/CSV)。JOIN Product 填 product_name 便于客户阅读。"""
|
||||||
|
rows = (
|
||||||
|
db.query(BenchmarkNote, Product.name)
|
||||||
|
.outerjoin(Product, BenchmarkNote.product_id == Product.id)
|
||||||
|
.filter(BenchmarkNote.workspace_id == current_user.workspace_id)
|
||||||
|
.order_by(BenchmarkNote.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
"id": b.id,
|
||||||
|
"product_id": b.product_id,
|
||||||
|
"product_name": pname,
|
||||||
|
"highlights": b.highlights or "",
|
||||||
|
"link_url": b.link_url or "",
|
||||||
|
"analyze_status": b.analyze_status,
|
||||||
|
"created_at": b.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
for b, pname in rows
|
||||||
|
]
|
||||||
|
if format == "csv":
|
||||||
|
fields = ["id", "product_id", "product_name", "highlights",
|
||||||
|
"link_url", "analyze_status", "created_at"]
|
||||||
|
return _csv_response(data, fields, "benchmarks.csv")
|
||||||
|
return data
|
||||||
121
backend/app/api/v1/product_images.py
Normal file
121
backend/app/api/v1/product_images.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
app/api/v1/product_images.py — R5 产品多图管理(列/删/改场景/设主图)
|
||||||
|
|
||||||
|
上传走 products.py 的 /products/{id}/upload-image(带 scene)。
|
||||||
|
本文件管已上传图的增删改。product.image_path 始终同步当前主图(向后兼容)。
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.constants.enums import ProductImageScene
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.response import ok, raise_not_found, raise_business
|
||||||
|
from app.middleware.workspace_guard import CurrentUser, require_write_permission
|
||||||
|
from app.models.product import Product, ProductImage
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(tags=["product-images"])
|
||||||
|
|
||||||
|
|
||||||
|
class SceneUpdate(BaseModel):
|
||||||
|
scene: str
|
||||||
|
|
||||||
|
|
||||||
|
def _get_product(db: Session, product_id: int, workspace_id: int) -> Product:
|
||||||
|
p = db.query(Product).filter(
|
||||||
|
Product.id == product_id, Product.workspace_id == workspace_id
|
||||||
|
).first()
|
||||||
|
if not p:
|
||||||
|
raise_not_found("产品不存在")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _get_image(db: Session, product_id: int, image_id: int) -> ProductImage:
|
||||||
|
img = db.query(ProductImage).filter(
|
||||||
|
ProductImage.id == image_id, ProductImage.product_id == product_id
|
||||||
|
).first()
|
||||||
|
if not img:
|
||||||
|
raise_not_found("产品图不存在")
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/products/{product_id}/images")
|
||||||
|
def list_images(
|
||||||
|
product_id: int,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_get_product(db, product_id, current_user.workspace_id)
|
||||||
|
imgs = db.query(ProductImage).filter(
|
||||||
|
ProductImage.product_id == product_id
|
||||||
|
).order_by(ProductImage.sort_order).all()
|
||||||
|
return ok([
|
||||||
|
{"id": im.id, "path": im.path, "scene": im.scene,
|
||||||
|
"is_primary": im.is_primary, "sort_order": im.sort_order}
|
||||||
|
for im in imgs
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/products/{product_id}/images/{image_id}/scene")
|
||||||
|
def update_scene(
|
||||||
|
product_id: int, image_id: int, body: SceneUpdate,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_get_product(db, product_id, current_user.workspace_id)
|
||||||
|
if body.scene not in {s.value for s in ProductImageScene}:
|
||||||
|
raise_business(f"非法场景类型 {body.scene}")
|
||||||
|
img = _get_image(db, product_id, image_id)
|
||||||
|
img.scene = body.scene
|
||||||
|
db.commit()
|
||||||
|
logger.info("product image scene updated: id=%s scene=%s", image_id, body.scene)
|
||||||
|
return ok({"id": image_id, "scene": body.scene})
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/products/{product_id}/images/{image_id}/primary")
|
||||||
|
def set_primary(
|
||||||
|
product_id: int, image_id: int,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
p = _get_product(db, product_id, current_user.workspace_id)
|
||||||
|
img = _get_image(db, product_id, image_id)
|
||||||
|
db.query(ProductImage).filter(
|
||||||
|
ProductImage.product_id == product_id, ProductImage.is_primary == True
|
||||||
|
).update({"is_primary": False})
|
||||||
|
img.is_primary = True
|
||||||
|
p.image_path = img.path # 同步主图字段(管道/校验读此字段)
|
||||||
|
db.commit()
|
||||||
|
logger.info("product primary image set: product_id=%s image_id=%s", product_id, image_id)
|
||||||
|
return ok({"product_id": product_id, "primary_image_id": image_id})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/products/{product_id}/images/{image_id}")
|
||||||
|
def delete_image(
|
||||||
|
product_id: int, image_id: int,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
p = _get_product(db, product_id, current_user.workspace_id)
|
||||||
|
img = _get_image(db, product_id, image_id)
|
||||||
|
was_primary = img.is_primary
|
||||||
|
db.delete(img)
|
||||||
|
db.flush()
|
||||||
|
# 删的是主图:把剩余 sort_order 最小的一张顶上来当主图,保证 image_path 不悬空
|
||||||
|
if was_primary:
|
||||||
|
nxt = db.query(ProductImage).filter(
|
||||||
|
ProductImage.product_id == product_id
|
||||||
|
).order_by(ProductImage.sort_order).first()
|
||||||
|
if nxt:
|
||||||
|
nxt.is_primary = True
|
||||||
|
p.image_path = nxt.path
|
||||||
|
else:
|
||||||
|
p.image_path = None
|
||||||
|
db.commit()
|
||||||
|
logger.info("product image deleted: product_id=%s image_id=%s was_primary=%s",
|
||||||
|
product_id, image_id, was_primary)
|
||||||
|
return ok({"deleted": image_id})
|
||||||
@@ -7,14 +7,14 @@ category 是纯数据字段,不在代码里做枚举(基石A)。
|
|||||||
import logging
|
import logging
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, UploadFile, File
|
from fastapi import APIRouter, Depends, UploadFile, File, Form
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.response import ok, paginate, raise_not_found
|
from app.core.response import ok, paginate, raise_not_found
|
||||||
from app.middleware.workspace_guard import CurrentUser, require_admin, require_write_permission
|
from app.middleware.workspace_guard import CurrentUser, require_admin, require_write_permission
|
||||||
from app.models.product import BannedWord, BenchmarkNote, Product
|
from app.models.product import BannedWord, BenchmarkNote, Product, ProductImage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(tags=["products"])
|
router = APIRouter(tags=["products"])
|
||||||
@@ -50,6 +50,7 @@ class BannedWordCreate(BaseModel):
|
|||||||
|
|
||||||
def _fmt_product(p: Product) -> dict:
|
def _fmt_product(p: Product) -> dict:
|
||||||
import json
|
import json
|
||||||
|
imgs = sorted(getattr(p, "images", None) or [], key=lambda im: im.sort_order)
|
||||||
return {
|
return {
|
||||||
"id": p.id, "name": p.name, "category": p.category,
|
"id": p.id, "name": p.name, "category": p.category,
|
||||||
"source": p.source, "is_active": p.is_active,
|
"source": p.source, "is_active": p.is_active,
|
||||||
@@ -58,6 +59,11 @@ def _fmt_product(p: Product) -> dict:
|
|||||||
"text_angles": json.loads(p.text_angles) if p.text_angles else [],
|
"text_angles": json.loads(p.text_angles) if p.text_angles else [],
|
||||||
"custom_prompt": p.custom_prompt,
|
"custom_prompt": p.custom_prompt,
|
||||||
"image_path": p.image_path,
|
"image_path": p.image_path,
|
||||||
|
"images": [
|
||||||
|
{"id": im.id, "path": im.path, "scene": im.scene,
|
||||||
|
"is_primary": im.is_primary, "sort_order": im.sort_order}
|
||||||
|
for im in imgs
|
||||||
|
],
|
||||||
"brand_keyword": p.brand_keyword, # 012: 套2字段暴露
|
"brand_keyword": p.brand_keyword, # 012: 套2字段暴露
|
||||||
"target_audience": p.target_audience, # 012: 套2字段暴露
|
"target_audience": p.target_audience, # 012: 套2字段暴露
|
||||||
"created_at": p.created_at.isoformat(),
|
"created_at": p.created_at.isoformat(),
|
||||||
@@ -82,7 +88,7 @@ def list_products(
|
|||||||
@router.post("/products")
|
@router.post("/products")
|
||||||
def create_product(
|
def create_product(
|
||||||
body: ProductCreate,
|
body: ProductCreate,
|
||||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
import json
|
import json
|
||||||
@@ -118,7 +124,7 @@ def get_product(
|
|||||||
@router.put("/products/{product_id}")
|
@router.put("/products/{product_id}")
|
||||||
def update_product(
|
def update_product(
|
||||||
product_id: int, body: ProductCreate,
|
product_id: int, body: ProductCreate,
|
||||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
import json
|
import json
|
||||||
@@ -155,10 +161,28 @@ _ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
|||||||
_MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
|
_MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||||
|
|
||||||
|
|
||||||
|
def _check_image_magic(data: bytes) -> bool:
|
||||||
|
"""
|
||||||
|
校验文件头 magic number,防 .exe 改名 .jpg 伪造 content_type 绕过。
|
||||||
|
content_type 来自客户端可伪造,magic number 是真实字节,二者都要过。
|
||||||
|
JPEG: FF D8 FF / PNG: 89 50 4E 47 0D 0A 1A 0A / WebP: RIFF....WEBP
|
||||||
|
"""
|
||||||
|
if len(data) < 12:
|
||||||
|
return False
|
||||||
|
if data[:3] == b"\xff\xd8\xff": # JPEG
|
||||||
|
return True
|
||||||
|
if data[:8] == b"\x89PNG\r\n\x1a\n": # PNG
|
||||||
|
return True
|
||||||
|
if data[:4] == b"RIFF" and data[8:12] == b"WEBP": # WebP
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@router.post("/products/{product_id}/upload-image")
|
@router.post("/products/{product_id}/upload-image")
|
||||||
async def upload_product_image(
|
async def upload_product_image(
|
||||||
product_id: int,
|
product_id: int,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
|
scene: str = Form("primary"),
|
||||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -166,10 +190,12 @@ async def upload_product_image(
|
|||||||
上传产品参考图(铁律:生图必须带产品图)。
|
上传产品参考图(铁律:生图必须带产品图)。
|
||||||
文件类型:JPEG/PNG/WebP;大小上限 10 MB。
|
文件类型:JPEG/PNG/WebP;大小上限 10 MB。
|
||||||
存储路径:uploads/products/{workspace_id}/{product_id}/{filename}
|
存储路径:uploads/products/{workspace_id}/{product_id}/{filename}
|
||||||
写回 product.image_path,生图管道读此字段构建 reference_images。
|
R5多图:每次上传落一条 product_images(带 scene 场景类型)。
|
||||||
|
首张或 scene=primary 时同步写 product.image_path 当主图(向后兼容)。
|
||||||
"""
|
"""
|
||||||
from app.core.response import raise_business
|
from app.core.response import raise_business
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
from app.constants.enums import ProductImageScene
|
||||||
import os, uuid
|
import os, uuid
|
||||||
|
|
||||||
p = db.query(Product).filter(
|
p = db.query(Product).filter(
|
||||||
@@ -179,12 +205,18 @@ async def upload_product_image(
|
|||||||
if not p:
|
if not p:
|
||||||
raise_not_found("产品不存在")
|
raise_not_found("产品不存在")
|
||||||
|
|
||||||
|
valid_scenes = {s.value for s in ProductImageScene}
|
||||||
|
if scene not in valid_scenes:
|
||||||
|
raise_business(f"非法场景类型 {scene},仅支持 {sorted(valid_scenes)}")
|
||||||
|
|
||||||
if file.content_type not in _ALLOWED_CONTENT_TYPES:
|
if file.content_type not in _ALLOWED_CONTENT_TYPES:
|
||||||
raise_business(f"不支持的文件类型 {file.content_type},仅支持 JPEG/PNG/WebP")
|
raise_business(f"不支持的文件类型 {file.content_type},仅支持 JPEG/PNG/WebP")
|
||||||
|
|
||||||
data = await file.read()
|
data = await file.read()
|
||||||
if len(data) > _MAX_SIZE_BYTES:
|
if len(data) > _MAX_SIZE_BYTES:
|
||||||
raise_business("文件超过 10 MB 限制")
|
raise_business("文件超过 10 MB 限制")
|
||||||
|
if not _check_image_magic(data):
|
||||||
|
raise_business("文件内容与扩展名不符(非真实 JPEG/PNG/WebP 图片)")
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
ext = os.path.splitext(file.filename or "img.jpg")[1] or ".jpg"
|
ext = os.path.splitext(file.filename or "img.jpg")[1] or ".jpg"
|
||||||
@@ -197,10 +229,29 @@ async def upload_product_image(
|
|||||||
with open(save_path, "wb") as f:
|
with open(save_path, "wb") as f:
|
||||||
f.write(data)
|
f.write(data)
|
||||||
|
|
||||||
p.image_path = save_path # 绝对路径,worker 直接 open 可读
|
# 是否首张:决定要不要兜底设主图
|
||||||
|
existing = db.query(ProductImage).filter(
|
||||||
|
ProductImage.product_id == product_id
|
||||||
|
).count()
|
||||||
|
is_primary = (existing == 0) or (scene == ProductImageScene.PRIMARY.value)
|
||||||
|
if is_primary:
|
||||||
|
# 同产品其它主图标记降级,保证唯一主图
|
||||||
|
db.query(ProductImage).filter(
|
||||||
|
ProductImage.product_id == product_id,
|
||||||
|
ProductImage.is_primary == True,
|
||||||
|
).update({"is_primary": False})
|
||||||
|
|
||||||
|
img = ProductImage(
|
||||||
|
product_id=product_id, path=save_path, scene=scene,
|
||||||
|
is_primary=is_primary, sort_order=existing,
|
||||||
|
)
|
||||||
|
db.add(img)
|
||||||
|
if is_primary:
|
||||||
|
p.image_path = save_path # 向后兼容:管道/校验仍读此字段当主图
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(p)
|
db.refresh(p)
|
||||||
logger.info("product image uploaded: product_id=%s path=%s", product_id, save_path)
|
logger.info("product image uploaded: product_id=%s scene=%s primary=%s path=%s",
|
||||||
|
product_id, scene, is_primary, save_path)
|
||||||
return ok(_fmt_product(p))
|
return ok(_fmt_product(p))
|
||||||
|
|
||||||
|
|
||||||
@@ -273,6 +324,8 @@ async def analyze_product_image(
|
|||||||
data = await file.read()
|
data = await file.read()
|
||||||
if len(data) > _MAX_SIZE_BYTES:
|
if len(data) > _MAX_SIZE_BYTES:
|
||||||
raise_business("文件超过 10 MB 限制")
|
raise_business("文件超过 10 MB 限制")
|
||||||
|
if not _check_image_magic(data):
|
||||||
|
raise_business("文件内容与扩展名不符(非真实 JPEG/PNG/WebP 图片)")
|
||||||
|
|
||||||
# key 解密(照抄 pipeline_steps.decrypt_user_key,基石B)
|
# key 解密(照抄 pipeline_steps.decrypt_user_key,基石B)
|
||||||
api_key_row = db.query(UserApiKey).filter(
|
api_key_row = db.query(UserApiKey).filter(
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ def select_image(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""选图(飞轮信号 image_select +3)。"""
|
"""选图(飞轮信号 image_select +3)。"""
|
||||||
|
import json
|
||||||
from app.services.flywheel_service import record_signal
|
from app.services.flywheel_service import record_signal
|
||||||
|
from app.constants.enums import IMAGE_STRATEGY_ANGLE
|
||||||
task = _check_task_ownership(
|
task = _check_task_ownership(
|
||||||
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||||||
current_user.workspace_id,
|
current_user.workspace_id,
|
||||||
@@ -70,7 +72,14 @@ def select_image(
|
|||||||
raise_not_found("图片候选不存在")
|
raise_not_found("图片候选不存在")
|
||||||
ic.is_selected = True
|
ic.is_selected = True
|
||||||
db.commit()
|
db.commit()
|
||||||
record_signal(db, current_user, task, "image_select", candidate_id=ic.id)
|
# R7 断点2:选图带 strategy → 映射叙事角度标签进飞轮,与文案 angle_label 并轨;
|
||||||
|
# strategy 原值另存 signal_meta,便于后续按套别复盘。angle_label=None 不再空转。
|
||||||
|
angle = IMAGE_STRATEGY_ANGLE.get(ic.strategy or "")
|
||||||
|
meta = json.dumps({"strategy": ic.strategy, "role": ic.role}, ensure_ascii=False) if ic.strategy else None
|
||||||
|
record_signal(
|
||||||
|
db, current_user, task, "image_select",
|
||||||
|
candidate_id=ic.id, angle_label=angle, signal_meta=meta,
|
||||||
|
)
|
||||||
return ok({"selected": body.candidate_id})
|
return ok({"selected": body.candidate_id})
|
||||||
|
|
||||||
|
|
||||||
@@ -159,7 +168,14 @@ def get_preference_context(
|
|||||||
current_user.workspace_id,
|
current_user.workspace_id,
|
||||||
)
|
)
|
||||||
from app.services.flywheel_service import get_preference_context
|
from app.services.flywheel_service import get_preference_context
|
||||||
ctx = get_preference_context(db, current_user.workspace_id, task.product_id)
|
# 取产品档案供冷启动基线(与生产链同口径)
|
||||||
|
from app.models.product import Product
|
||||||
|
_p = db.query(Product).filter(Product.id == task.product_id).first()
|
||||||
|
product_dict = {
|
||||||
|
"custom_prompt": getattr(_p, "custom_prompt", "") or "",
|
||||||
|
"style_tone": getattr(_p, "style_tone", "") or "",
|
||||||
|
} if _p else {}
|
||||||
|
ctx = get_preference_context(db, current_user.workspace_id, task.product_id, product_dict)
|
||||||
return ok(ctx)
|
return ok(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -170,16 +170,45 @@ def create_task(
|
|||||||
def list_tasks(
|
def list_tasks(
|
||||||
page: int = 1, page_size: int = 20,
|
page: int = 1, page_size: int = 20,
|
||||||
status: list[str] | None = Query(default=None),
|
status: list[str] | None = Query(default=None),
|
||||||
|
date_from: str | None = Query(default=None, description="起始日(YYYY-MM-DD)"),
|
||||||
|
date_to: str | None = Query(default=None, description="结束日(YYYY-MM-DD,含当日)"),
|
||||||
|
product_id: int | None = Query(default=None),
|
||||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from app.models.product import Product
|
||||||
q = db.query(GenerationTask).filter(GenerationTask.workspace_id == current_user.workspace_id)
|
q = db.query(GenerationTask).filter(GenerationTask.workspace_id == current_user.workspace_id)
|
||||||
if status:
|
if status:
|
||||||
# 支持多状态(?status=approved&status=rejected),单值也兼容
|
# 支持多状态(?status=approved&status=rejected),单值也兼容
|
||||||
q = q.filter(GenerationTask.status.in_(status))
|
q = q.filter(GenerationTask.status.in_(status))
|
||||||
|
if product_id is not None:
|
||||||
|
q = q.filter(GenerationTask.product_id == product_id)
|
||||||
|
# 日期筛选:date 形参非法 → 40001(不静默吞成全量,防运营误判)
|
||||||
|
try:
|
||||||
|
if date_from:
|
||||||
|
q = q.filter(GenerationTask.created_at >= datetime.fromisoformat(date_from))
|
||||||
|
if date_to:
|
||||||
|
# date_to 含当日:取次日 0 点为开区间上界,覆盖当日全部时刻
|
||||||
|
_end = datetime.fromisoformat(date_to) + timedelta(days=1)
|
||||||
|
q = q.filter(GenerationTask.created_at < _end)
|
||||||
|
except ValueError:
|
||||||
|
from app.core.response import raise_param_error
|
||||||
|
raise_param_error("date_from/date_to 需为 YYYY-MM-DD 格式")
|
||||||
total = q.count()
|
total = q.count()
|
||||||
items = q.order_by(GenerationTask.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
items = q.order_by(GenerationTask.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||||
return ok(paginate([_fmt_task(t) for t in items], total, page, page_size))
|
# product_name 一次性批量查(防 N+1):收集本页 product_id → id→name dict
|
||||||
|
pids = {t.product_id for t in items if t.product_id}
|
||||||
|
name_map: dict[int, str] = {}
|
||||||
|
if pids:
|
||||||
|
for p in db.query(Product.id, Product.name).filter(Product.id.in_(pids)).all():
|
||||||
|
name_map[p.id] = p.name
|
||||||
|
rows = []
|
||||||
|
for t in items:
|
||||||
|
d = _fmt_task(t)
|
||||||
|
d["product_name"] = name_map.get(t.product_id)
|
||||||
|
rows.append(d)
|
||||||
|
return ok(paginate(rows, total, page, page_size))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{task_id}")
|
@router.get("/{task_id}")
|
||||||
|
|||||||
@@ -75,6 +75,16 @@ SIGNAL_WEIGHTS: dict[str, int] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 生图3套正交叙事策略 → 飞轮角度标签 ───────────────────
|
||||||
|
# 选图信号 angle_label 用此映射,与文案 angle_label 体系并轨进同一偏好聚合。
|
||||||
|
# A痛点先行/B场景先行/C成分背书先行(倩倩姐2026-06-15起草,北哥过目版)。
|
||||||
|
IMAGE_STRATEGY_ANGLE: dict[str, str] = {
|
||||||
|
"A": "痛点先行",
|
||||||
|
"B": "场景先行",
|
||||||
|
"C": "成分背书先行",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── 数据归属 ──────────────────────────────────────────
|
# ── 数据归属 ──────────────────────────────────────────
|
||||||
class DataOwnership(str, Enum):
|
class DataOwnership(str, Enum):
|
||||||
CLIENT_DATA = "client_data" # 原始输入产出,客户可导出
|
CLIENT_DATA = "client_data" # 原始输入产出,客户可导出
|
||||||
@@ -110,6 +120,15 @@ class ImageRole(str, Enum):
|
|||||||
MAIN = "main"
|
MAIN = "main"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 产品参考图场景类型(R5多图:标注每张产品图拍的是什么,供生图按分镜role选用)──
|
||||||
|
class ProductImageScene(str, Enum):
|
||||||
|
PRIMARY = "primary" # 白底/正面主图(默认主图,必有)
|
||||||
|
SCENE = "scene" # 使用场景图(梳妆台/生活环境)
|
||||||
|
TEXTURE = "texture" # 质地特写(膏体/涂抹)
|
||||||
|
INGREDIENT = "ingredient" # 成分/包装细节
|
||||||
|
MODEL = "model" # 上脸/使用中
|
||||||
|
|
||||||
|
|
||||||
# ── AI 图片提供商 ─────────────────────────────────────
|
# ── AI 图片提供商 ─────────────────────────────────────
|
||||||
class ImageProvider(str, Enum):
|
class ImageProvider(str, Enum):
|
||||||
GPT = "gpt"
|
GPT = "gpt"
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ class Settings(BaseSettings):
|
|||||||
CELERY_RESULT_BACKEND: str = ""
|
CELERY_RESULT_BACKEND: str = ""
|
||||||
|
|
||||||
# ── 并发限制(可配置,不写死)─────────────────────
|
# ── 并发限制(可配置,不写死)─────────────────────
|
||||||
MAX_CONCURRENT_TASKS_PER_USER: int = 2
|
# 倩倩姐红线:每用户并发上限 5 个任务(客户侧实际跑几个再观察调整)。
|
||||||
|
MAX_CONCURRENT_TASKS_PER_USER: int = 5
|
||||||
|
|
||||||
# ── 文件存储路径 ──────────────────────────────────
|
# ── 文件存储路径 ──────────────────────────────────
|
||||||
UPLOAD_BASE_PATH: str = "uploads/packages"
|
UPLOAD_BASE_PATH: str = "uploads/packages"
|
||||||
|
|||||||
@@ -59,5 +59,9 @@ def raise_business(message: str) -> None:
|
|||||||
raise CloverHTTPException(422, ErrorCode.BUSINESS_ERROR, message)
|
raise CloverHTTPException(422, ErrorCode.BUSINESS_ERROR, message)
|
||||||
|
|
||||||
|
|
||||||
|
def raise_param_error(message: str = "参数非法") -> None:
|
||||||
|
raise CloverHTTPException(400, ErrorCode.PARAM_INVALID, message)
|
||||||
|
|
||||||
|
|
||||||
def raise_state_invalid(message: str = "状态机非法流转") -> None:
|
def raise_state_invalid(message: str = "状态机非法流转") -> None:
|
||||||
raise CloverHTTPException(409, ErrorCode.STATE_INVALID, message)
|
raise CloverHTTPException(409, ErrorCode.STATE_INVALID, message)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from sqlalchemy import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.constants.enums import BannedWordLevel, ProductSource
|
from app.constants.enums import BannedWordLevel, ProductImageScene, ProductSource
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -52,12 +52,45 @@ class Product(Base):
|
|||||||
benchmark_notes: Mapped[list["BenchmarkNote"]] = relationship(
|
benchmark_notes: Mapped[list["BenchmarkNote"]] = relationship(
|
||||||
back_populates="product", lazy="noload"
|
back_populates="product", lazy="noload"
|
||||||
)
|
)
|
||||||
|
images: Mapped[list["ProductImage"]] = relationship(
|
||||||
|
back_populates="product", lazy="selectin",
|
||||||
|
cascade="all, delete-orphan", order_by="ProductImage.sort_order",
|
||||||
|
)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("idx_products_workspace_id", "workspace_id"),
|
Index("idx_products_workspace_id", "workspace_id"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductImage(Base):
|
||||||
|
"""产品参考图(R5多图):一产品多张,每张标 scene 场景类型,生图按分镜role选用。
|
||||||
|
|
||||||
|
product.image_path 仍保留当主图(向后兼容,零破坏);本表是其超集。
|
||||||
|
"""
|
||||||
|
__tablename__ = "product_images"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
product_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("products.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
path: Mapped[str] = mapped_column(String(512), nullable=False) # 绝对路径,worker 直接 open
|
||||||
|
scene: Mapped[str] = mapped_column(
|
||||||
|
Enum(ProductImageScene, values_callable=lambda x: [e.value for e in x]),
|
||||||
|
default=ProductImageScene.PRIMARY, nullable=False,
|
||||||
|
)
|
||||||
|
is_primary: Mapped[bool] = mapped_column(default=False, nullable=False) # 主图(同步 product.image_path)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
product: Mapped["Product"] = relationship(back_populates="images")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_product_images_product_id", "product_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BenchmarkNote(Base):
|
class BenchmarkNote(Base):
|
||||||
"""标杆笔记(截图+手填亮点为主通道)"""
|
"""标杆笔记(截图+手填亮点为主通道)"""
|
||||||
__tablename__ = "benchmark_notes"
|
__tablename__ = "benchmark_notes"
|
||||||
|
|||||||
@@ -131,6 +131,38 @@ angle(本条角度标签)/ coverTitle(封面大字≤10字)/ imageBrief
|
|||||||
硬性格式:只输出JSON,不要markdown代码块,字符串内用中文引号「」。"""
|
硬性格式:只输出JSON,不要markdown代码块,字符串内用中文引号「」。"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── 第2环标杆爆款配方 → 文案 prompt(借方法层结构,禁抄竞品原话)──────────────
|
||||||
|
_BM_DIM_LABELS: list[tuple[str, str]] = [
|
||||||
|
("title_formula", "标题公式"),
|
||||||
|
("opening_hook", "开篇钩子"),
|
||||||
|
("content_structure", "内容结构"),
|
||||||
|
("selling_point_style", "卖点表达风格"),
|
||||||
|
("emotion_tone", "情绪基调"),
|
||||||
|
("topic_tags", "话题标签思路"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_benchmark_block(refs: list[dict]) -> str:
|
||||||
|
"""把标杆 8维配方渲染成 prompt 块。只借'方法结构',硬约束禁抄竞品品牌/功效原话。"""
|
||||||
|
if not refs:
|
||||||
|
return ""
|
||||||
|
lines: list[str] = []
|
||||||
|
for i, f in enumerate(refs[:3], 1): # 最多取3条标杆,避免prompt膨胀
|
||||||
|
dims = [f"{label}=「{str(f.get(key, '')).strip()}」"
|
||||||
|
for key, label in _BM_DIM_LABELS if str(f.get(key, "")).strip()]
|
||||||
|
if dims:
|
||||||
|
lines.append(f" 标杆{i}:" + ";".join(dims))
|
||||||
|
if not lines:
|
||||||
|
return ""
|
||||||
|
body = "\n".join(lines)
|
||||||
|
return (
|
||||||
|
"\n【对标爆款配方参考(只借方法层结构,绝不照抄)】\n"
|
||||||
|
f"{body}\n"
|
||||||
|
"硬性约束:仅参考上面的标题套路/开篇方式/结构节奏/情绪基调来组织本产品文案;"
|
||||||
|
"禁止照搬竞品品牌名、产品名、功效原话或具体数字;产品信息一律以本产品为准。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_prompt(product: dict, count: int, extra_rules: str = "") -> str:
|
def build_prompt(product: dict, count: int, extra_rules: str = "") -> str:
|
||||||
"""
|
"""
|
||||||
组装文案生成 user_prompt。
|
组装文案生成 user_prompt。
|
||||||
@@ -153,6 +185,7 @@ def build_prompt(product: dict, count: int, extra_rules: str = "") -> str:
|
|||||||
|
|
||||||
angle_hint = f"文案角度要覆盖:{'、'.join(angles)}(每条用不同角度)。" if angles else ""
|
angle_hint = f"文案角度要覆盖:{'、'.join(angles)}(每条用不同角度)。" if angles else ""
|
||||||
brand_rule = f"每条正文和标题中植入品牌词「{brand_kw}」一次(自然融入,不生硬)。" if brand_kw else ""
|
brand_rule = f"每条正文和标题中植入品牌词「{brand_kw}」一次(自然融入,不生硬)。" if brand_kw else ""
|
||||||
|
benchmark_block = _build_benchmark_block(product.get("benchmark_refs") or [])
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
f"产品:{name}",
|
f"产品:{name}",
|
||||||
@@ -161,6 +194,7 @@ def build_prompt(product: dict, count: int, extra_rules: str = "") -> str:
|
|||||||
angle_hint,
|
angle_hint,
|
||||||
brand_rule,
|
brand_rule,
|
||||||
custom,
|
custom,
|
||||||
|
benchmark_block,
|
||||||
f"\n【Q1随机变量池·每条身份/起因/小缺点各不相同,严格按下方分配使用】",
|
f"\n【Q1随机变量池·每条身份/起因/小缺点各不相同,严格按下方分配使用】",
|
||||||
combos_text,
|
combos_text,
|
||||||
extra_rules,
|
extra_rules,
|
||||||
|
|||||||
@@ -70,6 +70,23 @@ PAGE_ROLES = [
|
|||||||
]
|
]
|
||||||
PAGE_ROLE_MAP = {r["role"]: r for r in PAGE_ROLES}
|
PAGE_ROLE_MAP = {r["role"]: r for r in PAGE_ROLES}
|
||||||
|
|
||||||
|
# ── R5多图:分镜role → 优先产品图场景(scene) 偏好表 ──────────
|
||||||
|
# 生图时按分镜 role 选该场景的产品图当参考;取不到回落主图(primary)。
|
||||||
|
# scene 取值见 enums.ProductImageScene:primary/scene/texture/ingredient/model
|
||||||
|
ROLE_SCENE_PREFERENCE = {
|
||||||
|
"hook": ["scene", "primary"], # 封面:真实生活场景优先
|
||||||
|
"pain_scene": ["scene", "primary"], # 痛点共鸣:使用前情境
|
||||||
|
"product_closeup": ["primary"], # 单品特写:白底主图
|
||||||
|
"ingredient": ["ingredient", "primary"], # 成分拆解:成分/包装细节
|
||||||
|
"texture": ["texture", "primary"], # 质地展示:质地特写
|
||||||
|
"applied_proof": ["model", "scene", "primary"], # 上脸:上脸图/场景
|
||||||
|
"social_proof": ["scene", "primary"], # 社交背书:场景
|
||||||
|
"closer": ["primary"], # 促单收尾:主图
|
||||||
|
"scenario": ["scene", "primary"],
|
||||||
|
"tutorial": ["model", "scene", "primary"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── 生图风格预设(扒 image.js STYLE_PROMPTS:26-29)──────────
|
# ── 生图风格预设(扒 image.js STYLE_PROMPTS:26-29)──────────
|
||||||
# 按 style 参数选小红书风格调性,注入 base_prompt 的"视觉风格"行
|
# 按 style 参数选小红书风格调性,注入 base_prompt 的"视觉风格"行
|
||||||
STYLE_PROMPTS = {
|
STYLE_PROMPTS = {
|
||||||
|
|||||||
@@ -14,13 +14,36 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
from .constants import IMAGE_RETRY_ATTEMPTS, IMAGE_RETRY_BACKOFF_BASE, IMAGE_SIZE_DEFAULT
|
from .constants import IMAGE_RETRY_ATTEMPTS, IMAGE_RETRY_BACKOFF_BASE, IMAGE_SIZE_DEFAULT, ROLE_SCENE_PREFERENCE
|
||||||
from .image_scorer import score_image
|
from .image_scorer import score_image
|
||||||
from .storyboard import plan_image_set, sanitize_text
|
from .storyboard import plan_image_set, sanitize_text
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_reference_for_role(
|
||||||
|
role: str,
|
||||||
|
images_by_scene: dict[str, list[bytes]] | None,
|
||||||
|
fallback: list[bytes] | None,
|
||||||
|
) -> tuple[list[bytes] | None, str]:
|
||||||
|
"""R5多图:按分镜 role 选该场景的产品图。取不到回落主图。
|
||||||
|
|
||||||
|
返回 (参考图bytes列表, 命中scene标签用于日志)。
|
||||||
|
"""
|
||||||
|
if images_by_scene:
|
||||||
|
for scene in ROLE_SCENE_PREFERENCE.get(role, ["primary"]):
|
||||||
|
imgs = images_by_scene.get(scene)
|
||||||
|
if imgs:
|
||||||
|
return imgs, scene
|
||||||
|
# 偏好全落空:用任意可用图兜底(仍优先 primary)
|
||||||
|
if images_by_scene.get("primary"):
|
||||||
|
return images_by_scene["primary"], "primary"
|
||||||
|
for scene, imgs in images_by_scene.items():
|
||||||
|
if imgs:
|
||||||
|
return imgs, f"{scene}(兜底)"
|
||||||
|
return fallback, "fallback"
|
||||||
|
|
||||||
|
|
||||||
class ImageClient(Protocol):
|
class ImageClient(Protocol):
|
||||||
"""worker 注入的图片生成客户端协议(隔离 key 细节)"""
|
"""worker 注入的图片生成客户端协议(隔离 key 细节)"""
|
||||||
async def gpt_edits(
|
async def gpt_edits(
|
||||||
@@ -129,12 +152,18 @@ async def generate_storyboard_images(
|
|||||||
strategy: str | None = None,
|
strategy: str | None = None,
|
||||||
target_role: str | None = None,
|
target_role: str | None = None,
|
||||||
custom_prompt: str | None = None,
|
custom_prompt: str | None = None,
|
||||||
|
images_by_scene: dict[str, list[bytes]] | None = None,
|
||||||
|
flywheel_fragment: str | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
按 storyboard 逐张生图(asyncio.gather 并发),返回每张结果列表。
|
按 storyboard 逐张生图(asyncio.gather 并发),返回每张结果列表。
|
||||||
strategy: None=默认叙事,'A'/'B'/'C'=三套正交叙事策略
|
strategy: None=默认叙事,'A'/'B'/'C'=三套正交叙事策略
|
||||||
target_role: 非空时只生成该 role 那一张(R2 单张重生)
|
target_role: 非空时只生成该 role 那一张(R2 单张重生)
|
||||||
custom_prompt: 非空时追加到每张 per_prompt 末尾(R2 人工提示词)
|
custom_prompt: 非空时追加到每张 per_prompt 末尾(R2 人工提示词)
|
||||||
|
images_by_scene: R5多图,{scene: [bytes]},按分镜 role 选对应场景图;
|
||||||
|
为空则全分镜共用 reference_images(向后兼容)。
|
||||||
|
flywheel_fragment: R7 飞轮偏好片段(最近选图/打回真实信号聚合),注入图片
|
||||||
|
排版偏好;仅影响文字角度/版式取向,绝不改瓶身(合规红线)。
|
||||||
每项:{role, name, image_bytes, error}
|
每项:{role, name, image_bytes, error}
|
||||||
"""
|
"""
|
||||||
plan = plan_image_set(note, product, image_count, analysis, strategy=strategy)
|
plan = plan_image_set(note, product, image_count, analysis, strategy=strategy)
|
||||||
@@ -169,8 +198,20 @@ async def generate_storyboard_images(
|
|||||||
# R2 人工提示词:追加到末尾权重最高,但不覆盖前面合规/真实约束
|
# R2 人工提示词:追加到末尾权重最高,但不覆盖前面合规/真实约束
|
||||||
if custom_prompt:
|
if custom_prompt:
|
||||||
per_prompt += f"\n运营补充要求(在不违反上述合规与真实约束前提下尽量满足):{sanitize_text(custom_prompt, 200)}。"
|
per_prompt += f"\n运营补充要求(在不违反上述合规与真实约束前提下尽量满足):{sanitize_text(custom_prompt, 200)}。"
|
||||||
|
# R7 飞轮偏好:仅作用于文字角度/版式取向参考,绝不改瓶身(合规+真实红线)
|
||||||
|
if flywheel_fragment:
|
||||||
|
per_prompt += (
|
||||||
|
f"\n历史偏好参考(仅影响标题文字角度与排版取向,不得据此改动产品瓶身):"
|
||||||
|
f"{sanitize_text(flywheel_fragment, 300)}。"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
img_bytes = await generate_one_image(client, per_prompt, reference_images)
|
# R5多图:按本张分镜 role 选对应场景产品图;无多图则共用 reference_images
|
||||||
|
ref_for_item, _scene_hit = _pick_reference_for_role(
|
||||||
|
item["role"], images_by_scene, reference_images
|
||||||
|
)
|
||||||
|
if images_by_scene:
|
||||||
|
logger.info("分镜 %s 选用产品图场景=%s", item["role"], _scene_hit)
|
||||||
|
img_bytes = await generate_one_image(client, per_prompt, ref_for_item)
|
||||||
# 注:gpt-image-2 渲染中文偶发错别字(约1/6)。vision/OCR 文字校验闸门实测
|
# 注:gpt-image-2 渲染中文偶发错别字(约1/6)。vision/OCR 文字校验闸门实测
|
||||||
# 不可靠(漏报形近字+幻觉误伤品牌词),倩倩姐2026-06-16拍板先撤,纯生图,
|
# 不可靠(漏报形近字+幻觉误伤品牌词),倩倩姐2026-06-16拍板先撤,纯生图,
|
||||||
# 错别字作已知问题记录,后续迭代再处理。详见记忆 clover-image-text-check-shelved。
|
# 错别字作已知问题记录,后续迭代再处理。详见记忆 clover-image-text-check-shelved。
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ def aggregate_preference_context(
|
|||||||
weight = int(e.get("signal_weight", 1))
|
weight = int(e.get("signal_weight", 1))
|
||||||
|
|
||||||
# text_edit(改稿)是最强真实信号,角度按权重计入(倩倩姐2026-06-16拍板)
|
# text_edit(改稿)是最强真实信号,角度按权重计入(倩倩姐2026-06-16拍板)
|
||||||
if sig_type in ("text_select", "approve", "text_edit") and angle:
|
# image_select(选图)按套别叙事角度计入,让选图偏好真正闭环回生图(R7断点2)
|
||||||
|
if sig_type in ("text_select", "approve", "text_edit", "image_select") and angle:
|
||||||
angle_counts[angle] += weight
|
angle_counts[angle] += weight
|
||||||
elif sig_type == "reject_with_reason":
|
elif sig_type == "reject_with_reason":
|
||||||
reason = str(e.get("reason") or "").strip()
|
reason = str(e.get("reason") or "").strip()
|
||||||
|
|||||||
@@ -60,6 +60,19 @@ def retry_fission_note_images(
|
|||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.warning("裂变补图参考图读取失败,无参考图模式: %s", e)
|
logger.warning("裂变补图参考图读取失败,无参考图模式: %s", e)
|
||||||
|
|
||||||
|
# R5多图:补图也按场景分组,重生失败分镜时选对应场景图
|
||||||
|
images_by_scene: dict[str, list[bytes]] = {}
|
||||||
|
for _im in (product.get("images") or []):
|
||||||
|
_p = _resolve_image_path(_im.get("path", ""))
|
||||||
|
if _p and os.path.isfile(_p):
|
||||||
|
try:
|
||||||
|
with open(_p, "rb") as _f:
|
||||||
|
images_by_scene.setdefault(_im.get("scene") or "primary", []).append(_f.read())
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
if reference_images and not images_by_scene.get("primary"):
|
||||||
|
images_by_scene.setdefault("primary", []).extend(reference_images)
|
||||||
|
|
||||||
upload_base = get_settings().UPLOAD_ABS_ROOT
|
upload_base = get_settings().UPLOAD_ABS_ROOT
|
||||||
img_dir = os.path.join(upload_base, str(ft.workspace_id), f"fission_{ft.id}", str(fn.seq))
|
img_dir = os.path.join(upload_base, str(ft.workspace_id), f"fission_{ft.id}", str(fn.seq))
|
||||||
os.makedirs(img_dir, exist_ok=True)
|
os.makedirs(img_dir, exist_ok=True)
|
||||||
@@ -73,6 +86,7 @@ def retry_fission_note_images(
|
|||||||
client=clients, note=note, product=product,
|
client=clients, note=note, product=product,
|
||||||
image_count=image_count, reference_images=reference_images,
|
image_count=image_count, reference_images=reference_images,
|
||||||
target_role=role,
|
target_role=role,
|
||||||
|
images_by_scene=images_by_scene or None,
|
||||||
))
|
))
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.error("裂变补图 seq=%s role=%s 仍失败: %s", fn.seq, role, exc)
|
logger.error("裂变补图 seq=%s role=%s 仍失败: %s", fn.seq, role, exc)
|
||||||
|
|||||||
@@ -39,6 +39,19 @@ def generate_fission_images(
|
|||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.warning("裂变参考图读取失败,无参考图模式: %s", e)
|
logger.warning("裂变参考图读取失败,无参考图模式: %s", e)
|
||||||
|
|
||||||
|
# R5多图:裂变同样按场景分组,生图按分镜 role 选对应图
|
||||||
|
images_by_scene: dict[str, list[bytes]] = {}
|
||||||
|
for _im in (product.get("images") or []):
|
||||||
|
_p = _resolve_image_path(_im.get("path", ""))
|
||||||
|
if _p and os.path.isfile(_p):
|
||||||
|
try:
|
||||||
|
with open(_p, "rb") as _f:
|
||||||
|
images_by_scene.setdefault(_im.get("scene") or "primary", []).append(_f.read())
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
if reference_images and not images_by_scene.get("primary"):
|
||||||
|
images_by_scene.setdefault("primary", []).extend(reference_images)
|
||||||
|
|
||||||
upload_base = get_settings().UPLOAD_ABS_ROOT
|
upload_base = get_settings().UPLOAD_ABS_ROOT
|
||||||
for nid in note_ids:
|
for nid in note_ids:
|
||||||
fn = db.query(FissionNote).filter(FissionNote.id == nid).first()
|
fn = db.query(FissionNote).filter(FissionNote.id == nid).first()
|
||||||
@@ -52,6 +65,7 @@ def generate_fission_images(
|
|||||||
results = asyncio.run(generate_storyboard_images(
|
results = asyncio.run(generate_storyboard_images(
|
||||||
client=clients, note=note, product=product,
|
client=clients, note=note, product=product,
|
||||||
image_count=image_count, reference_images=reference_images,
|
image_count=image_count, reference_images=reference_images,
|
||||||
|
images_by_scene=images_by_scene or None,
|
||||||
))
|
))
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.error("裂变套 seq=%s 生图失败: %s", fn.seq, exc)
|
logger.error("裂变套 seq=%s 生图失败: %s", fn.seq, exc)
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ preference_aggregator:查最近50条 → 最常选角度 + 打回原因近3条
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import desc, func
|
from sqlalchemy import desc
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.constants.enums import SIGNAL_WEIGHTS, DataOwnership, SignalType
|
from app.constants.enums import SIGNAL_WEIGHTS, DataOwnership
|
||||||
from app.middleware.workspace_guard import CurrentUser
|
from app.middleware.workspace_guard import CurrentUser
|
||||||
from app.models.flywheel import PreferenceEvent
|
from app.models.flywheel import PreferenceEvent
|
||||||
from app.models.task import GenerationTask
|
from app.models.task import GenerationTask
|
||||||
@@ -20,8 +20,6 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# 实时聚合窗口:最近50条事件
|
# 实时聚合窗口:最近50条事件
|
||||||
_AGGREGATION_WINDOW = 50
|
_AGGREGATION_WINDOW = 50
|
||||||
# 冷启动阈值:不足5条信号用产品档案冷启动
|
|
||||||
_COLD_START_THRESHOLD = 5
|
|
||||||
|
|
||||||
|
|
||||||
def record_signal(
|
def record_signal(
|
||||||
@@ -32,12 +30,14 @@ def record_signal(
|
|||||||
candidate_id: int | None = None,
|
candidate_id: int | None = None,
|
||||||
angle_label: str | None = None,
|
angle_label: str | None = None,
|
||||||
reason: str | None = None,
|
reason: str | None = None,
|
||||||
|
signal_meta: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
写入飞轮信号。
|
写入飞轮信号。
|
||||||
workspace_id + product_id 都必须有(基石C + 按产品分开学)。
|
workspace_id + product_id 都必须有(基石C + 按产品分开学)。
|
||||||
signal_weight 用枚举默认值,北哥可校准。
|
signal_weight 用枚举默认值,北哥可校准。
|
||||||
data_ownership 默认 client_data(选择行为归客户)。
|
data_ownership 默认 client_data(选择行为归客户)。
|
||||||
|
signal_meta:JSON 扩展(如选图存 strategy),不参与角度聚合主逻辑。
|
||||||
"""
|
"""
|
||||||
weight = SIGNAL_WEIGHTS.get(signal_type, 0)
|
weight = SIGNAL_WEIGHTS.get(signal_type, 0)
|
||||||
event = PreferenceEvent(
|
event = PreferenceEvent(
|
||||||
@@ -50,6 +50,7 @@ def record_signal(
|
|||||||
candidate_id=candidate_id,
|
candidate_id=candidate_id,
|
||||||
angle_label=angle_label,
|
angle_label=angle_label,
|
||||||
reason=reason,
|
reason=reason,
|
||||||
|
signal_meta=signal_meta,
|
||||||
data_ownership=DataOwnership.CLIENT_DATA,
|
data_ownership=DataOwnership.CLIENT_DATA,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -69,14 +70,17 @@ def record_signal(
|
|||||||
|
|
||||||
|
|
||||||
def get_preference_context(
|
def get_preference_context(
|
||||||
db: Session, workspace_id: int, product_id: int
|
db: Session, workspace_id: int, product_id: int,
|
||||||
|
product_dict: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
实时聚合偏好上下文(最近50条 events)。
|
实时聚合偏好上下文(最近50条 events)供前端展示。
|
||||||
返回:recent_preference摘要 + reject_reasons近3条 + injected_count。
|
R7断点3:统一口径——委托给生产链同一个 aggregate_preference_context(权重口径),
|
||||||
不足5条 → 冷启动提示(产品档案兜底,由 AIE prompt 层读 products.custom_prompt)。
|
消除"前端展示按次数/生成按权重"的口径分裂(说一套做一套)。
|
||||||
按 workspace_id + product_id 严格过滤(不串数据,基石C)。
|
按 workspace_id + product_id 严格过滤(不串数据,基石C)。
|
||||||
"""
|
"""
|
||||||
|
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
|
||||||
|
|
||||||
recent = (
|
recent = (
|
||||||
db.query(PreferenceEvent)
|
db.query(PreferenceEvent)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -87,35 +91,18 @@ def get_preference_context(
|
|||||||
.limit(_AGGREGATION_WINDOW)
|
.limit(_AGGREGATION_WINDOW)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
events_dicts = [
|
||||||
if len(recent) < _COLD_START_THRESHOLD:
|
{"signal_type": e.signal_type, "workspace_id": e.workspace_id,
|
||||||
|
"product_id": e.product_id, "angle_label": e.angle_label or "",
|
||||||
|
"signal_weight": e.signal_weight, "reason": e.reason or ""}
|
||||||
|
for e in recent
|
||||||
|
]
|
||||||
|
ctx = aggregate_preference_context(
|
||||||
|
events_dicts, product_dict or {}, workspace_id, product_id
|
||||||
|
)
|
||||||
|
# 前端展示不需要 prompt_fragment(注入用),剥掉只回摘要+原因+计数
|
||||||
return {
|
return {
|
||||||
"recent_preference": "信号不足,使用产品档案基线(冷启动)",
|
"recent_preference": ctx.get("recent_preference", ""),
|
||||||
"reject_reasons": [],
|
"reject_reasons": ctx.get("reject_reasons", []),
|
||||||
"injected_count": len(recent),
|
"injected_count": ctx.get("injected_count", 0),
|
||||||
}
|
|
||||||
|
|
||||||
# 统计最常被选中的角度(text_edit 改稿=最强真实信号,按权重计入,倩倩姐2026-06-16)
|
|
||||||
angle_counts: dict[str, int] = {}
|
|
||||||
for ev in recent:
|
|
||||||
if ev.signal_type in (SignalType.TEXT_SELECT, SignalType.APPROVE, SignalType.TEXT_EDIT) and ev.angle_label:
|
|
||||||
angle_counts[ev.angle_label] = angle_counts.get(ev.angle_label, 0) + 1
|
|
||||||
|
|
||||||
top_angles = sorted(angle_counts.items(), key=lambda x: x[1], reverse=True)[:3]
|
|
||||||
if top_angles:
|
|
||||||
pref_desc = ";".join(f"{a}(已选{c}次)" for a, c in top_angles)
|
|
||||||
preference_summary = f"最近偏好:{pref_desc}"
|
|
||||||
else:
|
|
||||||
preference_summary = "暂无明显角度偏好"
|
|
||||||
|
|
||||||
# 取最近3条打回原因原文(不做 AI 归纳,契约§3)
|
|
||||||
reject_reasons = [
|
|
||||||
ev.reason for ev in recent
|
|
||||||
if ev.signal_type == SignalType.REJECT_WITH_REASON and ev.reason
|
|
||||||
][:3]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"recent_preference": preference_summary,
|
|
||||||
"reject_reasons": reject_reasons,
|
|
||||||
"injected_count": len(recent),
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,28 @@ def _check_user_has_key(db: Session, user_id: int, workspace_id: int) -> None:
|
|||||||
raise_business("尚未配置 API Key,请先在设置中录入")
|
raise_business("尚未配置 API Key,请先在设置中录入")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_concurrency_limit(db: Session, user_id: int, workspace_id: int) -> None:
|
||||||
|
"""
|
||||||
|
校验该用户未完成任务数未超并发上限(红线=5,可配置)。
|
||||||
|
只算 pending/generating(真占 worker),挑选/审核态不计。超限引导稍后再试。
|
||||||
|
"""
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.constants.enums import TaskStatus
|
||||||
|
|
||||||
|
limit = get_settings().MAX_CONCURRENT_TASKS_PER_USER
|
||||||
|
running = (
|
||||||
|
db.query(GenerationTask)
|
||||||
|
.filter(
|
||||||
|
GenerationTask.operator_id == user_id,
|
||||||
|
GenerationTask.workspace_id == workspace_id,
|
||||||
|
GenerationTask.status.in_([TaskStatus.PENDING.value, TaskStatus.GENERATING.value]),
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if running >= limit:
|
||||||
|
raise_business(f"您有 {running} 个任务正在生成,已达并发上限 {limit} 个,请等待完成后再发起")
|
||||||
|
|
||||||
|
|
||||||
def create_generation_task(
|
def create_generation_task(
|
||||||
db: Session,
|
db: Session,
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
@@ -42,6 +64,9 @@ def create_generation_task(
|
|||||||
if body.track == "ai":
|
if body.track == "ai":
|
||||||
# 轨A:先检查有没有 key
|
# 轨A:先检查有没有 key
|
||||||
_check_user_has_key(db, current_user.user_id, current_user.workspace_id)
|
_check_user_has_key(db, current_user.user_id, current_user.workspace_id)
|
||||||
|
# 并发上限:只算正在消耗生成资源的任务(pending/generating),
|
||||||
|
# 已生成完等挑选/审核的不占 worker。红线=每用户5个(可配置)。
|
||||||
|
_check_concurrency_limit(db, current_user.user_id, current_user.workspace_id)
|
||||||
|
|
||||||
# 禁降级铁律:本次产品入镜(need_product_image=True)时,产品必须已上传参考图,
|
# 禁降级铁律:本次产品入镜(need_product_image=True)时,产品必须已上传参考图,
|
||||||
# 否则拒绝建任务(不允许降级纯文生图,防产品包装跑偏/过抽检失败)。
|
# 否则拒绝建任务(不允许降级纯文生图,防产品包装跑偏/过抽检失败)。
|
||||||
@@ -57,6 +82,11 @@ def create_generation_task(
|
|||||||
if not (product.image_path or "").strip():
|
if not (product.image_path or "").strip():
|
||||||
raise_business("该产品未上传参考图,无法生成产品入镜内容;请先到产品库上传产品图,或关闭「产品入镜」开关")
|
raise_business("该产品未上传参考图,无法生成产品入镜内容;请先到产品库上传产品图,或关闭「产品入镜」开关")
|
||||||
|
|
||||||
|
# 第2环:关联标杆笔记ID存库(JSON list)。pipeline 据此读 features_json 注入文案 prompt。
|
||||||
|
import json
|
||||||
|
_bids = getattr(body, "benchmark_ids", None) or []
|
||||||
|
benchmark_ids_json = json.dumps([int(i) for i in _bids]) if _bids else None
|
||||||
|
|
||||||
task = GenerationTask(
|
task = GenerationTask(
|
||||||
workspace_id=current_user.workspace_id,
|
workspace_id=current_user.workspace_id,
|
||||||
product_id=body.product_id,
|
product_id=body.product_id,
|
||||||
@@ -66,6 +96,7 @@ def create_generation_task(
|
|||||||
image_count=body.image_count,
|
image_count=body.image_count,
|
||||||
track=body.track,
|
track=body.track,
|
||||||
need_product_image=need_img,
|
need_product_image=need_img,
|
||||||
|
benchmark_ids=benchmark_ids_json,
|
||||||
status="pending",
|
status="pending",
|
||||||
)
|
)
|
||||||
db.add(task)
|
db.add(task)
|
||||||
|
|||||||
@@ -42,47 +42,62 @@ def build_delivery_package(self, package_id: int) -> dict:
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
upload_base = settings.UPLOAD_BASE_PATH.rstrip("/")
|
upload_base = settings.UPLOAD_BASE_PATH.rstrip("/")
|
||||||
|
|
||||||
selected_text = db.query(TextCandidate).filter(
|
# A8 多套打包:按 strategy(A/B/C 三套正交叙事)分组,每套成一篇独立 note。
|
||||||
|
# 北哥拿到的交付包含完整 3 套(note_01/02/03),不再只打第 1 条文案。
|
||||||
|
selected_texts = db.query(TextCandidate).filter(
|
||||||
TextCandidate.task_id == task_id, TextCandidate.is_selected == True,
|
TextCandidate.task_id == task_id, TextCandidate.is_selected == True,
|
||||||
).first()
|
).order_by(TextCandidate.id).all()
|
||||||
# 整套全打(倩倩姐2026-06-08拍板):一条笔记的全部图按 seq 排序进包,
|
if not selected_texts:
|
||||||
# 不再只打 is_selected 的封面。北哥6张标准套 seq=1 是 hook 封面,天然排第一。
|
|
||||||
selected_images = db.query(ImageCandidate).filter(
|
|
||||||
ImageCandidate.task_id == task_id,
|
|
||||||
).order_by(ImageCandidate.seq).all()
|
|
||||||
|
|
||||||
if not selected_text:
|
|
||||||
raise ValueError("无已选文案,请先选择文案")
|
raise ValueError("无已选文案,请先选择文案")
|
||||||
|
|
||||||
text_data = json.loads(selected_text.content or "{}")
|
# 整套全打:一套内全部图按 seq 排序进包,不只打封面。重生场景同 (strategy,seq)
|
||||||
images_data = []
|
# 可能多条(新增不删旧),去重取最新(id最大),避免包内重复图。
|
||||||
for ic in selected_images:
|
all_images = db.query(ImageCandidate).filter(
|
||||||
|
ImageCandidate.task_id == task_id,
|
||||||
|
).order_by(ImageCandidate.strategy, ImageCandidate.seq).all()
|
||||||
|
|
||||||
|
def _read_image(ic) -> dict:
|
||||||
img_bytes = b""
|
img_bytes = b""
|
||||||
if ic.url:
|
if ic.url:
|
||||||
# url 形如 /uploads/ws/task/file.jpg,本身已含 uploads 前缀。
|
# url 已含 uploads 前缀;工作目录 /app,lstrip 当相对路径读,勿再拼 base(防 uploads/uploads)
|
||||||
# 工作目录是 /app,直接 lstrip("/") 当相对路径读,不能再拼 upload_base(会重复 uploads/uploads)。
|
|
||||||
rel = ic.url.lstrip("/")
|
|
||||||
abs_path = rel
|
|
||||||
try:
|
try:
|
||||||
with open(abs_path, "rb") as f:
|
with open(ic.url.lstrip("/"), "rb") as f:
|
||||||
img_bytes = f.read()
|
img_bytes = f.read()
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.warning("图片读取失败,跳过:%s %s", abs_path, e)
|
logger.warning("图片读取失败,跳过:%s %s", ic.url, e)
|
||||||
images_data.append({
|
return {
|
||||||
"seq": ic.seq,
|
"seq": ic.seq,
|
||||||
"role": ic.role.value if hasattr(ic.role, "value") else str(ic.role),
|
"role": ic.role.value if hasattr(ic.role, "value") else str(ic.role),
|
||||||
"data": img_bytes,
|
"data": img_bytes,
|
||||||
})
|
}
|
||||||
|
|
||||||
notes = [{
|
# 按 strategy 分组(A/B/C;老数据 strategy=None 归一套,向后兼容)
|
||||||
|
from collections import OrderedDict
|
||||||
|
groups: "OrderedDict[str, dict]" = OrderedDict()
|
||||||
|
for ic in all_images:
|
||||||
|
slot = groups.setdefault(ic.strategy or "_", {})
|
||||||
|
prev = slot.get(ic.seq)
|
||||||
|
if prev is None or ic.id > prev.id:
|
||||||
|
slot[ic.seq] = ic # 同 seq 留最新
|
||||||
|
|
||||||
|
notes = []
|
||||||
|
for idx, (_strategy, slot) in enumerate(groups.items()):
|
||||||
|
images_data = [_read_image(slot[k]) for k in sorted(slot)]
|
||||||
|
# 文案配对:选中文案数≥套数则一套一条;否则各套共用第 1 条
|
||||||
|
# (图均以第 1 条文案为语境生成,共用合理;多选则尊重运营按套选的文案)
|
||||||
|
tc = selected_texts[idx] if idx < len(selected_texts) else selected_texts[0]
|
||||||
|
text_data = json.loads(tc.content or "{}")
|
||||||
|
notes.append({
|
||||||
"title": text_data.get("title", ""),
|
"title": text_data.get("title", ""),
|
||||||
"content": text_data.get("content", ""),
|
"content": text_data.get("content", ""),
|
||||||
"tags": text_data.get("tags", []),
|
"tags": text_data.get("tags", []),
|
||||||
"images": images_data,
|
"images": images_data,
|
||||||
"banned_word_status": (selected_text.banned_word_status.value
|
"banned_word_status": (tc.banned_word_status.value
|
||||||
if hasattr(selected_text.banned_word_status, "value")
|
if hasattr(tc.banned_word_status, "value")
|
||||||
else str(selected_text.banned_word_status)),
|
else str(tc.banned_word_status)),
|
||||||
}]
|
})
|
||||||
|
if not notes:
|
||||||
|
raise ValueError("无图片候选,无法打包")
|
||||||
|
|
||||||
from app.services.ai_engine.package_exporter import build_delivery_package as do_build
|
from app.services.ai_engine.package_exporter import build_delivery_package as do_build
|
||||||
# 打包产物放专用目录 uploads/packages/,与图片目录 uploads/{ws}/{task}/ 分开
|
# 打包产物放专用目录 uploads/packages/,与图片目录 uploads/{ws}/{task}/ 分开
|
||||||
|
|||||||
@@ -148,7 +148,8 @@ def run_image_generation(db, clients, task, product_dict: dict,
|
|||||||
first_copy: dict, upload_base_path: str,
|
first_copy: dict, upload_base_path: str,
|
||||||
regen_strategy: str | None = None,
|
regen_strategy: str | None = None,
|
||||||
regen_role: str | None = None,
|
regen_role: str | None = None,
|
||||||
custom_prompt: str | None = None) -> int:
|
custom_prompt: str | None = None,
|
||||||
|
flywheel_fragment: str | None = None) -> int:
|
||||||
"""
|
"""
|
||||||
Step6+7+8(image): 调 generate_storyboard_images → 后处理 → 存 ImageCandidate → 推 SSE。
|
Step6+7+8(image): 调 generate_storyboard_images → 后处理 → 存 ImageCandidate → 推 SSE。
|
||||||
返回 next_seq。
|
返回 next_seq。
|
||||||
@@ -202,6 +203,23 @@ def run_image_generation(db, clients, task, product_dict: dict,
|
|||||||
"拒绝降级纯文生图。请确认产品已上传参考图。"
|
"拒绝降级纯文生图。请确认产品已上传参考图。"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# R5多图:按场景分组加载产品图,生图按分镜 role 选对应场景图
|
||||||
|
images_by_scene: dict[str, list[bytes]] = {}
|
||||||
|
for _im in (product_dict.get("images") or []):
|
||||||
|
_p = _resolve_image_path(_im.get("path", ""))
|
||||||
|
_scene = _im.get("scene") or "primary"
|
||||||
|
if _p and os.path.isfile(_p):
|
||||||
|
try:
|
||||||
|
with open(_p, "rb") as _f:
|
||||||
|
images_by_scene.setdefault(_scene, []).append(_f.read())
|
||||||
|
except Exception as _e:
|
||||||
|
logger.warning("产品图(scene=%s)读取失败,跳过:%s %s", _scene, _p, _e)
|
||||||
|
# 主图始终保底进 primary(多图表为空或主图未入表时仍可用)
|
||||||
|
if reference_images and not images_by_scene.get("primary"):
|
||||||
|
images_by_scene.setdefault("primary", []).extend(reference_images)
|
||||||
|
if images_by_scene:
|
||||||
|
logger.info("R5多图已加载:%s", {k: len(v) for k, v in images_by_scene.items()})
|
||||||
|
|
||||||
seq = seq_start
|
seq = seq_start
|
||||||
# R2: 限定重生套别(regen_strategy)则只跑该套,否则全量 A/B/C 三套正交叙事
|
# R2: 限定重生套别(regen_strategy)则只跑该套,否则全量 A/B/C 三套正交叙事
|
||||||
_strategies = (regen_strategy,) if regen_strategy else ("A", "B", "C")
|
_strategies = (regen_strategy,) if regen_strategy else ("A", "B", "C")
|
||||||
@@ -227,6 +245,8 @@ def run_image_generation(db, clients, task, product_dict: dict,
|
|||||||
strategy=strategy,
|
strategy=strategy,
|
||||||
target_role=regen_role,
|
target_role=regen_role,
|
||||||
custom_prompt=custom_prompt,
|
custom_prompt=custom_prompt,
|
||||||
|
images_by_scene=images_by_scene or None,
|
||||||
|
flywheel_fragment=flywheel_fragment,
|
||||||
))
|
))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
img_success = False
|
img_success = False
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ def build_clients_and_clear_key(plain_key: str):
|
|||||||
def build_product_dict(product) -> dict:
|
def build_product_dict(product) -> dict:
|
||||||
"""把 ORM product 转成 AI 引擎所需的 dict(不含任何 key)。"""
|
"""把 ORM product 转成 AI 引擎所需的 dict(不含任何 key)。"""
|
||||||
return {
|
return {
|
||||||
|
"id": product.id,
|
||||||
"name": product.name,
|
"name": product.name,
|
||||||
"category": product.category or "通用好物",
|
"category": product.category or "通用好物",
|
||||||
"selling_points": json.loads(product.selling_points or "[]"),
|
"selling_points": json.loads(product.selling_points or "[]"),
|
||||||
@@ -66,10 +67,52 @@ def build_product_dict(product) -> dict:
|
|||||||
"custom_prompt": product.custom_prompt or "",
|
"custom_prompt": product.custom_prompt or "",
|
||||||
"brand_keyword": product.brand_keyword or "", # S3: 品牌词透传进生成prompt(每条植入)
|
"brand_keyword": product.brand_keyword or "", # S3: 品牌词透传进生成prompt(每条植入)
|
||||||
"target_audience": product.target_audience or "", # 012: 人群透传进storyboard/文案prompt
|
"target_audience": product.target_audience or "", # 012: 人群透传进storyboard/文案prompt
|
||||||
"image_path": product.image_path or "", # 产品参考图路径(前端上传后填入)
|
"image_path": product.image_path or "", # 产品参考图路径(主图,向后兼容)
|
||||||
|
# R5多图:每张产品图 {path, scene},生图按分镜role选对应场景图
|
||||||
|
"images": [
|
||||||
|
{"path": im.path, "scene": im.scene}
|
||||||
|
for im in (getattr(product, "images", None) or [])
|
||||||
|
],
|
||||||
|
# 第2环标杆配方,默认空;走 AI 主链时由 load_benchmark_features 覆盖填充
|
||||||
|
"benchmark_refs": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_benchmark_features(db, task, workspace_id: int) -> list[dict]:
|
||||||
|
"""
|
||||||
|
第2环→第5环接线:读 task.benchmark_ids → 查 analyze_status=done 的标杆 features_json。
|
||||||
|
返回 8维配方 dict 列表(供 build_prompt 借方法层结构,禁抄竞品品牌/功效原话)。
|
||||||
|
未选/未分析完/解析失败都安全返空,绝不阻断生成。
|
||||||
|
"""
|
||||||
|
from app.models.product import BenchmarkNote
|
||||||
|
|
||||||
|
raw_ids = getattr(task, "benchmark_ids", None)
|
||||||
|
if not raw_ids:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
ids = [int(i) for i in json.loads(raw_ids)]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
rows = db.query(BenchmarkNote).filter(
|
||||||
|
BenchmarkNote.id.in_(ids),
|
||||||
|
BenchmarkNote.workspace_id == workspace_id,
|
||||||
|
BenchmarkNote.analyze_status == "done",
|
||||||
|
).all()
|
||||||
|
|
||||||
|
feats: list[dict] = []
|
||||||
|
for b in rows:
|
||||||
|
if not b.features_json:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
feats.append(json.loads(b.features_json))
|
||||||
|
except Exception:
|
||||||
|
logger.warning("标杆 features_json 解析失败 id=%s", b.id)
|
||||||
|
return feats
|
||||||
|
|
||||||
|
|
||||||
def load_flywheel_context(db, workspace_id: int, product_id: int, product_dict: dict) -> tuple[str, dict]:
|
def load_flywheel_context(db, workspace_id: int, product_id: int, product_dict: dict) -> tuple[str, dict]:
|
||||||
"""
|
"""
|
||||||
查最近50条飞轮事件,聚合偏好上下文。
|
查最近50条飞轮事件,聚合偏好上下文。
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
|
|||||||
build_clients_and_clear_key,
|
build_clients_and_clear_key,
|
||||||
build_product_dict,
|
build_product_dict,
|
||||||
load_flywheel_context,
|
load_flywheel_context,
|
||||||
|
load_benchmark_features,
|
||||||
)
|
)
|
||||||
from app.workers.pipeline_io import run_text_generation, run_image_generation
|
from app.workers.pipeline_io import run_text_generation, run_image_generation
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
@@ -135,6 +136,8 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
|
|||||||
}, seq)
|
}, seq)
|
||||||
|
|
||||||
product_dict = build_product_dict(product)
|
product_dict = build_product_dict(product)
|
||||||
|
# 第2环爆款配方接进文案链:选中并分析完的标杆 8维配方注入 build_prompt(借方法层结构)
|
||||||
|
product_dict["benchmark_refs"] = load_benchmark_features(db, task, workspace_id)
|
||||||
flywheel_fragment, flywheel_ctx = load_flywheel_context(db, workspace_id, task.product_id, product_dict)
|
flywheel_fragment, flywheel_ctx = load_flywheel_context(db, workspace_id, task.product_id, product_dict)
|
||||||
|
|
||||||
if flywheel_fragment:
|
if flywheel_fragment:
|
||||||
@@ -192,6 +195,7 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
|
|||||||
first_copy, settings.UPLOAD_BASE_PATH,
|
first_copy, settings.UPLOAD_BASE_PATH,
|
||||||
regen_strategy=regen_strategy, regen_role=regen_role,
|
regen_strategy=regen_strategy, regen_role=regen_role,
|
||||||
custom_prompt=custom_prompt,
|
custom_prompt=custom_prompt,
|
||||||
|
flywheel_fragment=flywheel_fragment,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 最终状态 + task_done
|
# 最终状态 + task_done
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ def _register_routers(app: FastAPI) -> None:
|
|||||||
from app.api.v1.auth import router as auth_router
|
from app.api.v1.auth import router as auth_router
|
||||||
from app.api.v1.api_keys import router as api_keys_router
|
from app.api.v1.api_keys import router as api_keys_router
|
||||||
from app.api.v1.products import router as products_router
|
from app.api.v1.products import router as products_router
|
||||||
|
from app.api.v1.product_images import router as product_images_router
|
||||||
from app.api.v1.benchmarks import router as benchmarks_router
|
from app.api.v1.benchmarks import router as benchmarks_router
|
||||||
from app.api.v1.tasks import router as tasks_router
|
from app.api.v1.tasks import router as tasks_router
|
||||||
from app.api.v1.task_actions import router as task_actions_router
|
from app.api.v1.task_actions import router as task_actions_router
|
||||||
@@ -79,11 +80,15 @@ def _register_routers(app: FastAPI) -> None:
|
|||||||
from app.api.v1.stream import router as stream_router
|
from app.api.v1.stream import router as stream_router
|
||||||
from app.api.v1.workspaces import router as workspaces_router
|
from app.api.v1.workspaces import router as workspaces_router
|
||||||
from app.api.v1.fission import router as fission_router
|
from app.api.v1.fission import router as fission_router
|
||||||
|
from app.api.v1.exports import router as exports_router
|
||||||
|
|
||||||
prefix = "/api/v1"
|
prefix = "/api/v1"
|
||||||
app.include_router(auth_router, prefix=prefix)
|
app.include_router(auth_router, prefix=prefix)
|
||||||
app.include_router(api_keys_router, prefix=prefix)
|
app.include_router(api_keys_router, prefix=prefix)
|
||||||
|
# 导出 router 必须在 products_router 之前:否则 /products/export 被 /products/{product_id} 吞掉
|
||||||
|
app.include_router(exports_router, prefix=prefix)
|
||||||
app.include_router(products_router, prefix=prefix)
|
app.include_router(products_router, prefix=prefix)
|
||||||
|
app.include_router(product_images_router, prefix=prefix)
|
||||||
app.include_router(benchmarks_router, prefix=prefix)
|
app.include_router(benchmarks_router, prefix=prefix)
|
||||||
app.include_router(tasks_router, prefix=prefix)
|
app.include_router(tasks_router, prefix=prefix)
|
||||||
app.include_router(task_actions_router, prefix=prefix)
|
app.include_router(task_actions_router, prefix=prefix)
|
||||||
|
|||||||
@@ -156,7 +156,10 @@ class TestPoint4SseFlywheelEvent:
|
|||||||
mq.limit.return_value = mq
|
mq.limit.return_value = mq
|
||||||
mq.all.return_value = orm_events
|
mq.all.return_value = orm_events
|
||||||
|
|
||||||
result = get_preference_context(mock_db, workspace_id=1, product_id=10)
|
result = get_preference_context(
|
||||||
|
mock_db, workspace_id=1, product_id=10,
|
||||||
|
product_dict={"custom_prompt": "", "style_tone": ""},
|
||||||
|
)
|
||||||
|
|
||||||
fe_dto_keys = {"recent_preference", "reject_reasons", "injected_count"}
|
fe_dto_keys = {"recent_preference", "reject_reasons", "injected_count"}
|
||||||
missing = fe_dto_keys - result.keys()
|
missing = fe_dto_keys - result.keys()
|
||||||
|
|||||||
@@ -1,30 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
/**
|
/**
|
||||||
* 屏1 配置台(管理员 /config)
|
* 系统管理 /config(仅管理员)
|
||||||
* 产品库 / 标杆笔记 / 违禁词 / API Key 录入
|
* 标杆笔记 / 违禁词库 —— 平台级配置,写操作后端锁 admin
|
||||||
* 🔴 红线:key 只录不显余额(不做 Token 用量展示)
|
* 拆分自原 /config(B方案):运营日常用的 API Key/产品档案已移到 /settings「工作配置」
|
||||||
*/
|
*/
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ProductsTab } from '@/components/config/ProductsTab';
|
|
||||||
import { BenchmarksTab } from '@/components/config/BenchmarksTab';
|
import { BenchmarksTab } from '@/components/config/BenchmarksTab';
|
||||||
import { BannedWordsTab } from '@/components/config/BannedWordsTab';
|
import { BannedWordsTab } from '@/components/config/BannedWordsTab';
|
||||||
import { ApiKeysTab } from '@/components/config/ApiKeysTab';
|
|
||||||
|
|
||||||
type TabKey = 'products' | 'benchmarks' | 'banned_words' | 'api_keys';
|
type TabKey = 'benchmarks' | 'banned_words';
|
||||||
|
|
||||||
const TABS: { key: TabKey; label: string }[] = [
|
const TABS: { key: TabKey; label: string }[] = [
|
||||||
{ key: 'products', label: '产品档案' },
|
|
||||||
{ key: 'benchmarks', label: '标杆笔记' },
|
{ key: 'benchmarks', label: '标杆笔记' },
|
||||||
{ key: 'banned_words', label: '违禁词库' },
|
{ key: 'banned_words', label: '违禁词库' },
|
||||||
{ key: 'api_keys', label: 'API Key' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ConfigPage() {
|
export default function ConfigPage() {
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>('products');
|
const [activeTab, setActiveTab] = useState<TabKey>('benchmarks');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-5xl">
|
<div className="p-6 max-w-5xl">
|
||||||
<h2 className="text-xl font-bold text-text-primary mb-6">配置台</h2>
|
<h2 className="text-xl font-bold text-text-primary mb-6">系统管理</h2>
|
||||||
|
|
||||||
{/* Tab 导航 */}
|
{/* Tab 导航 */}
|
||||||
<div className="flex gap-1 border-b border-border-default mb-6" role="tablist">
|
<div className="flex gap-1 border-b border-border-default mb-6" role="tablist">
|
||||||
@@ -48,10 +44,8 @@ export default function ConfigPage() {
|
|||||||
|
|
||||||
{/* Tab 内容 */}
|
{/* Tab 内容 */}
|
||||||
<div id={`panel-${activeTab}`} role="tabpanel">
|
<div id={`panel-${activeTab}`} role="tabpanel">
|
||||||
{activeTab === 'products' && <ProductsTab />}
|
|
||||||
{activeTab === 'benchmarks' && <BenchmarksTab />}
|
{activeTab === 'benchmarks' && <BenchmarksTab />}
|
||||||
{activeTab === 'banned_words' && <BannedWordsTab />}
|
{activeTab === 'banned_words' && <BannedWordsTab />}
|
||||||
{activeTab === 'api_keys' && <ApiKeysTab />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
77
frontend/src/app/history/HistoryFilters.tsx
Normal file
77
frontend/src/app/history/HistoryFilters.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
'use client';
|
||||||
|
/**
|
||||||
|
* HistoryFilters — 历史归档页筛选条(日期区间 + 产品下拉 + 导出按钮)
|
||||||
|
* 按文件约束从 page.tsx 拆出,避免 page 超行。
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
interface ProductOpt { id: number; name: string }
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
productId: string;
|
||||||
|
onChange: (patch: { dateFrom?: string; dateTo?: string; productId?: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用导出:getBlob 下载,带 Authorization header(复用 DownloadButton 同款 blob 方案)
|
||||||
|
async function exportData(path: string, filename: string) {
|
||||||
|
const blob = await api.getBlob(path);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryFilters({ dateFrom, dateTo, productId, onChange }: Props) {
|
||||||
|
const [products, setProducts] = useState<ProductOpt[]>([]);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<{ items: ProductOpt[] }>('/api/v1/products?page_size=100')
|
||||||
|
.then((d) => setProducts(d.items || []))
|
||||||
|
.catch(() => setProducts([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
await exportData('/api/v1/products/export', 'products.json');
|
||||||
|
} catch { /* 失败静默,按钮恢复可重试 */ }
|
||||||
|
finally { setExporting(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls = 'rounded-lg border border-border-default px-3 py-1.5 text-sm text-text-primary focus:border-brand-orange outline-none';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-text-secondary">
|
||||||
|
起
|
||||||
|
<input type="date" value={dateFrom} max={dateTo || undefined}
|
||||||
|
onChange={(e) => onChange({ dateFrom: e.target.value })} className={inputCls} />
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-text-secondary">
|
||||||
|
止
|
||||||
|
<input type="date" value={dateTo} min={dateFrom || undefined}
|
||||||
|
onChange={(e) => onChange({ dateTo: e.target.value })} className={inputCls} />
|
||||||
|
</label>
|
||||||
|
<select value={productId} onChange={(e) => onChange({ productId: e.target.value })} className={inputCls}>
|
||||||
|
<option value="">全部产品</option>
|
||||||
|
{products.map((p) => <option key={p.id} value={String(p.id)}>{p.name}</option>)}
|
||||||
|
</select>
|
||||||
|
{(dateFrom || dateTo || productId) && (
|
||||||
|
<button onClick={() => onChange({ dateFrom: '', dateTo: '', productId: '' })}
|
||||||
|
className="text-xs text-text-tertiary hover:text-brand-orange underline">清除</button>
|
||||||
|
)}
|
||||||
|
<button onClick={handleExport} disabled={exporting}
|
||||||
|
className="ml-auto rounded-lg border border-border-default px-3 py-1.5 text-sm text-text-secondary hover:border-brand-orange disabled:opacity-50">
|
||||||
|
{exporting ? '导出中…' : '导出产品数据'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
* window.open 无法附带 Authorization header,会 401。
|
* window.open 无法附带 Authorization header,会 401。
|
||||||
* 改法:api.getBlob 拿二进制流 → URL.createObjectURL → a.click → revoke。
|
* 改法:api.getBlob 拿二进制流 → URL.createObjectURL → a.click → revoke。
|
||||||
*/
|
*/
|
||||||
import { useState } from 'react';
|
import { useState, Fragment } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { TaskListItem } from '@/types';
|
import { TaskListItem } from '@/types';
|
||||||
@@ -76,6 +76,7 @@ export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-surface-secondary text-text-secondary">
|
<tr className="bg-surface-secondary text-text-secondary">
|
||||||
<th className="px-4 py-3 text-left font-medium">任务</th>
|
<th className="px-4 py-3 text-left font-medium">任务</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">产品</th>
|
||||||
<th className="px-4 py-3 text-left font-medium">状态</th>
|
<th className="px-4 py-3 text-left font-medium">状态</th>
|
||||||
<th className="px-4 py-3 text-left font-medium">文案</th>
|
<th className="px-4 py-3 text-left font-medium">文案</th>
|
||||||
<th className="px-4 py-3 text-left font-medium">图片</th>
|
<th className="px-4 py-3 text-left font-medium">图片</th>
|
||||||
@@ -85,8 +86,8 @@ export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{tasks.map((task, i) => (
|
{tasks.map((task, i) => (
|
||||||
|
<Fragment key={task.id}>
|
||||||
<tr
|
<tr
|
||||||
key={task.id}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'border-t border-border-default hover:bg-surface-tertiary transition-colors',
|
'border-t border-border-default hover:bg-surface-tertiary transition-colors',
|
||||||
i % 2 === 0 ? 'bg-white' : 'bg-surface-primary'
|
i % 2 === 0 ? 'bg-white' : 'bg-surface-primary'
|
||||||
@@ -95,6 +96,9 @@ export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) {
|
|||||||
<td className="px-4 py-3 text-text-primary font-medium max-w-xs truncate">
|
<td className="px-4 py-3 text-text-primary font-medium max-w-xs truncate">
|
||||||
{task.theme}
|
{task.theme}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
{task.product_name ?? '-'}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className={clsx(
|
<span className={clsx(
|
||||||
'px-2 py-0.5 rounded-full text-xs font-medium',
|
'px-2 py-0.5 rounded-full text-xs font-medium',
|
||||||
@@ -128,6 +132,17 @@ export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{/* 被打回任务:原因独占一行展示,运营见"为何被打回"可重做(R8历史复用) */}
|
||||||
|
{task.status === 'rejected' && task.reject_reason && (
|
||||||
|
<tr className={i % 2 === 0 ? 'bg-white' : 'bg-surface-primary'}>
|
||||||
|
<td colSpan={7} className="px-4 pb-3 pt-0">
|
||||||
|
<div className="rounded-md bg-red-50 border border-red-200 px-3 py-2 text-xs text-red-600">
|
||||||
|
<span className="font-medium">打回原因:</span>{task.reject_reason}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -142,6 +157,7 @@ export function HistorySkeleton() {
|
|||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<div key={i} className="flex gap-4 px-4 py-3 border-t border-border-default first:border-t-0">
|
<div key={i} className="flex gap-4 px-4 py-3 border-t border-border-default first:border-t-0">
|
||||||
<div className="h-4 bg-gray-200 rounded flex-1" />
|
<div className="h-4 bg-gray-200 rounded flex-1" />
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-20" />
|
||||||
<div className="h-4 bg-gray-200 rounded w-16" />
|
<div className="h-4 bg-gray-200 rounded w-16" />
|
||||||
<div className="h-4 bg-gray-200 rounded w-12" />
|
<div className="h-4 bg-gray-200 rounded w-12" />
|
||||||
<div className="h-4 bg-gray-200 rounded w-12" />
|
<div className="h-4 bg-gray-200 rounded w-12" />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { TaskListItem, PaginatedResponse } from '@/types';
|
|||||||
import { getErrorAction } from '@/types/errors';
|
import { getErrorAction } from '@/types/errors';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { HistoryTable, HistorySkeleton, Pagination } from './components';
|
import { HistoryTable, HistorySkeleton, Pagination } from './components';
|
||||||
|
import { HistoryFilters } from './HistoryFilters';
|
||||||
|
|
||||||
const HISTORY_STATUSES = ['approved', 'archived', 'rejected', 'failed'];
|
const HISTORY_STATUSES = ['approved', 'archived', 'rejected', 'failed'];
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
@@ -24,14 +25,21 @@ export default function HistoryPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
|
// M4: 日期区间 + 产品筛选
|
||||||
|
const [filters, setFilters] = useState({ dateFrom: '', dateTo: '', productId: '' });
|
||||||
|
|
||||||
const fetchHistory = useCallback(async (p: number, status: string) => {
|
const fetchHistory = useCallback(async (
|
||||||
|
p: number, status: string, f: { dateFrom: string; dateTo: string; productId: string },
|
||||||
|
) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ page: String(p), page_size: String(PAGE_SIZE) });
|
const params = new URLSearchParams({ page: String(p), page_size: String(PAGE_SIZE) });
|
||||||
if (status !== 'all') params.append('status', status);
|
if (status !== 'all') params.append('status', status);
|
||||||
else HISTORY_STATUSES.forEach((s) => params.append('status', s));
|
else HISTORY_STATUSES.forEach((s) => params.append('status', s));
|
||||||
|
if (f.dateFrom) params.append('date_from', f.dateFrom);
|
||||||
|
if (f.dateTo) params.append('date_to', f.dateTo);
|
||||||
|
if (f.productId) params.append('product_id', f.productId);
|
||||||
const res = await api.get<PaginatedResponse<TaskListItem>>(`/api/v1/tasks?${params}`);
|
const res = await api.get<PaginatedResponse<TaskListItem>>(`/api/v1/tasks?${params}`);
|
||||||
setTasks(res.items);
|
setTasks(res.items);
|
||||||
setTotal(res.total);
|
setTotal(res.total);
|
||||||
@@ -43,7 +51,7 @@ export default function HistoryPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { fetchHistory(page, statusFilter); }, [page, statusFilter, fetchHistory]);
|
useEffect(() => { fetchHistory(page, statusFilter, filters); }, [page, statusFilter, filters, fetchHistory]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||||
|
|
||||||
@@ -74,10 +82,18 @@ export default function HistoryPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* M4: 日期区间 + 产品筛选 + 导出 */}
|
||||||
|
<HistoryFilters
|
||||||
|
dateFrom={filters.dateFrom}
|
||||||
|
dateTo={filters.dateTo}
|
||||||
|
productId={filters.productId}
|
||||||
|
onChange={(patch) => { setFilters((f) => ({ ...f, ...patch })); setPage(1); }}
|
||||||
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-600">
|
<div className="rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-600">
|
||||||
{error}
|
{error}
|
||||||
<button className="ml-4 underline" onClick={() => fetchHistory(page, statusFilter)}>
|
<button className="ml-4 underline" onClick={() => fetchHistory(page, statusFilter, filters)}>
|
||||||
重试
|
重试
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
53
frontend/src/app/settings/page.tsx
Normal file
53
frontend/src/app/settings/page.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
/**
|
||||||
|
* 工作配置 /settings(运营+组长+管理员)
|
||||||
|
* API Key 录入 / 产品档案 —— 各角色日常自助配置
|
||||||
|
* 🔴 红线:key 各人自录自用各算各的;只录不显余额
|
||||||
|
* 拆分自原 /config(B方案):admin 专属的标杆/违禁词留在 /config「系统管理」
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ProductsTab } from '@/components/config/ProductsTab';
|
||||||
|
import { ApiKeysTab } from '@/components/config/ApiKeysTab';
|
||||||
|
|
||||||
|
type TabKey = 'api_keys' | 'products';
|
||||||
|
|
||||||
|
const TABS: { key: TabKey; label: string }[] = [
|
||||||
|
{ key: 'api_keys', label: 'API Key' },
|
||||||
|
{ key: 'products', label: '产品档案' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabKey>('api_keys');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-5xl">
|
||||||
|
<h2 className="text-xl font-bold text-text-primary mb-6">工作配置</h2>
|
||||||
|
|
||||||
|
{/* Tab 导航 */}
|
||||||
|
<div className="flex gap-1 border-b border-border-default mb-6" role="tablist">
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab.key}
|
||||||
|
aria-controls={`panel-${tab.key}`}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'bg-surface-primary border border-border-default border-b-white text-brand-orange'
|
||||||
|
: 'text-text-secondary hover:text-text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab 内容 */}
|
||||||
|
<div id={`panel-${activeTab}`} role="tabpanel">
|
||||||
|
{activeTab === 'api_keys' && <ApiKeysTab />}
|
||||||
|
{activeTab === 'products' && <ProductsTab />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import { RecentTasksBar } from '@/components/tasks/RecentTasksBar';
|
|||||||
import { NewTaskForm } from '@/components/tasks/NewTaskForm';
|
import { NewTaskForm } from '@/components/tasks/NewTaskForm';
|
||||||
import { ImportTextModal } from '@/components/tasks/ImportTextModal';
|
import { ImportTextModal } from '@/components/tasks/ImportTextModal';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Product, CreateTaskRequest } from '@/types/index';
|
import { Product, CreateTaskRequest, Benchmark } from '@/types/index';
|
||||||
import { PaginatedResponse } from '@/types/index';
|
import { PaginatedResponse } from '@/types/index';
|
||||||
import { usePreferenceStore } from '@/stores/preferenceStore';
|
import { usePreferenceStore } from '@/stores/preferenceStore';
|
||||||
import { getErrorAction } from '@/types/errors';
|
import { getErrorAction } from '@/types/errors';
|
||||||
@@ -23,6 +23,7 @@ export default function NewTaskPage() {
|
|||||||
const { context } = usePreferenceStore();
|
const { context } = usePreferenceStore();
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||||
|
const [benchmarks, setBenchmarks] = useState<Benchmark[]>([]);
|
||||||
const [form, setForm] = useState<Omit<CreateTaskRequest, 'product_id'>>({
|
const [form, setForm] = useState<Omit<CreateTaskRequest, 'product_id'>>({
|
||||||
benchmark_ids: [],
|
benchmark_ids: [],
|
||||||
theme: '',
|
theme: '',
|
||||||
@@ -37,6 +38,15 @@ export default function NewTaskPage() {
|
|||||||
|
|
||||||
useEffect(() => { loadProducts(); }, []);
|
useEffect(() => { loadProducts(); }, []);
|
||||||
|
|
||||||
|
// 选品变化:拉该产品标杆笔记;切产品时清空已选标杆(避免跨产品串用)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedProduct) { setBenchmarks([]); return; }
|
||||||
|
setForm((f) => ({ ...f, benchmark_ids: [] }));
|
||||||
|
api.get<{ items: Benchmark[] }>(`/api/v1/products/${selectedProduct.id}/benchmarks`)
|
||||||
|
.then((d) => setBenchmarks(d.items || []))
|
||||||
|
.catch(() => setBenchmarks([]));
|
||||||
|
}, [selectedProduct]);
|
||||||
|
|
||||||
async function loadProducts() {
|
async function loadProducts() {
|
||||||
try {
|
try {
|
||||||
const data = await api.get<PaginatedResponse<Product>>('/api/v1/products');
|
const data = await api.get<PaginatedResponse<Product>>('/api/v1/products');
|
||||||
@@ -125,6 +135,7 @@ export default function NewTaskPage() {
|
|||||||
products={products}
|
products={products}
|
||||||
selectedProduct={selectedProduct}
|
selectedProduct={selectedProduct}
|
||||||
onSelectProduct={setSelectedProduct}
|
onSelectProduct={setSelectedProduct}
|
||||||
|
benchmarks={benchmarks}
|
||||||
form={form}
|
form={form}
|
||||||
onFormChange={(patch) => setForm((f) => ({ ...f, ...patch }))}
|
onFormChange={(patch) => setForm((f) => ({ ...f, ...patch }))}
|
||||||
submitting={submitting}
|
submitting={submitting}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
/**
|
/**
|
||||||
* AuthGuard — 路由守卫
|
* AuthGuard — 路由守卫
|
||||||
* - 未登录 → /login
|
* - 未登录 → /login
|
||||||
* - 越权(组长访问 /config,非管理员等)→ /dashboard
|
* - 越权(运营访问 /config 系统管理等)→ /dashboard
|
||||||
* 扒 banana AuthGuard 范式,全新重建
|
* 扒 banana AuthGuard 范式,全新重建
|
||||||
*/
|
*/
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
@@ -13,6 +13,7 @@ import { UserRole } from '@/types/index';
|
|||||||
// 路由 → 最低所需角色
|
// 路由 → 最低所需角色
|
||||||
const ROUTE_ROLES: Record<string, UserRole[]> = {
|
const ROUTE_ROLES: Record<string, UserRole[]> = {
|
||||||
'/config': ['admin'],
|
'/config': ['admin'],
|
||||||
|
'/settings': ['admin', 'supervisor', 'operator'],
|
||||||
'/review': ['admin', 'supervisor'],
|
'/review': ['admin', 'supervisor'],
|
||||||
'/history': ['admin', 'supervisor', 'operator'],
|
'/history': ['admin', 'supervisor', 'operator'],
|
||||||
'/tasks/new': ['admin', 'supervisor', 'operator'],
|
'/tasks/new': ['admin', 'supervisor', 'operator'],
|
||||||
|
|||||||
154
frontend/src/components/config/ProductImageManager.tsx
Normal file
154
frontend/src/components/config/ProductImageManager.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
'use client';
|
||||||
|
/**
|
||||||
|
* ProductImageManager — R5 产品多图管理
|
||||||
|
* 多图上传(带场景类型) + 设主图 + 改场景 + 删图。
|
||||||
|
* scene 场景类型决定生图时哪张图喂给哪个分镜 role。
|
||||||
|
*/
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { Product, ProductImage, ProductImageScene } from '@/types/dto';
|
||||||
|
|
||||||
|
const SCENE_OPTIONS: { value: ProductImageScene; label: string }[] = [
|
||||||
|
{ value: 'primary', label: '主图/白底' },
|
||||||
|
{ value: 'scene', label: '使用场景' },
|
||||||
|
{ value: 'texture', label: '质地特写' },
|
||||||
|
{ value: 'ingredient', label: '成分/包装' },
|
||||||
|
{ value: 'model', label: '上脸/使用中' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const sceneLabel = (s: string) =>
|
||||||
|
SCENE_OPTIONS.find((o) => o.value === s)?.label ?? s;
|
||||||
|
|
||||||
|
function toUploadSrc(path: string) {
|
||||||
|
return `/uploads/${path.replace(/^.*\/uploads\//, '').replace(/^uploads\//, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductImageManager({
|
||||||
|
product,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
product: Product;
|
||||||
|
onChanged: (updated: Product) => void;
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [scene, setScene] = useState<ProductImageScene>('primary');
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [busyId, setBusyId] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const images = product.images ?? [];
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const updated = await api.get<Product>(`/api/v1/products/${product.id}`);
|
||||||
|
onChanged(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (file.size > 10 * 1024 * 1024) { setError('文件超过 10 MB'); return; }
|
||||||
|
setError('');
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
form.append('scene', scene);
|
||||||
|
await api.postForm<Product>(`/api/v1/products/${product.id}/upload-image`, form);
|
||||||
|
await refresh();
|
||||||
|
} catch {
|
||||||
|
setError('上传失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (inputRef.current) inputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setPrimary(imageId: number) {
|
||||||
|
setBusyId(imageId);
|
||||||
|
try {
|
||||||
|
await api.put(`/api/v1/products/${product.id}/images/${imageId}/primary`, {});
|
||||||
|
await refresh();
|
||||||
|
} finally { setBusyId(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeScene(imageId: number, next: string) {
|
||||||
|
setBusyId(imageId);
|
||||||
|
try {
|
||||||
|
await api.put(`/api/v1/products/${product.id}/images/${imageId}/scene`, { scene: next });
|
||||||
|
await refresh();
|
||||||
|
} finally { setBusyId(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeImage(imageId: number) {
|
||||||
|
setBusyId(imageId);
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/v1/products/${product.id}/images/${imageId}`);
|
||||||
|
await refresh();
|
||||||
|
} finally { setBusyId(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{images.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{images.map((im: ProductImage) => (
|
||||||
|
<div key={im.id}
|
||||||
|
className="flex items-center gap-2 border border-border-default rounded-lg p-2">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={toUploadSrc(im.path)} alt={`${sceneLabel(im.scene)}图`}
|
||||||
|
className="h-12 w-12 object-cover rounded border border-border-default shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
|
<select value={im.scene} disabled={busyId === im.id}
|
||||||
|
onChange={(e) => changeScene(im.id, e.target.value)}
|
||||||
|
aria-label="图片场景类型"
|
||||||
|
className="w-full text-xs border border-border-default rounded px-1 py-0.5">
|
||||||
|
{SCENE_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
{im.is_primary ? (
|
||||||
|
<span className="text-brand-orange font-medium">★ 主图</span>
|
||||||
|
) : (
|
||||||
|
<button type="button" disabled={busyId === im.id}
|
||||||
|
onClick={() => setPrimary(im.id)}
|
||||||
|
className="text-text-secondary hover:text-brand-orange">设主图</button>
|
||||||
|
)}
|
||||||
|
<button type="button" disabled={busyId === im.id}
|
||||||
|
onClick={() => removeImage(im.id)}
|
||||||
|
className="text-red-500 hover:underline ml-auto">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select value={scene} onChange={(e) => setScene(e.target.value as ProductImageScene)}
|
||||||
|
aria-label="新图场景类型"
|
||||||
|
className="text-xs border border-border-default rounded px-2 py-1">
|
||||||
|
{SCENE_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="button" onClick={() => inputRef.current?.click()} disabled={uploading}
|
||||||
|
className="text-xs border border-dashed border-border-default rounded px-3 py-1
|
||||||
|
text-text-secondary hover:border-brand-orange hover:text-brand-orange
|
||||||
|
disabled:opacity-50 transition-colors">
|
||||||
|
{uploading ? '上传中…' : '+ 上传产品图'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{images.length === 0 && (
|
||||||
|
<p className="text-xs text-text-tertiary leading-relaxed">
|
||||||
|
首张必须含产品主体(瓶身/包装本体)当主图,生图以此为锚点保证产品一致。
|
||||||
|
质地图、上脸图等设对应场景类型,生图会按分镜自动选用。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
|
<input ref={inputRef} type="file" accept="image/jpeg,image/png,image/webp"
|
||||||
|
className="hidden" aria-label="选择产品图" onChange={handleFile} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,92 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
/**
|
/**
|
||||||
* ProductCard + ProductForm + ProductsSkeleton + ProductImageUpload — 产品档案子组件
|
* ProductCard + ProductForm + ProductsSkeleton — 产品档案子组件
|
||||||
|
* 产品多图管理拆到 ProductImageManager(R5)。
|
||||||
*/
|
*/
|
||||||
import { useRef, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Product, CreateProductRequest } from '@/types/index';
|
import { Product, CreateProductRequest } from '@/types/index';
|
||||||
|
import { ProductImageManager } from './ProductImageManager';
|
||||||
|
|
||||||
// --- ProductImageUpload ---
|
// ProductImageUpload 旧单图组件已被 ProductImageManager(多图+场景)取代。
|
||||||
export function ProductImageUpload({
|
// 保留导出名向后兼容引用方。
|
||||||
product,
|
export { ProductImageManager as ProductImageUpload };
|
||||||
onUploaded,
|
|
||||||
}: {
|
|
||||||
product: Product;
|
|
||||||
onUploaded: (updated: Product) => void;
|
|
||||||
}) {
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
if (file.size > 10 * 1024 * 1024) { setError('文件超过 10 MB'); return; }
|
|
||||||
setError('');
|
|
||||||
setUploading(true);
|
|
||||||
try {
|
|
||||||
const form = new FormData();
|
|
||||||
form.append('file', file);
|
|
||||||
const updated = await api.postForm<Product>(
|
|
||||||
`/api/v1/products/${product.id}/upload-image`, form
|
|
||||||
);
|
|
||||||
onUploaded(updated);
|
|
||||||
} catch {
|
|
||||||
setError('上传失败,请重试');
|
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
if (inputRef.current) inputRef.current.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-2">
|
|
||||||
{product.image_path ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={`/uploads/${product.image_path.replace(/^.*\/uploads\//, '').replace(/^uploads\//, '')}`}
|
|
||||||
alt={`${product.name}参考图`}
|
|
||||||
className="h-12 w-12 object-cover rounded border border-border-default"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => inputRef.current?.click()}
|
|
||||||
className="text-xs text-brand-orange hover:underline"
|
|
||||||
>
|
|
||||||
换图
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => inputRef.current?.click()}
|
|
||||||
disabled={uploading}
|
|
||||||
className="text-xs border border-dashed border-border-default rounded px-3 py-1
|
|
||||||
text-text-secondary hover:border-brand-orange hover:text-brand-orange
|
|
||||||
disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{uploading ? '上传中…' : '+ 上传产品参考图'}
|
|
||||||
</button>
|
|
||||||
<p className="text-xs text-text-tertiary mt-1 leading-relaxed">
|
|
||||||
必须含产品主体(瓶身/包装本体),生图以此为锚点保证产品一致。
|
|
||||||
质地图、手臂上脸图等可作补充,但不能替代主图单独上传。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg,image/png,image/webp"
|
|
||||||
className="hidden"
|
|
||||||
aria-label="选择产品参考图"
|
|
||||||
onChange={handleFile}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProductCard({ product, onUpdated }: { product: Product; onUpdated: () => void }) {
|
export function ProductCard({ product, onUpdated }: { product: Product; onUpdated: () => void }) {
|
||||||
const [current, setCurrent] = useState<Product>(product);
|
const [current, setCurrent] = useState<Product>(product);
|
||||||
@@ -109,9 +33,9 @@ export function ProductCard({ product, onUpdated }: { product: Product; onUpdate
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<ProductImageUpload
|
<ProductImageManager
|
||||||
product={current}
|
product={current}
|
||||||
onUploaded={(updated) => { setCurrent(updated); onUpdated(); }}
|
onChanged={(updated) => { setCurrent(updated); onUpdated(); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -124,14 +48,18 @@ export function ProductForm({ onSaved, onCancel }: { onSaved: () => void; onCanc
|
|||||||
custom_prompt: null, banned_word_ids: [],
|
custom_prompt: null, banned_word_ids: [],
|
||||||
});
|
});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
async function handleSave(e: React.FormEvent) {
|
async function handleSave(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!form.name) return;
|
if (!form.name.trim()) { setError('请填写产品名称'); return; }
|
||||||
setSaving(true);
|
setSaving(true); setError('');
|
||||||
try {
|
try {
|
||||||
await api.post('/api/v1/products', form);
|
await api.post('/api/v1/products', form);
|
||||||
onSaved();
|
onSaved();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { message?: string })?.message;
|
||||||
|
setError(msg || '保存失败,请重试');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -158,6 +86,7 @@ export function ProductForm({ onSaved, onCancel }: { onSaved: () => void; onCanc
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
|
||||||
<div className="flex gap-3 justify-end">
|
<div className="flex gap-3 justify-end">
|
||||||
<button type="button" onClick={onCancel}
|
<button type="button" onClick={onCancel}
|
||||||
className="px-4 py-2 text-sm text-text-secondary border border-border-default rounded-lg hover:bg-surface-tertiary">
|
className="px-4 py-2 text-sm text-text-secondary border border-border-default rounded-lg hover:bg-surface-tertiary">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
/**
|
/**
|
||||||
* Sidebar — 左侧8项导航
|
* Sidebar — 左侧8项导航
|
||||||
* 按 PRD-前端§2 全局布局
|
* 按 PRD-前端§2 全局布局
|
||||||
* 角色守卫:/review 只组长+管理员;/config 只管理员
|
* 角色守卫:/review 只组长+管理员;/settings 全员;/config 只管理员
|
||||||
*/
|
*/
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
@@ -26,7 +26,8 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
{ label: '裂变扩散', href: '/fission', icon: '🌱' },
|
{ label: '裂变扩散', href: '/fission', icon: '🌱' },
|
||||||
{ label: '审核台', href: '/review', roles: ['supervisor', 'admin'], icon: '✅' },
|
{ label: '审核台', href: '/review', roles: ['supervisor', 'admin'], icon: '✅' },
|
||||||
{ label: '历史归档', href: '/history', icon: '🗂️' },
|
{ label: '历史归档', href: '/history', icon: '🗂️' },
|
||||||
{ label: '配置中心', href: '/config', roles: ['admin'], icon: '⚙️' },
|
{ label: '工作配置', href: '/settings', icon: '🔧' },
|
||||||
|
{ label: '系统管理', href: '/config', roles: ['admin'], icon: '⚙️' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
* NewTaskForm — 开任务表单(屏2主体)
|
* NewTaskForm — 开任务表单(屏2主体)
|
||||||
* 选产品 + 今天主题 + 数量 + 双轨入口
|
* 选产品 + 今天主题 + 数量 + 双轨入口
|
||||||
*/
|
*/
|
||||||
import { Product, CreateTaskRequest } from '@/types/index';
|
import { Product, CreateTaskRequest, Benchmark } from '@/types/index';
|
||||||
import { CountStepper } from './CountStepper';
|
import { CountStepper } from './CountStepper';
|
||||||
|
|
||||||
interface NewTaskFormProps {
|
interface NewTaskFormProps {
|
||||||
products: Product[];
|
products: Product[];
|
||||||
selectedProduct: Product | null;
|
selectedProduct: Product | null;
|
||||||
onSelectProduct: (p: Product | null) => void;
|
onSelectProduct: (p: Product | null) => void;
|
||||||
|
benchmarks: Benchmark[];
|
||||||
form: Omit<CreateTaskRequest, 'product_id'>;
|
form: Omit<CreateTaskRequest, 'product_id'>;
|
||||||
onFormChange: (patch: Partial<Omit<CreateTaskRequest, 'product_id'>>) => void;
|
onFormChange: (patch: Partial<Omit<CreateTaskRequest, 'product_id'>>) => void;
|
||||||
submitting: boolean;
|
submitting: boolean;
|
||||||
@@ -19,7 +20,7 @@ interface NewTaskFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function NewTaskForm({
|
export function NewTaskForm({
|
||||||
products, selectedProduct, onSelectProduct,
|
products, selectedProduct, onSelectProduct, benchmarks,
|
||||||
form, onFormChange, submitting, error,
|
form, onFormChange, submitting, error,
|
||||||
onSubmitAi, onSubmitImport,
|
onSubmitAi, onSubmitImport,
|
||||||
}: NewTaskFormProps) {
|
}: NewTaskFormProps) {
|
||||||
@@ -28,6 +29,15 @@ export function NewTaskForm({
|
|||||||
onFormChange({ [field]: Math.max(1, Math.min(max, form[field] + delta)) });
|
onFormChange({ [field]: Math.max(1, Math.min(max, form[field] + delta)) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 只允许选已分析完(analyze_status=done)的标杆——未分析的无 features 注入无意义
|
||||||
|
const usableBenchmarks = benchmarks.filter((b) => b.analyze_status === 'done');
|
||||||
|
function toggleBenchmark(id: number) {
|
||||||
|
const cur = form.benchmark_ids || [];
|
||||||
|
onFormChange({
|
||||||
|
benchmark_ids: cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 产品参考图状态:image_path 绝对路径(/app/uploads/...)取末段映射成 /uploads/... 供前端访问
|
// 产品参考图状态:image_path 绝对路径(/app/uploads/...)取末段映射成 /uploads/... 供前端访问
|
||||||
const rawPath = selectedProduct?.image_path || '';
|
const rawPath = selectedProduct?.image_path || '';
|
||||||
const hasProductImage = !!rawPath;
|
const hasProductImage = !!rawPath;
|
||||||
@@ -93,6 +103,30 @@ export function NewTaskForm({
|
|||||||
<p className="text-xs text-text-tertiary mt-1">{form.theme.length}/100</p>
|
<p className="text-xs text-text-tertiary mt-1">{form.theme.length}/100</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 对标爆款(可选,多选):选中标杆把其8维配方注入文案生成 */}
|
||||||
|
{usableBenchmarks.length > 0 && (
|
||||||
|
<div className="bg-surface-secondary rounded-lg p-4 space-y-2">
|
||||||
|
<p className="text-sm font-medium text-text-primary">
|
||||||
|
对标爆款(可选)<span className="text-xs text-text-tertiary ml-1">借爆款方法结构,不抄竞品原话</span>
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5 max-h-40 overflow-auto">
|
||||||
|
{usableBenchmarks.map((b) => (
|
||||||
|
<label key={b.id} className="flex items-start gap-2 cursor-pointer text-sm">
|
||||||
|
<input type="checkbox"
|
||||||
|
checked={(form.benchmark_ids || []).includes(b.id)}
|
||||||
|
onChange={() => toggleBenchmark(b.id)}
|
||||||
|
className="w-4 h-4 mt-0.5 accent-brand-orange"
|
||||||
|
aria-label={`选用标杆 ${b.id}`} />
|
||||||
|
<span className="text-text-secondary line-clamp-2">
|
||||||
|
{b.highlights || `标杆 #${b.id}`}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-tertiary">已选 {(form.benchmark_ids || []).length} 个,最多取前 3 个注入</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 数量设定(不写死) */}
|
{/* 数量设定(不写死) */}
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<CountStepper label="生几条文案" value={form.text_count}
|
<CountStepper label="生几条文案" value={form.text_count}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type TaskStatus =
|
|||||||
export interface TaskListItem {
|
export interface TaskListItem {
|
||||||
id: number;
|
id: number;
|
||||||
product_id: number;
|
product_id: number;
|
||||||
|
product_name?: string | null; // M4: 历史页"产品"列,list_tasks 批量 JOIN 填充
|
||||||
theme: string;
|
theme: string;
|
||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ export interface ApiKey {
|
|||||||
export interface CreateApiKeyRequest { provider: string; api_key: string; }
|
export interface CreateApiKeyRequest { provider: string; api_key: string; }
|
||||||
|
|
||||||
// 产品档案
|
// 产品档案
|
||||||
|
export type ProductImageScene = 'primary' | 'scene' | 'texture' | 'ingredient' | 'model';
|
||||||
|
|
||||||
|
export interface ProductImage {
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
scene: ProductImageScene;
|
||||||
|
is_primary: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Product {
|
export interface Product {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -47,7 +57,8 @@ export interface Product {
|
|||||||
text_angles: string[];
|
text_angles: string[];
|
||||||
custom_prompt: string | null;
|
custom_prompt: string | null;
|
||||||
banned_word_ids: number[];
|
banned_word_ids: number[];
|
||||||
image_path: string | null; // 产品参考图路径(铁律:生图带产品图)
|
image_path: string | null; // 产品参考图路径(铁律:生图带产品图,向后兼容=主图)
|
||||||
|
images?: ProductImage[]; // R5多图:每张带 scene 场景类型
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateProductRequest {
|
export interface CreateProductRequest {
|
||||||
@@ -67,6 +78,8 @@ export interface Benchmark {
|
|||||||
screenshot_url: string;
|
screenshot_url: string;
|
||||||
highlights: string;
|
highlights: string;
|
||||||
link_url: string | null;
|
link_url: string | null;
|
||||||
|
analyze_status?: 'pending' | 'analyzing' | 'done' | 'failed';
|
||||||
|
features?: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateBenchmarkRequest {
|
export interface CreateBenchmarkRequest {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type {
|
|||||||
SwitchWorkspaceRequest, SwitchWorkspaceResponse,
|
SwitchWorkspaceRequest, SwitchWorkspaceResponse,
|
||||||
ApiKey, CreateApiKeyRequest,
|
ApiKey, CreateApiKeyRequest,
|
||||||
Product, CreateProductRequest,
|
Product, CreateProductRequest,
|
||||||
|
ProductImage, ProductImageScene,
|
||||||
Benchmark, CreateBenchmarkRequest,
|
Benchmark, CreateBenchmarkRequest,
|
||||||
BannedWordLevel, BannedWord, CreateBannedWordRequest,
|
BannedWordLevel, BannedWord, CreateBannedWordRequest,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
|
|||||||
@@ -64,3 +64,25 @@
|
|||||||
## 搁置/待北哥(攒批)
|
## 搁置/待北哥(攒批)
|
||||||
- 裂变三档/参考度真实业务措辞
|
- 裂变三档/参考度真实业务措辞
|
||||||
- N套真差异化质量验收(北哥过目)
|
- N套真差异化质量验收(北哥过目)
|
||||||
|
|
||||||
|
## 补图机制(中转站波动兜底,2026-06-18 追加并实测✅)
|
||||||
|
|
||||||
|
> 实测发现 codeproxy 偶发不稳,单套中某张图重试3次仍失败会记 error。倩倩姐拍板"不稳定的需要加再次重试的机制"。原则:只重生失败分镜,成功的图与 token 不浪费;生图引擎零改动(R2)。
|
||||||
|
|
||||||
|
| # | 文件 | 改什么 | 状态 |
|
||||||
|
|---|------|--------|------|
|
||||||
|
| RT1 | alembic 019 + models/fission.py | fission_tasks 加 source_task_id(新架构不扇出GenerationTask,补图需复刻首轮上下文:产品/operator key/张数,故落库回查;原 source_fission_id 反查链在新架构下断裂) | ✅ 迁移019 head,列已加 |
|
||||||
|
| RT2 | services/fission_service.py | create_fission 写入 source_task_id | ✅ |
|
||||||
|
| RT3 | services/fission_image_retry.py | 只补标 error 的 role(引擎 target_role 单张重生,R2零改动),merge 回 images_json,全恢复则 status=image_done | ✅ 105行 |
|
||||||
|
| RT4 | workers/tasks.py | retry_fission_images Celery task,key归属源任务operator(基石B不传明文key),幂等锁防重复补图 | ✅ 已注册(celery inspect registered) |
|
||||||
|
| RT5 | api/v1/fission.py | POST /fission/{id}/notes/{seq}/retry-images,无失败分镜守卫 queued=false | ✅ |
|
||||||
|
| RT6 | 前端 FissionNoteCard + notes/page.tsx | 失败提示改"重试补图"按钮+loading,父页轮询6次×8s刷新 | ✅ tsc过 |
|
||||||
|
|
||||||
|
- 端到端实测✅: task5 seq1(hook图首轮codeproxy重试3次失败,note_id=2)触发补图→**只补hook**(1024×1536去AI化新落盘)、**applied_proof原图不动**(时间戳未变)、recovered=1 still_failed=0、status=image_done; apiports503回落codeproxy gpt-5.5强档兜底(R3); seq0无失败图守卫返回queued=false不浪费token
|
||||||
|
- git: 285791c "裂变补图:中转站波动导致单张失败时只重生失败分镜"(8文件)
|
||||||
|
|
||||||
|
## git 提交记录(2026-06-18)
|
||||||
|
- 7f419f4 存量后端(生图素人感约束+评图分+幂等防重跑+审核回路)
|
||||||
|
- d85dcd4 第11环裂变重写(一次LLM出N套完整笔记包)
|
||||||
|
- 25f86b2 存量前端(生图分组/灯箱/重生控件等)
|
||||||
|
- 285791c 裂变补图机制
|
||||||
|
|||||||
@@ -24,15 +24,15 @@ KR1链路连通 + KR2质量达标 + KR3自主可验 + KR4引擎补全(第2环+
|
|||||||
|
|
||||||
| ID | 任务 | 模块核销表 | 细化 | 状态 |
|
| ID | 任务 | 模块核销表 | 细化 | 状态 |
|
||||||
|----|------|-----------|------|------|
|
|----|------|-----------|------|------|
|
||||||
| M3 | 第8环 审核(死链/打回回路) | plans/核销-第8环-审核.md | 🟢S1-S6/A1-A8 | □ |
|
| M3 | 第8环 审核(死链/打回回路) | plans/核销-第8环-审核.md | 🟢S1-S6/A1-A8 | ✅ 对账核实(2026-06-18独立agent):review.py approve(:101)/reject(:127)端点真在,_fmt_task返review_status/reject_reason/reviewed_at(:64-66),RejectReasonBanner在text/images页均挂载,/review审核工作台页+ReviewCard+RejectModal全在,打回→status=pending_selection→运营见原因可重做闭环真通。曾误标□ |
|
||||||
| M4 | 第12环 归档(日期品类筛选/导出) | plans/核销-第12环-归档.md | 🟢S1-S8/A1-A12 | □ |
|
| M4 | 第12环 归档(日期品类筛选/导出) | plans/核销-第12环-归档.md | 🟢S1-S8/A1-A12 | ◐ 后端验过(2026-06-18独立agent):✅A1日期筛选(79→当日4/未来0/非法→40001)✅A2品类筛选(product_id=1命中78+product_name批量防N+1)✅A9产品导出(3条+selling_points真数组)✅A6/A7 zip结构+文案格式;前端代码就绪过typecheck(产品列/日期控件/打回原因banner/导出按钮)但A3/A4/A12目视项待Z阶段浏览器实跑;A10标杆导出逻辑已审ws3无数据未触发 |
|
||||||
| M5 | 工程底座(5并发/magic/选标杆/置灰/lightbox) | plans/核销-工程底座.md | 🟢S1-S6/A1-A8 | □ |
|
| M5 | 工程底座(5并发/magic/选标杆/置灰/lightbox) | plans/核销-工程底座.md | 🟢S1-S6/A1-A8 | ✅ 完成(2026-06-18):✅lightbox ✅置灰 ✅magic(文件头校验,products.py _check_image_magic,2端点接入,验证真图过.exe拒) ✅5并发(config值2→5红线,task_service._check_concurrency_limit算pending/generating,验证5拦4放) ✅选标杆(注入链4断点全打通:task_service存benchmark_ids→pipeline_steps.load_benchmark_features读done态→tasks塞product_dict→_text_prompt渲染8维配方+负向约束;前端NewTaskForm多选UI;DB回环验证benchmark_ids真存库+done过滤+ws隔离;独立agent重核5项真修好,不入图片prompt合规保持) |
|
||||||
|
|
||||||
## P2 引擎补全(第2环标杆 / 第11环裂变)
|
## P2 引擎补全(第2环标杆 / 第11环裂变)
|
||||||
|
|
||||||
| ID | 任务 | 模块核销表 | 细化 | 状态 |
|
| ID | 任务 | 模块核销表 | 细化 | 状态 |
|
||||||
|----|------|-----------|------|------|
|
|----|------|-----------|------|------|
|
||||||
| M6 | 第2环 标杆8特征分析 | plans/核销-第2环-标杆.md | 🟢S1-S12(S12拆a/b)/A1-A12 | ◐ **引擎双路径端到端真验完成**(2026-06-16 c1ac2a6):①文本路径(手填亮点)8维齐全conf0.9 ②截图vision路径(真喂4.7MB素颜霜图)source=vision真读图,识别三段式卖点/√图标/大字报模板/办公背景,看不到的诚实标"截图未体现"。features_json落库done。端点健壮(404/422/读图失败降级/key缺失提示)。**剩非引擎本体待办:①北哥过目8维草案标✅满分 ②第5/6环消费features_json对标(接线,归M6下游或随M1/M2)**。8维=banana不变量+俊达配方+3链接 |
|
| M6 | 第2环 标杆8特征分析 | plans/核销-第2环-标杆.md | 🟢S1-S12(S12拆a/b)/A1-A12 | ◐ **引擎双路径+第5环消费接线完成**(2026-06-16~18):①文本路径(手填亮点)8维齐全conf0.9 ②截图vision路径(真喂4.7MB素颜霜图)source=vision真读图,识别三段式卖点/√图标/大字报模板/办公背景,看不到的诚实标"截图未体现"。features_json落库done。端点健壮(404/422/读图失败降级/key缺失提示)。**✅第5环消费features_json已接通(2026-06-18)**:选标杆注入链4断点全打通(详见M5),用户选的标杆8维配方真进文案build_prompt(借方法层结构+禁抄竞品负向约束),独立agent重核真修好。**剩非引擎本体待办:北哥过目8维草案标✅满分**。8维=banana不变量+俊达配方+3链接 |
|
||||||
| M7 | 第11环 裂变(工作台内) | plans/核销-第11环-裂变.md | 🟢S1-S13(含S10.5)/AC1-12 | ◐ **引擎端到端真跑通**(2026-06-16 cefdbaa):①fission_service扇出1源→N子任务(回填source_fission_id,复用主管道run_generation_pipeline),MAX_FANOUT=5红线 ②fission_prompt三档(high/mid/low)规则 ③tasks.py worker识别裂变子任务→注入对应档位进文案上下文(自抓关键缺口,**worker曾跑旧内存代码未热重载,restart后实测注入日志level=high真出现**) ④POST/fission触发+GET/fission/{id}进度聚合,main.py注册路由 ⑤实测fission_id=2扇出task70/71,子任务带source_fission_id+档位注入生效,进度端点聚合正确(total=2/done=0/generating)。**剩待办:①三档真实措辞待北哥定义(现占位规则) ②前端裂变入口+进度呈现(归前端攒批) ③子任务真出3套差异化文案+配图的质量验收(需北哥过目)** |
|
| M7 | 第11环 裂变(工作台内) | plans/核销-第11环-裂变.md | 🟢S1-S13(含S10.5)/AC1-12 | ◐ **引擎端到端真跑通**(2026-06-16 cefdbaa):①fission_service扇出1源→N子任务(回填source_fission_id,复用主管道run_generation_pipeline),MAX_FANOUT=5红线 ②fission_prompt三档(high/mid/low)规则 ③tasks.py worker识别裂变子任务→注入对应档位进文案上下文(自抓关键缺口,**worker曾跑旧内存代码未热重载,restart后实测注入日志level=high真出现**) ④POST/fission触发+GET/fission/{id}进度聚合,main.py注册路由 ⑤实测fission_id=2扇出task70/71,子任务带source_fission_id+档位注入生效,进度端点聚合正确(total=2/done=0/generating)。**剩待办:①三档真实措辞待北哥定义(现占位规则) ②前端裂变入口+进度呈现(归前端攒批) ③子任务真出3套差异化文案+配图的质量验收(需北哥过目)** |
|
||||||
|
|
||||||
## P3 前端打通+飞轮(2026-06-16 九文档审核结论·按序逐条修)
|
## P3 前端打通+飞轮(2026-06-16 九文档审核结论·按序逐条修)
|
||||||
@@ -44,7 +44,7 @@ KR1链路连通 + KR2质量达标 + KR3自主可验 + KR4引擎补全(第2环+
|
|||||||
| ID | 任务 | 根据 | 状态 |
|
| ID | 任务 | 根据 | 状态 |
|
||||||
|----|------|------|------|
|
|----|------|------|------|
|
||||||
| A1 | Sidebar 4个死链路由(/tasks /images /tasks/build /tasks/list 全不存在) | Sidebar.tsx确证 | ✅ 2026-06-16 删4死链+补回/review审核台(限supervisor+admin),tsc全量EXIT0,commit 77f52e7 |
|
| A1 | Sidebar 4个死链路由(/tasks /images /tasks/build /tasks/list 全不存在) | Sidebar.tsx确证 | ✅ 2026-06-16 删4死链+补回/review审核台(限supervisor+admin),tsc全量EXIT0,commit 77f52e7 |
|
||||||
| D1 | 改稿进飞轮(SignalType.text_edit + TextCandidate.edited + 改稿端点走task_actions) | 最强真实信号 | ◐ 后端✅2026-06-16端到端真跑(edited False→True+飞轮信号落库weight=5,实测抓出ENUM未同步bug已修,commit e3fb6f2)。**前端改稿框入口攒批做(倩倩姐2026-06-16:后端先串完前端攒批)** |
|
| D1 | 改稿进飞轮(SignalType.text_edit + TextCandidate.edited + 改稿端点走task_actions) | 最强真实信号 | ✅ 全链完整(2026-06-18独立agent核实):后端✅端到端真跑(edited False→True+飞轮信号weight=5,commit e3fb6f2)。**前端✅已做完整**:TextCandidateCard.tsx:29-47可编辑textarea+保存改稿/取消按钮,text/page.tsx:29-35真调POST /text-candidates/{id}/edit+同步store,保存后展示"🌿已学习你的改动"+"已改稿"角标。曾误标◐ |
|
||||||
| C2 | 审核界面显分(读score_json总分,不读eval_score NULL列) | 审核可判断 | ✅ 2026-06-16 review.py复用_score_array_to_obj算总分+7维,ReviewCard接ScoreDimBars(passLine=80红线),端到端验task40返回total=83,新旧维度兼容,commit ea423e8 |
|
| C2 | 审核界面显分(读score_json总分,不读eval_score NULL列) | 审核可判断 | ✅ 2026-06-16 review.py复用_score_array_to_obj算总分+7维,ReviewCard接ScoreDimBars(passLine=80红线),端到端验task40返回total=83,新旧维度兼容,commit ea423e8 |
|
||||||
| B7 | 事件处理补齐(SSE/轮询状态流转) | 前端诊断 | ✅ 2026-06-16 核实已做无缺口:SSE签ticket换JWT(?ticket=合规红线)+useSse消费progress/done/error全链+轮询兜底,后端事件发布到位,标绿 |
|
| B7 | 事件处理补齐(SSE/轮询状态流转) | 前端诊断 | ✅ 2026-06-16 核实已做无缺口:SSE签ticket换JWT(?ticket=合规红线)+useSse消费progress/done/error全链+轮询兜底,后端事件发布到位,标绿 |
|
||||||
| B6 | 错误态呈现(status=failed前端可见) | 前端诊断 | ✅ 2026-06-16 后端加TaskStatus.FAILED终态+异常区分重试中vs耗尽(耗尽落failed不retry)+014迁移ALTER status ENUM;前端fatalError state+useSse error不再静默丢+FatalErrorBanner提示+failed标签(列表/历史页可见,history纳入HISTORY_STATUSES),tsc EXIT0 |
|
| B6 | 错误态呈现(status=failed前端可见) | 前端诊断 | ✅ 2026-06-16 后端加TaskStatus.FAILED终态+异常区分重试中vs耗尽(耗尽落failed不retry)+014迁移ALTER status ENUM;前端fatalError state+useSse error不再静默丢+FatalErrorBanner提示+failed标签(列表/历史页可见,history纳入HISTORY_STATUSES),tsc EXIT0 |
|
||||||
@@ -52,16 +52,16 @@ KR1链路连通 + KR2质量达标 + KR3自主可验 + KR4引擎补全(第2环+
|
|||||||
### 桶B 飞轮专项(产品最大亮点·做扎实+前端呈现)
|
### 桶B 飞轮专项(产品最大亮点·做扎实+前端呈现)
|
||||||
| ID | 任务 | 守的底线 | 状态 |
|
| ID | 任务 | 守的底线 | 状态 |
|
||||||
|----|------|---------|------|
|
|----|------|---------|------|
|
||||||
| E12 | AI评图分进飞轮:仅"生成时筛选+前端展示X分已入飞轮" | 权重只认真实信号(选/改/审),eval_score留NULL铁律 | □ |
|
| E12 | AI评图分进飞轮:仅"生成时筛选+前端展示X分已入飞轮" | 权重只认真实信号(选/改/审),eval_score留NULL铁律 | ✅ 全链完整(2026-06-18独立agent核实):ImageStrategyGroup.tsx:89-96 ai_visual_score徽章(≥80绿/≥60橙/<60灰),后端image_gen.py:228→pipeline_io.py:303→tasks.py:146落库透传;eval_score全程NULL(models/task.py:93/124注释+image_gen/pipeline_io/review.py均不碰),红线守住。曾误标□ |
|
||||||
| FV | 飞轮可视化=轻量化关键节点反馈(让客户感到确实越用越好) | 不做独立飞轮主视图大页(砍E01) | □ |
|
| FV | 飞轮可视化=轻量化关键节点反馈(让客户感到确实越用越好) | 不做独立飞轮主视图大页(砍E01) | ✅ 符合决策(2026-06-18核实):FlywheelBanner(new页/text页/images页SSE flywheel_injected事件)+TextCandidateCard"🌿已学习"+AI分徽章,轻量节点反馈嵌现有流程;无独立大看板=砍E01已拍板。曾误标□ |
|
||||||
|
|
||||||
### 桶C 接线(数据流薄,主链通后接)
|
### 桶C 接线(数据流薄,主链通后接)
|
||||||
| ID | 任务 | 状态 |
|
| ID | 任务 | 状态 |
|
||||||
|----|------|------|
|
|----|------|------|
|
||||||
| B1 | 图片按strategy分组(generationStore现一维平铺) | □ |
|
| B1 | 图片按strategy分组(generationStore现一维平铺) | ✅ 同R1已做(对账核实):ImageStrategyGroup按strategy分A/B/C三组,images页:45-47分组+:96-108渲染,非空壳。曾重复挂账□ |
|
||||||
| C1 | 任务看板 | □ |
|
| C1 | 任务看板 | □ 真欠账(2026-06-18核实:仅/history列表+状态tab,无kanban按状态分列视图,零代码)。⚠️攒批问倩倩姐:/history已有状态筛选,是否还需独立kanban看板?可能伪需求 |
|
||||||
| B2/B3/B4/B5 | 前端数据流补齐 | □ |
|
| B2/B3/B4/B5 | 前端数据流补齐 | ✅ 主链完整(2026-06-18核实:generationStore/taskStore/preferenceStore全实现无空壳无TODO,hydrateFromTask历史回填已做);B2-B5核销表本身需求定义模糊,代码层无欠账 |
|
||||||
| C3/C4 | 展示层补薄 | □ |
|
| C3/C4 | 展示层补薄 | C3✅留口子(OVERLAY_TEXT_RENDER_ENABLED=False拍板缓做);C4□代码无对应实现且需求不明,⚠️攒批问倩倩姐C4具体指什么 |
|
||||||
|
|
||||||
### 桶D 踩刹车(砍/缓,防堆伪需求偏离纵切片)
|
### 桶D 踩刹车(砍/缓,防堆伪需求偏离纵切片)
|
||||||
| ID | 任务 | 裁决 |
|
| ID | 任务 | 裁决 |
|
||||||
@@ -72,10 +72,10 @@ KR1链路连通 + KR2质量达标 + KR3自主可验 + KR4引擎补全(第2环+
|
|||||||
### 桶E 需补充(缺口补全)
|
### 桶E 需补充(缺口补全)
|
||||||
| ID | 任务 | 状态 |
|
| ID | 任务 | 状态 |
|
||||||
|----|------|------|
|
|----|------|------|
|
||||||
| 补1 | 上传主图校验(必含产品瓶身,防质地图/手臂图当主图) | □ |
|
| 补1 | 上传主图校验(必含产品瓶身,防质地图/手臂图当主图) | ✅ 同R3已做(对账核实):后端2层硬拦截(task_service.py:46-58建任务校验+pipeline_io.py:199-204 worker兜底防绕过)+前端NewTaskForm:66-77红色警告+按钮置灰。曾重复挂账□ |
|
||||||
| 补2 | status=failed 完整处理(后端+前端) | ✅ 同B6(2026-06-16),后端FAILED终态+前端可见,commit 1b48875/ffc213f |
|
| 补2 | status=failed 完整处理(后端+前端) | ✅ 同B6(2026-06-16),后端FAILED终态+前端可见,commit 1b48875/ffc213f |
|
||||||
| 补3 | git版本兜底 | ✅2026-06-16已建Clover独立仓库,基线6a2632d |
|
| 补3 | git版本兜底 | ✅2026-06-16已建Clover独立仓库,基线6a2632d |
|
||||||
| D2 | 数据复盘(飞轮喂料可见) | □ |
|
| D2 | 数据复盘(飞轮喂料可见) | ◐ 实时注入反馈已做(FlywheelBanner+"已学习"提示,见FV),但历史注入摘要/复盘页未做。⚠️攒批问倩倩姐:轻量节点反馈是否已够,还需独立历史复盘页? |
|
||||||
| D3 | 爆款解析(并入M6标杆) | ⏸️随M6 |
|
| D3 | 爆款解析(并入M6标杆) | ⏸️随M6 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -131,33 +131,33 @@ KR1链路连通 + KR2质量达标 + KR3自主可验 + KR4引擎补全(第2环+
|
|||||||
### R5 产品多图(P1)
|
### R5 产品多图(P1)
|
||||||
| 子项 | 去重映射 | 根因证据 | 状态 |
|
| 子项 | 去重映射 | 根因证据 | 状态 |
|
||||||
|------|---------|---------|------|
|
|------|---------|---------|------|
|
||||||
| 产品图多张(DB+上传端点+前端multiple) | 问题2/14新 | image_path单值字符串,无multiple,覆盖式 | □ |
|
| 产品图多张(DB+上传端点+前端multiple) | 问题2/14新 | image_path单值字符串,无multiple,覆盖式 | ✅ product_images表(alembic020)+ProductImage模型(lazy=selectin)+5端点(upload带scene/列/改场景/设主图/删图,删主图自动提升下张防image_path悬空);前端ProductImageManager.tsx多图画廊(每张缩略图+场景下拉+设主图+删除);tsc EXIT0;API链路实测建品→传2图(primary+scene)→改场景→设主图→删图全code=0 |
|
||||||
| 生图按场景选用对应图 | 问题2新 | pipeline_io只读单条image_path | □ |
|
| 生图按场景选用对应图 | 问题2新 | pipeline_io只读单条image_path | ✅ ROLE_SCENE_PREFERENCE覆盖全10分镜role;_pick_reference_for_role按偏好链选图缺失回落primary再回落fallback;image_gen/pipeline_io/fission_images/fission_image_retry四路均构造并传images_by_scene;真实数据验证texture分镜命中texture图(不同字节确认)、其余回落primary、无多图回落原reference;独立agent交叉核验6链路点全真0实质bug |
|
||||||
| operator选品/快速建品/产品入镜 | 问题1新 | POST/products限admin,operator不能建 | □ |
|
| operator选品/快速建品/产品入镜 | 问题1新 | POST/products限admin,operator不能建 | ✅ create/update放开require_admin→require_write_permission;delete_product保留require_admin(破坏性不放开);operator账号实测建品id=6+传图成功code=0 |
|
||||||
|
|
||||||
### R6 账号体系+Key+权限(P1)
|
### R6 账号体系+Key+权限(P1)
|
||||||
| 子项 | 去重映射 | 根因证据 | 状态 |
|
| 子项 | 去重映射 | 根因证据 | 状态 |
|
||||||
|------|---------|---------|------|
|
|------|---------|---------|------|
|
||||||
| 后端users.py增/删/列用户(删=软删is_active) | 问题15新 | 无users CRUD,is_active登录已校验:37 | □ |
|
| 后端users.py增/删/列用户(删=软删is_active) | 问题15新 | 无users CRUD,is_active登录已校验:37 | ⏸️暂停(倩倩姐2026-06-18拍板:现有4种子账号够北哥测,加人能力留后;先推R7飞轮) |
|
||||||
| 前端配置台账号管理Tab | 问题15新 | 无账号管理入口 | □ |
|
| 前端配置台账号管理Tab | 问题15新 | 无账号管理入口 | ⏸️暂停(同上,随后端users CRUD一起做) |
|
||||||
| Key绑定账号显式展示 | 问题15新 | UserApiKey已按user隔离,只缺前端展示 | □ |
|
| Key绑定账号显式展示 | 问题15新 | UserApiKey已按user隔离,只缺前端展示 | ✅ B方案拆页:运营进/settings「工作配置」自录key;实测operator录key(id=4 last4=0001)归user_id=3、admin的key(id=2)归user_id=1,DB核对user_id严格隔离各算各的;只显末4位不显余额(守红线) |
|
||||||
| operator配置编辑权(放开全部产品) | 问题13a新 | /config硬限admin,products CRUD require_admin | □ |
|
| operator配置编辑权(放开全部产品) | 问题13a新 | /config硬限admin,products CRUD require_admin | ✅ B方案:配置页按角色拆/settings(运营+组长+admin,含APIKey+产品档案)+/config(仅admin,标杆+违禁词);Sidebar+AuthGuard双层放开/settings给三角色;前缀匹配无串扰;角色矩阵静态核查无死链;独立agent交叉核验0穿透漏洞;后端require_write_permission已对齐 |
|
||||||
| 保存按钮补错误提示(治点不动) | 问题13b新 | ProductForm校验静默return无提示 | □ |
|
| 保存按钮补错误提示(治点不动) | 问题13b新 | ProductForm校验静默return无提示 | ✅ ProductForm加error state:空名提示"请填写产品名称"+catch后端报错展示(role=alert);tsc EXIT0 |
|
||||||
|
|
||||||
### R7 飞轮闭环+可视化(P2)
|
### R7 飞轮闭环+可视化(P2)
|
||||||
| 子项 | 去重映射 | 根因证据 | 状态 |
|
| 子项 | 去重映射 | 根因证据 | 状态 |
|
||||||
|------|---------|---------|------|
|
|------|---------|---------|------|
|
||||||
| 飞轮片段注入生图链路(补断点1) | 新 | run_image_generation没传flywheel_fragment | □ |
|
| 飞轮片段注入生图链路(补断点1) | 新 | run_image_generation没传flywheel_fragment | ✅ generate_storyboard_images+run_image_generation加flywheel_fragment参数,tasks.py传load_flywheel_context真实聚合结果;拼per_prompt措辞"仅影响文字角度/排版不改瓶身"守红线;独立agent复核"真修好" |
|
||||||
| 选图信号带strategy进飞轮(补断点2) | 新 | image_select的angle_label=None空转 | □ |
|
| 选图信号带strategy进飞轮(补断点2) | 新 | image_select的angle_label=None空转 | ✅ IMAGE_STRATEGY_ANGLE映射A痛点/B场景/C成分背书,image_select取ic.strategy映射angle_label+原值存signal_meta;record_signal加signal_meta参;strategy空安全降级;复核"真修好" |
|
||||||
| 统一前端查询与注入的计数口径 | 新 | service用次数/aggregator用权重不一致 | □ |
|
| 统一前端查询与注入的计数口径 | 新 | service用次数/aggregator用权重不一致 | ✅ get_preference_context改委托aggregate_preference_context(权重口径),image_select纳入角度聚合;展示端点与生产链同源,交叉验证输出字符串完全一致;复核"真修好" |
|
||||||
| AI评图分仅展示+生成筛选 | =既有E12 | 守eval_score NULL红线 | □ |
|
| AI评图分仅展示+生成筛选 | =既有E12 | 守eval_score NULL红线 | ✅ 既有已达标:ai_visual_score只展示+筛选,eval_score全程NULL,本轮改动复核确认未碰;ImageStrategyGroup徽章展示AI分 |
|
||||||
| 飞轮可视化轻量节点反馈 | =既有FV/D2 | 让客户感到越用越好 | □ |
|
| 飞轮可视化轻量节点反馈 | =既有FV/D2 | 让客户感到越用越好 | ◐ 已有雏形:生成时FlywheelBanner"本次已注入偏好角度"+改稿TextCandidateCard"🌿已学习你的改动"+AI分徽章;无独立大看板(符合砍E01红线) |
|
||||||
|
|
||||||
### R8 归档(P2)
|
### R8 归档(P2)
|
||||||
| 子项 | 去重映射 | 根因证据 | 状态 |
|
| 子项 | 去重映射 | 根因证据 | 状态 |
|
||||||
|------|---------|---------|------|
|
|------|---------|---------|------|
|
||||||
| 历史打回显示原因 | =问题10,既有M4沾边 | _fmt_task()没返reject_reason,前端没读 | □ |
|
| 历史打回显示原因 | =问题10,既有M4沾边 | ✅后端_fmt_task返review_status/reject_reason/reviewed_at(tasks.py:58),前端HistoryTable加rejected行红banner展示打回原因(代码就绪,Z阶段目视) | ◐ |
|
||||||
| 日期/品类筛选+导出 | =既有M4 | M4未开工 | □ |
|
| 日期/品类筛选+导出 | =既有M4 | ✅后端筛选链+导出端点独立agent验过(A1/A2/A9),前端HistoryFilters就绪待目视 | ◐ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -14,24 +14,24 @@
|
|||||||
| S3 | history/page.tsx 加 dateFrom/dateTo state + 两个 `<input type=date>`(提 DateRangeFilter 子组件防超100行) | history/page.tsx:28-44 + JSX 50-94 |
|
| S3 | history/page.tsx 加 dateFrom/dateTo state + 两个 `<input type=date>`(提 DateRangeFilter 子组件防超100行) | history/page.tsx:28-44 + JSX 50-94 |
|
||||||
| S4 | HistoryTable 表头/行加"产品"列(显 product_name ?? '-') | history/components.tsx:70-127 |
|
| S4 | HistoryTable 表头/行加"产品"列(显 product_name ?? '-') | history/components.tsx:70-127 |
|
||||||
| S5 | TaskListItem 加 `product_name?:string` | dto.tasks.ts:8-16 |
|
| S5 | TaskListItem 加 `product_name?:string` | dto.tasks.ts:8-16 |
|
||||||
| S6 | ⚠️多套打包:查所有 is_selected=True TextCandidate 按 angle_label 分组每组取第一;notes 列表长度=选中套数(改 line 77-85 为 for loop);images_data 循环外查一次,每套传 `list(images_data)` 副本(退化:图共享复制进各套夹) | packaging_task.py:45-47,77-85 |
|
| S6 | ✅多套打包(2026-06-18实做修正分组依据):实测真相=图按 `ImageCandidate.strategy`(A/B/C三套正交叙事)分组,三套图均以first_copy第1条文案为语境生成→**按strategy分组比angle_label更贴数据实际,且无需加set_index列**。改 packaging_task.py:查全部图按strategy分组(OrderedDict)+同(strategy,seq)取最新id去重(防重生重复)+老数据strategy=None归"_"单套兼容;文案配对:选中文案数≥套数则一套一条否则共用第1条;每套成独立note进exporter | packaging_task.py:45-100 |
|
||||||
| S7 | 新建 GET /products/export + /benchmarks/export(format=json/csv);require_write_permission;⚠️benchmarks/export 需 Product JOIN 填 product_name(复用 _fmt_benchmark 不够,只有 product_id) | products.py + benchmarks.py |
|
| S7 | 新建 GET /products/export + /benchmarks/export(format=json/csv);require_write_permission;⚠️benchmarks/export 需 Product JOIN 填 product_name(复用 _fmt_benchmark 不够,只有 product_id) | products.py + benchmarks.py |
|
||||||
| S8 | 配置页产品 tab + 历史归档页各加"导出数据"按钮,通用 ExportButton(url,filename) 调 getBlob 下载 | history/components.tsx 或 config/ExportTab.tsx |
|
| S8 | 配置页产品 tab + 历史归档页各加"导出数据"按钮,通用 ExportButton(url,filename) 调 getBlob 下载 | history/components.tsx 或 config/ExportTab.tsx |
|
||||||
|
|
||||||
## 验收清单(做完一个勾一个)
|
## 验收清单(做完一个勾一个)
|
||||||
|
|
||||||
- [ ] **A1**〔auto〕日期筛选:传 date_from/date_to → items 全落区间;传 date_from=2099 → 空数组 total=0。⚠️审计补:再验 `date_from=not-a-date` → code=40001(非500)。
|
- [x] **A1**〔auto〕日期筛选:✅2026-06-18 ws3实测:全量79条→当日(2026-06-17)区间过滤=4条,未来(2099+)=0条;非法日期"not-a-date"→raise_param_error返400/40001(非500)。date_to含当日(+1天开区间)。
|
||||||
- [ ] **A2**〔auto〕product_id 筛选:传 product_id=1 → 每条 product_id=1 且 product_name 非空。
|
- [x] **A2**〔auto〕product_id 筛选:✅2026-06-18 ws3实测:product_id=1过滤命中78条,product_name批量查到'素颜霜'(防N+1:set收集→一次in_查询建name_map)。
|
||||||
- [ ] **A3**〔human〕前端产品列:/history 表头有"产品"列,每行显产品名(非数字),与 GET /products/{id}.name 一致。
|
- [ ] **A3**〔human〕前端产品列:/history 表头有"产品"列,每行显产品名(非数字),与 GET /products/{id}.name 一致。【代码已就绪:components.tsx加产品列+dto加product_name,typecheck过+独立审核过;待Z阶段浏览器目视】
|
||||||
- [ ] **A4**〔human〕日期控件:有开始/结束 date input,填后表格刷新,XHR URL 含 date_from/date_to,结果≤全量。
|
- [ ] **A4**〔human〕日期控件:有开始/结束 date input,填后表格刷新,XHR URL 含 date_from/date_to,结果≤全量。【代码已就绪:HistoryFilters.tsx日期input+产品下拉+清除,page接入筛选变化重置页码;待Z阶段浏览器目视】
|
||||||
- [ ] **A5**〔human〕下载交付包:approved 任务点"下载交付包",≤60s 触发下载 delivery-{id}.zip(>1KB);`unzip -l` 含 note_01/文案.txt/发布清单/合规说明。
|
- [ ] **A5**〔human〕下载交付包:approved 任务点"下载交付包",≤60s 触发下载 delivery-{id}.zip(>1KB);`unzip -l` 含 note_01/文案.txt/发布清单/合规说明。
|
||||||
- [ ] **A6**〔auto〕zip 结构完整:`unzip -l` grep 文案.txt/发布清单/合规说明/note_01//.jpg 全非空。⚠️审计补:若 .jpg 空先查 ImageCandidate.url 文件容器内可达(packaging_task.py:64-70 读失败静默跳过)。
|
- [x] **A6**〔auto〕zip 结构完整:✅2026-06-18 task74端到端验:zip解出note_01/02/03各7项(6图+文案.txt),发布清单+合规说明在root,18张图全非空真写入(磁盘文件真读到)。
|
||||||
- [ ] **A7**〔auto〕文案.txt 格式:`unzip -p note_01/文案.txt` 含"【标题】"、不以 `{` 开头(JSON 已解析)。
|
- [x] **A7**〔auto〕文案.txt 格式:✅2026-06-18 task74验:note_02/文案.txt以"【标题】懒人三分钟搞定,倍分子上脸气色回来了"开头(非{,JSON已解析),品牌词"倍分子"已植入。
|
||||||
- [ ] **A8**〔human+auto〕多套打包:⚠️审计改 human(需先 seed 3条 is_selected TextCandidate)→构造后 `unzip -l|grep 'note_0[1-3]/'|wc -l`≥3,三套标题互不相同。
|
- [x] **A8**〔human+auto〕多套打包:✅2026-06-18 task74(6图×3套A/B/C)端到端真跑:选1条文案→建package→build_delivery_package→`grep note_0[1-3]`=3套各6图,每套独立note_0N夹+文案.txt。独立agent交叉验证7条全过(分组/去重/老数据兼容/越界/异常/契约/红线)。⚠️攒批问倩倩姐:当前只生成选1条文案,三套图共用同文案→交付包"3套"仅图风格(A/B/C)不同、文案全同,品牌词"倍分子"已正确植入。是否要三套各配不同角度文案?
|
||||||
- [ ] **A9**〔auto〕产品 JSON 导出:GET /products/export → 200,application/json,数组,每元素含 id/name/selling_points,且 selling_points 是数组(非JSON字符串)。
|
- [x] **A9**〔auto〕产品 JSON 导出:✅2026-06-18 ws3实测:export_products返3条,selling_points类型=list(['烟酰胺改善暗沉提亮','水解珍珠锁水匀肤']非JSON字符串);端点真注册路由表+顺序在/products/{product_id}之前(idx9<13不被吞)。
|
||||||
- [ ] **A10**〔auto〕标杆 CSV 导出:GET /benchmarks/export?format=csv → 200,text/csv,首行表头含 product_id/highlights/created_at,行数≥已录标杆数。
|
- [ ] **A10**〔auto〕标杆 CSV 导出:⏸️逻辑已审(CSV带UTF-8 BOM+JOIN Product填product_name+json/csv双格式),但ws3无标杆数据未触发真导出;待录标杆后验或Z阶段一条龙带验。
|
||||||
- [ ] **A11**〔auto〕导出权限:⚠️审计注——require_write_permission 当前只查 workspace_members 不分角色(workspace_guard.py:65-88);未登录调 → code=40101,workspace 成员 → 200。
|
- [ ] **A11**〔auto〕导出权限:⚠️审计注——require_write_permission 当前只查 workspace_members 不分角色(workspace_guard.py:65-88);未登录调 → code=40101,workspace 成员 → 200。【两端点均挂require_write_permission,独立审核确认】
|
||||||
- [ ] **A12**〔human〕不显 token 余额(红线):⚠️审计改 human(curl 拿不到Next.js水合后DOM)——浏览器 Ctrl+F 搜"余额/用量/tokens/balance"无结果。
|
- [ ] **A12**〔human〕不显 token 余额(红线):⚠️审计改 human(curl 拿不到Next.js水合后DOM)——浏览器 Ctrl+F 搜"余额/用量/tokens/balance"无结果。【HistoryFilters/page/components均无余额字段,独立审核确认】
|
||||||
|
|
||||||
## 待北哥定义(不阻断代码)
|
## 待北哥定义(不阻断代码)
|
||||||
- 多套打包图片套间对应:ImageCandidate 无 set_index,是否加列关联文案?还是退化"图共享复制进每套夹"(本期默认退化)。
|
- 多套打包图片套间对应:ImageCandidate 无 set_index,是否加列关联文案?还是退化"图共享复制进每套夹"(本期默认退化)。
|
||||||
|
|||||||
Reference in New Issue
Block a user