fix: 测试清单一轮bug修复(商品库/视频项目/设置/消费/平台套图)
- 商品库编辑删图假成功+悬停无删除图标;商品三视图错显角色三视图 - 视频项目演员删除入口;角色三视图防呆;故事板审核失败原因透出 - 设置:管理团队死按钮/通知未接入项屏蔽/邮箱验证链路 - 消费:账单流水切换类型重置分页+独立总页数 - 资产库上传按钮隐藏;月限额两处对齐 - 平台套图:提示词框放大/选模型胶囊内嵌/未读任务角标/工作台记录持久化 - 新建商品独立添加卖点按钮;商品库超1920自适应 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1248,7 +1248,7 @@ def _run_image_with_retry(make, *, attempts: int = _IMAGE_GEN_ATTEMPTS):
|
|||||||
time.sleep(2 * (i + 1))
|
time.sleep(2 * (i + 1))
|
||||||
|
|
||||||
|
|
||||||
def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "", group_id: str | None = None, reference_asset_id: str | None = None) -> AITask:
|
def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "", group_id: str | None = None, reference_asset_id: str | None = None, auto_triview: bool = False) -> AITask:
|
||||||
"""提交基础资产生成(**异步**):Web 请求只建 RESERVED 任务 + 预留额度(秒级),
|
"""提交基础资产生成(**异步**):Web 请求只建 RESERVED 任务 + 预留额度(秒级),
|
||||||
慢出图(文生图 / 商品 image_edit)交给 Celery worker(run_base_asset_task)跑。
|
慢出图(文生图 / 商品 image_edit)交给 Celery worker(run_base_asset_task)跑。
|
||||||
|
|
||||||
@@ -1293,6 +1293,9 @@ def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "
|
|||||||
"model": model_config.name, "endpoint": model_config.endpoint, "prompt": gen_prompt,
|
"model": model_config.name, "endpoint": model_config.endpoint, "prompt": gen_prompt,
|
||||||
"kind": kind, "label": label or "", "group_id": str(group_id) if group_id else "",
|
"kind": kind, "label": label or "", "group_id": str(group_id) if group_id else "",
|
||||||
"use_edit": use_edit, "reference_image": ref_url,
|
"use_edit": use_edit, "reference_image": ref_url,
|
||||||
|
# 角色立绘出图成功后,worker 内自动接力生成它配套的三视图(ZWQ#3)。仅人物立绘生效;
|
||||||
|
# 默认关闭(显式由调用方传 True),避免给商品/场景或不需要三视图的流程白白多扣一次费。
|
||||||
|
"auto_triview": bool(auto_triview) and kind == BaseAssetGroup.Kind.PERSON,
|
||||||
}
|
}
|
||||||
task = create_ai_task(
|
task = create_ai_task(
|
||||||
project=project,
|
project=project,
|
||||||
@@ -1397,6 +1400,17 @@ def run_base_asset_task(*, task_id: str) -> None:
|
|||||||
from apps.assets.review import submit_asset_for_review
|
from apps.assets.review import submit_asset_for_review
|
||||||
|
|
||||||
transaction.on_commit(lambda a=asset: submit_asset_for_review(a))
|
transaction.on_commit(lambda a=asset: submit_asset_for_review(a))
|
||||||
|
# ZWQ#3 · 角色立绘出图成功后自动接力生成它配套的三视图(opt-in;商品/场景不触发)。
|
||||||
|
# 放 on_commit:确保立绘已落库再据它发起三视图(image_edit 以该立绘为参考)。
|
||||||
|
# best-effort:三视图发起失败(额度不足/模型不支持 image_edit 等)不影响立绘本身已成功。
|
||||||
|
if kind == BaseAssetGroup.Kind.PERSON and payload.get("auto_triview"):
|
||||||
|
def _kickoff_triview(portrait=asset, proj=project, usr=user):
|
||||||
|
try:
|
||||||
|
generate_person_triview(project=proj, user=usr, portrait_asset=portrait)
|
||||||
|
except Exception: # noqa: BLE001 — 三视图接力是附加能力,失败绝不回头弄挂立绘流程
|
||||||
|
logger.exception("auto triview kickoff failed for portrait %s", getattr(portrait, "id", "?"))
|
||||||
|
|
||||||
|
transaction.on_commit(_kickoff_triview)
|
||||||
except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
|
except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
|
||||||
task.status = AITask.Status.FAILED
|
task.status = AITask.Status.FAILED
|
||||||
task.error_message = str(exc)
|
task.error_message = str(exc)
|
||||||
@@ -1807,12 +1821,46 @@ def _is_transient_error(exc: Exception) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 审核类别英文 → 给用户的中文说明(摘自第三方 safety_violations 字段)。命不中则原样保留英文。
|
||||||
|
_MODERATION_CATEGORY_CN = {
|
||||||
|
"sexual": "性暗示 / 露骨",
|
||||||
|
"sexual/minors": "涉及未成年的性内容",
|
||||||
|
"violence": "暴力",
|
||||||
|
"violence/graphic": "血腥暴力",
|
||||||
|
"self-harm": "自残",
|
||||||
|
"hate": "仇恨",
|
||||||
|
"harassment": "骚扰",
|
||||||
|
"illicit": "违禁",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_moderation_categories(raw: str) -> list[str]:
|
||||||
|
"""从原始报错里抽出被审核命中的类别(如 safety_violations=[sexual] / "categories":["sexual"]),
|
||||||
|
译成中文标签。抽不到返回 []。供友好提示点名真实类别,而非泛化的「疑似敏感内容」。"""
|
||||||
|
cats: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
# 抓 safety_violations / categories / category 后面的值块,到 ] / } / 换行 / 句末为止。
|
||||||
|
# 兼容 [sexual] / ["sexual","violence"] / sexual,violence / : "sexual" 等多种写法。
|
||||||
|
for m in re.finditer(
|
||||||
|
r"(?:safety_violations|categories|violation_categor(?:y|ies)|category)\s*[=:]\s*\[?\s*([^\]\}\n]+)",
|
||||||
|
raw or "",
|
||||||
|
):
|
||||||
|
for tok in re.split(r"[\s,;\"']+", m.group(1)):
|
||||||
|
key = tok.strip().strip("\"'[]").lower()
|
||||||
|
if key and key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
cats.append(_MODERATION_CATEGORY_CN.get(key, key))
|
||||||
|
return cats
|
||||||
|
|
||||||
|
|
||||||
def friendly_generation_error(raw: str) -> str:
|
def friendly_generation_error(raw: str) -> str:
|
||||||
"""把模型/中转站的原始报错翻成给用户的中文友好提示(前端直接展示)。原始报错仍记进 AITask 供排查。
|
"""把模型/中转站的原始报错翻成给用户的中文友好提示(前端直接展示)。原始报错仍记进 AITask 供排查。
|
||||||
覆盖:内容审核拦截 / 超时 / 限流 / 凭证 / 参考图被拒 等;命不中给通用兜底。"""
|
覆盖:内容审核拦截 / 超时 / 限流 / 凭证 / 参考图被拒 等;命不中给通用兜底。"""
|
||||||
s = (raw or "").lower()
|
s = (raw or "").lower()
|
||||||
if any(k in s for k in ("moderation_blocked", "safety system", "safety_violation", "content_policy", "content policy")):
|
if any(k in s for k in ("moderation_blocked", "safety system", "safety_violation", "content_policy", "content policy")):
|
||||||
return "画面或文案被内容审核拦截(疑似敏感内容)。请调整脚本措辞(如避免「胸罩 / 内衣 / 抚摸 / 贴身」等直白表述,改用「产品 / 包装展示」),或更换参考图后重试。"
|
cats = _extract_moderation_categories(raw)
|
||||||
|
cat_note = f"(命中类别:{('、'.join(cats))})" if cats else "(疑似敏感内容)"
|
||||||
|
return f"画面或文案被内容审核拦截{cat_note}。请调整脚本措辞(如避免「胸罩 / 内衣 / 抚摸 / 贴身」等直白表述,改用「产品 / 包装展示」),或更换参考图后重试。"
|
||||||
if any(k in s for k in ("timed out", "timeout", "read timed out")):
|
if any(k in s for k in ("timed out", "timeout", "read timed out")):
|
||||||
return "生成超时,可能是网络波动或模型繁忙,请稍后重试。"
|
return "生成超时,可能是网络波动或模型繁忙,请稍后重试。"
|
||||||
if any(k in s for k in ("429", "too many requests", "rate limit", "forbidden", "403")):
|
if any(k in s for k in ("429", "too many requests", "rate limit", "forbidden", "403")):
|
||||||
|
|||||||
@@ -129,10 +129,20 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
if p.get("product"):
|
if p.get("product"):
|
||||||
# 资产归属商品:独立生图写 metadata.product_id;项目内生成回溯 origin_task→project→product;
|
# 资产归属商品:独立生图写 metadata.product_id;项目内生成回溯 origin_task→project→product;
|
||||||
# 上传的商品图经 ProductImage 关联。三路并集 = 前端 ProductDetail 的 belongsToProduct。
|
# 上传的商品图经 ProductImage 关联。三路并集 = 前端 ProductDetail 的 belongsToProduct。
|
||||||
|
# ★ ZWQ#5:项目里同时会生成「角色立绘/三视图(person/tri_view)、场景图(scene)、分镜图(storyboard)」,
|
||||||
|
# 这些都挂在同一 project 上但**不属于商品**。若 origin_task→project 这一路不限类目,商品库的
|
||||||
|
# 「AI 生成三视图」会把项目里的角色三视图当成商品三视图错显。故 project 回溯路只认「确属商品」的类目
|
||||||
|
# (商品图/模特上身图/平台套图/自由创作);角色/场景/分镜资产仍可经 metadata.product_id 显式归属命中。
|
||||||
pid = p["product"]
|
pid = p["product"]
|
||||||
|
product_categories = (
|
||||||
|
Asset.Category.PRODUCT_IMAGE,
|
||||||
|
Asset.Category.MODEL_TRYON,
|
||||||
|
Asset.Category.PLATFORM_KIT,
|
||||||
|
Asset.Category.FREE_CREATE,
|
||||||
|
)
|
||||||
qs = qs.filter(
|
qs = qs.filter(
|
||||||
Q(metadata__product_id=pid)
|
Q(metadata__product_id=pid)
|
||||||
| Q(origin_task__project__product_id=pid)
|
| (Q(origin_task__project__product_id=pid) & Q(category__in=product_categories))
|
||||||
| Q(product_images__product_id=pid)
|
| Q(product_images__product_id=pid)
|
||||||
).distinct()
|
).distinct()
|
||||||
if p.get("q"):
|
if p.get("q"):
|
||||||
|
|||||||
@@ -487,7 +487,17 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
if kind not in BaseAssetGroup.Kind.values:
|
if kind not in BaseAssetGroup.Kind.values:
|
||||||
return Response({"detail": "invalid base asset kind"}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": "invalid base asset kind"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
try:
|
try:
|
||||||
task = generate_base_asset(project=project, user=request.user, kind=kind, prompt=request.data.get("prompt", ""), label=request.data.get("label", ""), reference_asset_id=request.data.get("reference_asset_id") or None)
|
task = generate_base_asset(
|
||||||
|
project=project,
|
||||||
|
user=request.user,
|
||||||
|
kind=kind,
|
||||||
|
prompt=request.data.get("prompt", ""),
|
||||||
|
label=request.data.get("label", ""),
|
||||||
|
reference_asset_id=request.data.get("reference_asset_id") or None,
|
||||||
|
# ZWQ#3:角色立绘可让 worker 出图后自动接力生成三视图。前端勾选时传 auto_triview=true;
|
||||||
|
# 不传则维持原行为(只出立绘,用户再手点「AI 生成三视图」)。
|
||||||
|
auto_triview=bool(request.data.get("auto_triview")),
|
||||||
|
)
|
||||||
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)
|
||||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.BASE_ASSETS)
|
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.BASE_ASSETS)
|
||||||
|
|||||||
@@ -847,6 +847,7 @@ export function App() {
|
|||||||
<AccountPage
|
<AccountPage
|
||||||
billing={billing}
|
billing={billing}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
|
team={currentTeam}
|
||||||
onRecharge={(amount, bonus) => action(() => api.recharge({ amount, bonus }), "充值成功")}
|
onRecharge={(amount, bonus) => action(() => api.recharge({ amount, bonus }), "充值成功")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,8 +42,9 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
|||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [category, setCategory] = useState("");
|
const [category, setCategory] = useState("");
|
||||||
const [target, setTarget] = useState("");
|
const [target, setTarget] = useState("");
|
||||||
|
// 卖点 = 一组可直接编辑的输入行(每行一条);点「添加卖点」按钮新增一条空行,
|
||||||
|
// 不再靠「在一个输入框里回车隐晦追加」(YYX#17)。
|
||||||
const [bullets, setBullets] = useState<string[]>([]);
|
const [bullets, setBullets] = useState<string[]>([]);
|
||||||
const [bulletDraft, setBulletDraft] = useState("");
|
|
||||||
const [images, setImages] = useState<PfImage[]>([]);
|
const [images, setImages] = useState<PfImage[]>([]);
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const [titleError, setTitleError] = useState(false);
|
const [titleError, setTitleError] = useState(false);
|
||||||
@@ -74,7 +75,6 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
|||||||
setCategory("");
|
setCategory("");
|
||||||
setTarget("");
|
setTarget("");
|
||||||
setBullets([]);
|
setBullets([]);
|
||||||
setBulletDraft("");
|
|
||||||
setImages((list) => { list.forEach((item) => URL.revokeObjectURL(item.url)); return []; });
|
setImages((list) => { list.forEach((item) => URL.revokeObjectURL(item.url)); return []; });
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
setTitleError(false);
|
setTitleError(false);
|
||||||
@@ -84,7 +84,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
|||||||
|
|
||||||
// YYX#24:表单是否已填内容(有任一内容即视为「编辑中」)
|
// YYX#24:表单是否已填内容(有任一内容即视为「编辑中」)
|
||||||
function isDirty() {
|
function isDirty() {
|
||||||
return Boolean(title.trim() || target.trim() || bulletDraft.trim() || bullets.length || images.length);
|
return Boolean(title.trim() || target.trim() || bullets.some((b) => b.trim()) || images.length);
|
||||||
}
|
}
|
||||||
// 关闭入口统一走这里:有内容则先二次确认,否则直接关
|
// 关闭入口统一走这里:有内容则先二次确认,否则直接关
|
||||||
function guardedClose() {
|
function guardedClose() {
|
||||||
@@ -152,11 +152,12 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
|||||||
if (event.dataTransfer?.files?.length) addImages(event.dataTransfer.files);
|
if (event.dataTransfer?.files?.length) addImages(event.dataTransfer.files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 点「添加卖点」→ 追加一条空的可编辑卖点行(显式新增,不靠回车隐晦添加)
|
||||||
function addBullet() {
|
function addBullet() {
|
||||||
const value = bulletDraft.trim();
|
setBullets((list) => [...list, ""]);
|
||||||
if (!value) return;
|
}
|
||||||
setBullets((list) => [...list, value]);
|
function updateBullet(index: number, value: string) {
|
||||||
setBulletDraft("");
|
setBullets((list) => list.map((item, position) => (position === index ? value : item)));
|
||||||
}
|
}
|
||||||
function removeBullet(index: number) {
|
function removeBullet(index: number) {
|
||||||
setBullets((list) => list.filter((_, position) => position !== index));
|
setBullets((list) => list.filter((_, position) => position !== index));
|
||||||
@@ -169,10 +170,9 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
|||||||
// upload-first:图必须先全部传完进桶(拿到 assetId)才能带进创建
|
// upload-first:图必须先全部传完进桶(拿到 assetId)才能带进创建
|
||||||
if (images.some((im) => im.status === "uploading")) { flash("图片上传中", "请等图片传完再创建"); return; }
|
if (images.some((im) => im.status === "uploading")) { flash("图片上传中", "请等图片传完再创建"); return; }
|
||||||
if (images.some((im) => im.status === "error" || !im.assetId)) { flash("有图片未传成功", "请移除失败的图后重试"); return; }
|
if (images.some((im) => im.status === "error" || !im.assetId)) { flash("有图片未传成功", "请移除失败的图后重试"); return; }
|
||||||
// 提交时自动收编未回车的 bulletDraft,避免用户输入了卖点却被丢弃
|
// 卖点行可能有空行(用户点了「添加卖点」还没填):提交时过滤掉空行再去重判空
|
||||||
const pending = bulletDraft.trim();
|
const finalBullets = bullets.map((item) => item.trim()).filter(Boolean);
|
||||||
const finalBullets = pending ? [...bullets, pending] : bullets;
|
if (finalBullets.length === 0) { flash("请填写核心卖点", "至少 1 条"); return; }
|
||||||
if (finalBullets.length === 0) { flash("请填写核心卖点", "至少 1 条 · 回车确认"); return; }
|
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -284,23 +284,28 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
|||||||
|
|
||||||
<div className="field pc-field-last">
|
<div className="field pc-field-last">
|
||||||
<label className="field-label">核心卖点<span className="req">*</span></label>
|
<label className="field-label">核心卖点<span className="req">*</span></label>
|
||||||
|
{/* YYX#17:每条卖点 = 一行可直接编辑的输入;新增卖点走下方独立「添加卖点」按钮,
|
||||||
|
不再在一个框里靠回车隐晦追加。 */}
|
||||||
<ul className="bullet-list">
|
<ul className="bullet-list">
|
||||||
{bullets.map((bullet, index) => (
|
{bullets.map((bullet, index) => (
|
||||||
<li className="bl-item" key={`${bullet}-${index}`}>
|
<li className="bl-item bl-item-edit" key={index}>
|
||||||
<span className="num">{index + 1}</span>
|
<span className="num">{index + 1}</span>
|
||||||
<span className="bl-text">{bullet}</span>
|
<input
|
||||||
|
className="bl-input"
|
||||||
|
value={bullet}
|
||||||
|
onChange={(event) => updateBullet(index, event.target.value)}
|
||||||
|
placeholder="例: 玻尿酸双效保湿,4 小时持久水润"
|
||||||
|
/>
|
||||||
<button className="bl-x" type="button" onClick={() => removeBullet(index)} aria-label="删除卖点">
|
<button className="bl-x" type="button" onClick={() => removeBullet(index)} aria-label="删除卖点">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
|
<svg 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>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
<li className="bl-add">
|
|
||||||
<span className="num">+</span>
|
|
||||||
<input className="bl-input" value={bulletDraft} onChange={(event) => setBulletDraft(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addBullet(); } }} placeholder="输入卖点后点「添加」或回车" />
|
|
||||||
{/* YYX#G29:回车确认太隐晦 → 补一个显式「添加」按钮 */}
|
|
||||||
<button type="button" className="bl-add-btn" onClick={addBullet} disabled={!bulletDraft.trim()}>添加</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
<button type="button" className="bl-add-row" onClick={addBullet}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
|
||||||
|
添加卖点
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
.asset-thumb:hover .lib-play-badge { background: rgba(0, 0, 0, .76); }
|
.asset-thumb:hover .lib-play-badge { background: rgba(0, 0, 0, .76); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 编辑模式:开启「管理资产」后,资产卡删除按钮常显(否则全局只 hover 显) */
|
/* 编辑模式:开启「管理资产」后,批次卡/素材包卡删除按钮常显 */
|
||||||
body.edit-mode .asset-card .card-del-btn { opacity: 1 !important; pointer-events: auto !important; }
|
body.edit-mode .library-page .pack-card .card-del-btn { opacity: 1 !important; pointer-events: auto !important; }
|
||||||
|
|
||||||
/* ─────────────────────────────────────────────────────────────
|
/* ─────────────────────────────────────────────────────────────
|
||||||
批量编辑 · 多选 + 吸底 bulk-bar(scope 到资产库;蓝本 products-page.css)
|
批量编辑 · 多选 + 吸底 bulk-bar(scope 到资产库;蓝本 products-page.css)
|
||||||
|
|||||||
@@ -219,12 +219,15 @@
|
|||||||
color: var(--black-alpha-56);
|
color: var(--black-alpha-56);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
/* 收件箱副文案:超出一行即省略号截断,不换行撑破行高对齐(UI 反馈:文字太长换行后与 UI 框错位) */
|
/* 最多两行后省略:display:-webkit-box 建立块级盒受父 grid 列 min-width:0 约束;
|
||||||
|
overflow-wrap + word-break 保证连续长字符串在列内折行而不溢出框;
|
||||||
|
换行第二行与 .msg-item-main 左边界对齐 */
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 1;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
word-break: break-all;
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
.msg-item-foot {
|
.msg-item-foot {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -403,8 +403,7 @@
|
|||||||
|
|
||||||
/* 核心卖点 · bullet-list(drawer 变体) */
|
/* 核心卖点 · bullet-list(drawer 变体) */
|
||||||
.pc-drawer .form-card .bullet-list { list-style: none; padding: 0; margin: 0; }
|
.pc-drawer .form-card .bullet-list { list-style: none; padding: 0; margin: 0; }
|
||||||
.pc-drawer .form-card .bullet-list .bl-item,
|
.pc-drawer .form-card .bullet-list .bl-item {
|
||||||
.pc-drawer .form-card .bullet-list .bl-add {
|
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
background: var(--background-lighter);
|
background: var(--background-lighter);
|
||||||
@@ -413,7 +412,7 @@
|
|||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.pc-drawer .form-card .bullet-list .bl-add { background: transparent; border-style: dashed; }
|
.pc-drawer .form-card .bullet-list .bl-item:focus-within { border-color: var(--heat-40); }
|
||||||
.pc-drawer .form-card .bullet-list .num {
|
.pc-drawer .form-card .bullet-list .num {
|
||||||
width: 22px; height: 22px;
|
width: 22px; height: 22px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@@ -437,17 +436,19 @@
|
|||||||
}
|
}
|
||||||
.pc-drawer .form-card .bullet-list .bl-x:hover { color: var(--accent-crimson); background: var(--crimson-bg); }
|
.pc-drawer .form-card .bullet-list .bl-x:hover { color: var(--accent-crimson); background: var(--crimson-bg); }
|
||||||
.pc-drawer .form-card .bullet-list .bl-x svg { width: 11px; height: 11px; }
|
.pc-drawer .form-card .bullet-list .bl-x svg { width: 11px; height: 11px; }
|
||||||
/* YYX#G29:显式「添加」卖点按钮(回车太隐晦)· 二级按钮观感,scoped 不污染全局 */
|
/* YYX#17:独立「添加卖点」按钮 —— 列表下方整行虚线按钮,点一下新增一条可编辑卖点行
|
||||||
.pc-drawer .form-card .bullet-list .bl-add-btn {
|
(替代原来「在一个框里回车隐晦追加」)。二级按钮观感,scoped 不污染全局。 */
|
||||||
flex-shrink: 0;
|
.pc-drawer .form-card .bl-add-row {
|
||||||
height: 26px; padding: 0 12px;
|
width: 100%;
|
||||||
background: var(--surface);
|
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||||
border: 1px solid var(--border-faint); border-radius: var(--r-sm);
|
height: 38px;
|
||||||
font-size: 12px; color: var(--heat); font-family: inherit; cursor: pointer;
|
background: transparent;
|
||||||
transition: background var(--t-base), border-color var(--t-base), color var(--t-base);
|
border: 1px dashed var(--border-loud); border-radius: var(--r-md);
|
||||||
|
font-size: 13px; color: var(--heat); font-family: inherit; cursor: pointer;
|
||||||
|
transition: background var(--t-base), border-color var(--t-base);
|
||||||
}
|
}
|
||||||
.pc-drawer .form-card .bullet-list .bl-add-btn:hover:not(:disabled) { background: var(--heat-12); border-color: var(--heat-20); }
|
.pc-drawer .form-card .bl-add-row:hover { background: var(--heat-12); border-color: var(--heat-40); }
|
||||||
.pc-drawer .form-card .bullet-list .bl-add-btn:disabled { color: var(--black-alpha-24); cursor: default; }
|
.pc-drawer .form-card .bl-add-row svg { width: 14px; height: 14px; }
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.pc-drawer .drawer-b .pf-upload-row { grid-template-columns: 1fr; }
|
.pc-drawer .drawer-b .pf-upload-row { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,16 @@
|
|||||||
.chip .chip-count { display: inline-flex; align-items: center; justify-content: center; height: 18px; min-width: 18px; padding: 0 5px; background: var(--heat-12); color: var(--heat); border: 1px solid var(--heat-20); border-radius: var(--r-pill); font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; letter-spacing: .02em; margin-left: 2px; }
|
.chip .chip-count { display: inline-flex; align-items: center; justify-content: center; height: 18px; min-width: 18px; padding: 0 5px; background: var(--heat-12); color: var(--heat); border: 1px solid var(--heat-20); border-radius: var(--r-pill); font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; letter-spacing: .02em; margin-left: 2px; }
|
||||||
|
|
||||||
.product-grid-wrap { margin: 0 -8px; padding: 2px 8px 24px; }
|
.product-grid-wrap { margin: 0 -8px; padding: 2px 8px 24px; }
|
||||||
|
/* 自适应铺满:auto-fill + minmax,列数随容器宽度增长(不写死列数、不 max-width 截断)。
|
||||||
|
普通屏 ≥240px 每张;超宽屏(>1920px)把下限收到 220px,让多出来的横向空间真的再多排
|
||||||
|
1-2 列商品,而不是把现有几张拉得过宽、右侧空出可放两张的位置(YYX#20)。 */
|
||||||
.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }
|
.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }
|
||||||
|
@media (min-width: 1920px) {
|
||||||
|
.product-grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
|
||||||
|
}
|
||||||
|
@media (min-width: 2560px) {
|
||||||
|
.product-grid { grid-template-columns: repeat(auto-fill, minmax(208px, 1fr)); }
|
||||||
|
}
|
||||||
.product-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background .15s, border-color .15s; position: relative; overflow: hidden; display: flex; flex-direction: column; }
|
.product-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background .15s, border-color .15s; position: relative; overflow: hidden; display: flex; flex-direction: column; }
|
||||||
.product-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }
|
.product-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }
|
||||||
.product-thumb { aspect-ratio: 1.4 / 1; }
|
.product-thumb { aspect-ratio: 1.4 / 1; }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { CreditCard, X } from "lucide-react";
|
import { CreditCard, X } from "lucide-react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import type { BillingSummary, BillingTrend, Ledger, Project, TeamMember } from "../types";
|
import type { BillingSummary, BillingTrend, Ledger, Project, Team, TeamMember } from "../types";
|
||||||
import { money, stageMeta } from "./stage-config";
|
import { money, stageMeta } from "./stage-config";
|
||||||
import { pageWindow } from "../components/pager";
|
import { pageWindow } from "../components/pager";
|
||||||
import { useBodyScrollLock, useOverlayTransition } from "../components/overlays";
|
import { useBodyScrollLock, useOverlayTransition } from "../components/overlays";
|
||||||
@@ -110,9 +110,11 @@ function TopupModal({ open, channel, amount, bonus, close, onDone }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountPage({ billing, projects, onRecharge }: {
|
export function AccountPage({ billing, projects, team, onRecharge }: {
|
||||||
billing: BillingSummary | null;
|
billing: BillingSummary | null;
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
|
// team prop 用于读取团队级月限额(与团队管理页保持同一来源 · #14)
|
||||||
|
team?: Team | null;
|
||||||
onRecharge: (amount: number, bonus: number) => void | Promise<unknown>;
|
onRecharge: (amount: number, bonus: number) => void | Promise<unknown>;
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<Tab>("overview");
|
const [tab, setTab] = useState<Tab>("overview");
|
||||||
@@ -150,15 +152,8 @@ export function AccountPage({ billing, projects, onRecharge }: {
|
|||||||
}).catch(() => {}).finally(() => { if (alive) setLedgersLoading(false); });
|
}).catch(() => {}).finally(() => { if (alive) setLedgersLoading(false); });
|
||||||
return () => { alive = false; };
|
return () => { alive = false; };
|
||||||
}, [billPage, reloadFlag]);
|
}, [billPage, reloadFlag]);
|
||||||
const billTotalPages = Math.max(1, Math.ceil(ledgerCount / BILLS_PER_PAGE));
|
|
||||||
const safeBillPage = Math.min(billPage, billTotalPages);
|
|
||||||
// row40:跳页输入框 —— 输入页码回车/点「跳转」即钳到 [1,总页数] 并翻页
|
// row40:跳页输入框 —— 输入页码回车/点「跳转」即钳到 [1,总页数] 并翻页
|
||||||
const [billJump, setBillJump] = useState("");
|
const [billJump, setBillJump] = useState("");
|
||||||
function gotoBillPage() {
|
|
||||||
const n = parseInt(billJump, 10);
|
|
||||||
if (!Number.isNaN(n)) setBillPage(Math.min(billTotalPages, Math.max(1, n)));
|
|
||||||
setBillJump("");
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedCard = RECHARGE.find((item) => item.amt === recharge);
|
const selectedCard = RECHARGE.find((item) => item.amt === recharge);
|
||||||
const effectiveAmount = Number(customAmt) > 0 ? Number(customAmt) : recharge;
|
const effectiveAmount = Number(customAmt) > 0 ? Number(customAmt) : recharge;
|
||||||
@@ -175,8 +170,11 @@ export function AccountPage({ billing, projects, onRecharge }: {
|
|||||||
|
|
||||||
const balance = Number(billing?.account.balance || 0);
|
const balance = Number(billing?.account.balance || 0);
|
||||||
const used = Number(billing?.charged_total || 0);
|
const used = Number(billing?.charged_total || 0);
|
||||||
const memberLimit = teamMembers.reduce((sum, m) => sum + Math.max(0, Number(m.monthly_credit_limit || 0)), 0);
|
// 月限额:与团队管理页保持同一来源(team.monthly_credit_limit) · #14
|
||||||
const limit = memberLimit || balance;
|
// 优先用团队级月限额;-1 = 不限(显示余额);0/null = 未设置 → 用成员额度累加,再 fallback 余额
|
||||||
|
const savedMonthlyLimit = team?.monthly_credit_limit == null ? 0 : Number(team.monthly_credit_limit);
|
||||||
|
const memberLimitSum = teamMembers.reduce((sum, m) => sum + Math.max(0, Number(m.monthly_credit_limit || 0)), 0);
|
||||||
|
const limit = savedMonthlyLimit === -1 ? balance : (savedMonthlyLimit > 0 ? savedMonthlyLimit : (memberLimitSum || balance));
|
||||||
const left = Math.max(0, limit - used);
|
const left = Math.max(0, limit - used);
|
||||||
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0;
|
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0;
|
||||||
|
|
||||||
@@ -219,7 +217,8 @@ export function AccountPage({ billing, projects, onRecharge }: {
|
|||||||
const memFiltered = memRole !== "all";
|
const memFiltered = memRole !== "all";
|
||||||
|
|
||||||
// ── 账单流水筛选(类型 + 成员)── 后端为服务端分页且无过滤参数(api.ledgers 仅收 page/pageSize),
|
// ── 账单流水筛选(类型 + 成员)── 后端为服务端分页且无过滤参数(api.ledgers 仅收 page/pageSize),
|
||||||
// 故只能对「当前页」已载入的 10 条做客户端过滤;计数标注为本页范围,避免误导成全量过滤。
|
// 故只能对「当前页」已载入的 10 条做客户端过滤。
|
||||||
|
// 切换筛选条件时跳回第 1 页(#9a);过滤后按实际可见行数重算总页数(#9b)。
|
||||||
const [billType, setBillType] = useState<string>("all");
|
const [billType, setBillType] = useState<string>("all");
|
||||||
const [billMember, setBillMember] = useState<string>("all");
|
const [billMember, setBillMember] = useState<string>("all");
|
||||||
const billMemberOptions = useMemo(() => {
|
const billMemberOptions = useMemo(() => {
|
||||||
@@ -235,6 +234,16 @@ export function AccountPage({ billing, projects, onRecharge }: {
|
|||||||
[ledgerRows, billType, billMember]
|
[ledgerRows, billType, billMember]
|
||||||
);
|
);
|
||||||
const billFiltered = billType !== "all" || billMember !== "all";
|
const billFiltered = billType !== "all" || billMember !== "all";
|
||||||
|
// 有筛选时按当前页已过滤的可见行数重算总页数,避免总页数用全量致翻到空页(#9b)
|
||||||
|
const billTotalPages = billFiltered
|
||||||
|
? Math.max(1, Math.ceil(visibleLedgers.length / BILLS_PER_PAGE))
|
||||||
|
: Math.max(1, Math.ceil(ledgerCount / BILLS_PER_PAGE));
|
||||||
|
const safeBillPage = Math.min(billPage, billTotalPages);
|
||||||
|
function gotoBillPage() {
|
||||||
|
const n = parseInt(billJump, 10);
|
||||||
|
if (!Number.isNaN(n)) setBillPage(Math.min(billTotalPages, Math.max(1, n)));
|
||||||
|
setBillJump("");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="account-page">
|
<section className="account-page">
|
||||||
@@ -398,7 +407,7 @@ export function AccountPage({ billing, projects, onRecharge }: {
|
|||||||
|
|
||||||
<div className={`tab-panel ${tab === "bills" ? "active" : ""}`}>
|
<div className={`tab-panel ${tab === "bills" ? "active" : ""}`}>
|
||||||
<div className="filter-bar">
|
<div className="filter-bar">
|
||||||
<select value={billType} onChange={(e) => setBillType(e.target.value)} aria-label="按类型筛选">
|
<select value={billType} onChange={(e) => { setBillType(e.target.value); setBillPage(1); }} aria-label="按类型筛选">
|
||||||
<option value="all">全部类型</option>
|
<option value="all">全部类型</option>
|
||||||
<option value="charge">扣费</option>
|
<option value="charge">扣费</option>
|
||||||
<option value="recharge">充值</option>
|
<option value="recharge">充值</option>
|
||||||
@@ -407,12 +416,12 @@ export function AccountPage({ billing, projects, onRecharge }: {
|
|||||||
<option value="adjustment">调整</option>
|
<option value="adjustment">调整</option>
|
||||||
<option value="refund">退款</option>
|
<option value="refund">退款</option>
|
||||||
</select>
|
</select>
|
||||||
<select value={billMember} onChange={(e) => setBillMember(e.target.value)} aria-label="按成员筛选">
|
<select value={billMember} onChange={(e) => { setBillMember(e.target.value); setBillPage(1); }} aria-label="按成员筛选">
|
||||||
<option value="all">全部成员</option>
|
<option value="all">全部成员</option>
|
||||||
{billMemberOptions.map((m) => <option key={m} value={m}>{m}</option>)}
|
{billMemberOptions.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||||
</select>
|
</select>
|
||||||
{billFiltered && (
|
{billFiltered && (
|
||||||
<button className="filter-reset" type="button" onClick={() => { setBillType("all"); setBillMember("all"); }}>清除筛选</button>
|
<button className="filter-reset" type="button" onClick={() => { setBillType("all"); setBillMember("all"); setBillPage(1); }}>清除筛选</button>
|
||||||
)}
|
)}
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
<span className="ct">本页 <b>{visibleLedgers.length}</b> 条 · 共 {ledgerCount} 条</span>
|
<span className="ct">本页 <b>{visibleLedgers.length}</b> 条 · 共 {ledgerCount} 条</span>
|
||||||
|
|||||||
@@ -568,10 +568,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
|
||||||
<span className="lib-manage-label">{editMode ? "完成" : "管理资产"}</span>
|
<span className="lib-manage-label">{editMode ? "完成" : "管理资产"}</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-primary" type="button" id="open-upload-btn" onClick={() => setUploadOpen(true)}>
|
{/* 上传资产入口已隐藏:资产由 AI 生成流水线自动入库,不开放手动上传 */}
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" /></svg>
|
|
||||||
上传资产
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -588,8 +585,15 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
|||||||
) : (
|
) : (
|
||||||
packs.map((pack) => {
|
packs.map((pack) => {
|
||||||
const first = pack.clips[0];
|
const first = pack.clips[0];
|
||||||
|
// 视频成品的所有片段 id:批量删除整个素材包
|
||||||
|
const packClipIds = pack.clips.map((c) => c.id).filter(Boolean);
|
||||||
return (
|
return (
|
||||||
<article className="pack-card" key={pack.project_id || pack.project_name} onClick={() => setOpenPack(pack)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenPack(pack); } }}>
|
<article className="pack-card" key={pack.project_id || pack.project_name} onClick={() => !editMode && setOpenPack(pack)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (!editMode) setOpenPack(pack); } }}>
|
||||||
|
{editMode && onDelete && packClipIds.length > 0 && (
|
||||||
|
<button className="card-del-btn" type="button" title="删除素材包" onClick={(event) => { event.stopPropagation(); setConfirmIds(packClipIds); }}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="placeholder asset-thumb pack-thumb">
|
<div className="placeholder asset-thumb pack-thumb">
|
||||||
{first?.url ? (
|
{first?.url ? (
|
||||||
<video src={first.url} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
|
<video src={first.url} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
|
||||||
@@ -700,7 +704,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
|||||||
batches.length ? (
|
batches.length ? (
|
||||||
<div className="packs-grid" id="batch-grid">
|
<div className="packs-grid" id="batch-grid">
|
||||||
{batches.map((batch) => (
|
{batches.map((batch) => (
|
||||||
<article className="pack-card" key={batch.batch_id} onClick={() => setOpenBatch(batch)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenBatch(batch); } }}>
|
<article className="pack-card" key={batch.batch_id} onClick={() => !editMode && setOpenBatch(batch)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (!editMode) setOpenBatch(batch); } }}>
|
||||||
{editMode && onDelete && (
|
{editMode && onDelete && (
|
||||||
<button className="card-del-btn" type="button" title="删除整批" onClick={(event) => { event.stopPropagation(); setConfirmIds(batch.items.map((a) => a.id)); }}>
|
<button className="card-del-btn" type="button" title="删除整批" onClick={(event) => { event.stopPropagation(); setConfirmIds(batch.items.map((a) => a.id)); }}>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
|
||||||
|
|||||||
@@ -852,24 +852,43 @@ export function PipelinePage(props: {
|
|||||||
}
|
}
|
||||||
// ── 流程步骤4/5 · 兜底弹窗拦截:生成故事板/视频前,查被引用的角色/场景是否都有「按名字采用」的参考图 ──
|
// ── 流程步骤4/5 · 兜底弹窗拦截:生成故事板/视频前,查被引用的角色/场景是否都有「按名字采用」的参考图 ──
|
||||||
// 缺了就弹窗(去补齐 / 仍要生成),不静默退化文生图、不让用户花冤枉钱。商品永远有主图、不算缺。
|
// 缺了就弹窗(去补齐 / 仍要生成),不静默退化文生图、不让用户花冤枉钱。商品永远有主图、不算缺。
|
||||||
const [refGate, setRefGate] = useState<{ missing: Array<{ name: string; type: string }>; proceed: () => void } | null>(null);
|
// reason:"noref" = 还没参考图(立绘/场景图);"notri" = 角色有立绘但缺三视图(故事板合成需多角度参考)。
|
||||||
|
type RefMiss = { name: string; type: string; reason: "noref" | "notri" };
|
||||||
|
const [refGate, setRefGate] = useState<{ missing: RefMiss[]; proceed: () => void } | null>(null);
|
||||||
useBodyScrollLock(Boolean(refGate));
|
useBodyScrollLock(Boolean(refGate));
|
||||||
function missingRefsFor(segs: Array<{ entity_refs?: string[] }>): Array<{ name: string; type: string }> {
|
// 某角色(按名字)已采用的立绘有没有配套三视图:取该角色代表组的 adopted_asset → 查它的三视图组。
|
||||||
|
// 用与详情弹窗同款判定(triview 组有候选 / 资产 metadata 标记 / 模特库正面图)。
|
||||||
|
function personHasTriview(name: string): boolean {
|
||||||
|
const ent = buildEntities("person").find((e) => (e.name || "").trim() === (name || "").trim());
|
||||||
|
const portrait = ent?.group.adopted_asset;
|
||||||
|
if (!portrait) return false;
|
||||||
|
const tri = triGroupForAsset(portrait);
|
||||||
|
if (tri && (tri.adopted_asset || (tri.candidate_assets?.length ?? 0) > 0)) return true;
|
||||||
|
const m: Record<string, unknown> = byId.get(portrait)?.metadata || {};
|
||||||
|
const has = m.tri_view ?? m.triview ?? m.has_triview ?? m.three_view ?? m.tri_views;
|
||||||
|
if (has === true || (Array.isArray(has) && has.length > 0) || (typeof has === "string" && (has as string).trim())) return true;
|
||||||
|
if (m.view === "frontal") return true; // 模特库正面图与三视图同批生成
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function missingRefsFor(segs: Array<{ entity_refs?: string[] }>): RefMiss[] {
|
||||||
const ents = project.metadata?.script_entities;
|
const ents = project.metadata?.script_entities;
|
||||||
if (!Array.isArray(ents) || !ents.length) return []; // 没提取过实体就不拦(交给提取闸门)
|
if (!Array.isArray(ents) || !ents.length) return []; // 没提取过实体就不拦(交给提取闸门)
|
||||||
const byId = new Map(ents.map((e) => [e.id, e]));
|
const entById = new Map(ents.map((e) => [e.id, e]));
|
||||||
const adopted = {
|
const adopted = {
|
||||||
person: new Set(buildEntities("person").filter((e) => e.group.adopted_asset).map((e) => (e.name || "").trim())),
|
person: new Set(buildEntities("person").filter((e) => e.group.adopted_asset).map((e) => (e.name || "").trim())),
|
||||||
scene: new Set(buildEntities("scene").filter((e) => e.group.adopted_asset).map((e) => (e.name || "").trim())),
|
scene: new Set(buildEntities("scene").filter((e) => e.group.adopted_asset).map((e) => (e.name || "").trim())),
|
||||||
};
|
};
|
||||||
const miss = new Map<string, { name: string; type: string }>();
|
const miss = new Map<string, RefMiss>();
|
||||||
for (const seg of segs) {
|
for (const seg of segs) {
|
||||||
for (const rid of seg.entity_refs || []) {
|
for (const rid of seg.entity_refs || []) {
|
||||||
const ent = byId.get(rid);
|
const ent = entById.get(rid);
|
||||||
if (!ent || ent.type === "product") continue; // 商品永远有主图,不算缺
|
if (!ent || ent.type === "product") continue; // 商品永远有主图,不算缺
|
||||||
const kind = ent.type === "character" ? "person" : "scene";
|
const kind = ent.type === "character" ? "person" : "scene";
|
||||||
const name = (ent.name || "").trim();
|
const name = (ent.name || "").trim();
|
||||||
if (name && !adopted[kind].has(name)) miss.set(name, { name, type: ent.type });
|
if (!name) continue;
|
||||||
|
if (!adopted[kind].has(name)) { miss.set(name, { name, type: ent.type, reason: "noref" }); continue; }
|
||||||
|
// 已有立绘/场景图 → 角色再查三视图:故事板 @图 合成需正/侧/背多角度参考,缺则拦(ZWQ#3 防呆)
|
||||||
|
if (kind === "person" && !personHasTriview(name)) miss.set(name, { name, type: ent.type, reason: "notri" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...miss.values()];
|
return [...miss.values()];
|
||||||
@@ -3870,28 +3889,52 @@ export function PipelinePage(props: {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{/* ── 流程步骤4/5 · 兜底弹窗拦截:参考图不齐时拦住生成,让用户去补 / 仍要生成(随他便) ── */}
|
{/* ── 流程步骤4/5 · 兜底弹窗拦截:参考图不齐时拦住生成,让用户去补 / 仍要生成(随他便) ── */}
|
||||||
{refGate && (
|
{refGate && (() => {
|
||||||
|
// 拆两类:noref=连参考图都没有;notri=有立绘但缺三视图。文案分别告诉用户「去哪里点什么」。
|
||||||
|
const noRef = refGate.missing.filter((m) => m.reason === "noref");
|
||||||
|
const noTri = refGate.missing.filter((m) => m.reason === "notri");
|
||||||
|
return (
|
||||||
<div className="ref-gate-mask" onClick={() => setRefGate(null)}>
|
<div className="ref-gate-mask" onClick={() => setRefGate(null)}>
|
||||||
<div className="ref-gate-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
<div className="ref-gate-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="rg-title">参考图还没齐,先补一下?</div>
|
<div className="rg-title">资产还没齐,先补一下?</div>
|
||||||
<div className="rg-body">
|
{noRef.length > 0 && (
|
||||||
下面这些角色 / 场景<strong>还没生成参考图</strong>。现在直接生成,出来的画面会跟你设定的对不上(像纯文生图、白花钱):
|
<>
|
||||||
</div>
|
<div className="rg-body">
|
||||||
<div className="rg-list">
|
下面这些角色 / 场景<strong>还没生成参考图</strong>。请回「基础资产」页,点对应卡片进详情后<strong>「生成立绘 / 场景图」</strong>并采用,否则故事板会变成纯文生图、跟你的设定对不上(白花钱):
|
||||||
{refGate.missing.map((m) => (
|
</div>
|
||||||
<span className="rg-chip" key={`${m.type}:${m.name}`}>
|
<div className="rg-list">
|
||||||
<span className={`rg-kind rg-kind-${m.type === "character" ? "person" : "scene"}`}>{m.type === "character" ? "角色" : "场景"}</span>
|
{noRef.map((m) => (
|
||||||
{m.name}
|
<span className="rg-chip" key={`noref:${m.type}:${m.name}`}>
|
||||||
</span>
|
<span className={`rg-kind rg-kind-${m.type === "character" ? "person" : "scene"}`}>{m.type === "character" ? "角色" : "场景"}</span>
|
||||||
))}
|
{m.name}
|
||||||
</div>
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{noTri.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="rg-body" style={{ marginTop: noRef.length > 0 ? 14 : 0 }}>
|
||||||
|
下面这些角色已有立绘,但<strong>还没生成三视图</strong>。故事板 @图 合成要靠正 / 侧 / 背多角度锁人物,缺三视图角色容易跑形。请回「基础资产」页,点角色卡进详情后点<strong>「AI 生成三视图」</strong>:
|
||||||
|
</div>
|
||||||
|
<div className="rg-list">
|
||||||
|
{noTri.map((m) => (
|
||||||
|
<span className="rg-chip" key={`notri:${m.type}:${m.name}`}>
|
||||||
|
<span className="rg-kind rg-kind-person">角色</span>
|
||||||
|
{m.name} <span className="rg-warn">缺三视图</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="rg-actions">
|
<div className="rg-actions">
|
||||||
<button type="button" className="btn btn-primary" onClick={() => { setRefGate(null); goStage(2); }}>去补齐参考图</button>
|
<button type="button" className="btn btn-primary" onClick={() => { setRefGate(null); goStage(2); }}>去基础资产页补齐</button>
|
||||||
<button type="button" className="btn btn-ghost rg-force" onClick={() => { const go = refGate.proceed; setRefGate(null); go(); }}>仍要生成 <span className="rg-warn">可能跟角色对不上</span></button>
|
<button type="button" className="btn btn-ghost rg-force" onClick={() => { const go = refGate.proceed; setRefGate(null); go(); }}>仍要继续 <span className="rg-warn">可能跟角色对不上</span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
{/* ── 流程步骤5 · 过审闸弹窗:含真人脸的人物/分镜未过审 → 列出哪一镜/哪个,先过审再生成(火山合规要求) ── */}
|
{/* ── 流程步骤5 · 过审闸弹窗:含真人脸的人物/分镜未过审 → 列出哪一镜/哪个,先过审再生成(火山合规要求) ── */}
|
||||||
{reviewGate && (
|
{reviewGate && (
|
||||||
<div className="ref-gate-mask" onClick={() => setReviewGate(null)}>
|
<div className="ref-gate-mask" onClick={() => setReviewGate(null)}>
|
||||||
|
|||||||
@@ -798,11 +798,6 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
|||||||
}, [productImageKeys]);
|
}, [productImageKeys]);
|
||||||
|
|
||||||
const visibleProductImages = productImages.filter((im) => !deletedImageKeys.has(im.key));
|
const visibleProductImages = productImages.filter((im) => !deletedImageKeys.has(im.key));
|
||||||
// 可删的图(有 ProductImage 关联)数量:只剩一张时锁住最后一张,不允许删到没图
|
|
||||||
// 用 productImages(真实数量)而非 visibleProductImages(乐观隐藏后数量):
|
|
||||||
// 乐观隐藏一张不应影响其他图的 canDelete 判断,否则乐观隐藏 → deletableImageCount 减少 →
|
|
||||||
// 其余图的 canDelete 变 false → hover 删除图标全部消失(Bug#1b 根因)。
|
|
||||||
const deletableImageCount = productImages.filter((im) => im.imageId).length;
|
|
||||||
|
|
||||||
// AI 生成素材 · 服务端已按 ?product 把「该商品」的素材(metadata.product_id / origin_task→project→product /
|
// AI 生成素材 · 服务端已按 ?product 把「该商品」的素材(metadata.product_id / origin_task→project→product /
|
||||||
// ProductImage 关联)懒加载到 productAssets;这里只按可显示的图片类型过滤。
|
// ProductImage 关联)懒加载到 productAssets;这里只按可显示的图片类型过滤。
|
||||||
@@ -1040,27 +1035,31 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid" id="ov-images-grid">
|
<div className="grid" id="ov-images-grid">
|
||||||
{visibleProductImages.map((image) => {
|
{visibleProductImages.map((image) => {
|
||||||
// 商品至少保留一张图:只剩一张可删图时,那张锁住不可删(要移除请删整个商品)
|
// 编辑态下每张有 ProductImage 关联的图都给删除按钮(hover 出垃圾桶),不再因
|
||||||
const canDelete = editing && Boolean(image.imageId) && deletableImageCount > 1;
|
// 「只剩一张可删」而整张隐藏删除入口 —— 否则单图商品 / 删到最后一张时悬停看不到垃圾桶,
|
||||||
const lastLocked = editing && Boolean(image.imageId) && deletableImageCount <= 1;
|
// 体感像「删除坏了,只能放大」。最后一张的「至少保留一张」约束由后端兜底(删时退 400),
|
||||||
|
// 这里不再前置静默禁用,改成点了真调接口、失败回滚 + 弹后端原文(如「至少保留一张图」)。
|
||||||
|
const canDelete = editing && Boolean(image.imageId) && Boolean(onDeleteImage);
|
||||||
const handleDelete = async (event: { stopPropagation: () => void }) => {
|
const handleDelete = async (event: { stopPropagation: () => void }) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (!canDelete || !image.imageId || !onDeleteImage) return;
|
if (!canDelete || !image.imageId || !onDeleteImage) return;
|
||||||
// 乐观隐藏:立刻从本地列表移除,toast/loadData 并行回填
|
// 乐观隐藏:立刻从本地列表移除;真正持久化由后端 DELETE 完成,
|
||||||
|
// 成功后父组件刷新会把该图从 product.images 抹掉(reconcile effect 同步清理乐观标记)。
|
||||||
setDeletedImageKeys((prev) => new Set([...prev, image.key]));
|
setDeletedImageKeys((prev) => new Set([...prev, image.key]));
|
||||||
const result = await onDeleteImage(image.imageId);
|
const result = await onDeleteImage(image.imageId);
|
||||||
if (result === null) {
|
if (result === null || result === undefined) {
|
||||||
// action 返回 null 表示调用失败(会弹错误 toast),回滚乐观隐藏
|
// action 返回 null/undefined 表示接口调用失败(已弹错误 toast,如「至少保留一张图」),
|
||||||
|
// 回滚乐观隐藏 —— 图重新出现,不会出现「弹了删除成功但其实没删」的错觉。
|
||||||
setDeletedImageKeys((prev) => { const next = new Set(prev); next.delete(image.key); return next; });
|
setDeletedImageKeys((prev) => { const next = new Set(prev); next.delete(image.key); return next; });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`thumb placeholder${editing ? " is-edit" : ""}${canDelete ? " is-del" : ""}${lastLocked ? " is-locked" : ""}`}
|
className={`thumb placeholder${editing ? " is-edit" : ""}${canDelete ? " is-del" : ""}`}
|
||||||
key={image.key}
|
key={image.key}
|
||||||
role={image.url ? "button" : undefined}
|
role={image.url ? "button" : undefined}
|
||||||
tabIndex={image.url ? 0 : undefined}
|
tabIndex={image.url ? 0 : undefined}
|
||||||
title={lastLocked ? "商品至少保留一张图;要移除请删除整个商品" : image.url ? "点击放大" : undefined}
|
title={image.url ? "点击放大" : undefined}
|
||||||
style={{ cursor: image.url ? "zoom-in" : "default" }}
|
style={{ cursor: image.url ? "zoom-in" : "default" }}
|
||||||
onClick={() => { if (image.url) setPreview({ src: image.url, name: realName }); }}
|
onClick={() => { if (image.url) setPreview({ src: image.url, name: realName }); }}
|
||||||
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); if (image.url) setPreview({ src: image.url, name: realName }); } }}
|
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); if (image.url) setPreview({ src: image.url, name: realName }); } }}
|
||||||
|
|||||||
@@ -54,11 +54,12 @@ function deviceName(ua: string): string {
|
|||||||
return `${os} · ${browser}`;
|
return `${os} · ${browser}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 仅保留已真实接入「站内通知」的行;邮箱/短信/异地登录告警渠道未接入,隐藏对应行。
|
||||||
const NOTIFY_ROWS: Array<{ key: string; title: string; sub?: string; channels: string }> = [
|
const NOTIFY_ROWS: Array<{ key: string; title: string; sub?: string; channels: string }> = [
|
||||||
{ key: "n-export", title: "项目完成通知", sub: "// 视频导出后", channels: "站内 · 邮件 · 短信" },
|
{ key: "n-export", title: "项目完成通知", sub: "// 视频导出后", channels: "站内" },
|
||||||
{ key: "n-fail", title: "任务失败告警", channels: "站内 · 邮件" },
|
{ key: "n-fail", title: "任务失败告警", channels: "站内" },
|
||||||
{ key: "n-quota", title: "额度不足提醒", sub: "// 团队或个人剩余 < 20%", channels: "站内 · 短信" },
|
{ key: "n-quota", title: "额度不足提醒", sub: "// 团队或个人剩余 < 20%", channels: "站内" },
|
||||||
{ key: "n-login", title: "异地登录告警", channels: "短信" },
|
// n-login(异地登录告警·短信渠道)未接入,已移除。
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── 偏好默认值 · 与后端 UserPreference 默认一致(后端到达前的占位) ───
|
// ─── 偏好默认值 · 与后端 UserPreference 默认一致(后端到达前的占位) ───
|
||||||
@@ -69,7 +70,7 @@ const DEFAULT_PREFS = {
|
|||||||
bgm: "kapian",
|
bgm: "kapian",
|
||||||
transition: "fade",
|
transition: "fade",
|
||||||
twoFactor: false,
|
twoFactor: false,
|
||||||
notify: { "n-export": true, "n-fail": true, "n-quota": true, "n-login": true } as Record<string, boolean>,
|
notify: { "n-export": true, "n-fail": true, "n-quota": true } as Record<string, boolean>,
|
||||||
appearance: "system",
|
appearance: "system",
|
||||||
language: "zh",
|
language: "zh",
|
||||||
density: "standard",
|
density: "standard",
|
||||||
@@ -428,7 +429,7 @@ export function SettingsPage({
|
|||||||
{section === "profile" && (
|
{section === "profile" && (
|
||||||
<section className="pane" aria-label="个人信息">
|
<section className="pane" aria-label="个人信息">
|
||||||
<h3>个人信息</h3>
|
<h3>个人信息</h3>
|
||||||
<div className="pane-desc">// 头像、姓名、联系方式 · 邮箱用于接收通知</div>
|
<div className="pane-desc">// 头像、姓名、联系方式</div>
|
||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="lbl">头像</div>
|
<div className="lbl">头像</div>
|
||||||
@@ -447,10 +448,11 @@ export function SettingsPage({
|
|||||||
<div className="val"><input className="input" value={name} onChange={(event) => patchDraft("name", event.target.value)} /></div>
|
<div className="val"><input className="input" value={name} onChange={(event) => patchDraft("name", event.target.value)} /></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="lbl">登录邮箱</div>
|
<div className="lbl">登录邮箱<div className="lbl-sub">// 仅做记录用</div></div>
|
||||||
<div className="val">
|
<div className="val">
|
||||||
<input className="input" type="email" value={email} onChange={(event) => patchDraft("email", event.target.value)} />
|
<input className="input" type="email" value={email} onChange={(event) => patchDraft("email", event.target.value)} />
|
||||||
<button className="btn btn-ghost btn-sm" type="button" onClick={() => onNotify?.(email ? `已向 ${email} 发送验证邮件` : "请先填写邮箱")}>验证</button>
|
{/* 邮件服务未接入,验证功能暂不可用,入口已隐藏 */}
|
||||||
|
<span className="switch-note">// 邮件验证未启用</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
@@ -465,7 +467,17 @@ export function SettingsPage({
|
|||||||
<div className="val">
|
<div className="val">
|
||||||
<span className="static">{team.name}</span>
|
<span className="static">{team.name}</span>
|
||||||
<span className="role-tag"><span className="dot" />超管 · 创建者</span>
|
<span className="role-tag"><span className="dot" />超管 · 创建者</span>
|
||||||
<a href="#team" className="row-link">管理团队 →</a>
|
<a
|
||||||
|
href="/team"
|
||||||
|
className="row-link"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
window.history.pushState(null, "", "/team");
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
管理团队 →
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
@@ -535,7 +547,7 @@ export function SettingsPage({
|
|||||||
{section === "notify" && (
|
{section === "notify" && (
|
||||||
<section className="pane" aria-label="通知">
|
<section className="pane" aria-label="通知">
|
||||||
<h3>通知</h3>
|
<h3>通知</h3>
|
||||||
<div className="pane-desc">// 邮件、短信、站内提示开关</div>
|
<div className="pane-desc">// 站内通知开关 · 邮件/短信渠道未接入</div>
|
||||||
{NOTIFY_ROWS.map((row) => (
|
{NOTIFY_ROWS.map((row) => (
|
||||||
<div className="form-row" key={row.key}>
|
<div className="form-row" key={row.key}>
|
||||||
<div className="lbl">{row.title}{row.sub ? <div className="lbl-sub">{row.sub}</div> : null}</div>
|
<div className="lbl">{row.title}{row.sub ? <div className="lbl-sub">{row.sub}</div> : null}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user