fix(core): 商品详情卖点可编辑+去假数据 / 出图后不再整页重载 / 图片长效缓存
- 商品详情页核心卖点:编辑态原是纯静态文本、且 save 不提交 selling_points, 既改不了也存不了;改为可改/删/回车增,save 带 selling_points 一起 PATCH。 - 去掉详情页 name/cat/target/卖点 的「设计稿假数据」fallback —— 商品没卖点 时不再凭空显示护肤品卖点,空值显示「未填写」。 - 出图成功后 submitAndPollAsset 不再整页全量 loadData(十几个 setState 触发 全页大重渲染),只刷当前项目详情 + 轻量刷余额。 - TOS 上传对象加 Cache-Control: immutable 长效强缓存,重渲染不再重拉图。 - products 序列化器内嵌商品图/封面 preview_url(并修回错位的 read_only_fields)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,9 @@ class TosStorage:
|
||||
fileobj,
|
||||
self.bucket,
|
||||
object_key,
|
||||
ExtraArgs={"ContentType": content_type},
|
||||
# object_key 按 asset id 唯一、内容不可变 → 长效强缓存 + immutable:
|
||||
# 浏览器/CDN 永不重拉,前端重渲染(背景图重新赋值)也命中缓存,不再"每次生成全页图闪一下重载"。
|
||||
ExtraArgs={"ContentType": content_type, "CacheControl": "public, max-age=31536000, immutable"},
|
||||
)
|
||||
return StoredObject(
|
||||
bucket=self.bucket,
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.assets.serializers import AssetFileSerializer
|
||||
|
||||
from .models import Product, ProductImage, ProductSellingPoint
|
||||
|
||||
|
||||
def _asset_preview_url(asset) -> str:
|
||||
"""资产主文件的可显示 URL,内嵌进商品序列化,让前端缩略图不再依赖(分页的)团队 assets 全量列表解析。
|
||||
与 projects.serializers._asset_preview_url 同套路。"""
|
||||
if asset is None:
|
||||
return ""
|
||||
files = list(asset.files.all())
|
||||
primary = next((f for f in files if f.is_primary), files[0] if files else None)
|
||||
return AssetFileSerializer().get_preview_url(primary) if primary else ""
|
||||
|
||||
|
||||
class ProductImageSerializer(serializers.ModelSerializer):
|
||||
preview_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ProductImage
|
||||
fields = ["id", "asset", "sort_order", "is_primary"]
|
||||
fields = ["id", "asset", "preview_url", "sort_order", "is_primary"]
|
||||
|
||||
def get_preview_url(self, obj) -> str:
|
||||
return _asset_preview_url(obj.asset)
|
||||
|
||||
|
||||
class ProductSellingPointSerializer(serializers.ModelSerializer):
|
||||
@@ -18,6 +35,7 @@ class ProductSellingPointSerializer(serializers.ModelSerializer):
|
||||
class ProductSerializer(serializers.ModelSerializer):
|
||||
images = ProductImageSerializer(many=True, required=False)
|
||||
selling_points = ProductSellingPointSerializer(many=True, required=False)
|
||||
cover_preview_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Product
|
||||
@@ -31,6 +49,7 @@ class ProductSerializer(serializers.ModelSerializer):
|
||||
"description",
|
||||
"status",
|
||||
"cover_asset",
|
||||
"cover_preview_url",
|
||||
"images",
|
||||
"selling_points",
|
||||
"created_at",
|
||||
@@ -38,6 +57,9 @@ class ProductSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
def get_cover_preview_url(self, obj) -> str:
|
||||
return _asset_preview_url(obj.cover_asset)
|
||||
|
||||
def create(self, validated_data):
|
||||
images = validated_data.pop("images", [])
|
||||
selling_points = validated_data.pop("selling_points", [])
|
||||
|
||||
@@ -282,11 +282,32 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
@action(detail=True, methods=["post"], url_path="attach-base-asset")
|
||||
@transaction.atomic
|
||||
def attach_base_asset(self, request, pk=None):
|
||||
"""流程步骤4 · 用演员库现有资产替换某基础资产卡:把团队内现有 Asset 挂为该组候选并采用。"""
|
||||
"""流程步骤4 · 用演员库现有资产替换某基础资产卡:把团队内现有 Asset 挂为该组候选并采用。
|
||||
seed 占位卡还没有 group,此时不传 group_id,改传 kind+label:按 label 命中同实体组,没有则据 label 建组再挂(不出图)。"""
|
||||
project = self.get_object()
|
||||
group = BaseAssetGroup.objects.select_for_update().filter(project=project, id=request.data.get("group_id")).first()
|
||||
if group is None:
|
||||
return Response({"detail": "group not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
group = None
|
||||
group_id = request.data.get("group_id")
|
||||
if group_id:
|
||||
group = BaseAssetGroup.objects.select_for_update().filter(project=project, id=group_id).first()
|
||||
if group is None:
|
||||
return Response({"detail": "group not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
else:
|
||||
# 无 group(seed 占位卡):据 kind+label 复用/新建实体组,把所选演员资产挂成该 tag 的立绘
|
||||
kind = request.data.get("kind")
|
||||
if kind not in BaseAssetGroup.Kind.values:
|
||||
return Response({"detail": "group_id or valid kind is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
label = str(request.data.get("label") or "").strip()
|
||||
# 按 label 命中同实体组(排除三视图组);商品=该项目唯一非三视图组;都没有则据 label 新建
|
||||
candidates = [g for g in project.base_asset_groups.filter(kind=kind).order_by("created_at")
|
||||
if not (g.metadata or {}).get("triview_of")]
|
||||
if kind == BaseAssetGroup.Kind.PRODUCT:
|
||||
group = candidates[0] if candidates else None
|
||||
elif label:
|
||||
group = next((g for g in candidates if (g.metadata or {}).get("label") == label), None)
|
||||
if group is None:
|
||||
group_meta = {"label": label} if label else {}
|
||||
group = BaseAssetGroup.objects.create(project=project, kind=kind, metadata=group_meta)
|
||||
group = BaseAssetGroup.objects.select_for_update().get(id=group.id)
|
||||
asset = Asset.objects.filter(team=project.team, id=request.data.get("asset_id")).first()
|
||||
if asset is None:
|
||||
return Response({"detail": "asset not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
Reference in New Issue
Block a user