fix(core): 商品详情页三视图改走 image_edit 参考真实主图 + 该商品 AI 素材归属
- 商品详情页「AI 生成三视图」原走独立生图老链路(纯文生图),只凭商品名脑补, 出来通用图不像真品。改:前端传 reference_product;后端 run_standalone_image_task 据此取商品主图走 image_edit(复用 build_product_triview_prompt_refs 锁包装), 取不到主图优雅回落文生图。图片创作页(自由创作)不带标记,不受影响。 - 商品详情页素材区改为只显示「该商品」AI 素材(asset.product 归属)而非全团队。 - 首登水合带重试,失败不再静默(否则页面卡在全 0,要刷新才好)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1334,7 +1334,7 @@ def _reap_stale_standalone_image_tasks(*, team) -> None:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1) -> list[AITask]:
|
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False) -> list[AITask]:
|
||||||
"""独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 +
|
"""独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 +
|
||||||
预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。
|
预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。
|
||||||
|
|
||||||
@@ -1360,7 +1360,7 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
|
|||||||
status=AITask.Status.CREATED,
|
status=AITask.Status.CREATED,
|
||||||
model_config=model_config,
|
model_config=model_config,
|
||||||
idempotency_key=f"standalone-image:{team.id}:{uuid.uuid4()}",
|
idempotency_key=f"standalone-image:{team.id}:{uuid.uuid4()}",
|
||||||
request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index},
|
request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product)},
|
||||||
estimated_cost=cost,
|
estimated_cost=cost,
|
||||||
)
|
)
|
||||||
# 预留额度若余额不足会抛 ValueError,在同步的 Web 请求里立刻反馈给前端(不会先建半套任务)
|
# 预留额度若余额不足会抛 ValueError,在同步的 Web 请求里立刻反馈给前端(不会先建半套任务)
|
||||||
@@ -1386,11 +1386,27 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
|||||||
prompt = str(payload.get("prompt") or "")
|
prompt = str(payload.get("prompt") or "")
|
||||||
mode = str(payload.get("mode") or "image")
|
mode = str(payload.get("mode") or "image")
|
||||||
index = int(payload.get("index") or 0)
|
index = int(payload.get("index") or 0)
|
||||||
|
product_id = payload.get("product_id") or None
|
||||||
category = _STANDALONE_CATEGORY.get(mode, Asset.Category.UNCATEGORIZED)
|
category = _STANDALONE_CATEGORY.get(mode, Asset.Category.UNCATEGORIZED)
|
||||||
model_config = task.model_config
|
model_config = task.model_config
|
||||||
provider = get_image_provider(model_config)
|
provider = get_image_provider(model_config)
|
||||||
reservation = task.credit_reservation
|
reservation = task.credit_reservation
|
||||||
|
# 商品三视图(商品详情页按 reference_product 提交):有真实商品主图 + 模型支持 image_edit →
|
||||||
|
# 以主图为参考锁包装一致性,否则回落纯文生图(仅凭商品名脑补,不保证还原真实包装)。
|
||||||
|
ref_url = ""
|
||||||
|
product = None
|
||||||
|
if bool(payload.get("reference_product")) and product_id:
|
||||||
|
from apps.products.models import Product
|
||||||
|
|
||||||
|
product = Product.objects.filter(id=product_id).first()
|
||||||
|
if product is not None:
|
||||||
|
ref_url = _product_cover_url(product)
|
||||||
|
use_edit = bool(ref_url) and hasattr(provider, "image_edit")
|
||||||
try:
|
try:
|
||||||
|
if use_edit:
|
||||||
|
edit_prompt = build_product_triview_prompt_refs(product, "")
|
||||||
|
response = provider.image_edit(model=model_config.name, prompt=edit_prompt, images=[ref_url], size="1536x1024")
|
||||||
|
else:
|
||||||
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt)
|
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt)
|
||||||
media = provider.extract_first_media_url(response)
|
media = provider.extract_first_media_url(response)
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
@@ -1408,6 +1424,8 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
|||||||
asset = Asset.objects.create(
|
asset = Asset.objects.create(
|
||||||
id=asset_id, team=team, created_by=user, name=f"AI 生成 · {mode} · {index + 1}",
|
id=asset_id, team=team, created_by=user, name=f"AI 生成 · {mode} · {index + 1}",
|
||||||
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=category, origin_task=task,
|
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=category, origin_task=task,
|
||||||
|
# 记下生图时选中的商品,商品详情页据此只展示「该商品」的 AI 素材(而非全团队)
|
||||||
|
metadata={"product_id": str(product_id)} if product_id else {},
|
||||||
)
|
)
|
||||||
AssetFile.objects.create(asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True)
|
AssetFile.objects.create(asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True)
|
||||||
except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
|
except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
|
||||||
|
|||||||
@@ -29,9 +29,11 @@ class GenerateImageView(APIView):
|
|||||||
count = int(request.data.get("count") or 1)
|
count = int(request.data.get("count") or 1)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
count = 1
|
count = 1
|
||||||
|
product_id = str(request.data.get("product_id") or "").strip() or None
|
||||||
|
reference_product = bool(request.data.get("reference_product"))
|
||||||
team = get_current_team(request.user)
|
team = get_current_team(request.user)
|
||||||
try:
|
try:
|
||||||
tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count)
|
tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count, product_id=product_id, reference_product=reference_product)
|
||||||
except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈
|
except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈
|
||||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
return Response(
|
return Response(
|
||||||
|
|||||||
@@ -58,6 +58,21 @@ class AssetFileSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class AssetSerializer(serializers.ModelSerializer):
|
class AssetSerializer(serializers.ModelSerializer):
|
||||||
files = AssetFileSerializer(many=True, read_only=True)
|
files = AssetFileSerializer(many=True, read_only=True)
|
||||||
|
# 资产归属商品(只读):商品详情页据此只展示「该商品」的 AI 素材,而非全团队
|
||||||
|
product = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
def get_product(self, obj):
|
||||||
|
"""解析资产所属商品:
|
||||||
|
1) 独立生图(图片创作/模特图/平台套图):生图时已写入 metadata.product_id;
|
||||||
|
2) 项目内生成(基础资产/分镜图等):回溯 origin_task → project → product。
|
||||||
|
两条路都拿不到则归属 None(如纯手动上传到素材库、与任何商品无关的图)。"""
|
||||||
|
pid = (obj.metadata or {}).get("product_id")
|
||||||
|
if pid:
|
||||||
|
return str(pid)
|
||||||
|
task = obj.origin_task
|
||||||
|
if task and task.project_id and task.project.product_id:
|
||||||
|
return str(task.project.product_id)
|
||||||
|
return None
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Asset
|
model = Asset
|
||||||
@@ -71,6 +86,7 @@ class AssetSerializer(serializers.ModelSerializer):
|
|||||||
"metadata",
|
"metadata",
|
||||||
"is_deleted",
|
"is_deleted",
|
||||||
"origin_task",
|
"origin_task",
|
||||||
|
"product",
|
||||||
"files",
|
"files",
|
||||||
"review_status",
|
"review_status",
|
||||||
"review_error",
|
"review_error",
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ class AssetPagination(PageNumberPagination):
|
|||||||
|
|
||||||
|
|
||||||
class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||||
queryset = Asset.objects.prefetch_related("files").all()
|
# select_related 回溯链:asset → origin_task → project,供序列化器解析资产归属商品(避免 N+1)
|
||||||
|
queryset = Asset.objects.prefetch_related("files").select_related("origin_task__project").all()
|
||||||
serializer_class = AssetSerializer
|
serializer_class = AssetSerializer
|
||||||
pagination_class = AssetPagination
|
pagination_class = AssetPagination
|
||||||
search_fields = ["name", "description"]
|
search_fields = ["name", "description"]
|
||||||
|
|||||||
@@ -168,6 +168,21 @@ export function App() {
|
|||||||
setActiveProductId((current) => current || productData.results[0]?.id || "");
|
setActiveProductId((current) => current || productData.results[0]?.id || "");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 首登水合带重试:loadData 里 products/projects/allAssets 没有 .catch,任一瞬时失败会让整个
|
||||||
|
// Promise.all 直接 reject、一个 setter 都不跑 → 页面卡在全 0。boot 路径有 api.me 重试兜底,
|
||||||
|
// 故刷新就好;首登(onAuthed)以前是 void loadData().catch(log) 静默吞掉 → 数据全错。这里统一重试。
|
||||||
|
const loadDataWithRetry = useCallback(async (attempts = 3) => {
|
||||||
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||||
|
try {
|
||||||
|
await loadData();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === attempts - 1) throw error;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
// 设置页数据:偏好 + 登录会话(进入设置页时按需加载)
|
// 设置页数据:偏好 + 登录会话(进入设置页时按需加载)
|
||||||
const loadSettingsData = useCallback(async () => {
|
const loadSettingsData = useCallback(async () => {
|
||||||
const [pref, sess] = await Promise.all([
|
const [pref, sess] = await Promise.all([
|
||||||
@@ -227,7 +242,7 @@ export function App() {
|
|||||||
if (cancelled || !identity) return;
|
if (cancelled || !identity) return;
|
||||||
setUser(identity.user);
|
setUser(identity.user);
|
||||||
setTeam(identity.team);
|
setTeam(identity.team);
|
||||||
await loadData();
|
await loadDataWithRetry();
|
||||||
} catch (bootError) {
|
} catch (bootError) {
|
||||||
console.error("[boot] failed:", bootError);
|
console.error("[boot] failed:", bootError);
|
||||||
setToken(null);
|
setToken(null);
|
||||||
@@ -239,7 +254,7 @@ export function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [loadData]);
|
}, [loadDataWithRetry]);
|
||||||
|
|
||||||
// Keep route in sync with browser navigation.
|
// Keep route in sync with browser navigation.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -393,7 +408,7 @@ export function App() {
|
|||||||
if (res) setUser(res);
|
if (res) setUser(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) {
|
function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean }) {
|
||||||
// 异步生图:提交后立刻拿到任务列表,前端轮询直到出图。慢的 ARK 出图在 Celery worker 里跑——
|
// 异步生图:提交后立刻拿到任务列表,前端轮询直到出图。慢的 ARK 出图在 Celery worker 里跑——
|
||||||
// Web 层不被 ~30s 请求占住 → 健康探针不饿死 → 根治"几张图整站 502";且提交成功后浏览器关掉/断网,
|
// Web 层不被 ~30s 请求占住 → 健康探针不饿死 → 根治"几张图整站 502";且提交成功后浏览器关掉/断网,
|
||||||
// worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见。
|
// worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见。
|
||||||
@@ -487,8 +502,10 @@ export function App() {
|
|||||||
setBooting(false);
|
setBooting(false);
|
||||||
setAuthed(true);
|
setAuthed(true);
|
||||||
navigate("dashboard", { replace: true });
|
navigate("dashboard", { replace: true });
|
||||||
void loadData().catch((error) => {
|
// 首登水合:与 boot 路径一致地重试,失败不再静默(否则页面卡在全 0,要刷新才好)
|
||||||
|
loadDataWithRetry().catch((error) => {
|
||||||
console.error("[login] data hydrate failed:", error);
|
console.error("[login] data hydrate failed:", error);
|
||||||
|
setNotice({ type: "error", text: "数据加载失败,请刷新页面重试" });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -417,7 +417,7 @@ export const api = {
|
|||||||
return request<Paginated<AITask>>("/api/ai/tasks/");
|
return request<Paginated<AITask>>("/api/ai/tasks/");
|
||||||
},
|
},
|
||||||
// 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果
|
// 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果
|
||||||
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) {
|
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean }) {
|
||||||
return request<{ tasks: { id: string; status: string }[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
|
return request<{ tasks: { id: string; status: string }[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
|
||||||
},
|
},
|
||||||
generateImageStatus(ids: string[]) {
|
generateImageStatus(ids: string[]) {
|
||||||
|
|||||||
@@ -485,7 +485,7 @@ export function ImageWorkbenchPage({
|
|||||||
modelConfigs: ModelConfig[];
|
modelConfigs: ModelConfig[];
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
navigate?: (page: Page) => void;
|
navigate?: (page: Page) => void;
|
||||||
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) => Promise<{ assets: Asset[] } | null>;
|
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string }) => Promise<{ assets: Asset[] } | null>;
|
||||||
onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
|
onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
|
||||||
/** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */
|
/** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */
|
||||||
initialProductId?: string;
|
initialProductId?: string;
|
||||||
@@ -574,7 +574,7 @@ export function ImageWorkbenchPage({
|
|||||||
// 追加新批次(行32②:不覆盖,在下面新增一行)
|
// 追加新批次(行32②:不覆盖,在下面新增一行)
|
||||||
setBatches((prev) => [...prev, newBatch]);
|
setBatches((prev) => [...prev, newBatch]);
|
||||||
try {
|
try {
|
||||||
const result = await onGenerate({ prompt: prompt.trim(), mode, count: candidateCount });
|
const result = await onGenerate({ prompt: prompt.trim(), mode, count: candidateCount, product_id: product?.id });
|
||||||
setBatches((prev) => {
|
setBatches((prev) => {
|
||||||
const next = prev.map((b) =>
|
const next = prev.map((b) =>
|
||||||
b.id === batchId
|
b.id === batchId
|
||||||
@@ -608,7 +608,7 @@ export function ImageWorkbenchPage({
|
|||||||
};
|
};
|
||||||
setBatches((prev) => [...prev, newBatch]);
|
setBatches((prev) => [...prev, newBatch]);
|
||||||
try {
|
try {
|
||||||
const result = await onGenerate({ prompt: src.prompt, mode, count: src.count });
|
const result = await onGenerate({ prompt: src.prompt, mode, count: src.count, product_id: product?.id });
|
||||||
setBatches((prev) => {
|
setBatches((prev) => {
|
||||||
const next = prev.map((b) =>
|
const next = prev.map((b) =>
|
||||||
b.id === batchId
|
b.id === batchId
|
||||||
|
|||||||
@@ -668,7 +668,7 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass
|
|||||||
onUpdate: (payload: Partial<Product>) => Promise<unknown> | void;
|
onUpdate: (payload: Partial<Product>) => Promise<unknown> | void;
|
||||||
onUploadImage?: (formData: FormData) => Promise<unknown> | void;
|
onUploadImage?: (formData: FormData) => Promise<unknown> | void;
|
||||||
onDeleteImage?: (imageId: string) => Promise<unknown> | void;
|
onDeleteImage?: (imageId: string) => Promise<unknown> | void;
|
||||||
onGenerateImages?: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) => Promise<{ assets: Asset[] } | null>;
|
onGenerateImages?: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean }) => Promise<{ assets: Asset[] } | null>;
|
||||||
// 采用三视图版本:把该版本的 asset 作为商品图持久化进 AI 素材库(由 App.tsx 接线;不传则仅本地标记)
|
// 采用三视图版本:把该版本的 asset 作为商品图持久化进 AI 素材库(由 App.tsx 接线;不传则仅本地标记)
|
||||||
onAdoptTriView?: (asset: Asset) => Promise<unknown> | void;
|
onAdoptTriView?: (asset: Asset) => Promise<unknown> | void;
|
||||||
}) {
|
}) {
|
||||||
@@ -727,7 +727,10 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass
|
|||||||
const res = await onGenerateImages({
|
const res = await onGenerateImages({
|
||||||
prompt: `${product.title || "商品"} 白底商品三视图,正面 / 侧面 / 背面三视图并排于同一张 16:9 横图,电商主图,干净白底,高清`,
|
prompt: `${product.title || "商品"} 白底商品三视图,正面 / 侧面 / 背面三视图并排于同一张 16:9 横图,电商主图,干净白底,高清`,
|
||||||
mode: "image",
|
mode: "image",
|
||||||
count: 1
|
count: 1,
|
||||||
|
product_id: product.id,
|
||||||
|
// 锁真实商品主图:后端据此走 image_edit 以主图为参考,而非纯文生图脑补
|
||||||
|
reference_product: true
|
||||||
});
|
});
|
||||||
const asset = res?.assets?.[0];
|
const asset = res?.assets?.[0];
|
||||||
const url = asset?.files?.[0]?.preview_url;
|
const url = asset?.files?.[0]?.preview_url;
|
||||||
@@ -756,10 +759,16 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass
|
|||||||
const productImages = imageIds
|
const productImages = imageIds
|
||||||
.map((id) => ({ id, url: pdAssetPreview(assetById.get(id)) }));
|
.map((id) => ({ id, url: pdAssetPreview(assetById.get(id)) }));
|
||||||
|
|
||||||
// AI 生成素材 · 团队资产中筛与该商品相关的类别(模特/场景/三视图/商品图/背景),取真图;无则回退到全部图片资产
|
// AI 生成素材 · 只取「该商品」的 AI 素材,而非全团队资产。归属由后端解析的 asset.product 给出
|
||||||
|
// (独立生图记 metadata.product_id;项目内生成的图回溯 origin_task→project→product,历史图也能归位);
|
||||||
|
// 再纳入该商品上传的商品图(product.images 的 asset)。
|
||||||
const AI_CATS = new Set(["product_image", "person", "scene", "tri_view", "background"]);
|
const AI_CATS = new Set(["product_image", "person", "scene", "tri_view", "background"]);
|
||||||
const aiSource = assets.filter((asset) => AI_CATS.has(asset.category) || AI_CATS.has(asset.asset_type));
|
const productImageIds = new Set(imageIds);
|
||||||
const allImageAssets = aiSource.length ? aiSource : assets.filter((asset) => asset.asset_type === "image");
|
const belongsToProduct = (asset: Asset) =>
|
||||||
|
asset.product === product.id || productImageIds.has(asset.id);
|
||||||
|
const allImageAssets = assets.filter(
|
||||||
|
(asset) => belongsToProduct(asset) && (AI_CATS.has(asset.category) || AI_CATS.has(asset.asset_type) || asset.asset_type === "image")
|
||||||
|
);
|
||||||
// 类型筛选选项(当前素材里真实存在的 category)
|
// 类型筛选选项(当前素材里真实存在的 category)
|
||||||
const typeOptions = Array.from(new Set(allImageAssets.map((a) => a.category).filter(Boolean)));
|
const typeOptions = Array.from(new Set(allImageAssets.map((a) => a.category).filter(Boolean)));
|
||||||
const filteredAssets = allImageAssets
|
const filteredAssets = allImageAssets
|
||||||
@@ -1008,7 +1017,7 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass
|
|||||||
<div className={`tab-pane${tab === "assets" ? " active" : ""}`} data-pane="assets">
|
<div className={`tab-pane${tab === "assets" ? " active" : ""}`} data-pane="assets">
|
||||||
|
|
||||||
<div className="pd-toolbar">
|
<div className="pd-toolbar">
|
||||||
<div className="total">全部 AI 素材 <span className="ct">({assetCount})</span></div>
|
<div className="total">该商品 AI 素材 <span className="ct">({assetCount})</span></div>
|
||||||
<div className={`chip-wrap${openFilter === "type" ? " open" : ""}`} style={{ display: "inline-flex" }} data-key="type">
|
<div className={`chip-wrap${openFilter === "type" ? " open" : ""}`} style={{ display: "inline-flex" }} data-key="type">
|
||||||
<button className="filter" type="button" onClick={() => setOpenFilter((f) => (f === "type" ? "" : "type"))}>
|
<button className="filter" type="button" onClick={() => setOpenFilter((f) => (f === "type" ? "" : "type"))}>
|
||||||
{typeFilter ? (pdAssetTypeLabel({ category: typeFilter, asset_type: "" } as Asset) || typeFilter) : "全部类型"}
|
{typeFilter ? (pdAssetTypeLabel({ category: typeFilter, asset_type: "" } as Asset) || typeFilter) : "全部类型"}
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export type Asset = {
|
|||||||
description: string;
|
description: string;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
origin_task?: string | null;
|
origin_task?: string | null;
|
||||||
|
// 归属商品 id(后端解析:metadata.product_id 或 origin_task→project→product),无归属为 null
|
||||||
|
product?: string | null;
|
||||||
files?: Array<{
|
files?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
object_key: string;
|
object_key: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user