diff --git a/core/backend/apps/assets/views.py b/core/backend/apps/assets/views.py index 09f192c..190dabc 100644 --- a/core/backend/apps/assets/views.py +++ b/core/backend/apps/assets/views.py @@ -399,6 +399,29 @@ class ModelLibraryViewSet(ModelViewSet): ) return Response(ModelLibrarySerializer(model).data, status=status.HTTP_201_CREATED) + @action(detail=False, methods=["post"], url_path="enroll-asset") + def enroll_asset(self, request): + """图片创作「加入模特库」:把一张已生成的图复用为模特形象图建 Model 条目。 + 资产生成时已落库扣过费,此处只引用、不重新上传/不二次扣费。同一资产已建过则幂等返回。""" + team = self.get_team() + asset_id = request.data.get("asset_id") + asset = Asset.objects.filter(id=asset_id, team=team, is_deleted=False).first() if asset_id else None + if asset is None: + raise ValidationError({"asset_id": "资产不存在或不属于本团队"}) + existing = Model.objects.filter(team=team, portrait_asset=asset, is_deleted=False).first() + if existing is not None: + return Response(ModelLibrarySerializer(existing).data, status=status.HTTP_200_OK) + name = (request.data.get("name") or (asset.name or "模特").split("·")[0].strip() or "模特")[:255] + model = Model.objects.create( + team=team, + created_by=request.user, + name=name, + source=Model.Source.AI, + portrait_asset=asset, + metadata={"source": "enroll_from_asset", "asset_id": str(asset.id)}, + ) + return Response(ModelLibrarySerializer(model).data, status=status.HTTP_201_CREATED) + class AssetUploadView(APIView): parser_classes = [MultiPartParser, FormParser] diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 5fdcdd9..6da0be0 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -574,6 +574,10 @@ export const api = { uploadModel(formData: FormData) { return request("/api/models/upload/", { method: "POST", body: formData }); }, + // 图片创作「加入模特库」:把一张已生成的图复用为模特形象图建 Model 条目(不重传、不二次扣费;幂等) + enrollModelFromAsset(assetId: string, name?: string) { + return request("/api/models/enroll-asset/", { method: "POST", body: JSON.stringify({ asset_id: assetId, name }) }); + }, // 删除模特(软删;官方模特不可删) deleteModel(id: string) { return request(`/api/models/${id}/`, { method: "DELETE" }); diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index a8bc498..745af1a 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -1249,6 +1249,20 @@ export function ImageWorkbenchPage({ applyLibraryState(batchId, [assetId], next); } + /* 图片创作「加入模特库」:把该图复用为模特形象图建 Model 条目(后端不二次扣费;幂等)。 + 成功后该图气泡项变「已加入模特库」禁用,与「加入资产库」同款单图粒度操作。 */ + const [enrolledModelIds, setEnrolledModelIds] = useState>(new Set()); + const [enrollingModelId, setEnrollingModelId] = useState(""); + async function enrollModelFromImage(assetId: string) { + if (!assetId || enrollingModelId) return; + setEnrollingModelId(assetId); + try { + await api.enrollModelFromAsset(assetId); + setEnrolledModelIds((prev) => new Set(prev).add(assetId)); + } catch { /* 失败静默,可重试 */ } + finally { setEnrollingModelId(""); } + } + /* 单图「再生成」/「重跑这张」(§4.18 悬浮① / YYX#5): PMC#25——补一张图进原批次卡(不新开任务、不覆盖已有好图)。 */ async function regenSingleImage(src: GenBatch) { @@ -1403,6 +1417,10 @@ export function ImageWorkbenchPage({ {inLib ? "取消加入资产库" : "加入资产库"} +