Commit 232de8ef authored by yaoke.yk's avatar yaoke.yk

客户端设定-道具修改

parent 347882ac
import { useState } from "react"; import { useRef, useState, type ChangeEvent } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { import {
ArrowRight, Box, Sparkles, Users, MapPin, Plus, ArrowRight,
Edit2, Trash2, Upload, X, Check, RefreshCw, Loader2, Box,
Sparkles,
Users,
MapPin,
Plus,
Edit2,
Trash2,
Upload,
X,
Check,
RefreshCw,
Loader2,
} from "lucide-react"; } from "lucide-react";
import { useGlobalAssets, useCreateAsset, useDeleteAsset } from "../../hooks/useAssets"; import {
useGlobalAssets,
useCreateAsset,
useUpdateAsset,
useUploadAssetImage,
useGenerateAssetImage,
useDeleteAsset,
} from "../../hooks/useAssets";
import type { GlobalAsset } from "../../lib/api/assets"; import type { GlobalAsset } from "../../lib/api/assets";
type SettingsTab = "characters" | "scenes" | "props"; type SettingsTab = "characters" | "scenes" | "props";
...@@ -19,6 +37,19 @@ interface EditForm { ...@@ -19,6 +37,19 @@ interface EditForm {
const CATEGORIES = ["首饰", "文件", "配饰", "文具", "饮品", "电子设备", "武器", "交通工具", "其他"]; const CATEGORIES = ["首饰", "文件", "配饰", "文具", "饮品", "电子设备", "武器", "交通工具", "其他"];
const emptyForm = (): EditForm => ({
name: "",
category: "配饰",
description: "",
imageUrl: "",
});
function revokeObjectPreview(url: string | null) {
if (url?.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
}
export function PropsGeneration() { export function PropsGeneration() {
const navigate = useNavigate(); const navigate = useNavigate();
const { projectId } = useParams(); const { projectId } = useParams();
...@@ -26,11 +57,19 @@ export function PropsGeneration() { ...@@ -26,11 +57,19 @@ export function PropsGeneration() {
const { data: props = [], isLoading } = useGlobalAssets("prop", "personal"); const { data: props = [], isLoading } = useGlobalAssets("prop", "personal");
const createAsset = useCreateAsset("prop", "personal"); const createAsset = useCreateAsset("prop", "personal");
const updateAsset = useUpdateAsset("prop", "personal");
const uploadImage = useUploadAssetImage("prop", "personal");
const generateImage = useGenerateAssetImage("prop", "personal");
const deleteAsset = useDeleteAsset("prop", "personal"); const deleteAsset = useDeleteAsset("prop", "personal");
const [activeTab, setActiveTab] = useState<SettingsTab>("props"); const [activeTab, setActiveTab] = useState<SettingsTab>("props");
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editForm, setEditForm] = useState<EditForm | null>(null); const [editForm, setEditForm] = useState<EditForm>(emptyForm());
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [saving, setSaving] = useState(false);
const [generatingImageId, setGeneratingImageId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const settingsTabs = [ const settingsTabs = [
{ id: "characters" as SettingsTab, label: "角色", icon: Users, path: `/project/${projectId}/characters` }, { id: "characters" as SettingsTab, label: "角色", icon: Users, path: `/project/${projectId}/characters` },
...@@ -38,12 +77,21 @@ export function PropsGeneration() { ...@@ -38,12 +77,21 @@ export function PropsGeneration() {
{ id: "props" as SettingsTab, label: "道具", icon: Box, path: `/project/${projectId}/props` }, { id: "props" as SettingsTab, label: "道具", icon: Box, path: `/project/${projectId}/props` },
]; ];
const resetImageInput = () => {
if (fileInputRef.current) fileInputRef.current.value = "";
};
const openAdd = () => { const openAdd = () => {
setEditForm({ name: "", category: "配饰", description: "", imageUrl: "" }); revokeObjectPreview(imagePreview);
setEditForm(emptyForm());
setImagePreview(null);
setPendingFile(null);
resetImageInput();
setShowModal(true); setShowModal(true);
}; };
const openEdit = (p: GlobalAsset) => { const openEdit = (p: GlobalAsset) => {
revokeObjectPreview(imagePreview);
setEditForm({ setEditForm({
id: p.id, id: p.id,
name: p.name, name: p.name,
...@@ -51,23 +99,107 @@ export function PropsGeneration() { ...@@ -51,23 +99,107 @@ export function PropsGeneration() {
description: p.description ?? "", description: p.description ?? "",
imageUrl: p.imageUrl ?? "", imageUrl: p.imageUrl ?? "",
}); });
setImagePreview(p.imageUrl ?? null);
setPendingFile(null);
resetImageInput();
setShowModal(true); setShowModal(true);
}; };
const closeModal = () => { setShowModal(false); setEditForm(null); }; const closeModal = () => {
revokeObjectPreview(imagePreview);
setShowModal(false);
setEditForm(emptyForm());
setImagePreview(null);
setPendingFile(null);
resetImageInput();
};
const handleSave = async () => { const handleFileSelect = (event: ChangeEvent<HTMLInputElement>) => {
if (!editForm || !editForm.name.trim()) return; const file = event.target.files?.[0];
await createAsset.mutateAsync({ if (!file) return;
revokeObjectPreview(imagePreview);
setPendingFile(file);
setImagePreview(URL.createObjectURL(file));
event.target.value = "";
};
const clearSelectedImage = () => {
revokeObjectPreview(imagePreview);
setPendingFile(null);
setImagePreview(editForm.imageUrl || null);
resetImageInput();
};
const saveAsset = async (uploadPendingImage = true) => {
if (!editForm.name.trim()) return null;
const payload = {
assetType: "prop", assetType: "prop",
libraryType: "personal", libraryType: "personal",
name: editForm.name, name: editForm.name.trim(),
description: editForm.description, description: editForm.description.trim(),
tags: editForm.category, tags: editForm.category,
imageUrl: editForm.imageUrl || undefined, imageUrl: editForm.imageUrl || undefined,
sourceProjectId: numProjectId || undefined, sourceProjectId: numProjectId || undefined,
}); };
let saved = editForm.id
? await updateAsset.mutateAsync({ id: editForm.id, patch: payload })
: await createAsset.mutateAsync(payload);
if (uploadPendingImage && pendingFile) {
saved = await uploadImage.mutateAsync({ id: saved.id, file: pendingFile });
}
return saved;
};
const handleSave = async () => {
if (!editForm.name.trim()) return;
setSaving(true);
try {
await saveAsset(true);
closeModal(); closeModal();
} catch (error) {
alert("保存失败: " + (error as Error).message);
} finally {
setSaving(false);
}
};
const handleGenerateImage = async (prop: GlobalAsset) => {
setGeneratingImageId(prop.id);
try {
await generateImage.mutateAsync(prop.id);
} catch (error) {
alert("道具图生成失败: " + (error as Error).message);
} finally {
setGeneratingImageId(null);
}
};
const handleGenerateFromModal = async () => {
if (!editForm.name.trim()) return;
setSaving(true);
setGeneratingImageId(editForm.id ?? "draft");
try {
const saved = await saveAsset(false);
if (!saved) return;
const generated = await generateImage.mutateAsync(saved.id);
revokeObjectPreview(imagePreview);
setEditForm({
id: generated.id,
name: generated.name,
category: generated.tags?.[0] ?? editForm.category,
description: generated.description ?? editForm.description,
imageUrl: generated.imageUrl ?? "",
});
setImagePreview(generated.imageUrl ?? null);
setPendingFile(null);
resetImageInput();
} catch (error) {
alert("道具图生成失败: " + (error as Error).message);
} finally {
setSaving(false);
setGeneratingImageId(null);
}
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
...@@ -76,6 +208,8 @@ export function PropsGeneration() { ...@@ -76,6 +208,8 @@ export function PropsGeneration() {
}; };
const withImage = props.filter((p) => p.imageUrl).length; const withImage = props.filter((p) => p.imageUrl).length;
const isSaving = saving || createAsset.isPending || updateAsset.isPending || uploadImage.isPending;
const isBusy = isSaving || generateImage.isPending;
return ( return (
<div className="h-full overflow-auto bg-background relative"> <div className="h-full overflow-auto bg-background relative">
...@@ -91,7 +225,6 @@ export function PropsGeneration() { ...@@ -91,7 +225,6 @@ export function PropsGeneration() {
<p className="text-sm text-muted-foreground">管理角色、场景和道具设定</p> <p className="text-sm text-muted-foreground">管理角色、场景和道具设定</p>
</div> </div>
{/* Tabs */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{settingsTabs.map((tab) => { {settingsTabs.map((tab) => {
...@@ -121,7 +254,6 @@ export function PropsGeneration() { ...@@ -121,7 +254,6 @@ export function PropsGeneration() {
</button> </button>
</div> </div>
{/* Stats bar */}
<div className="flex items-center justify-between mb-6 rounded-lg border border-border bg-card p-4"> <div className="flex items-center justify-between mb-6 rounded-lg border border-border bg-card p-4">
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
...@@ -145,7 +277,6 @@ export function PropsGeneration() { ...@@ -145,7 +277,6 @@ export function PropsGeneration() {
</button> </button>
</div> </div>
{/* Grid */}
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-primary" /> <Loader2 className="w-8 h-8 animate-spin text-primary" />
...@@ -153,7 +284,7 @@ export function PropsGeneration() { ...@@ -153,7 +284,7 @@ export function PropsGeneration() {
) : props.length === 0 ? ( ) : props.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center"> <div className="flex flex-col items-center justify-center py-20 text-center">
<Box className="w-12 h-12 text-muted-foreground/30 mb-4" /> <Box className="w-12 h-12 text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground mb-4">暂无道具,点击"新增道具"开始添加</p> <p className="text-muted-foreground mb-4">暂无道具,点击“新增道具”开始添加</p>
<button onClick={openAdd} className="px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white text-sm flex items-center gap-2"> <button onClick={openAdd} className="px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white text-sm flex items-center gap-2">
<Plus className="w-4 h-4" />新增道具 <Plus className="w-4 h-4" />新增道具
</button> </button>
...@@ -172,12 +303,32 @@ export function PropsGeneration() { ...@@ -172,12 +303,32 @@ export function PropsGeneration() {
)} )}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1"> <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1">
<button <button
onClick={() => handleGenerateImage(prop)}
disabled={generatingImageId === prop.id || generateImage.isPending}
className="p-1.5 rounded-lg bg-white/20 backdrop-blur-sm hover:bg-white/30 transition-colors disabled:opacity-50"
title={prop.imageUrl ? "AI重新生成道具图" : "AI生成道具图"}
>
{generatingImageId === prop.id ? (
<Loader2 className="w-4 h-4 text-white animate-spin" />
) : prop.imageUrl ? (
<RefreshCw className="w-4 h-4 text-white" />
) : (
<Sparkles className="w-4 h-4 text-white" />
)}
</button>
<button
onClick={() => openEdit(prop)} onClick={() => openEdit(prop)}
className="p-1.5 rounded-lg bg-white/20 backdrop-blur-sm hover:bg-white/30 transition-colors" className="p-1.5 rounded-lg bg-white/20 backdrop-blur-sm hover:bg-white/30 transition-colors"
> >
<Edit2 className="w-4 h-4 text-white" /> <Edit2 className="w-4 h-4 text-white" />
</button> </button>
</div> </div>
{generatingImageId === prop.id && (
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex flex-col items-center justify-center gap-2">
<Loader2 className="w-6 h-6 text-primary animate-spin" />
<span className="text-xs text-foreground">生成中</span>
</div>
)}
</div> </div>
<div className="p-3"> <div className="p-3">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
...@@ -206,7 +357,6 @@ export function PropsGeneration() { ...@@ -206,7 +357,6 @@ export function PropsGeneration() {
</div> </div>
</div> </div>
{/* Floating bottom bar */}
<div className="fixed bottom-0 left-0 right-0 border-t border-border bg-card/95 backdrop-blur-sm px-6 py-3 z-10"> <div className="fixed bottom-0 left-0 right-0 border-t border-border bg-card/95 backdrop-blur-sm px-6 py-3 z-10">
<div className="max-w-7xl mx-auto flex items-center justify-end gap-3"> <div className="max-w-7xl mx-auto flex items-center justify-end gap-3">
<button <button
...@@ -219,12 +369,11 @@ export function PropsGeneration() { ...@@ -219,12 +369,11 @@ export function PropsGeneration() {
</div> </div>
</div> </div>
{/* Add / Edit Modal */} {showModal && (
{showModal && editForm && ( <div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-6">
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8"> <div className="bg-card rounded-2xl border border-border w-full max-w-2xl max-h-[90vh] flex flex-col">
<div className="bg-card rounded-2xl border border-border p-6 max-w-lg w-full"> <div className="flex items-center justify-between p-6 border-b border-border flex-shrink-0">
<div className="flex items-center justify-between mb-6"> <h2 className="text-lg font-semibold text-foreground">
<h2 className="text-xl font-semibold text-foreground">
{editForm.id ? "编辑道具" : "新增道具"} {editForm.id ? "编辑道具" : "新增道具"}
</h2> </h2>
<button onClick={closeModal} className="p-2 hover:bg-muted rounded-lg transition-colors"> <button onClick={closeModal} className="p-2 hover:bg-muted rounded-lg transition-colors">
...@@ -232,10 +381,78 @@ export function PropsGeneration() { ...@@ -232,10 +381,78 @@ export function PropsGeneration() {
</button> </button>
</div> </div>
<div className="space-y-4"> <div className="overflow-auto p-6 space-y-4 flex-1">
<div className="rounded-xl border border-border bg-muted/20 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<label className="block text-sm font-medium text-foreground">道具图片(可选)</label>
<p className="mt-1 text-xs text-muted-foreground">可以上传参考图,也可以根据道具名称和描述 AI 生成。</p>
</div>
{pendingFile && (
<button onClick={clearSelectedImage} className="text-xs text-destructive hover:underline">
撤销更换
</button>
)}
</div>
<div
className="w-full aspect-video rounded-lg border-2 border-dashed border-border bg-background flex items-center justify-center cursor-pointer hover:border-primary transition-colors overflow-hidden"
onClick={() => fileInputRef.current?.click()}
>
{imagePreview ? (
<img src={imagePreview} alt="道具图片预览" className="w-full h-full object-cover" />
) : (
<div className="text-center px-4">
<Upload className="w-8 h-8 text-muted-foreground mx-auto mb-2" />
<span className="text-sm text-muted-foreground">点击上传道具参考图</span>
</div>
)}
</div>
{imagePreview && (
<div className="mt-2 flex items-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1 text-xs text-foreground hover:bg-muted"
>
<Upload className="h-3.5 w-3.5" />
更换图片
</button>
<button
type="button"
onClick={handleGenerateFromModal}
disabled={!editForm.name.trim() || isBusy}
className="inline-flex items-center gap-1.5 rounded-md border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
>
{generatingImageId === (editForm.id ?? "draft") ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
AI重新生成
</button>
</div>
)}
{!imagePreview && (
<button
type="button"
onClick={handleGenerateFromModal}
disabled={!editForm.name.trim() || isBusy}
className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
>
{generatingImageId === (editForm.id ?? "draft") ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
{editForm.id ? "AI生成道具图" : "保存并AI生成"}
</button>
)}
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileSelect} />
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">道具名称</label> <label className="block text-sm font-medium text-foreground mb-2">道具名称 <span className="text-destructive">*</span></label>
<input <input
type="text" type="text"
value={editForm.name} value={editForm.name}
...@@ -262,33 +479,22 @@ export function PropsGeneration() { ...@@ -262,33 +479,22 @@ export function PropsGeneration() {
value={editForm.description} value={editForm.description}
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })} onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
placeholder="描述道具的外观、用途和重要性" placeholder="描述道具的外观、用途和重要性"
rows={3} rows={4}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 resize-none" className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 resize-none"
/> />
</div> </div>
<div>
<label className="block text-sm font-medium text-foreground mb-2">图片 URL(可选)</label>
<input
type="text"
value={editForm.imageUrl}
onChange={(e) => setEditForm({ ...editForm, imageUrl: e.target.value })}
placeholder="https://example.com/prop.jpg"
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 font-mono text-sm"
/>
</div>
</div> </div>
<div className="flex gap-3 mt-6 pt-6 border-t border-border"> <div className="flex gap-3 p-6 border-t border-border flex-shrink-0">
<button onClick={closeModal} className="flex-1 px-4 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"> <button onClick={closeModal} className="flex-1 px-4 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors">
取消 取消
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={!editForm.name.trim() || createAsset.isPending} disabled={!editForm.name.trim() || isBusy}
className="flex-1 px-4 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center justify-center gap-2 disabled:opacity-50" className="flex-1 px-4 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center justify-center gap-2 disabled:opacity-50"
> >
{createAsset.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />} {isBusy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
保存 保存
</button> </button>
</div> </div>
......
...@@ -27,6 +27,31 @@ export function useToggleFavorite(type: string, library: string) { ...@@ -27,6 +27,31 @@ export function useToggleFavorite(type: string, library: string) {
}); });
} }
export function useUpdateAsset(type: string, library: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, patch }: { id: string; patch: Partial<CreateAssetPayload> }) =>
assetsApi.update(id, patch),
onSuccess: () => qc.invalidateQueries({ queryKey: assetsKey(type, library) }),
});
}
export function useUploadAssetImage(type: string, library: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, file }: { id: string; file: File }) => assetsApi.uploadImage(id, file),
onSuccess: () => qc.invalidateQueries({ queryKey: assetsKey(type, library) }),
});
}
export function useGenerateAssetImage(type: string, library: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => assetsApi.generateImage(id),
onSuccess: () => qc.invalidateQueries({ queryKey: assetsKey(type, library) }),
});
}
export function useDeleteAsset(type: string, library: string) { export function useDeleteAsset(type: string, library: string) {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
......
...@@ -42,6 +42,21 @@ export const assetsApi = { ...@@ -42,6 +42,21 @@ export const assetsApi = {
const r = await apiClient.put(`/assets/${id}`, patch); const r = await apiClient.put(`/assets/${id}`, patch);
return r.data.data; return r.data.data;
}, },
uploadImage: async (id: string, file: File): Promise<GlobalAsset> => {
const form = new FormData();
form.append("file", file);
const r = await apiClient.post(`/assets/${id}/upload-image`, form, {
headers: { "Content-Type": "multipart/form-data" },
timeout: 600_000,
});
return r.data.data;
},
generateImage: async (id: string): Promise<GlobalAsset> => {
const r = await apiClient.post(`/assets/${id}/generate-image`, undefined, {
timeout: 180_000,
});
return r.data.data;
},
delete: async (id: string): Promise<void> => { delete: async (id: string): Promise<void> => {
await apiClient.delete(`/assets/${id}`); await apiClient.delete(`/assets/${id}`);
}, },
......
...@@ -7,12 +7,17 @@ import com.yaoai.common.exception.ErrorCode; ...@@ -7,12 +7,17 @@ import com.yaoai.common.exception.ErrorCode;
import com.yaoai.common.response.ApiResponse; import com.yaoai.common.response.ApiResponse;
import com.yaoai.domain.entity.GlobalAsset; import com.yaoai.domain.entity.GlobalAsset;
import com.yaoai.domain.mapper.GlobalAssetMapper; import com.yaoai.domain.mapper.GlobalAssetMapper;
import com.yaoai.pipeline.service.ImageGenPipelineService;
import com.yaoai.security.context.TenantContext; import com.yaoai.security.context.TenantContext;
import com.yaoai.storage.service.TosService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
...@@ -23,6 +28,8 @@ import java.util.Map; ...@@ -23,6 +28,8 @@ import java.util.Map;
public class GlobalAssetsController { public class GlobalAssetsController {
private final GlobalAssetMapper globalAssetMapper; private final GlobalAssetMapper globalAssetMapper;
private final TosService tosService;
private final ImageGenPipelineService imageGenPipelineService;
@Operation(summary = "获取资产列表") @Operation(summary = "获取资产列表")
@GetMapping @GetMapping
...@@ -54,7 +61,11 @@ public class GlobalAssetsController { ...@@ -54,7 +61,11 @@ public class GlobalAssetsController {
asset.setDescription((String) body.get("description")); asset.setDescription((String) body.get("description"));
asset.setIsFavorite(0); asset.setIsFavorite(0);
Object srcProject = body.get("sourceProjectId"); Object srcProject = body.get("sourceProjectId");
if (srcProject instanceof Number) asset.setSourceProjectId(((Number) srcProject).longValue()); if (srcProject instanceof Number) {
asset.setSourceProjectId(((Number) srcProject).longValue());
} else if (srcProject instanceof String srcProjectText && !srcProjectText.isBlank()) {
asset.setSourceProjectId(Long.valueOf(srcProjectText));
}
globalAssetMapper.insert(asset); globalAssetMapper.insert(asset);
return ApiResponse.success(GlobalAssetDTO.from(asset)); return ApiResponse.success(GlobalAssetDTO.from(asset));
...@@ -90,6 +101,65 @@ public class GlobalAssetsController { ...@@ -90,6 +101,65 @@ public class GlobalAssetsController {
return ApiResponse.success(GlobalAssetDTO.from(asset)); return ApiResponse.success(GlobalAssetDTO.from(asset));
} }
@Operation(summary = "AI 生成资产图片")
@PostMapping("/{id}/generate-image")
public ApiResponse<GlobalAssetDTO> generateImage(@PathVariable Long id) {
Long tenantId = TenantContext.get();
Long userId = StpUtil.getLoginIdAsLong();
GlobalAsset asset = globalAssetMapper.selectById(id);
if (asset == null || !tenantId.equals(asset.getTenantId())) {
throw new BizException(ErrorCode.NOT_FOUND, "资产不存在");
}
if (!"prop".equals(asset.getAssetType())) {
throw new BizException(ErrorCode.INVALID_PARAM, "当前仅支持生成道具图片");
}
Long projectKeyId = asset.getSourceProjectId() != null ? asset.getSourceProjectId() : 0L;
String key = imageGenPipelineService.generateAndStore(
tenantId,
userId,
projectKeyId,
"global-assets/" + asset.getAssetType(),
buildPropImagePrompt(asset)
);
asset.setImageTosKey(key);
asset.setImageUrl(tosService.publicUrl(key));
globalAssetMapper.updateById(asset);
return ApiResponse.success(GlobalAssetDTO.from(asset));
}
@Operation(summary = "上传资产图片")
@PostMapping(value = "/{id}/upload-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<GlobalAssetDTO> uploadImage(@PathVariable Long id,
@RequestPart("file") MultipartFile file) throws IOException {
Long tenantId = TenantContext.get();
GlobalAsset asset = globalAssetMapper.selectById(id);
if (asset == null || !tenantId.equals(asset.getTenantId())) {
throw new BizException(ErrorCode.NOT_FOUND, "资产不存在");
}
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.INVALID_PARAM, "图片文件不能为空");
}
String contentType = file.getContentType() != null ? file.getContentType() : "application/octet-stream";
if (!contentType.startsWith("image/")) {
throw new BizException(ErrorCode.INVALID_PARAM, "请上传图片文件");
}
Long projectKeyId = asset.getSourceProjectId() != null ? asset.getSourceProjectId() : 0L;
String key = TosService.buildKey(
tenantId,
projectKeyId,
"global-assets/" + asset.getAssetType(),
file.getOriginalFilename()
);
tosService.upload(key, file.getInputStream(), file.getSize(), contentType);
asset.setImageTosKey(key);
asset.setImageUrl(tosService.publicUrl(key));
globalAssetMapper.updateById(asset);
return ApiResponse.success(GlobalAssetDTO.from(asset));
}
@Operation(summary = "删除资产") @Operation(summary = "删除资产")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) { public ApiResponse<Void> delete(@PathVariable Long id) {
...@@ -101,4 +171,20 @@ public class GlobalAssetsController { ...@@ -101,4 +171,20 @@ public class GlobalAssetsController {
globalAssetMapper.deleteById(id); globalAssetMapper.deleteById(id);
return ApiResponse.success(); return ApiResponse.success();
} }
private String buildPropImagePrompt(GlobalAsset asset) {
String category = asset.getTags() != null && !asset.getTags().isBlank() ? asset.getTags() : "道具";
String description = asset.getDescription() != null && !asset.getDescription().isBlank()
? asset.getDescription()
: "根据名称和分类设计清晰可识别的影视道具";
return """
Cinematic AI short-drama prop reference image, single object only.
Create one clear hero reference image for a prop used in video generation.
Neutral clean studio background, three-quarter view, accurate material, scale cues, sharp silhouette, realistic lighting.
No people, no hands, no environment clutter, no text, no labels, no watermark, no logo, no duplicated objects.
Prop name: %s
Prop category: %s
Prop description: %s
""".formatted(asset.getName(), category, description);
}
} }
...@@ -13,4 +13,6 @@ public interface ImageGenPipelineService { ...@@ -13,4 +13,6 @@ public interface ImageGenPipelineService {
String generateAndStore(Long tenantId, Long projectId, String prompt); String generateAndStore(Long tenantId, Long projectId, String prompt);
String generateAndStore(Long tenantId, Long userId, Long projectId, String prompt); String generateAndStore(Long tenantId, Long userId, Long projectId, String prompt);
String generateAndStore(Long tenantId, Long userId, Long projectId, String assetType, String prompt);
} }
...@@ -39,6 +39,11 @@ public class ImageGenPipelineServiceImpl implements ImageGenPipelineService { ...@@ -39,6 +39,11 @@ public class ImageGenPipelineServiceImpl implements ImageGenPipelineService {
@Override @Override
public String generateAndStore(Long tenantId, Long userId, Long projectId, String prompt) { public String generateAndStore(Long tenantId, Long userId, Long projectId, String prompt) {
return generateAndStore(tenantId, userId, projectId, "scene", prompt);
}
@Override
public String generateAndStore(Long tenantId, Long userId, Long projectId, String assetType, String prompt) {
billingService.checkBalance(BillingChargeRequest.builder() billingService.checkBalance(BillingChargeRequest.builder()
.tenantId(tenantId) .tenantId(tenantId)
.userId(userId) .userId(userId)
...@@ -60,9 +65,10 @@ public class ImageGenPipelineServiceImpl implements ImageGenPipelineService { ...@@ -60,9 +65,10 @@ public class ImageGenPipelineServiceImpl implements ImageGenPipelineService {
byte[] imageBytes = downloadImage(imageUrl); byte[] imageBytes = downloadImage(imageUrl);
// 3. 上传到 TOS // 3. 上传到 TOS
String key = TosService.buildKey(tenantId, projectId, "scene", "scene.jpg"); String normalizedAssetType = assetType != null && !assetType.isBlank() ? assetType : "image";
String key = TosService.buildKey(tenantId, projectId, normalizedAssetType, normalizedAssetType + ".jpg");
tosService.upload(key, new ByteArrayInputStream(imageBytes), imageBytes.length, "image/jpeg"); tosService.upload(key, new ByteArrayInputStream(imageBytes), imageBytes.length, "image/jpeg");
log.info("Scene image stored: key={}", key); log.info("Generated image stored: key={}", key);
billingService.charge(BillingChargeRequest.builder() billingService.charge(BillingChargeRequest.builder()
.tenantId(tenantId) .tenantId(tenantId)
.userId(userId) .userId(userId)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment