fix: 修复资产/视频/三视图三处 bug + 脚本模型切 YunQi gemini
1. 视频「全部生成」串行短路:任一段提交失败就 break 致后面段不提交 (4 个框只跑 2 个)→ 改 Promise.allSettled 并发,单段失败不拖累其余, 部分失败也刷新并如实报告。(App.tsx) 2. 基础资产/三视图 AI 出图偶发失败「生成不出来」:中转站瞬时抖动 (网络/5xx/429/空返回)一次失败即退费 → 加退避重试 3 次,永久错误 (4xx 内容违规)仍立即退费。(services.py run_base_asset_task/run_triview_task) 3. 商品三视图无法用已有素材:商品卡加「使用已有素材」,选商品已有图 / 上传本地图直接 attach 成三视图(不走平台 AI 生成、不计费)。(pipeline.tsx) 4. 脚本助手:tokenssr 欠费致 GPT-5.5/Gemini 全挂 → 文本模型切 YunQi。 实测 YunQi gpt-5.5 频道扛不住重型结构化任务(skill 全提示词仅 1/5 成功、 常吐空无 JSON),gemini-3.1-pro 5/5 稳定 → 默认改 gemini,停用 gpt-5.5。 按「一 provider 一 key」拆 yunqi_gpt / yunqi_gemini 两 provider。 (.env / settings / 迁移 0017+0018) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,9 @@ VIDEO_ARK_API_KEY=9225161a-a640-47e6-94ed-81f6c5610072
|
||||
DEFAULT_TRIAL_CREDITS=1000.0000
|
||||
YUNQI_API_KEY=sk-xdP2iy5kzmehinLkI1lxV2BmpGSXma2wvKbSVP3tZBPHH6zf
|
||||
YUNQI_BASE_URL=https://www.yunqiai.chat/v1
|
||||
# YunQi 对话(脚本助手)· 按模型分开计费的两把 key(同一 base_url,各自独立额度)
|
||||
YUNQI_GPT_API_KEY=sk-WX9ClkkjJmquaz4CsmtiUTNQ7fYgDmngNasDUUjbK504uFEd
|
||||
YUNQI_GEMINI_API_KEY=sk-WNmbNpwHiln5PUfSFgepXKdfoIe3L59rSpaYffp1hHqZ0s5N
|
||||
|
||||
# tokenssr 中转站(主力:gpt-image-2 参考图 + gpt-5.5/gemini 文本 + gemini 图像)· 一把 key 通吃 90 模型
|
||||
TOKENSSR_API_KEY=sk-vDg00IAX6EW4ABo4ePwAFPRaIjScFLUs1ZM1lInZJvs7z6M0
|
||||
|
||||
@@ -206,11 +206,17 @@ TOKENSSR = {
|
||||
PROVIDER_BASE_URLS = {
|
||||
"volcengine": env("VOLCANO_ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"),
|
||||
"yunqi": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"),
|
||||
# YunQi 对话(脚本助手):gpt-5.5 与 gemini-3.1-pro-preview 在 YunQi 同一地址、各有独立计费 key,
|
||||
# 故拆成两个 provider(一 provider 一 key 是现有约定)。base_url 都指 YunQi。
|
||||
"yunqi_gpt": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"),
|
||||
"yunqi_gemini": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"),
|
||||
"tokenssr": env("TOKENSSR_BASE_URL", "https://king.tokenssr.com/v1"),
|
||||
}
|
||||
PROVIDER_KEYS = {
|
||||
"volcengine": env("VOLCANO_ARK_API_KEY", ""),
|
||||
"yunqi": env("YUNQI_API_KEY", ""),
|
||||
"yunqi_gpt": env("YUNQI_GPT_API_KEY", ""),
|
||||
"yunqi_gemini": env("YUNQI_GEMINI_API_KEY", ""),
|
||||
"tokenssr": env("TOKENSSR_API_KEY", ""),
|
||||
}
|
||||
# 临时:视频(Seedance)单独借 AirDrama 火山账号的 ARK key。真人素材库审核(ASSETS_API)建在 AirDrama 账号下,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""脚本助手文本模型从 tokenssr 切到 YunQi(tokenssr 欠费致 GPT-5.5/Gemini 全挂)。
|
||||
|
||||
YunQi 同一地址(https://www.yunqiai.chat/v1)对 gpt / gemini 分开计费、各一把 key,故按
|
||||
「一 provider 一 key」拆成两个 provider:
|
||||
- yunqi_gpt → gpt-5.5(默认脚本模型) · key=.env YUNQI_GPT_API_KEY
|
||||
- yunqi_gemini → gemini-3.1-pro-preview · key=.env YUNQI_GEMINI_API_KEY
|
||||
凭证留空 → 运行时由 settings.PROVIDER_KEYS 从 .env 解析(密钥不进库)。
|
||||
模型名经实测确认:gpt-5.5 / gemini-3.1-pro-preview 在 YunQi 返回 200 且能流式出正文;
|
||||
其余名(gpt-5 / gemini-3-pro-preview …)YunQi 报 model_not_found。
|
||||
|
||||
同时停用旧的 tokenssr 文本模型(gpt-5.5 / gemini-3-pro-preview),让 get_default_model(text)
|
||||
选到新的 YunQi gpt-5.5。幂等:全部 update_or_create / 显式置位,可重复 apply。
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
YUNQI_BASE_URL = "https://www.yunqiai.chat/v1"
|
||||
|
||||
# (provider_name, display, model_name, model_display, is_default)
|
||||
YUNQI_TEXT = [
|
||||
("yunqi_gpt", "YunQi · GPT", "gpt-5.5", "GPT-5.5", True),
|
||||
("yunqi_gemini", "YunQi · Gemini", "gemini-3.1-pro-preview", "Gemini 3.1 Pro", False),
|
||||
]
|
||||
|
||||
|
||||
def seed(apps, schema_editor):
|
||||
ModelProvider = apps.get_model("ai", "ModelProvider")
|
||||
ModelConfig = apps.get_model("ai", "ModelConfig")
|
||||
|
||||
for prov_name, prov_display, model_name, model_display, is_default in YUNQI_TEXT:
|
||||
provider, _ = ModelProvider.objects.get_or_create(
|
||||
name=prov_name,
|
||||
defaults={
|
||||
"display_name": prov_display,
|
||||
"status": "active",
|
||||
"base_url": YUNQI_BASE_URL,
|
||||
"api_key": "", # 运行时从 .env(PROVIDER_KEYS)解析,密钥不进库
|
||||
"metadata": {"kind": "relay", "gateway": "yunqi"},
|
||||
},
|
||||
)
|
||||
# down→up 循环后 get_or_create 命中旧行(可能 disabled / 缺 base_url),显式补齐
|
||||
changed = []
|
||||
if provider.status != "active":
|
||||
provider.status = "active"; changed.append("status")
|
||||
if not provider.base_url:
|
||||
provider.base_url = YUNQI_BASE_URL; changed.append("base_url")
|
||||
if changed:
|
||||
provider.save(update_fields=changed)
|
||||
|
||||
ModelConfig.objects.update_or_create(
|
||||
provider=provider,
|
||||
name=model_name,
|
||||
capability="text",
|
||||
defaults={
|
||||
"display_name": model_display,
|
||||
"endpoint": "chat/completions",
|
||||
"unit_price": "1.0000",
|
||||
"status": "active",
|
||||
"is_default": is_default,
|
||||
"metadata": {"gateway": "yunqi", "verified": True},
|
||||
},
|
||||
)
|
||||
|
||||
# 停用旧的 tokenssr 文本模型(欠费),并清掉它们可能带的 is_default,避免与新默认打架
|
||||
ModelConfig.objects.filter(
|
||||
provider__name="tokenssr", capability="text",
|
||||
).update(status="disabled", is_default=False)
|
||||
|
||||
|
||||
def unseed(apps, schema_editor):
|
||||
ModelConfig = apps.get_model("ai", "ModelConfig")
|
||||
# 回滚:停用 YunQi 文本模型,恢复 tokenssr 文本模型为 active(gpt-5.5 复位默认)
|
||||
ModelConfig.objects.filter(provider__name__in=["yunqi_gpt", "yunqi_gemini"], capability="text").update(
|
||||
status="disabled", is_default=False,
|
||||
)
|
||||
ModelConfig.objects.filter(provider__name="tokenssr", capability="text").update(status="active")
|
||||
ModelConfig.objects.filter(provider__name="tokenssr", capability="text", name="gpt-5.5").update(is_default=True)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("ai", "0016_person_triview_16_9"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed, unseed),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""脚本默认模型从 gpt-5.5 改为 gemini-3.1-pro-preview,并停用 YunQi 的 gpt-5.5。
|
||||
|
||||
实测(完整电商脚本 skill ~28KB 重型结构化任务,各连发 5 次):
|
||||
- YunQi gpt-5.5 → 仅 1/5 成功(3 次吐空、1 次出文无合法 JSON,成功那次还要 69-95s)→ 频道扛不住,不可用
|
||||
- YunQi gemini-3.1-pro → 5/5 成功(每次 ~2300 字合法 JSON,30-48s 稳定)
|
||||
故默认切 gemini;gpt-5.5 停用(YunQi 频道修好后改回 active 即可,一行)。
|
||||
幂等:显式置位,可重复 apply。
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def apply(apps, schema_editor):
|
||||
ModelConfig = apps.get_model("ai", "ModelConfig")
|
||||
# gemini 设默认 + 确保 active
|
||||
ModelConfig.objects.filter(
|
||||
provider__name="yunqi_gemini", capability="text", name="gemini-3.1-pro-preview",
|
||||
).update(is_default=True, status="active")
|
||||
# gpt-5.5 取消默认 + 停用(频道不可用)
|
||||
ModelConfig.objects.filter(
|
||||
provider__name="yunqi_gpt", capability="text", name="gpt-5.5",
|
||||
).update(is_default=False, status="disabled")
|
||||
|
||||
|
||||
def revert(apps, schema_editor):
|
||||
ModelConfig = apps.get_model("ai", "ModelConfig")
|
||||
ModelConfig.objects.filter(provider__name="yunqi_gemini", capability="text", name="gemini-3.1-pro-preview").update(is_default=False)
|
||||
ModelConfig.objects.filter(provider__name="yunqi_gpt", capability="text", name="gpt-5.5").update(is_default=True, status="active")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("ai", "0017_switch_script_to_yunqi"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(apply, revert),
|
||||
]
|
||||
@@ -2,11 +2,14 @@ import json
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import transaction
|
||||
@@ -865,6 +868,38 @@ def build_person_frontal_prompt(description: str = "") -> str:
|
||||
return render_prompt("person_portrait", default, 描述=desc)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 出图重试(中转站偶发抖动 → 一次失败就「生成不出来」的根因兜底)
|
||||
# --------------------------------------------------------------------------- #
|
||||
_IMAGE_GEN_ATTEMPTS = 3
|
||||
|
||||
|
||||
def _is_transient_image_error(exc: Exception) -> bool:
|
||||
"""判定出图失败是否「瞬时」(值得重试)。瞬时:网络抖动 / 5xx / 429 限流 / 中转站吐空无 media url。
|
||||
永久(不重试,立即退费报错):4xx 内容违规 / 参数错(如 invalid_image_file)、配置缺失等。"""
|
||||
if isinstance(exc, (requests.ConnectionError, requests.Timeout)):
|
||||
return True
|
||||
if isinstance(exc, requests.HTTPError):
|
||||
code = getattr(getattr(exc, "response", None), "status_code", 0) or 0
|
||||
return code >= 500 or code == 429
|
||||
# extract_first_media_url 在响应没有 url 时抛 ValueError —— 多为中转站偶发空返回,重试常能拿到
|
||||
if isinstance(exc, ValueError) and "media url" in str(exc).lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _run_image_with_retry(make, *, attempts: int = _IMAGE_GEN_ATTEMPTS):
|
||||
"""跑一次出图(provider 调用 + 取 media url),瞬时错误按退避重试,永久错误立即抛。
|
||||
make() 须返回 (response, media)。退避 2s / 4s,总耗时上限 ~6s + 出图本身,worker 内执行对用户无感。"""
|
||||
for i in range(attempts):
|
||||
try:
|
||||
return make()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if i == attempts - 1 or not _is_transient_image_error(exc):
|
||||
raise
|
||||
time.sleep(2 * (i + 1))
|
||||
|
||||
|
||||
def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "", group_id: str | None = None) -> AITask:
|
||||
"""提交基础资产生成(**异步**):Web 请求只建 RESERVED 任务 + 预留额度(秒级),
|
||||
慢出图(文生图 / 商品 image_edit)交给 Celery worker(run_base_asset_task)跑。
|
||||
@@ -927,18 +962,22 @@ def run_base_asset_task(*, task_id: str) -> None:
|
||||
provider = get_image_provider(model_config)
|
||||
reservation = task.credit_reservation
|
||||
try:
|
||||
if use_edit and ref_url:
|
||||
# 商品三视图:比例可在 admin 改(默认横)
|
||||
size = prompt_ratio_size("product_triview", "1536x1024")
|
||||
response = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=size)
|
||||
else:
|
||||
# 场景默认横、人物立绘默认竖;两者比例都可在 admin 改
|
||||
if kind == BaseAssetGroup.Kind.SCENE:
|
||||
gen_size = prompt_ratio_size("scene", "1536x1024")
|
||||
def _make():
|
||||
if use_edit and ref_url:
|
||||
# 商品三视图:比例可在 admin 改(默认横)
|
||||
size = prompt_ratio_size("product_triview", "1536x1024")
|
||||
resp = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=size)
|
||||
else:
|
||||
gen_size = prompt_ratio_size("person_portrait", "1024x1536")
|
||||
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt, size=gen_size)
|
||||
media = provider.extract_first_media_url(response)
|
||||
# 场景默认横、人物立绘默认竖;两者比例都可在 admin 改
|
||||
if kind == BaseAssetGroup.Kind.SCENE:
|
||||
gen_size = prompt_ratio_size("scene", "1536x1024")
|
||||
else:
|
||||
gen_size = prompt_ratio_size("person_portrait", "1024x1536")
|
||||
resp = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt, size=gen_size)
|
||||
return resp, provider.extract_first_media_url(resp)
|
||||
|
||||
# 中转站偶发抖动(网络/5xx/限流/空返回)按退避重试,避免「一次失败就生成不出来」;永久错误仍立即退费
|
||||
response, media = _run_image_with_retry(_make)
|
||||
with transaction.atomic():
|
||||
task.status = AITask.Status.SUCCEEDED
|
||||
task.response_payload = response
|
||||
@@ -1027,9 +1066,13 @@ def run_triview_task(*, task_id: str) -> None:
|
||||
provider = get_image_provider(model_config)
|
||||
reservation = task.credit_reservation
|
||||
try:
|
||||
tri_size = prompt_ratio_size("person_triview", "1536x1024")
|
||||
response = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=tri_size)
|
||||
media = provider.extract_first_media_url(response)
|
||||
def _make():
|
||||
tri_size = prompt_ratio_size("person_triview", "1536x1024")
|
||||
resp = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=tri_size)
|
||||
return resp, provider.extract_first_media_url(resp)
|
||||
|
||||
# 同基础资产:瞬时抖动重试,永久错误立即退费报错
|
||||
response, media = _run_image_with_retry(_make)
|
||||
with transaction.atomic():
|
||||
task.status = AITask.Status.SUCCEEDED
|
||||
task.response_payload = response
|
||||
|
||||
@@ -916,7 +916,7 @@ export function App() {
|
||||
onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")}
|
||||
onSetAdoptState={(groupId, state) => action(() => api.setBaseAssetAdopt(pipelineProject.id, { group_id: groupId, state }), state === "adopted" ? "已采用" : "已标为未采用")}
|
||||
onDeleteBaseAsset={(groupId) => action(() => api.deleteBaseAsset(pipelineProject.id, groupId), "已删除")}
|
||||
onAttachBaseAsset={(target, assetId) => action(() => api.attachBaseAsset(pipelineProject.id, { ...target, asset_id: assetId }), "已替换为所选演员")}
|
||||
onAttachBaseAsset={(target, assetId) => action(() => api.attachBaseAsset(pipelineProject.id, { ...target, asset_id: assetId }), "已采用所选素材")}
|
||||
onGenerateTriview={async (portraitAssetId) => {
|
||||
// 异步:image_edit 慢出图在 worker 跑,轮询期间整站不卡;出图后刷新即见三视图
|
||||
const assetId = await submitAndPollAsset(() => api.generateTriview(pipelineProject.id, { portrait_asset_id: portraitAssetId }), "三视图已据立绘生成");
|
||||
@@ -940,11 +940,24 @@ export function App() {
|
||||
action(async () => {
|
||||
// 「全部重跑」要把已完成的也重跑(否则全成功的项目点了等于没点);只跳过在途(running/queued)避免重复提交
|
||||
const targets = pipelineProject.video_segments.filter((segment) => !["running", "queued"].includes(segment.status));
|
||||
for (const segment of targets) {
|
||||
await api.submitVideo(pipelineProject.id, {
|
||||
video_segment_id: segment.id,
|
||||
prompt: `${prompt} 第 ${segment.sort_order + 1} 段,时长 ${segment.target_duration_seconds} 秒`
|
||||
});
|
||||
// ★ 并发提交,且单段失败绝不拖累其余段。旧实现用 for+await 串行,任一段抛错(如人脸需走素材库
|
||||
// 的 502、中转限流)整个循环就 break → 后面的段永不提交,前端出现「4 个框只有 2 个在跑」。
|
||||
// 改 allSettled:每段各自独立提交,失败只标该段,成功的照常进生成。
|
||||
const results = await Promise.allSettled(
|
||||
targets.map((segment) =>
|
||||
api.submitVideo(pipelineProject.id, {
|
||||
video_segment_id: segment.id,
|
||||
prompt: `${prompt} 第 ${segment.sort_order + 1} 段,时长 ${segment.target_duration_seconds} 秒`,
|
||||
})
|
||||
)
|
||||
);
|
||||
const failed = results.filter((r): r is PromiseRejectedResult => r.status === "rejected");
|
||||
if (failed.length) {
|
||||
// 部分失败也要刷新,让已成功提交的段立刻进「生成中」(否则 action 走 catch 分支会跳过刷新)
|
||||
await refreshProjectDetail();
|
||||
const firstErr = failed[0].reason;
|
||||
const detail = firstErr instanceof Error ? firstErr.message : "提交失败";
|
||||
throw new Error(`${targets.length - failed.length}/${targets.length} 段已提交,${failed.length} 段失败:${detail}(可对失败的段单独点重跑)`);
|
||||
}
|
||||
return targets.length;
|
||||
}, "多段视频已提交,生成中…")
|
||||
|
||||
@@ -603,6 +603,36 @@ export function PipelinePage(props: {
|
||||
// 每次生成=一个 product group=一版三视图;预览态(triPreviewId)只切主图,采用态存 metadata.product_tri_group
|
||||
const [triPanelOpen, setTriPanelOpen] = useState(false);
|
||||
const [triPreviewId, setTriPreviewId] = useState<string | null>(null);
|
||||
// 问题5(a):用「已有素材」直接当最终三视图——选商品已有图 / 上传本地图,attach 成商品组采用版(不走平台 AI 生成、不计费)。
|
||||
const [triPickOpen, setTriPickOpen] = useState(false);
|
||||
const [triPickBusy, setTriPickBusy] = useState(false);
|
||||
const triFileRef = useRef<HTMLInputElement | null>(null);
|
||||
async function attachExistingTri(assetId: string) {
|
||||
setTriPickBusy(true);
|
||||
try {
|
||||
await onAttachBaseAsset({ kind: "product" }, assetId); // 后端 attach-base-asset 按 kind=product 命中/建商品组并采用
|
||||
setTriPickOpen(false);
|
||||
} finally {
|
||||
setTriPickBusy(false);
|
||||
}
|
||||
}
|
||||
async function uploadTriFromFile(file: File) {
|
||||
setTriPickBusy(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("asset_type", "image");
|
||||
fd.append("category", "tri_view"); // 标三视图类(与生成的三视图同类,送审/展示口径一致)
|
||||
fd.append("name", `${productName || "商品"}·三视图`);
|
||||
const asset = await api.uploadAsset(fd); // 落 TOS + 建 team 资产,返回带 id
|
||||
await onAttachBaseAsset({ kind: "product" }, asset.id);
|
||||
setTriPickOpen(false);
|
||||
} catch (e) {
|
||||
onNotify?.("error", e instanceof Error && e.message ? e.message : "上传失败,请重试");
|
||||
} finally {
|
||||
setTriPickBusy(false);
|
||||
}
|
||||
}
|
||||
function jumpAssetSection(kind: "product" | "person" | "scene") {
|
||||
setAssetTab(kind);
|
||||
if (typeof document !== "undefined") {
|
||||
@@ -2818,6 +2848,8 @@ export function PipelinePage(props: {
|
||||
<svg className="ai-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3z" /><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7L19 14z" /></svg>
|
||||
{triGenerating ? "生成中…" : (hasTriView ? "AI 重新生成三视图" : "AI 生成三视图")}
|
||||
</button>
|
||||
{/* 问题5(a):不想用平台生成 → 直接用已有素材(选商品图 / 上传本地图)当三视图 */}
|
||||
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={triGenerating || triPickBusy} onClick={() => setTriPickOpen(true)}>使用已有素材</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 行39 · 三视图预览面板(对齐原版 prod-preview):生成中转圈 → 主图(可放大)+ 重跑/采用 + 历史版本切换 */}
|
||||
@@ -3936,6 +3968,38 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* 问题5(a):用已有素材作三视图——选商品已有图 / 上传本地图,attach 成商品采用版(不走平台 AI 生成、不计费) */}
|
||||
{triPickOpen && (
|
||||
<div className="asset-modal-bg" role="dialog" aria-modal="true" aria-label="使用已有素材作三视图" onClick={(e) => { if (e.target === e.currentTarget && !triPickBusy) setTriPickOpen(false); }}>
|
||||
<div className="asset-modal">
|
||||
<div className="asset-modal-h">
|
||||
<h2>使用已有素材作三视图</h2>
|
||||
<span className="mono" style={{ fontSize: "12px", color: "var(--black-alpha-48)" }}>// 选商品图或上传本地图,直接作最终三视图</span>
|
||||
<button className="x" type="button" aria-label="关闭" disabled={triPickBusy} onClick={() => setTriPickOpen(false)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="asset-modal-body">
|
||||
<input ref={triFileRef} type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) void uploadTriFromFile(f); e.target.value = ""; }} />
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))", gap: "12px" }}>
|
||||
{/* 上传本地图 tile */}
|
||||
<div className="placeholder" role="button" tabIndex={0} aria-disabled={triPickBusy} title="上传本地图片作三视图" style={{ aspectRatio: "16/9", display: "grid", placeItems: "center", cursor: triPickBusy ? "default" : "pointer" }} onClick={() => { if (!triPickBusy) triFileRef.current?.click(); }} onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && !triPickBusy) { e.preventDefault(); triFileRef.current?.click(); } }}>
|
||||
<span className="mono" style={{ fontSize: "12px", color: "var(--black-alpha-48)" }}>{triPickBusy ? "处理中…" : "+ 上传本地图"}</span>
|
||||
</div>
|
||||
{/* 商品已有图:点击直接采用为三视图 */}
|
||||
{(productRecord?.images ?? []).map((img) => (
|
||||
<div key={img.id} className={`placeholder${img.preview_url ? " has-mock-media is-zoomable" : ""}`} role="button" tabIndex={0} title="点击采用为三视图" aria-disabled={triPickBusy} style={{ aspectRatio: "16/9", cursor: triPickBusy ? "default" : "pointer", ...(img.preview_url ? mediaStyle(img.preview_url) : {}) }} onClick={() => { if (!triPickBusy) void attachExistingTri(img.asset); }} onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && !triPickBusy) { e.preventDefault(); void attachExistingTri(img.asset); } }}>
|
||||
{!img.preview_url && <span className="mono" style={{ fontSize: "11px", color: "var(--black-alpha-48)" }}>// 无预览</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{(productRecord?.images ?? []).length === 0 && (
|
||||
<div className="mono" style={{ marginTop: "10px", fontSize: "12px", color: "var(--black-alpha-48)" }}>// 该商品暂无已有图,可直接上传本地图</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 流程步骤4 · 演员库:浏览 / 添加演员(AI生成·本地上传)/ 替换回填 */}
|
||||
<ActorLibrary
|
||||
open={Boolean(actorLib)}
|
||||
|
||||
Reference in New Issue
Block a user