fix: PMC 测试清单未完成项 + YYX#row22 未读角标
PMC#16 账户登录地址不正确: - 写死的 https://airshelf.com/login(不存在的域名)改成 ${location.origin}/login, 与邀请链接同源,成员拿到的就是当前真实访问地址。 PMC#17/#18 成员三档额度只有月度生效、日/总纯装饰 → 列表显示与配置对不上: - TeamMember 新增 daily_credit_limit / total_credit_limit(0=不限,迁移 0006); - reserve_credit 逐档真实管控(每日/每月/累计总额),口径=窗口内 CHARGE 流水 + 在途预留; - 成员列表"每日额度"由写死"不限"改成真实日额度+今日已用,并新增"总额度"列; - 创建/编辑弹窗三档额度真入库真生效;补 3 个计费单测(日/总拦截、0=不限)。 PMC#24 同批生成的图片文字/内容雷同: - 根因=套图/自由创作同批 N 张用同一 prompt 且无变化(只有模特上身图按 index 变); - 给平台套图/自由参考/纯文生图都加按 index 的版式变体,避开传 seed(防中转站 400)。 PMC#25 重跑应在原任务上重跑而非新开任务: - 全批"重跑"改为原地复位同一批次卡;"重跑这张/再生成"改为补图进原卡(不覆盖好图、不新开任务)。 YYX#row22 图片生成未读角标(本仓库既有改动一并提交): - AITask 新增 read_at(迁移 0020 + 索引一致性迁移 0021);导航栏"图片生成"未读胶囊、 商品预览右下角未读分数;查看某商品即标记其生成任务已读、清零角标。 注:PMC#14/#15/#20/#22/#23 早前提交已修,需部署重测;PMC#13/#21 为 provider 侧 生成失败,代码侧重试/退费/僵尸回收/错误外露已就位,需部署后看真实 error_message 定位; PMC#19 收件箱标记与设计稿逐字一致,待截图复现。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -113,6 +113,9 @@ export function App() {
|
||||
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([]);
|
||||
const [billing, setBilling] = useState<BillingSummary | null>(null);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
// YYX#row22:未读生成任务 —— 导航「图片生成」总数 + 每个商品的未读分数(商品角标)
|
||||
const [aiUnread, setAiUnread] = useState(0);
|
||||
const [aiUnreadByProduct, setAiUnreadByProduct] = useState<Record<string, number>>({});
|
||||
const [dataLoaded, setDataLoaded] = useState(false); // 全局数据(商品/项目)首次加载完成前,列表显示加载态而非空态
|
||||
const [projectDetail, setProjectDetail] = useState<Project | null>(null);
|
||||
const [projectDetailError, setProjectDetailError] = useState(false); // 进管线拉详情失败:从「永久 loading」改为可重试,避免卡死进不来
|
||||
@@ -210,6 +213,19 @@ export function App() {
|
||||
if (data) setUnreadCount(data.unread_count);
|
||||
}, []);
|
||||
|
||||
// YYX#row22:拉未读生成任务汇总(导航胶囊 + 商品角标)
|
||||
const reloadAiUnread = useCallback(async () => {
|
||||
const data = await api.aiTasksUnread().catch(() => null);
|
||||
if (data) { setAiUnread(data.total); setAiUnreadByProduct(data.by_product || {}); }
|
||||
}, []);
|
||||
// 标记已读(全部 / 按商品)→ 乐观清零本地角标,再刷一次真值
|
||||
const markAiRead = useCallback(async (productId?: string) => {
|
||||
if (productId) setAiUnreadByProduct((prev) => { const n = { ...prev }; delete n[productId]; return n; });
|
||||
else { setAiUnread(0); setAiUnreadByProduct({}); }
|
||||
await api.markAiTasksRead(productId ? { product_id: productId } : undefined).catch(() => undefined);
|
||||
void reloadAiUnread();
|
||||
}, [reloadAiUnread]);
|
||||
|
||||
// Boot: validate token, hydrate identity + data.
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
@@ -300,6 +316,20 @@ export function App() {
|
||||
loadSettingsData();
|
||||
}, [authed, page, loadSettingsData]);
|
||||
|
||||
// YYX#row22:登录后拉一次未读生成任务,并每 30s 静默轮询(生成是慢任务,出图后角标自动亮)
|
||||
useEffect(() => {
|
||||
if (!authed || !user) return;
|
||||
void reloadAiUnread();
|
||||
const timer = window.setInterval(() => { void reloadAiUnread(); }, 30000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [authed, user, reloadAiUnread]);
|
||||
|
||||
// YYX#row22:进入「图片生成」任务中心即把全部生成任务标记已读 → 清零导航胶囊
|
||||
useEffect(() => {
|
||||
if (!authed || page !== "assetFactory") return;
|
||||
void markAiRead();
|
||||
}, [authed, page, markAiRead]);
|
||||
|
||||
// Load full project detail when entering the pipeline.
|
||||
useEffect(() => {
|
||||
if (!authed || page !== "pipeline" || !activeProjectId) {
|
||||
@@ -843,11 +873,11 @@ export function App() {
|
||||
case "assetFactory":
|
||||
return <AssetFactoryPage navigate={navigate} />;
|
||||
case "imageOptimize":
|
||||
return <ImageWorkbenchPage mode="image" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
|
||||
return <ImageWorkbenchPage mode="image" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
|
||||
case "modelPhoto":
|
||||
return <ImageWorkbenchPage mode="model" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
|
||||
return <ImageWorkbenchPage mode="model" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
|
||||
case "platformCover":
|
||||
return <ImageWorkbenchPage mode="cover" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
|
||||
return <ImageWorkbenchPage mode="cover" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
|
||||
case "modelPhotoDemoA":
|
||||
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => navigate("modelPhoto")} navigate={navigate} />;
|
||||
case "modelPhotoDemoB":
|
||||
@@ -1025,7 +1055,7 @@ export function App() {
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Sidebar page={page} navigate={navigate} user={currentUser} team={currentTeam} canManageBilling={isOwner} products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} logout={logout} onOpenAdmin={() => navigateAdmin("")} />
|
||||
<Sidebar page={page} navigate={navigate} user={currentUser} team={currentTeam} canManageBilling={isOwner} products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} aiUnread={aiUnread} logout={logout} onOpenAdmin={() => navigateAdmin("")} />
|
||||
<main>
|
||||
<Decorations />
|
||||
<header className="topbar">
|
||||
|
||||
@@ -315,12 +315,24 @@
|
||||
.image-workbench .iw-prod-item:hover { background: var(--black-alpha-4); }
|
||||
.image-workbench .iw-prod-item.active { background: var(--heat-12); border-color: var(--heat-20); }
|
||||
.image-workbench .iw-prod-item .thumb {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 36px; height: 36px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.image-workbench .iw-prod-item .thumb.has-real-media { overflow: hidden; background: var(--background-lighter); }
|
||||
.image-workbench .iw-prod-item .thumb.has-real-media img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
/* YYX#row22:商品预览右下角 · 未读生成任务分数(H22 示例) */
|
||||
.image-workbench .iw-prod-item .thumb .iw-prod-unread {
|
||||
position: absolute; right: -4px; bottom: -4px;
|
||||
min-width: 16px; height: 16px; padding: 0 4px;
|
||||
display: grid; place-items: center;
|
||||
background: var(--heat); color: var(--accent-white);
|
||||
font-family: var(--font-mono); font-size: 10px; line-height: 1; font-weight: 600;
|
||||
border-radius: 999px; border: 2px solid var(--background-base);
|
||||
z-index: 2;
|
||||
}
|
||||
/* 不用 overflow:hidden 裁切(否则切掉右下角未读角标);改由 img 自己圆角 */
|
||||
.image-workbench .iw-prod-item .thumb.has-real-media { background: var(--background-lighter); }
|
||||
.image-workbench .iw-prod-item .thumb.has-real-media img { width: 100%; height: 100%; object-fit: cover; display: block; border-radius: var(--r-sm); }
|
||||
.image-workbench .iw-prod-item .body { flex: 1; min-width: 0; }
|
||||
.image-workbench .iw-prod-item .nm {
|
||||
font-size: 13px; color: var(--accent-black); font-weight: 500;
|
||||
@@ -693,6 +705,23 @@
|
||||
.image-workbench .iw-filter-opt .ck { color: var(--heat); opacity: 0; flex-shrink: 0; }
|
||||
.image-workbench .iw-filter-opt.on .ck { opacity: 1; }
|
||||
|
||||
/* ── YYX#G23:筛选已开启 · 结果区置顶提示栏 ── */
|
||||
.image-workbench .iw-filter-notice {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-bottom: 14px; padding: 10px 14px;
|
||||
background: var(--heat-12);
|
||||
border: 1px solid var(--heat-20); border-radius: var(--r-md);
|
||||
}
|
||||
.image-workbench .iw-filter-notice .ifn-text {
|
||||
flex: 1; font-size: 13px; color: var(--accent-black);
|
||||
}
|
||||
.image-workbench .iw-filter-notice .ifn-clear {
|
||||
flex-shrink: 0; display: inline-flex; align-items: center; gap: 4px;
|
||||
border: 0; background: none; cursor: pointer;
|
||||
font-size: 12px; color: var(--heat); font-family: inherit;
|
||||
}
|
||||
.image-workbench .iw-filter-notice .ifn-clear:hover { text-decoration: underline; }
|
||||
|
||||
/* ── YYX#18:结果卡精简头部(提示词折叠 + 灰色张数,无容器/引号/标题)── */
|
||||
.image-workbench .iw-batch-head { padding: 2px 2px 0; margin-bottom: 8px; }
|
||||
.image-workbench .iw-batch-head .ibh-prompt {
|
||||
@@ -933,6 +962,22 @@
|
||||
首个头紧跟 iw-preview 顶 padding,无需额外上间距(故只给跟在卡片后的头加 margin)。 */
|
||||
.image-workbench .gen-batch-card + .iw-pv-h { margin-top: 22px; }
|
||||
|
||||
/* ── YYX#row24/row25:去掉结果批次卡的白色容器 / 边框 / 内边距 + 底部分割线 ──
|
||||
让图片显示区铺满预览宽度、更大;批次间距由「卡片 → 下一个批次头」拉开。 */
|
||||
.image-workbench .gen-batch-card {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
gap: 12px;
|
||||
}
|
||||
.image-workbench .gen-batch-card .gen-batch-actions {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.image-workbench .gen-batch-card + .iw-batch-head { margin-top: 22px; }
|
||||
|
||||
/* P0③ · 平台套图多平台分组 section 头(平台 logo + 名 + 批次数) */
|
||||
.image-workbench .iw-cover-group-h {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
|
||||
@@ -181,11 +181,13 @@ export const api = {
|
||||
name?: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
daily_credit_limit?: number | string;
|
||||
monthly_credit_limit?: number | string;
|
||||
total_credit_limit?: number | string;
|
||||
}) {
|
||||
return request<TeamMember>("/api/auth/team/members/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
updateTeamMember(id: string, payload: { role?: string; monthly_credit_limit?: number | string; name?: string }) {
|
||||
updateTeamMember(id: string, payload: { role?: string; daily_credit_limit?: number | string; monthly_credit_limit?: number | string; total_credit_limit?: number | string; name?: string }) {
|
||||
return request<TeamMember>(`/api/auth/team/members/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
},
|
||||
removeTeamMember(id: string) {
|
||||
@@ -589,6 +591,14 @@ export const api = {
|
||||
// 以便 全部/已完成/失败 三个 tab 数按真实总量算。
|
||||
return request<Paginated<AITask>>("/api/ai/tasks/?page_size=200&task_type=person_image,product_image");
|
||||
},
|
||||
// YYX#row22:未读生成任务汇总(导航胶囊总数 + 按商品分组角标)
|
||||
aiTasksUnread() {
|
||||
return request<{ total: number; by_product: Record<string, number> }>("/api/ai/tasks/unread/");
|
||||
},
|
||||
// YYX#row22:标记已读 → 清零未读胶囊。不传参 = 全部已读;可按 product_id / batch_id / ids 局部清。
|
||||
markAiTasksRead(payload?: { product_id?: string; batch_id?: string; ids?: string[] }) {
|
||||
return request<{ updated: number }>("/api/ai/tasks/mark-read/", { method: "POST", body: JSON.stringify(payload || {}) });
|
||||
},
|
||||
// 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果。
|
||||
// 带 conversation_id 则归属该对话;不带则后端自动开一条新对话并回传其 id。
|
||||
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; model_entity_id?: string; ratio?: string; image_model?: string; conversation_id?: string; reference_image_ids?: string[] }) {
|
||||
|
||||
@@ -226,7 +226,7 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
|
||||
settingsNotify: "settings"
|
||||
};
|
||||
|
||||
export function Sidebar({ page, navigate, user, team, canManageBilling = true, products, projects, productTotal, projectTotal, logout, onOpenAdmin }: {
|
||||
export function Sidebar({ page, navigate, user, team, canManageBilling = true, products, projects, productTotal, projectTotal, aiUnread, logout, onOpenAdmin }: {
|
||||
page: Page;
|
||||
navigate: Navigate;
|
||||
user: User;
|
||||
@@ -237,6 +237,8 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
|
||||
projects: Project[];
|
||||
productTotal?: number;
|
||||
projectTotal?: number;
|
||||
// YYX#row22:未读生成任务总数 → 「图片生成」右侧数字胶囊
|
||||
aiUnread?: number;
|
||||
// 退出登录。父级(App.tsx)有完整 logout 闭包时传入;未传时用自带兜底
|
||||
// (best-effort 调登出接口 + 清 token + 跳 /login),保证侧栏账户菜单的"退出"今天就可用。
|
||||
logout?: () => void;
|
||||
@@ -248,6 +250,8 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
|
||||
const navItems = NAV.filter((item) => canManageBilling || (item.page !== "team" && item.page !== "account"));
|
||||
// 徽标用后端真实总数(分页 count),不用已加载的页内条数
|
||||
const badges: Partial<Record<string, number>> = { products: productTotal ?? products.length, projects: projectTotal ?? projects.length };
|
||||
// YYX#row22:有未读生成任务才显示胶囊(0 不显示)
|
||||
if (aiUnread && aiUnread > 0) badges["asset-factory"] = aiUnread;
|
||||
const avatar = (team?.name || user.username || "A").slice(0, 1).toUpperCase();
|
||||
|
||||
// 收窄/展开导航:与设计稿 Shell.toggleSidebarCollapse 一致 —— 切 body.sidebar-collapsed
|
||||
|
||||
@@ -296,7 +296,9 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
||||
))}
|
||||
<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="添加新卖点 · 回车确认" />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -437,6 +437,17 @@
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.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; }
|
||||
@media (max-width: 900px) {
|
||||
.pc-drawer .drawer-b .pf-upload-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@@ -600,7 +600,9 @@ export function ImageWorkbenchPage({
|
||||
onGenerate,
|
||||
onResume,
|
||||
initialProductId,
|
||||
onProductChange
|
||||
onProductChange,
|
||||
unreadByProduct,
|
||||
onProductViewed
|
||||
}: {
|
||||
mode: WorkMode;
|
||||
products: Product[];
|
||||
@@ -613,11 +615,17 @@ export function ImageWorkbenchPage({
|
||||
initialProductId?: string;
|
||||
/** 选中商品上抛给 App,持久化进 activeProductId;否则切走再回来选择会被重置回默认第一个商品 */
|
||||
onProductChange?: (productId: string) => void;
|
||||
/** YYX#row22:每个商品的未读生成任务数(商品角标) */
|
||||
unreadByProduct?: Record<string, number>;
|
||||
/** YYX#row22:查看某商品(选中)即标记该商品的生成任务已读 → 清零角标 */
|
||||
onProductViewed?: (productId: string) => void;
|
||||
}) {
|
||||
const meta = MODE_META[mode];
|
||||
const [productId, setProductId] = useState(initialProductId || products[0]?.id || "");
|
||||
// 选中商品同步到 App(activeProductId),保证切换栏目再回来时初始商品仍是上次选的那个
|
||||
useEffect(() => { if (productId) onProductChange?.(productId); }, [productId, onProductChange]);
|
||||
// YYX#row22:选中(查看)某商品即把它的未读生成任务标记已读 → 角标清零
|
||||
useEffect(() => { if (productId && (unreadByProduct?.[productId] ?? 0) > 0) onProductViewed?.(productId); }, [productId, unreadByProduct, onProductViewed]);
|
||||
const product = products.find((item) => item.id === productId) || products[0];
|
||||
// 图片创作(image)默认留空,只靠 placeholder 引导;模特/平台仍预填模板省一步
|
||||
const [prompt, setPrompt] = useState(mode === "image" ? "" : meta.promptTemplate(products[0]?.title || "商品"));
|
||||
@@ -940,27 +948,48 @@ export function ImageWorkbenchPage({
|
||||
platformIds?: string[];
|
||||
/** 图片创作:本批要参考的上传图(含 file 用于上传;已是 asset 的可只给 id) */
|
||||
refs?: { name: string; url: string; file?: File; assetId?: string }[];
|
||||
/** PMC#25:原地重跑——复位这张已有批次卡(清旧结果、重新生成),不新开任务卡 */
|
||||
reuseBatchId?: string;
|
||||
/** PMC#25:单图重跑——把新图追加进这张已有批次卡(不清旧好图、不新开卡) */
|
||||
appendToBatchId?: string;
|
||||
}) {
|
||||
const batchId = `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const newBatch: GenBatch = {
|
||||
id: batchId,
|
||||
prompt: opts.prompt,
|
||||
ratio: opts.ratio,
|
||||
count: opts.count,
|
||||
status: "generating",
|
||||
results: [],
|
||||
adopted: false,
|
||||
ts: Date.now(),
|
||||
productId: opts.productId,
|
||||
productTitle: opts.productTitle,
|
||||
modelId: opts.modelId,
|
||||
modelName: opts.modelName,
|
||||
platformIds: opts.platformIds,
|
||||
// 批次头回显「参考了哪些图」(只存名+预览,不存 file)
|
||||
refs: opts.refs?.map((r) => ({ name: r.name, url: r.url }))
|
||||
};
|
||||
setBatches((prev) => [...prev, newBatch]);
|
||||
// YYX#16:新批次(含重跑/单图再生成)一律追加到末尾,提交即把面板滚到底部新任务处
|
||||
// 原地重跑/追加:沿用原批次 id,不再生成新 id、不再新增卡片
|
||||
const batchId = opts.reuseBatchId || opts.appendToBatchId || `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
if (opts.reuseBatchId) {
|
||||
// 全批重跑:把原卡复位为「生成中」,清掉旧(失败/旧)结果,保留其归属与参数
|
||||
setBatches((prev) => {
|
||||
const next = prev.map((b) => (b.id === batchId ? { ...b, status: "generating" as const, results: [], pendingIds: undefined } : b));
|
||||
persistBatches(next);
|
||||
return next;
|
||||
});
|
||||
} else if (opts.appendToBatchId) {
|
||||
// 单图重跑:原卡转「生成中」补图,保留已有好图
|
||||
setBatches((prev) => {
|
||||
const next = prev.map((b) => (b.id === batchId ? { ...b, status: "generating" as const } : b));
|
||||
persistBatches(next);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
const newBatch: GenBatch = {
|
||||
id: batchId,
|
||||
prompt: opts.prompt,
|
||||
ratio: opts.ratio,
|
||||
count: opts.count,
|
||||
status: "generating",
|
||||
results: [],
|
||||
adopted: false,
|
||||
ts: Date.now(),
|
||||
productId: opts.productId,
|
||||
productTitle: opts.productTitle,
|
||||
modelId: opts.modelId,
|
||||
modelName: opts.modelName,
|
||||
platformIds: opts.platformIds,
|
||||
// 批次头回显「参考了哪些图」(只存名+预览,不存 file)
|
||||
refs: opts.refs?.map((r) => ({ name: r.name, url: r.url }))
|
||||
};
|
||||
setBatches((prev) => [...prev, newBatch]);
|
||||
}
|
||||
// YYX#16:新批次提交即把面板滚到底部新任务处;原地重跑滚到该卡也无妨
|
||||
window.setTimeout(() => resultsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }), 80);
|
||||
try {
|
||||
// 先把参考图上传成 Asset,拿到 id 列表带给后端 → 生成时真正作多图参考(image_edit)。
|
||||
@@ -990,13 +1019,24 @@ export function ImageWorkbenchPage({
|
||||
void loadConversations(false);
|
||||
}
|
||||
setBatches((prev) => {
|
||||
const next = prev.map((b) => (b.id === batchId ? { ...b, status: result?.assets ? ("done" as const) : ("failed" as const), results: result?.assets || [] } : b));
|
||||
const next = prev.map((b) => {
|
||||
if (b.id !== batchId) return b;
|
||||
const newAssets = result?.assets || [];
|
||||
// 单图追加重跑:把新图并进原有好图(按 id 去重),不覆盖;其余路径直接用本批结果
|
||||
if (opts.appendToBatchId) {
|
||||
const seen = new Set(b.results.map((a) => a.id));
|
||||
const merged = [...b.results, ...newAssets.filter((a) => !a.id || !seen.has(a.id))];
|
||||
return { ...b, status: merged.length ? ("done" as const) : ("failed" as const), results: merged };
|
||||
}
|
||||
return { ...b, status: newAssets.length ? ("done" as const) : ("failed" as const), results: newAssets };
|
||||
});
|
||||
persistBatches(next);
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
setBatches((prev) => {
|
||||
const next = prev.map((b) => (b.id === batchId ? { ...b, status: "failed" as const } : b));
|
||||
// 追加重跑失败:保留原有好图,只把状态落回(有图=done,无图=failed)
|
||||
const next = prev.map((b) => (b.id === batchId ? { ...b, status: ((opts.appendToBatchId && b.results.length ? "done" : "failed") as GenBatch["status"]) } : b));
|
||||
persistBatches(next);
|
||||
return next;
|
||||
});
|
||||
@@ -1047,7 +1087,7 @@ export function ImageWorkbenchPage({
|
||||
window.setTimeout(() => resultsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }), 80);
|
||||
}
|
||||
|
||||
/* 重跑指定批次(行32①②):用该批次的参数新起一个批次追加到列表末尾,原批次保留。 */
|
||||
/* 重跑指定批次(PMC#25:在原任务卡上原地重跑,不新开任务卡)。 */
|
||||
async function rerunBatch(src: GenBatch) {
|
||||
await startBatch({
|
||||
prompt: src.prompt,
|
||||
@@ -1058,7 +1098,8 @@ export function ImageWorkbenchPage({
|
||||
productTitle: src.productTitle ?? product?.title,
|
||||
modelId: src.modelId,
|
||||
modelName: src.modelName,
|
||||
platformIds: src.platformIds
|
||||
platformIds: src.platformIds,
|
||||
reuseBatchId: src.id
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1126,7 +1167,8 @@ export function ImageWorkbenchPage({
|
||||
applyLibraryState(batchId, [assetId], next);
|
||||
}
|
||||
|
||||
/* 单图「再生成」(§4.18 悬浮①):用该批参数另起一个 count=1 的批次,产出一张新图 */
|
||||
/* 单图「再生成」/「重跑这张」(§4.18 悬浮① / YYX#5):
|
||||
PMC#25——补一张图进原批次卡(不新开任务、不覆盖已有好图)。 */
|
||||
async function regenSingleImage(src: GenBatch) {
|
||||
await startBatch({
|
||||
prompt: src.prompt,
|
||||
@@ -1136,7 +1178,8 @@ export function ImageWorkbenchPage({
|
||||
productTitle: src.productTitle ?? product?.title,
|
||||
modelId: src.modelId,
|
||||
modelName: src.modelName,
|
||||
platformIds: src.platformIds
|
||||
platformIds: src.platformIds,
|
||||
appendToBatchId: src.id
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1270,8 +1313,13 @@ export function ImageWorkbenchPage({
|
||||
<button className="gen-img-btn" type="button" title="更多" onClick={() => setOpenMore((k) => (k === cellKey ? "" : cellKey))}>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
{/* YYX#19:删掉「更多」气泡里的加入资产库(与批次操作行重复),只保留删除 */}
|
||||
{/* YYX#20 更正:单图「更多」气泡保留「加入资产库」(单图粒度入库),这里不是重复入口;
|
||||
重复入口是批次底部那个,已在批次气泡里删掉。 */}
|
||||
<div className={`gen-bubble${openMore === cellKey ? " open" : ""}`}>
|
||||
<button type="button" className="gen-bubble-item" onClick={() => { setOpenMore(""); toggleAdoptImage(batch.id, assetId); }}>
|
||||
<Bookmark size={13} />
|
||||
{inLib ? "取消加入资产库" : "加入资产库"}
|
||||
</button>
|
||||
<button type="button" className="gen-bubble-item danger" onClick={() => { setOpenMore(""); removeImageFromBatch(batch.id, assetId); }}>
|
||||
<Trash2 size={13} />
|
||||
删除
|
||||
@@ -1323,16 +1371,12 @@ export function ImageWorkbenchPage({
|
||||
{batch.adopted ? <X size={13} /> : <Check size={13} />}
|
||||
{batch.adopted ? "取消加入资产库" : "加入资产库"}
|
||||
</button>
|
||||
{/* 行32③:批次底部「更多」气泡(删除当前批次 / 加入资产库) */}
|
||||
{/* YYX#20:批次底部「加入资产库」已是上面的独立按钮,这里气泡只留「删除当前批次」,去掉重复入口 */}
|
||||
<div className="gen-batch-more">
|
||||
<button className="btn btn-sm btn-ghost" type="button" title="更多">
|
||||
<MoreHorizontal size={13} />
|
||||
</button>
|
||||
<div className="gen-bubble gen-bubble-up">
|
||||
<button type="button" className="gen-bubble-item" disabled={generating || failed} onClick={() => toggleAdoptBatch(batch.id)}>
|
||||
<Bookmark size={13} />
|
||||
{batch.adopted ? "取消加入资产库" : "加入资产库"}
|
||||
</button>
|
||||
<button type="button" className="gen-bubble-item danger" onClick={() => removeBatch(batch.id)}>
|
||||
<Trash2 size={13} />
|
||||
删除当前批次
|
||||
@@ -1512,10 +1556,6 @@ export function ImageWorkbenchPage({
|
||||
<MoreHorizontal size={13} />
|
||||
</button>
|
||||
<div className="gen-bubble gen-bubble-up">
|
||||
<button type="button" className="gen-bubble-item" disabled={batch.status !== "done"} onClick={() => toggleAdoptBatch(batch.id)}>
|
||||
<Bookmark size={13} />
|
||||
{batch.adopted ? "取消加入资产库" : "加入资产库"}
|
||||
</button>
|
||||
<button type="button" className="gen-bubble-item danger" onClick={() => removeBatch(batch.id)}>
|
||||
<Trash2 size={13} />
|
||||
删除当前批次
|
||||
@@ -1654,6 +1694,7 @@ export function ImageWorkbenchPage({
|
||||
) : (
|
||||
visibleProducts.slice(0, 12).map((item) => {
|
||||
const cover = productCoverUrl(item);
|
||||
const unread = unreadByProduct?.[item.id] || 0; // YYX#row22:该商品未读生成任务数
|
||||
return (
|
||||
<button
|
||||
className={`iw-prod-item ${productId === item.id ? "active" : ""}`}
|
||||
@@ -1662,10 +1703,14 @@ export function ImageWorkbenchPage({
|
||||
onClick={() => setProductId(item.id)}
|
||||
>
|
||||
{cover ? (
|
||||
<div className="thumb has-real-media"><img src={cover} alt={item.title} loading="lazy" /></div>
|
||||
<div className="thumb has-real-media">
|
||||
<img src={cover} alt={item.title} loading="lazy" />
|
||||
{unread > 0 && <span className="iw-prod-unread">{unread > 99 ? "99+" : unread}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="placeholder thumb">
|
||||
<span className="ph-frame">{item.title.slice(0, 2)}</span>
|
||||
{unread > 0 && <span className="iw-prod-unread">{unread > 99 ? "99+" : unread}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="body">
|
||||
@@ -1975,6 +2020,16 @@ export function ImageWorkbenchPage({
|
||||
|
||||
{/* 右 · 结果预览(行31/32/33:多批次列表) */}
|
||||
<div className="iw-preview">
|
||||
{/* YYX#G23:平台套图筛选已开启 → 置顶提示栏(部分内容隐藏),点「清空筛选」退出筛选视图 */}
|
||||
{mode === "cover" && (platformFilter.length > 0 || gridQuery.trim()) && (
|
||||
<div className="iw-filter-notice">
|
||||
<span className="ifn-text">当前已开启筛选,部分内容可能隐藏</span>
|
||||
<button type="button" className="ifn-clear" onClick={() => { setPlatformFilter([]); setGridQuery(""); setSearchOpen(false); }}>
|
||||
<X size={12} />
|
||||
清空筛选
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!hasResults ? (
|
||||
<div className="iw-pv-empty">
|
||||
<div className="mono">// EMPTY STATE</div>
|
||||
|
||||
@@ -8,7 +8,8 @@ import { ConfirmModal, TeamModal } from "../components/overlays";
|
||||
import { Pager } from "../components/pager";
|
||||
|
||||
const MEMBERS_PER_PAGE = 10;
|
||||
const LOGIN_URL = "https://airshelf.com/login";
|
||||
// 登录地址必须是用户当前真实访问的域名(与邀请链接同源),写死的 airshelf.com 不存在 → PMC#16
|
||||
const LOGIN_URL = `${location.origin}/login`;
|
||||
const LIMIT_PRESETS = [1000, 3000, 5000, 10000, -1];
|
||||
|
||||
// 本地随机用户名:前缀 + 4 位数字(与 V1 设计稿一致)
|
||||
@@ -82,8 +83,8 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
user: User;
|
||||
billing: BillingSummary | null;
|
||||
navigate: (page: Page) => void;
|
||||
onCreateMember: (payload: { username: string; password: string; name?: string; role?: string; monthly_credit_limit?: number }) => void | Promise<unknown>;
|
||||
onUpdateMember: (id: string, payload: { role?: string; monthly_credit_limit?: number }) => void | Promise<unknown>;
|
||||
onCreateMember: (payload: { username: string; password: string; name?: string; role?: string; daily_credit_limit?: number; monthly_credit_limit?: number; total_credit_limit?: number }) => void | Promise<unknown>;
|
||||
onUpdateMember: (id: string, payload: { role?: string; daily_credit_limit?: number; monthly_credit_limit?: number; total_credit_limit?: number }) => void | Promise<unknown>;
|
||||
onRemoveMember: (id: string) => void | Promise<unknown>;
|
||||
onResetPassword: (id: string, password: string) => void | Promise<unknown>;
|
||||
onRecharge: (amount: number, bonus: number) => void | Promise<unknown>;
|
||||
@@ -191,9 +192,10 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
function openEdit(member: TeamMember) {
|
||||
setEditTarget(member);
|
||||
setEdRole(member.role === "owner" ? "admin" : member.role || "member");
|
||||
setEdDaily("");
|
||||
// 回填该成员当前三档真实额度(0=不限 → 输入框留空)
|
||||
setEdDaily(Number(member.daily_credit_limit || 0) > 0 ? String(Number(member.daily_credit_limit)) : "");
|
||||
setEdMonthly(Number(member.monthly_credit_limit || 0) > 0 ? String(Number(member.monthly_credit_limit)) : "");
|
||||
setEdTotal("-1");
|
||||
setEdTotal(Number(member.total_credit_limit || 0) > 0 ? String(Number(member.total_credit_limit)) : "");
|
||||
}
|
||||
|
||||
function openLimit() {
|
||||
@@ -209,8 +211,10 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
password: cuPass,
|
||||
name: cuName.trim() || undefined,
|
||||
role: cuRole,
|
||||
// 后端成员额度仅 monthly_credit_limit;每日/总额为展示档,不入参
|
||||
monthly_credit_limit: Number(cuMonthly) || 0
|
||||
// 三档额度均真实入库:-1/空 = 不限(后端归一成 0)。PMC#17/#18
|
||||
daily_credit_limit: cuDaily.trim() === "" ? -1 : Number(cuDaily),
|
||||
monthly_credit_limit: cuMonthly.trim() === "" ? -1 : Number(cuMonthly),
|
||||
total_credit_limit: cuTotal.trim() === "" ? -1 : Number(cuTotal)
|
||||
});
|
||||
// 创建失败(action 报错返回 null)时保持弹窗与已填内容,只成功才关闭并清空
|
||||
if (!created) return;
|
||||
@@ -228,7 +232,13 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
|
||||
async function submitEdit() {
|
||||
if (!editTarget) return;
|
||||
await onUpdateMember(editTarget.id, { role: edRole, monthly_credit_limit: Number(edMonthly) || 0 });
|
||||
// 三档额度均下发:留空 = 不限(-1,后端归一成 0)。PMC#17/#18
|
||||
await onUpdateMember(editTarget.id, {
|
||||
role: edRole,
|
||||
daily_credit_limit: edDaily.trim() === "" ? -1 : Number(edDaily),
|
||||
monthly_credit_limit: edMonthly.trim() === "" ? -1 : Number(edMonthly),
|
||||
total_credit_limit: edTotal.trim() === "" ? -1 : Number(edTotal)
|
||||
});
|
||||
setEditTarget(null);
|
||||
}
|
||||
|
||||
@@ -375,6 +385,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
<th>角色</th>
|
||||
<th>每日额度</th>
|
||||
<th>月度额度</th>
|
||||
<th>总额度</th>
|
||||
<th style={{ width: "140px" }}>当月已用</th>
|
||||
<th style={{ textAlign: "right", width: "88px" }}>操作</th>
|
||||
</tr>
|
||||
@@ -387,6 +398,10 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
const displayName = rawName && !rawName.includes("@") ? rawName : (email ? email.split("@")[0] : (rawName || "成员"));
|
||||
const showEmail = !!email && email.toLowerCase() !== displayName.toLowerCase();
|
||||
const role = roleUi(member.role);
|
||||
const daily = Number(member.daily_credit_limit || 0);
|
||||
const totalLimit = Number(member.total_credit_limit || 0);
|
||||
const dayUsed = Number(member.day_charged || 0);
|
||||
const totalUsed = Number(member.total_charged || 0);
|
||||
const monthly = Number(member.monthly_credit_limit || 0);
|
||||
const memberUsed = Number(member.month_charged || 0);
|
||||
// 月度不限时按团队月限额/余额作分母给参考进度;有消费即显可见细条,避免「用了钱进度条却空白」
|
||||
@@ -399,8 +414,9 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
<tr key={member.id} data-id={member.id}>
|
||||
<td><span className="member-cell"><span className="av">{displayName.slice(0, 1).toUpperCase()}</span><span className="member-meta"><span className="nm">{displayName}</span>{showEmail && <span className="em">{email}</span>}</span></span></td>
|
||||
<td><span className={`role-pill role-${role.key}`}><span className="dot"></span>{role.label}</span></td>
|
||||
<td><span className="quota-cell"><span className="v">不限</span></span></td>
|
||||
<td><span className="quota-cell"><span className="v">{daily > 0 ? money(daily) : "不限"}</span>{daily > 0 && <span className="lbl">今日用 {money(dayUsed)}</span>}</span></td>
|
||||
<td><span className="quota-cell"><span className="v">{monthly > 0 ? money(monthly) : "不限"}</span></span></td>
|
||||
<td><span className="quota-cell"><span className="v">{totalLimit > 0 ? money(totalLimit) : "不限"}</span>{totalLimit > 0 && <span className="lbl">累计用 {money(totalUsed)}</span>}</span></td>
|
||||
<td><div className="quota-cell"><span className="v">{money(memberUsed)}</span> <span className="lbl">/ {monthly > 0 ? `${memberPct.toFixed(0)}%` : "不限"}</span></div><div className="used-bar"><span className={barClass} style={{ width: `${barWidth}%` }}></span></div></td>
|
||||
<td><div className="acts">{isOwner
|
||||
? <span style={{ fontFamily: "var(--font-mono)", fontSize: "12px", color: "var(--black-alpha-32)", alignSelf: "center" }}>不可编辑</span>
|
||||
|
||||
@@ -16,8 +16,12 @@ export type TeamMember = {
|
||||
id: string;
|
||||
role: string;
|
||||
status: string;
|
||||
daily_credit_limit?: string;
|
||||
monthly_credit_limit: string;
|
||||
total_credit_limit?: string;
|
||||
month_charged?: string;
|
||||
day_charged?: string;
|
||||
total_charged?: string;
|
||||
user: User;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user