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:
zyc
2026-06-27 18:27:15 +08:00
co-authored by Claude Opus 4.8
parent 2e1c28abea
commit 6e7425048c
19 changed files with 483 additions and 98 deletions
+34 -4
View File
@@ -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">