feat: 图片创作每张图加「加入模特库」按钮 (row41)

图片工作台单图「更多」气泡新增「加入模特库」(与「加入资产库」并列):
把该生成图复用为模特形象图建 Model 条目,加入后变「已加入模特库」禁用。

后端新增 POST /api/models/enroll-asset/(ModelLibraryViewSet.enroll_asset):
复用已有资产作 portrait_asset 建 Model,不重新上传/不二次扣费;同一资产幂等返回。
需部署生效(后端)。

构建: tsc --noEmit=0, npm run build=0; 后端 py_compile OK。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-30 13:11:50 +08:00
co-authored by Claude Opus 4.8
parent 76d59d06fe
commit 16219a4132
3 changed files with 45 additions and 0 deletions
+23
View File
@@ -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]
+4
View File
@@ -574,6 +574,10 @@ export const api = {
uploadModel(formData: FormData) {
return request<ModelEntity>("/api/models/upload/", { method: "POST", body: formData });
},
// 图片创作「加入模特库」:把一张已生成的图复用为模特形象图建 Model 条目(不重传、不二次扣费;幂等)
enrollModelFromAsset(assetId: string, name?: string) {
return request<ModelEntity>("/api/models/enroll-asset/", { method: "POST", body: JSON.stringify({ asset_id: assetId, name }) });
},
// 删除模特(软删;官方模特不可删)
deleteModel(id: string) {
return request<void>(`/api/models/${id}/`, { method: "DELETE" });
+18
View File
@@ -1249,6 +1249,20 @@ export function ImageWorkbenchPage({
applyLibraryState(batchId, [assetId], next);
}
/* 图片创作「加入模特库」:把该图复用为模特形象图建 Model 条目(后端不二次扣费;幂等)。
成功后该图气泡项变「已加入模特库」禁用,与「加入资产库」同款单图粒度操作。 */
const [enrolledModelIds, setEnrolledModelIds] = useState<Set<string>>(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({
<Bookmark size={13} />
{inLib ? "取消加入资产库" : "加入资产库"}
</button>
<button type="button" className="gen-bubble-item" disabled={enrolledModelIds.has(assetId) || enrollingModelId === assetId} onClick={() => { setOpenMore(""); enrollModelFromImage(assetId); }}>
<Users size={13} />
{enrolledModelIds.has(assetId) ? "已加入模特库" : (enrollingModelId === assetId ? "加入中…" : "加入模特库")}
</button>
<button type="button" className="gen-bubble-item danger" onClick={() => { setOpenMore(""); removeImageFromBatch(batch.id, assetId); }}>
<Trash2 size={13} />