fix: 测试清单一轮bug修复(商品库/视频项目/设置/消费/平台套图)

- 商品库编辑删图假成功+悬停无删除图标;商品三视图错显角色三视图
- 视频项目演员删除入口;角色三视图防呆;故事板审核失败原因透出
- 设置:管理团队死按钮/通知未接入项屏蔽/邮箱验证链路
- 消费:账单流水切换类型重置分页+独立总页数
- 资产库上传按钮隐藏;月限额两处对齐
- 平台套图:提示词框放大/选模型胶囊内嵌/未读任务角标/工作台记录持久化
- 新建商品独立添加卖点按钮;商品库超1920自适应

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-30 19:46:35 +08:00
co-authored by Claude Opus 4.8
parent b17a716c06
commit 22268d608b
14 changed files with 261 additions and 107 deletions
+50 -2
View File
@@ -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")):
+11 -1
View File
@@ -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"):
+11 -1
View File
@@ -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)
+1
View File
@@ -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>
+2 -2
View File
@@ -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)
+6 -3
View File
@@ -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;
+14 -13
View File
@@ -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; }
} }
+9
View File
@@ -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; }
+24 -15
View File
@@ -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>
+10 -6
View File
@@ -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>
+65 -22
View File
@@ -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)}>
+12 -13
View File
@@ -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 }); } }}
+22 -10
View File
@@ -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>