fix(core): 测试清单 行4-15 — 记住我7天/工作台布局/商品创建选择弹窗/上传图持久化/清空筛选

- 记住我7天:勾选才持久化token+预填用户名,7天过期强制重登(localStorage vs sessionStorage)
- 工作台:最近项目进度/状态列对齐、快捷&提示卡片圆角边框、余额按钮金额左对齐
- 商品创建成功改为复用弹窗,选择 继续创建/去新建项目;主图随创建上传持久化
- 商品卡显示真实主图、点视频进详情视频项目tab
- 商品库/视频项目补 清空筛选 按钮

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-16 14:13:52 +08:00
co-authored by Claude Opus 4.8
parent b2288a2652
commit 57018fb101
10 changed files with 290 additions and 72 deletions
+85 -35
View File
@@ -63,6 +63,34 @@ const crumbLabels: Partial<Record<Page, string>> = {
settingsNotify: "设置"
};
/* 图片生成工作台·跨刷新持久化:把在跑的任务 id + 已出结果按 mode 存本地,
刷新后可恢复"生成中"占位并继续轮询(worker 在后台出图,任务永不丢)。 */
const imgwbKey = (mode?: string) => `airshelf:imgwb:${mode || "image"}`;
type ImgwbSaved = { pending?: string[]; results?: Asset[]; count?: number; ts?: number };
function loadImgwb(mode?: string): ImgwbSaved | null {
try {
const raw = localStorage.getItem(imgwbKey(mode));
if (!raw) return null;
const saved = JSON.parse(raw) as ImgwbSaved;
// 过期保护:1 小时前的残留不再恢复,避免显示陈旧"生成中"
if (saved.ts && Date.now() - saved.ts > 60 * 60 * 1000) {
localStorage.removeItem(imgwbKey(mode));
return null;
}
return saved;
} catch {
return null;
}
}
function saveImgwb(mode: string | undefined, patch: ImgwbSaved) {
try {
const prev = loadImgwb(mode) || {};
localStorage.setItem(imgwbKey(mode), JSON.stringify({ ...prev, ...patch, ts: Date.now() }));
} catch {
/* localStorage 不可用时静默降级,不影响生成 */
}
}
export function App() {
const [route, setRoute] = useState<ResolvedRoute>(() => resolveRoute());
const page = route.page;
@@ -308,7 +336,8 @@ export function App() {
setNotice(null);
try {
const result = await work();
setNotice({ type: "success", text: successText });
// successText 为空 → 不弹 toast(交给调用方自定义反馈,如成功弹窗)
if (successText) setNotice({ type: "success", text: successText });
await loadData();
await refreshProjectDetail();
return result;
@@ -360,32 +389,50 @@ export function App() {
// worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见。
return action(async () => {
const { tasks } = await api.submitGenerateImage(payload);
const pending = new Set(tasks.map((t) => t.id));
if (pending.size === 0) throw new Error("未能提交生成任务");
const TERMINAL = new Set(["succeeded", "failed", "cancelled", "compensating"]);
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const assets: Asset[] = [];
let lastErr = "";
const deadline = Date.now() + 6 * 60 * 1000; // 兜底:6 分钟还没全好就停止轮询(图仍会在后台出完)
while (pending.size > 0 && Date.now() < deadline) {
await sleep(2500);
const res = await api.generateImageStatus([...pending]);
for (const t of res.tasks) {
if (!TERMINAL.has(t.status)) continue;
pending.delete(t.id);
if (t.status === "succeeded") assets.push(...(t.assets || []));
else if (t.error_message) lastErr = t.error_message;
}
}
if (assets.length === 0) {
throw new Error(lastErr || (pending.size > 0 ? "生成超时,图片仍在后台生成,稍后可在素材库查看" : "未生成任何图片"));
}
return { assets };
const ids = tasks.map((t) => t.id);
if (ids.length === 0) throw new Error("未能提交生成任务");
// 提交成功即落盘:刷新页面也能恢复"生成中"并继续轮询(任务在 worker 里跑,关掉浏览器也不丢)
saveImgwb(payload.mode, { pending: ids, results: [], count: payload.count });
return pollImageTasks(payload.mode, ids);
}, "图片已生成");
}
async function onAuthed(payload: { token: string; user: User; team: Team }) {
setToken(payload.token);
// 轮询一批已提交的生图任务直到全部出图/超时;每出一张就回写本地,刷新后可恢复。
async function pollImageTasks(mode: string | undefined, ids: string[]): Promise<{ assets: Asset[] }> {
const pending = new Set(ids);
const TERMINAL = new Set(["succeeded", "failed", "cancelled", "compensating"]);
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const assets: Asset[] = [];
let lastErr = "";
const deadline = Date.now() + 6 * 60 * 1000; // 兜底:6 分钟还没全好就停止轮询(图仍会在后台出完)
while (pending.size > 0 && Date.now() < deadline) {
await sleep(2500);
const res = await api.generateImageStatus([...pending]);
for (const t of res.tasks) {
if (!TERMINAL.has(t.status)) continue;
pending.delete(t.id);
if (t.status === "succeeded") assets.push(...(t.assets || []));
else if (t.error_message) lastErr = t.error_message;
}
// 进度回写:保留还在跑的 id + 已出结果,供刷新后恢复
saveImgwb(mode, { pending: [...pending], results: assets });
}
if (assets.length === 0) {
// 没出任何图:清掉本地残留,避免刷新后卡在空"生成中"
if (pending.size === 0) saveImgwb(mode, { pending: [], results: [] });
throw new Error(lastErr || (pending.size > 0 ? "生成超时,图片仍在后台生成,稍后可在素材库查看" : "未生成任何图片"));
}
saveImgwb(mode, { pending: [...pending], results: assets });
return { assets };
}
// 刷新后恢复:对本地残留的 pending 任务继续轮询(不再重复提交、不再重复扣费)。
function resumeImages(mode: string | undefined, ids: string[]) {
return pollImageTasks(mode, ids).catch(() => null);
}
async function onAuthed(payload: { token: string; user: User; team: Team; remember?: boolean }) {
setToken(payload.token, payload.remember ?? true);
setUser(payload.user);
setTeam(payload.team);
setBooting(false);
@@ -451,35 +498,38 @@ export function App() {
<ProductsPage
products={products}
projects={projects}
assets={assets}
navigate={navigate}
openProduct={(productId) => navigate("productDetail", { productId })}
onCreate={(payload) => action(() => api.createProduct(payload), "商品已创建")}
openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })}
onCreate={(payload) => action(() => api.createProduct(payload), "")}
onUploadImage={(productId, formData) => action(() => api.uploadProductImage(productId, formData), "")}
onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")}
/>
);
case "productCreateUpload":
// 设计稿:新建商品是商品库页上的右侧 Drawer(非独立整页),进入即自动打开
// 创建成功后由 ProductsPage 弹「继续创建商品 / 去新建项目」选择弹窗,不再自动跳详情
return (
<ProductsPage
products={products}
projects={projects}
assets={assets}
navigate={navigate}
openProduct={(productId) => navigate("productDetail", { productId })}
onCreate={async (payload) => {
const created = await action(() => api.createProduct(payload), "商品已创建");
if (created) navigate("productDetail", { productId: created.id });
}}
openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })}
onCreate={(payload) => action(() => api.createProduct(payload), "")}
onUploadImage={(productId, formData) => action(() => api.uploadProductImage(productId, formData), "")}
onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")}
autoOpenCreate
/>
);
case "productDetail":
if (!activeProduct) return <ProductsPage products={products} navigate={navigate} openProduct={(productId) => navigate("productDetail", { productId })} onCreate={(payload) => action(() => api.createProduct(payload), "商品已创建")} onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} />;
if (!activeProduct) return <ProductsPage products={products} assets={assets} navigate={navigate} openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} onCreate={(payload) => action(() => api.createProduct(payload), "")} onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} />;
return (
<ProductDetailPage
product={activeProduct}
projects={projects.filter((project) => project.product === activeProduct.id)}
assets={assets}
initialTab={route.hash === "videos" ? "videos" : "assets"}
navigate={navigate}
onUpdate={(payload) => action(() => api.updateProduct(activeProduct.id, payload), "商品已更新")}
onUploadImage={(formData) => action(() => api.uploadProductImage(activeProduct.id, formData), "商品图已上传")}
@@ -568,11 +618,11 @@ export function App() {
case "assetFactory":
return <AssetFactoryPage navigate={navigate} aiTasks={aiTasks} assets={assets} />;
case "imageOptimize":
return <ImageWorkbenchPage mode="image" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />;
return <ImageWorkbenchPage mode="image" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
case "modelPhoto":
return <ImageWorkbenchPage mode="model" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />;
return <ImageWorkbenchPage mode="model" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
case "platformCover":
return <ImageWorkbenchPage mode="cover" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />;
return <ImageWorkbenchPage mode="cover" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
case "modelPhotoDemoA":
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => navigate("modelPhoto")} navigate={navigate} />;
case "modelPhotoDemoB":