fix: 测试清单一轮bug修复(商品库/视频项目/设置/消费/平台套图)
- 商品库编辑删图假成功+悬停无删除图标;商品三视图错显角色三视图 - 视频项目演员删除入口;角色三视图防呆;故事板审核失败原因透出 - 设置:管理团队死按钮/通知未接入项屏蔽/邮箱验证链路 - 消费:账单流水切换类型重置分页+独立总页数 - 资产库上传按钮隐藏;月限额两处对齐 - 平台套图:提示词框放大/选模型胶囊内嵌/未读任务角标/工作台记录持久化 - 新建商品独立添加卖点按钮;商品库超1920自适应 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -847,6 +847,7 @@ export function App() {
|
||||
<AccountPage
|
||||
billing={billing}
|
||||
projects={projects}
|
||||
team={currentTeam}
|
||||
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 [category, setCategory] = useState("");
|
||||
const [target, setTarget] = useState("");
|
||||
// 卖点 = 一组可直接编辑的输入行(每行一条);点「添加卖点」按钮新增一条空行,
|
||||
// 不再靠「在一个输入框里回车隐晦追加」(YYX#17)。
|
||||
const [bullets, setBullets] = useState<string[]>([]);
|
||||
const [bulletDraft, setBulletDraft] = useState("");
|
||||
const [images, setImages] = useState<PfImage[]>([]);
|
||||
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
|
||||
|
||||
<div className="field pc-field-last">
|
||||
<label className="field-label">核心卖点<span className="req">*</span></label>
|
||||
{/* YYX#17:每条卖点 = 一行可直接编辑的输入;新增卖点走下方独立「添加卖点」按钮,
|
||||
不再在一个框里靠回车隐晦追加。 */}
|
||||
<ul className="bullet-list">
|
||||
{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="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="删除卖点">
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<unknown>;
|
||||
}) {
|
||||
const [tab, setTab] = useState<Tab>("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<string>("all");
|
||||
const [billMember, setBillMember] = useState<string>("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 (
|
||||
<section className="account-page">
|
||||
@@ -398,7 +407,7 @@ export function AccountPage({ billing, projects, onRecharge }: {
|
||||
|
||||
<div className={`tab-panel ${tab === "bills" ? "active" : ""}`}>
|
||||
<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="charge">扣费</option>
|
||||
<option value="recharge">充值</option>
|
||||
@@ -407,12 +416,12 @@ export function AccountPage({ billing, projects, onRecharge }: {
|
||||
<option value="adjustment">调整</option>
|
||||
<option value="refund">退款</option>
|
||||
</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>
|
||||
{billMemberOptions.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
{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="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>
|
||||
<span className="lib-manage-label">{editMode ? "完成" : "管理资产"}</span>
|
||||
</button>
|
||||
<button className="btn btn-primary" type="button" id="open-upload-btn" onClick={() => setUploadOpen(true)}>
|
||||
<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>
|
||||
{/* 上传资产入口已隐藏:资产由 AI 生成流水线自动入库,不开放手动上传 */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<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">
|
||||
{first?.url ? (
|
||||
<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 ? (
|
||||
<div className="packs-grid" id="batch-grid">
|
||||
{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 && (
|
||||
<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>
|
||||
|
||||
@@ -852,24 +852,43 @@ export function PipelinePage(props: {
|
||||
}
|
||||
// ── 流程步骤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));
|
||||
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;
|
||||
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 = {
|
||||
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())),
|
||||
};
|
||||
const miss = new Map<string, { name: string; type: string }>();
|
||||
const miss = new Map<string, RefMiss>();
|
||||
for (const seg of segs) {
|
||||
for (const rid of seg.entity_refs || []) {
|
||||
const ent = byId.get(rid);
|
||||
const ent = entById.get(rid);
|
||||
if (!ent || ent.type === "product") continue; // 商品永远有主图,不算缺
|
||||
const kind = ent.type === "character" ? "person" : "scene";
|
||||
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()];
|
||||
@@ -3870,28 +3889,52 @@ export function PipelinePage(props: {
|
||||
);
|
||||
})()}
|
||||
{/* ── 流程步骤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-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="rg-title">参考图还没齐,先补一下?</div>
|
||||
<div className="rg-body">
|
||||
下面这些角色 / 场景<strong>还没生成参考图</strong>。现在直接生成,出来的画面会跟你设定的对不上(像纯文生图、白花钱):
|
||||
</div>
|
||||
<div className="rg-list">
|
||||
{refGate.missing.map((m) => (
|
||||
<span className="rg-chip" key={`${m.type}:${m.name}`}>
|
||||
<span className={`rg-kind rg-kind-${m.type === "character" ? "person" : "scene"}`}>{m.type === "character" ? "角色" : "场景"}</span>
|
||||
{m.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="rg-title">资产还没齐,先补一下?</div>
|
||||
{noRef.length > 0 && (
|
||||
<>
|
||||
<div className="rg-body">
|
||||
下面这些角色 / 场景<strong>还没生成参考图</strong>。请回「基础资产」页,点对应卡片进详情后<strong>「生成立绘 / 场景图」</strong>并采用,否则故事板会变成纯文生图、跟你的设定对不上(白花钱):
|
||||
</div>
|
||||
<div className="rg-list">
|
||||
{noRef.map((m) => (
|
||||
<span className="rg-chip" key={`noref:${m.type}:${m.name}`}>
|
||||
<span className={`rg-kind rg-kind-${m.type === "character" ? "person" : "scene"}`}>{m.type === "character" ? "角色" : "场景"}</span>
|
||||
{m.name}
|
||||
</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">
|
||||
<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-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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
{/* ── 流程步骤5 · 过审闸弹窗:含真人脸的人物/分镜未过审 → 列出哪一镜/哪个,先过审再生成(火山合规要求) ── */}
|
||||
{reviewGate && (
|
||||
<div className="ref-gate-mask" onClick={() => setReviewGate(null)}>
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
<div className="grid" id="ov-images-grid">
|
||||
{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 (
|
||||
<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}
|
||||
role={image.url ? "button" : undefined}
|
||||
tabIndex={image.url ? 0 : undefined}
|
||||
title={lastLocked ? "商品至少保留一张图;要移除请删除整个商品" : image.url ? "点击放大" : undefined}
|
||||
title={image.url ? "点击放大" : undefined}
|
||||
style={{ cursor: image.url ? "zoom-in" : "default" }}
|
||||
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 }); } }}
|
||||
|
||||
@@ -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<string, boolean>,
|
||||
notify: { "n-export": true, "n-fail": true, "n-quota": true } as Record<string, boolean>,
|
||||
appearance: "system",
|
||||
language: "zh",
|
||||
density: "standard",
|
||||
@@ -428,7 +429,7 @@ export function SettingsPage({
|
||||
{section === "profile" && (
|
||||
<section className="pane" aria-label="个人信息">
|
||||
<h3>个人信息</h3>
|
||||
<div className="pane-desc">// 头像、姓名、联系方式 · 邮箱用于接收通知</div>
|
||||
<div className="pane-desc">// 头像、姓名、联系方式</div>
|
||||
|
||||
<div className="form-row">
|
||||
<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>
|
||||
<div className="form-row">
|
||||
<div className="lbl">登录邮箱</div>
|
||||
<div className="lbl">登录邮箱<div className="lbl-sub">// 仅做记录用</div></div>
|
||||
<div className="val">
|
||||
<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 className="form-row">
|
||||
@@ -465,7 +467,17 @@ export function SettingsPage({
|
||||
<div className="val">
|
||||
<span className="static">{team.name}</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 className="form-row">
|
||||
@@ -535,7 +547,7 @@ export function SettingsPage({
|
||||
{section === "notify" && (
|
||||
<section className="pane" aria-label="通知">
|
||||
<h3>通知</h3>
|
||||
<div className="pane-desc">// 邮件、短信、站内提示开关</div>
|
||||
<div className="pane-desc">// 站内通知开关 · 邮件/短信渠道未接入</div>
|
||||
{NOTIFY_ROWS.map((row) => (
|
||||
<div className="form-row" key={row.key}>
|
||||
<div className="lbl">{row.title}{row.sub ? <div className="lbl-sub">{row.sub}</div> : null}</div>
|
||||
|
||||
Reference in New Issue
Block a user