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:
+85
-35
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user