diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index aef3b09..ba290d3 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -1248,7 +1248,7 @@ def _run_image_with_retry(make, *, attempts: int = _IMAGE_GEN_ATTEMPTS): 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 任务 + 预留额度(秒级), 慢出图(文生图 / 商品 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, "kind": kind, "label": label or "", "group_id": str(group_id) if group_id else "", "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( project=project, @@ -1397,6 +1400,17 @@ def run_base_asset_task(*, task_id: str) -> None: from apps.assets.review import submit_asset_for_review 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 重试二次扣费) task.status = AITask.Status.FAILED 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: """把模型/中转站的原始报错翻成给用户的中文友好提示(前端直接展示)。原始报错仍记进 AITask 供排查。 覆盖:内容审核拦截 / 超时 / 限流 / 凭证 / 参考图被拒 等;命不中给通用兜底。""" s = (raw or "").lower() 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")): return "生成超时,可能是网络波动或模型繁忙,请稍后重试。" if any(k in s for k in ("429", "too many requests", "rate limit", "forbidden", "403")): diff --git a/core/backend/apps/assets/views.py b/core/backend/apps/assets/views.py index 190dabc..bcba4e6 100644 --- a/core/backend/apps/assets/views.py +++ b/core/backend/apps/assets/views.py @@ -129,10 +129,20 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet): if p.get("product"): # 资产归属商品:独立生图写 metadata.product_id;项目内生成回溯 origin_task→project→product; # 上传的商品图经 ProductImage 关联。三路并集 = 前端 ProductDetail 的 belongsToProduct。 + # ★ ZWQ#5:项目里同时会生成「角色立绘/三视图(person/tri_view)、场景图(scene)、分镜图(storyboard)」, + # 这些都挂在同一 project 上但**不属于商品**。若 origin_task→project 这一路不限类目,商品库的 + # 「AI 生成三视图」会把项目里的角色三视图当成商品三视图错显。故 project 回溯路只认「确属商品」的类目 + # (商品图/模特上身图/平台套图/自由创作);角色/场景/分镜资产仍可经 metadata.product_id 显式归属命中。 pid = p["product"] + product_categories = ( + Asset.Category.PRODUCT_IMAGE, + Asset.Category.MODEL_TRYON, + Asset.Category.PLATFORM_KIT, + Asset.Category.FREE_CREATE, + ) qs = qs.filter( 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) ).distinct() if p.get("q"): diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index 812175a..be17a2e 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -487,7 +487,17 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): if kind not in BaseAssetGroup.Kind.values: return Response({"detail": "invalid base asset kind"}, status=status.HTTP_400_BAD_REQUEST) 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: # 无可用模型 / 余额不足等,立即反馈 return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.BASE_ASSETS) diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 18827cd..1ad6e89 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -847,6 +847,7 @@ export function App() { action(() => api.recharge({ amount, bonus }), "充值成功")} /> ); diff --git a/core/frontend/src/components/product-create-drawer.tsx b/core/frontend/src/components/product-create-drawer.tsx index e143fb2..487b979 100644 --- a/core/frontend/src/components/product-create-drawer.tsx +++ b/core/frontend/src/components/product-create-drawer.tsx @@ -42,8 +42,9 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio const [title, setTitle] = useState(""); const [category, setCategory] = useState(""); const [target, setTarget] = useState(""); + // 卖点 = 一组可直接编辑的输入行(每行一条);点「添加卖点」按钮新增一条空行, + // 不再靠「在一个输入框里回车隐晦追加」(YYX#17)。 const [bullets, setBullets] = useState([]); - const [bulletDraft, setBulletDraft] = useState(""); const [images, setImages] = useState([]); const [dragOver, setDragOver] = useState(false); const [titleError, setTitleError] = useState(false); @@ -74,7 +75,6 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio setCategory(""); setTarget(""); setBullets([]); - setBulletDraft(""); setImages((list) => { list.forEach((item) => URL.revokeObjectURL(item.url)); return []; }); setDragOver(false); setTitleError(false); @@ -84,7 +84,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio // YYX#24:表单是否已填内容(有任一内容即视为「编辑中」) 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() { @@ -152,11 +152,12 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio if (event.dataTransfer?.files?.length) addImages(event.dataTransfer.files); } + // 点「添加卖点」→ 追加一条空的可编辑卖点行(显式新增,不靠回车隐晦添加) function addBullet() { - const value = bulletDraft.trim(); - if (!value) return; - setBullets((list) => [...list, value]); - setBulletDraft(""); + setBullets((list) => [...list, ""]); + } + function updateBullet(index: number, value: string) { + setBullets((list) => list.map((item, position) => (position === index ? value : item))); } function removeBullet(index: number) { setBullets((list) => list.filter((_, position) => position !== index)); @@ -169,10 +170,9 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio // upload-first:图必须先全部传完进桶(拿到 assetId)才能带进创建 if (images.some((im) => im.status === "uploading")) { flash("图片上传中", "请等图片传完再创建"); return; } if (images.some((im) => im.status === "error" || !im.assetId)) { flash("有图片未传成功", "请移除失败的图后重试"); return; } - // 提交时自动收编未回车的 bulletDraft,避免用户输入了卖点却被丢弃 - const pending = bulletDraft.trim(); - const finalBullets = pending ? [...bullets, pending] : bullets; - if (finalBullets.length === 0) { flash("请填写核心卖点", "至少 1 条 · 回车确认"); return; } + // 卖点行可能有空行(用户点了「添加卖点」还没填):提交时过滤掉空行再去重判空 + const finalBullets = bullets.map((item) => item.trim()).filter(Boolean); + if (finalBullets.length === 0) { flash("请填写核心卖点", "至少 1 条"); return; } setSaving(true); try { @@ -284,23 +284,28 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
+ {/* YYX#17:每条卖点 = 一行可直接编辑的输入;新增卖点走下方独立「添加卖点」按钮, + 不再在一个框里靠回车隐晦追加。 */}
    {bullets.map((bullet, index) => ( -
  • +
  • {index + 1} - {bullet} + updateBullet(index, event.target.value)} + placeholder="例: 玻尿酸双效保湿,4 小时持久水润" + />
  • ))} -
  • - + - setBulletDraft(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addBullet(); } }} placeholder="输入卖点后点「添加」或回车" /> - {/* YYX#G29:回车确认太隐晦 → 补一个显式「添加」按钮 */} - -
+
diff --git a/core/frontend/src/library-page.css b/core/frontend/src/library-page.css index 30bf504..a21abad 100644 --- a/core/frontend/src/library-page.css +++ b/core/frontend/src/library-page.css @@ -24,8 +24,8 @@ .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) diff --git a/core/frontend/src/messages-page.css b/core/frontend/src/messages-page.css index 65cd968..dff0beb 100644 --- a/core/frontend/src/messages-page.css +++ b/core/frontend/src/messages-page.css @@ -219,12 +219,15 @@ color: var(--black-alpha-56); font-size: 12px; line-height: 1.55; - /* 收件箱副文案:超出一行即省略号截断,不换行撑破行高对齐(UI 反馈:文字太长换行后与 UI 框错位) */ + /* 最多两行后省略:display:-webkit-box 建立块级盒受父 grid 列 min-width:0 约束; + overflow-wrap + word-break 保证连续长字符串在列内折行而不溢出框; + 换行第二行与 .msg-item-main 左边界对齐 */ display: -webkit-box; - -webkit-line-clamp: 1; + -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; - word-break: break-all; + overflow-wrap: break-word; + word-break: break-word; } .msg-item-foot { display: flex; diff --git a/core/frontend/src/product-create-page.css b/core/frontend/src/product-create-page.css index 7e53707..9eb60db 100644 --- a/core/frontend/src/product-create-page.css +++ b/core/frontend/src/product-create-page.css @@ -403,8 +403,7 @@ /* 核心卖点 · bullet-list(drawer 变体) */ .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-add { +.pc-drawer .form-card .bullet-list .bl-item { display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: var(--background-lighter); @@ -413,7 +412,7 @@ margin-bottom: 6px; 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 { width: 22px; height: 22px; 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 svg { width: 11px; height: 11px; } -/* YYX#G29:显式「添加」卖点按钮(回车太隐晦)· 二级按钮观感,scoped 不污染全局 */ -.pc-drawer .form-card .bullet-list .bl-add-btn { - flex-shrink: 0; - height: 26px; padding: 0 12px; - background: var(--surface); - border: 1px solid var(--border-faint); border-radius: var(--r-sm); - font-size: 12px; color: var(--heat); font-family: inherit; cursor: pointer; - transition: background var(--t-base), border-color var(--t-base), color var(--t-base); +/* YYX#17:独立「添加卖点」按钮 —— 列表下方整行虚线按钮,点一下新增一条可编辑卖点行 + (替代原来「在一个框里回车隐晦追加」)。二级按钮观感,scoped 不污染全局。 */ +.pc-drawer .form-card .bl-add-row { + width: 100%; + display: flex; align-items: center; justify-content: center; gap: 6px; + height: 38px; + background: transparent; + 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 .bullet-list .bl-add-btn:disabled { color: var(--black-alpha-24); cursor: default; } +.pc-drawer .form-card .bl-add-row:hover { background: var(--heat-12); border-color: var(--heat-40); } +.pc-drawer .form-card .bl-add-row svg { width: 14px; height: 14px; } @media (max-width: 900px) { .pc-drawer .drawer-b .pf-upload-row { grid-template-columns: 1fr; } } diff --git a/core/frontend/src/products-page.css b/core/frontend/src/products-page.css index 76e0c1d..1c83dfc 100644 --- a/core/frontend/src/products-page.css +++ b/core/frontend/src/products-page.css @@ -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; } .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; } + @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:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); } .product-thumb { aspect-ratio: 1.4 / 1; } diff --git a/core/frontend/src/routes/account.tsx b/core/frontend/src/routes/account.tsx index 14c5366..cab0d53 100644 --- a/core/frontend/src/routes/account.tsx +++ b/core/frontend/src/routes/account.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { CreditCard, X } from "lucide-react"; import { createPortal } from "react-dom"; 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 { pageWindow } from "../components/pager"; 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; projects: Project[]; + // team prop 用于读取团队级月限额(与团队管理页保持同一来源 · #14) + team?: Team | null; onRecharge: (amount: number, bonus: number) => void | Promise; }) { const [tab, setTab] = useState("overview"); @@ -150,15 +152,8 @@ export function AccountPage({ billing, projects, onRecharge }: { }).catch(() => {}).finally(() => { if (alive) setLedgersLoading(false); }); return () => { alive = false; }; }, [billPage, reloadFlag]); - const billTotalPages = Math.max(1, Math.ceil(ledgerCount / BILLS_PER_PAGE)); - const safeBillPage = Math.min(billPage, billTotalPages); // row40:跳页输入框 —— 输入页码回车/点「跳转」即钳到 [1,总页数] 并翻页 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 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 used = Number(billing?.charged_total || 0); - const memberLimit = teamMembers.reduce((sum, m) => sum + Math.max(0, Number(m.monthly_credit_limit || 0)), 0); - const limit = memberLimit || balance; + // 月限额:与团队管理页保持同一来源(team.monthly_credit_limit) · #14 + // 优先用团队级月限额;-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 pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0; @@ -219,7 +217,8 @@ export function AccountPage({ billing, projects, onRecharge }: { const memFiltered = memRole !== "all"; // ── 账单流水筛选(类型 + 成员)── 后端为服务端分页且无过滤参数(api.ledgers 仅收 page/pageSize), - // 故只能对「当前页」已载入的 10 条做客户端过滤;计数标注为本页范围,避免误导成全量过滤。 + // 故只能对「当前页」已载入的 10 条做客户端过滤。 + // 切换筛选条件时跳回第 1 页(#9a);过滤后按实际可见行数重算总页数(#9b)。 const [billType, setBillType] = useState("all"); const [billMember, setBillMember] = useState("all"); const billMemberOptions = useMemo(() => { @@ -235,6 +234,16 @@ export function AccountPage({ billing, projects, onRecharge }: { [ledgerRows, billType, billMember] ); 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 (
@@ -398,7 +407,7 @@ export function AccountPage({ billing, projects, onRecharge }: {
- { setBillType(e.target.value); setBillPage(1); }} aria-label="按类型筛选"> @@ -407,12 +416,12 @@ export function AccountPage({ billing, projects, onRecharge }: { - { setBillMember(e.target.value); setBillPage(1); }} aria-label="按成员筛选"> {billMemberOptions.map((m) => )} {billFiltered && ( - + )} 本页 {visibleLedgers.length} 条 · 共 {ledgerCount} 条 diff --git a/core/frontend/src/routes/library.tsx b/core/frontend/src/routes/library.tsx index 986bb75..fc2d2e2 100644 --- a/core/frontend/src/routes/library.tsx +++ b/core/frontend/src/routes/library.tsx @@ -568,10 +568,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD {editMode ? "完成" : "管理资产"} - + {/* 上传资产入口已隐藏:资产由 AI 生成流水线自动入库,不开放手动上传 */}
@@ -588,8 +585,15 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD ) : ( packs.map((pack) => { const first = pack.clips[0]; + // 视频成品的所有片段 id:批量删除整个素材包 + const packClipIds = pack.clips.map((c) => c.id).filter(Boolean); return ( -
setOpenPack(pack)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenPack(pack); } }}> +
!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 && ( + + )}
{first?.url ? (
- )} + ); + })()} {/* ── 流程步骤5 · 过审闸弹窗:含真人脸的人物/分镜未过审 → 列出哪一镜/哪个,先过审再生成(火山合规要求) ── */} {reviewGate && (
setReviewGate(null)}> diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx index 85b5094..dff7f36 100644 --- a/core/frontend/src/routes/products.tsx +++ b/core/frontend/src/routes/products.tsx @@ -798,11 +798,6 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na }, [productImageKeys]); 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 / // ProductImage 关联)懒加载到 productAssets;这里只按可显示的图片类型过滤。 @@ -1040,27 +1035,31 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
{visibleProductImages.map((image) => { - // 商品至少保留一张图:只剩一张可删图时,那张锁住不可删(要移除请删整个商品) - const canDelete = editing && Boolean(image.imageId) && deletableImageCount > 1; - const lastLocked = editing && Boolean(image.imageId) && deletableImageCount <= 1; + // 编辑态下每张有 ProductImage 关联的图都给删除按钮(hover 出垃圾桶),不再因 + // 「只剩一张可删」而整张隐藏删除入口 —— 否则单图商品 / 删到最后一张时悬停看不到垃圾桶, + // 体感像「删除坏了,只能放大」。最后一张的「至少保留一张」约束由后端兜底(删时退 400), + // 这里不再前置静默禁用,改成点了真调接口、失败回滚 + 弹后端原文(如「至少保留一张图」)。 + const canDelete = editing && Boolean(image.imageId) && Boolean(onDeleteImage); const handleDelete = async (event: { stopPropagation: () => void }) => { event.stopPropagation(); if (!canDelete || !image.imageId || !onDeleteImage) return; - // 乐观隐藏:立刻从本地列表移除,toast/loadData 并行回填 + // 乐观隐藏:立刻从本地列表移除;真正持久化由后端 DELETE 完成, + // 成功后父组件刷新会把该图从 product.images 抹掉(reconcile effect 同步清理乐观标记)。 setDeletedImageKeys((prev) => new Set([...prev, image.key])); const result = await onDeleteImage(image.imageId); - if (result === null) { - // action 返回 null 表示调用失败(会弹错误 toast),回滚乐观隐藏 + if (result === null || result === undefined) { + // action 返回 null/undefined 表示接口调用失败(已弹错误 toast,如「至少保留一张图」), + // 回滚乐观隐藏 —— 图重新出现,不会出现「弹了删除成功但其实没删」的错觉。 setDeletedImageKeys((prev) => { const next = new Set(prev); next.delete(image.key); return next; }); } }; return (
{ 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 }); } }} diff --git a/core/frontend/src/routes/settings.tsx b/core/frontend/src/routes/settings.tsx index e86e89f..212626d 100644 --- a/core/frontend/src/routes/settings.tsx +++ b/core/frontend/src/routes/settings.tsx @@ -54,11 +54,12 @@ function deviceName(ua: string): string { return `${os} · ${browser}`; } +// 仅保留已真实接入「站内通知」的行;邮箱/短信/异地登录告警渠道未接入,隐藏对应行。 const NOTIFY_ROWS: Array<{ key: string; title: string; sub?: string; channels: string }> = [ - { key: "n-export", title: "项目完成通知", sub: "// 视频导出后", channels: "站内 · 邮件 · 短信" }, - { key: "n-fail", title: "任务失败告警", channels: "站内 · 邮件" }, - { key: "n-quota", title: "额度不足提醒", sub: "// 团队或个人剩余 < 20%", channels: "站内 · 短信" }, - { key: "n-login", title: "异地登录告警", channels: "短信" }, + { key: "n-export", title: "项目完成通知", sub: "// 视频导出后", channels: "站内" }, + { key: "n-fail", title: "任务失败告警", channels: "站内" }, + { key: "n-quota", title: "额度不足提醒", sub: "// 团队或个人剩余 < 20%", channels: "站内" }, + // n-login(异地登录告警·短信渠道)未接入,已移除。 ]; // ─── 偏好默认值 · 与后端 UserPreference 默认一致(后端到达前的占位) ─── @@ -69,7 +70,7 @@ const DEFAULT_PREFS = { bgm: "kapian", transition: "fade", twoFactor: false, - notify: { "n-export": true, "n-fail": true, "n-quota": true, "n-login": true } as Record, + notify: { "n-export": true, "n-fail": true, "n-quota": true } as Record, appearance: "system", language: "zh", density: "standard", @@ -428,7 +429,7 @@ export function SettingsPage({ {section === "profile" && (

个人信息

-
// 头像、姓名、联系方式 · 邮箱用于接收通知
+
// 头像、姓名、联系方式
头像
@@ -447,10 +448,11 @@ export function SettingsPage({
patchDraft("name", event.target.value)} />
-
登录邮箱
+
登录邮箱
// 仅做记录用
patchDraft("email", event.target.value)} /> - + {/* 邮件服务未接入,验证功能暂不可用,入口已隐藏 */} + // 邮件验证未启用
@@ -535,7 +547,7 @@ export function SettingsPage({ {section === "notify" && (

通知

-
// 邮件、短信、站内提示开关
+
// 站内通知开关 · 邮件/短信渠道未接入
{NOTIFY_ROWS.map((row) => (
{row.title}{row.sub ?
{row.sub}
: null}