diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index f110241..2a58ede 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -63,6 +63,34 @@ const crumbLabels: Partial> = { 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(() => 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() { 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 ( 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 navigate("productDetail", { productId })} onCreate={(payload) => action(() => api.createProduct(payload), "商品已创建")} onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} />; + if (!activeProduct) return navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} onCreate={(payload) => action(() => api.createProduct(payload), "")} onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} />; return ( 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 ; case "imageOptimize": - return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />; + return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; case "modelPhoto": - return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />; + return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; case "platformCover": - return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />; + return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; case "modelPhotoDemoA": return navigate("modelPhoto")} navigate={navigate} />; case "modelPhotoDemoB": diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index be50f45..8dcfb11 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -23,6 +23,28 @@ import type { const API_BASE = import.meta.env.VITE_API_BASE_URL || ""; const TOKEN_KEY = "airshelf_token"; +const REMEMBER_KEY = "airshelf_remember"; // { username, expireAt } · 勾选「记住我 7 天」时写 +const REMEMBER_DAYS = 7; + +export type RememberInfo = { username: string; expireAt: number }; + +export function getRemember(): RememberInfo | null { + try { + const raw = localStorage.getItem(REMEMBER_KEY); + return raw ? (JSON.parse(raw) as RememberInfo) : null; + } catch { + return null; + } +} + +// 登录成功后调用:勾选记住我则存用户名 + 7 天过期点;否则清除 +export function setRemember(username: string | null, remember: boolean) { + if (remember && username) { + localStorage.setItem(REMEMBER_KEY, JSON.stringify({ username, expireAt: Date.now() + REMEMBER_DAYS * 86400000 })); + } else { + localStorage.removeItem(REMEMBER_KEY); + } +} export class ApiError extends Error { status: number; @@ -35,12 +57,24 @@ export class ApiError extends Error { } export function getToken() { - return localStorage.getItem(TOKEN_KEY); + // 「记住我 7 天」过期则强制重新登录 + const remembered = getRemember(); + if (remembered && remembered.expireAt && Date.now() > remembered.expireAt) { + localStorage.removeItem(TOKEN_KEY); + sessionStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(REMEMBER_KEY); + return null; + } + // 记住我 → localStorage(跨会话);不记住 → sessionStorage(关浏览器即失效) + return localStorage.getItem(TOKEN_KEY) || sessionStorage.getItem(TOKEN_KEY); } -export function setToken(token: string | null) { - if (token) localStorage.setItem(TOKEN_KEY, token); - else localStorage.removeItem(TOKEN_KEY); +export function setToken(token: string | null, remember = true) { + localStorage.removeItem(TOKEN_KEY); + sessionStorage.removeItem(TOKEN_KEY); + if (!token) return; + if (remember) localStorage.setItem(TOKEN_KEY, token); + else sessionStorage.setItem(TOKEN_KEY, token); } async function request(path: string, options: RequestInit = {}): Promise { diff --git a/core/frontend/src/components/overlays.tsx b/core/frontend/src/components/overlays.tsx index 0bbc26c..e2cbbf9 100644 --- a/core/frontend/src/components/overlays.tsx +++ b/core/frontend/src/components/overlays.tsx @@ -1,6 +1,6 @@ import { useEffect, type ReactNode } from "react"; import { createPortal } from "react-dom"; -import { Music, Shield, X } from "lucide-react"; +import { CheckCircle2, Music, Shield, X } from "lucide-react"; // 浮层打开时锁住 body 滚动(否则滚轮会滚动遮罩后面的页面,体感"遮罩没盖住")。 // 多浮层叠开用计数,最后一个关闭才解锁。 @@ -119,6 +119,29 @@ export function ConfirmModal({ open, title, detail, confirmText, onCancel, onCon ); } +/* 成功提示弹窗(复用 .modal 设计系统)· 标题 + 说明 + 自定义动作按钮组 */ +export function SuccessModal({ open, title, detail, actions, close }: { + open: boolean; + title: string; + detail: string; + actions: ReactNode; + close: () => void; +}) { + useBodyScrollLock(open); + if (!open) return null; + return createPortal( +
+
event.stopPropagation()}> + ++ +
{title}// SUCCESS
+
{detail}
+
{actions}
+
+
, + document.body, + ); +} + export function Drawer({ title, open, close, children }: { title: string; open: boolean; close: () => void; children: ReactNode }) { useBodyScrollLock(open); if (!open) return null; diff --git a/core/frontend/src/design-restraint.css b/core/frontend/src/design-restraint.css index 7f2d241..a477822 100644 --- a/core/frontend/src/design-restraint.css +++ b/core/frontend/src/design-restraint.css @@ -721,6 +721,8 @@ main { position: relative; background: var(--background-base); min-width: 0; } position: relative; cursor: pointer; transition: background var(--t-base); + /* 余额是 - - diff --git a/core/frontend/src/routes/auth-screen.tsx b/core/frontend/src/routes/auth-screen.tsx index 0bfc745..efb0ab5 100644 --- a/core/frontend/src/routes/auth-screen.tsx +++ b/core/frontend/src/routes/auth-screen.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import type { FormEvent } from "react"; import { Eye, EyeOff, LockKeyhole, UserRound } from "lucide-react"; -import { api } from "../api"; +import { api, getRemember, setRemember as persistRemember } from "../api"; import type { Team, User } from "../types"; import type { AuthMode } from "./route-config"; @@ -36,10 +36,12 @@ export function AuthScreen({ }: { initialMode: AuthMode; onModeChange: (mode: AuthMode) => void; - onAuthed: (payload: { token: string; user: User; team: Team }) => void | Promise; + onAuthed: (payload: { token: string; user: User; team: Team; remember?: boolean }) => void | Promise; }) { - const [username, setUsername] = useState(""); + const remembered = getRemember(); + const [username, setUsername] = useState(remembered?.username || ""); const [password, setPassword] = useState(""); + const [remember, setRemember] = useState(Boolean(remembered)); const [showPwd, setShowPwd] = useState(false); const [fieldErrors, setFieldErrors] = useState({}); const [error, setError] = useState(""); @@ -92,12 +94,14 @@ export function AuthScreen({ try { const checkingStartedAt = Date.now(); const payload = await api.login({ username: username.trim(), password }); + // 记住我:勾选则存用户名 + 7 天有效期,token 落 localStorage;否则只存当前会话 + persistRemember(username.trim(), remember); await completeProgressStage(checkingStartedAt); setLoginProgress("verified"); await wait(LOGIN_PROGRESS_STAGE_MS); setLoginProgress("entering"); await wait(LOGIN_PROGRESS_STAGE_MS); - await onAuthed(payload); + await onAuthed({ ...payload, remember }); } catch (err) { setLoginProgress(null); const raw = err instanceof Error ? err.message : ""; @@ -167,7 +171,7 @@ export function AuthScreen({ diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx index 23e4b0e..8e5e58a 100644 --- a/core/frontend/src/routes/products.tsx +++ b/core/frontend/src/routes/products.tsx @@ -1,8 +1,8 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react"; import { createPortal } from "react-dom"; -import { ArrowLeft } from "lucide-react"; -import { ConfirmModal, MediaLightbox, useBodyScrollLock } from "../components/overlays"; +import { ArrowLeft, X } from "lucide-react"; +import { ConfirmModal, MediaLightbox, SuccessModal, useBodyScrollLock } from "../components/overlays"; import { Pager } from "../components/pager"; const PROD_PAGE_SIZE = 10; @@ -38,12 +38,23 @@ function productCover(name: string): string { } const prodMock = (file: string): CSSProperties => ({ ["--mock-media-url"]: `url(/exact/assets/mock/${file})` } as CSSProperties); -export function ProductsPage({ products, projects = [], navigate, openProduct, onCreate, onDelete, autoOpenCreate = false }: { +// 卡片真图:优先 cover_asset,其次首张商品图的 asset,反查团队 assets 的真 preview_url +function resolveCoverUrl(product: Product, assetById: Map): string { + const firstImage = [...(product.images || [])].sort((a, b) => a.sort_order - b.sort_order)[0]; + const id = product.cover_asset || firstImage?.asset; + if (!id) return ""; + const asset = assetById.get(id); + return asset?.files?.find((file) => file.is_primary)?.preview_url || asset?.files?.[0]?.preview_url || ""; +} + +export function ProductsPage({ products, projects = [], assets = [], navigate, openProduct, onCreate, onUploadImage, onDelete, autoOpenCreate = false }: { products: Product[]; projects?: Project[]; + assets?: Asset[]; navigate: (page: Page) => void; - openProduct: (productId: string) => void; - onCreate: (payload: ProductPayload) => Promise | void; + openProduct: (productId: string, tab?: "assets" | "videos") => void; + onCreate: (payload: ProductPayload) => Promise | void; + onUploadImage?: (productId: string, formData: FormData) => Promise | void; onDelete?: (id: string) => Promise | void; autoOpenCreate?: boolean; }) { @@ -70,19 +81,22 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o }; const [drawer, setDrawer] = useState(Boolean(autoOpenCreate)); useBodyScrollLock(drawer); + // 创建成功弹窗:存刚创建的商品名,非空即展示「继续创建 / 去新建项目」选择 + const [createdName, setCreatedName] = useState(null); const [title, setTitle] = useState(""); const [category, setCategory] = useState(""); const [target, setTarget] = useState(""); const [bullets, setBullets] = useState([]); const [bulletDraft, setBulletDraft] = useState(""); - // 新建抽屉:主图选择(选图即预览,创建商品后到详情页上传)+ 使用指南面板 + // 新建抽屉:主图选择(选图即预览,创建成功后随商品一起上传持久化)+ 使用指南面板 const [imagePreview, setImagePreview] = useState(""); + const [imageFile, setImageFile] = useState(null); const imgInputRef = useRef(null); const [showGuide, setShowGuide] = useState(false); const [titleError, setTitleError] = useState(false); function pickImage(event: ChangeEvent) { const file = event.target.files?.[0]; - if (file) setImagePreview(URL.createObjectURL(file)); + if (file) { setImagePreview(URL.createObjectURL(file)); setImageFile(file); } event.target.value = ""; } const [openChip, setOpenChip] = useState<"" | "cat" | "date">(""); @@ -125,6 +139,8 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o return matchQuery && matchCat && matchDate; }); // 分页:每页 10 个,搜索/筛选变化回第 1 页 + // 卡片真图:把团队 assets 做 id→asset 映射,卡片用 cover_asset / 首图 asset 反查真 preview_url + const assetById = useMemo(() => new Map(assets.map((asset) => [asset.id, asset])), [assets]); const [page, setPage] = useState(1); useEffect(() => { setPage(1); }, [query, catFilter, dateFilter]); const totalPages = Math.max(1, Math.ceil(filtered.length / PROD_PAGE_SIZE)); @@ -150,18 +166,31 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o setBullets([]); setBulletDraft(""); setImagePreview(""); + setImageFile(null); setTitleError(false); } - function submit() { - if (!title.trim()) { setTitleError(true); return; } // 空名:给必填校验提示(不再静默无反应) - onCreate({ - title: title.trim(), + async function submit() { + const name = title.trim(); + if (!name) { setTitleError(true); return; } // 空名:给必填校验提示(不再静默无反应) + const file = imageFile; + const created = await onCreate({ + title: name, category: category || PC_CAT_OPTIONS[0], target_audience: target, selling_points: bullets.map((item, index) => ({ title: item, detail: item, sort_order: index })) }); setDrawer(false); resetForm(); + if (!created) return; + // 把抽屉里选的主图随商品一起上传持久化(原先只做了本地预览 → 卡片/详情看不到图) + if (file && onUploadImage) { + const fd = new FormData(); + fd.append("file", file); + fd.append("name", `${name}-主图`); + await onUploadImage(created.id, fd); + } + // 创建成功 → 弹「继续创建商品 / 去新建项目」选择弹窗(替代纯 toast) + setCreatedName(name); } return ( @@ -217,6 +246,11 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o ))} + {(query || catFilter || dateFilter !== "all") && ( + + )}
@@ -229,10 +263,12 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o p.product === product.id).length} editMode={editMode} selected={selected.has(product.id)} onOpen={() => (editMode ? toggleSelect(product.id) : openProduct(product.id))} + onOpenVideos={() => openProduct(product.id, "videos")} onDelete={onDelete ? () => setConfirmIds([product.id]) : undefined} /> ))} @@ -267,6 +303,19 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o onConfirm={doDelete} /> + setCreatedName(null)} + actions={ + <> + + + + } + /> + {/* 新建商品 · 右侧 Drawer · portal 到 body,脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏(转写自 products.html #pc-drawer) */} {createPortal( <> @@ -378,14 +427,19 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o ); } -export function ProductCard({ product, videoCount = 0, onOpen, editMode = false, selected = false, onDelete }: { product: Product; videoCount?: number; onOpen: () => void; editMode?: boolean; selected?: boolean; onDelete?: () => void }) { - const cover = productCover(product.title); +export function ProductCard({ product, coverUrl = "", videoCount = 0, onOpen, onOpenVideos, editMode = false, selected = false, onDelete }: { product: Product; coverUrl?: string; videoCount?: number; onOpen: () => void; onOpenVideos?: () => void; editMode?: boolean; selected?: boolean; onDelete?: () => void }) { + // 优先显示真实上传/采用的主图;无真图时回退到按标题匹配的 mock 占位 + const mock = productCover(product.title); const assetCount = product.images?.length || 0; return (
event.key === "Enter" && onOpen()}> -
{product.title} · 1200×800
+ {coverUrl ? ( +
{product.title}
+ ) : ( +
{product.title} · 1200×800
+ )}
{product.title}
{product.category || "未分类"}
@@ -397,10 +451,10 @@ export function ProductCard({ product, videoCount = 0, onOpen, editMode = false, 素材 {assetCount} · - +
); @@ -590,17 +644,18 @@ function pdProjStatusLabel(project: Project) { } function pdProjPillClass(project: Project) { return project.status === "completed" ? "ok" : project.status === "failed" ? "err" : "info"; } -export function ProductDetailPage({ product, projects, assets, navigate, onUpdate, onUploadImage, onDeleteImage, onGenerateImages }: { +export function ProductDetailPage({ product, projects, assets, initialTab = "assets", navigate, onUpdate, onUploadImage, onDeleteImage, onGenerateImages }: { product: Product; projects: Project[]; assets: Asset[]; + initialTab?: "assets" | "videos"; navigate: (page: Page) => void; onUpdate: (payload: Partial) => Promise | void; onUploadImage?: (formData: FormData) => Promise | void; onDeleteImage?: (imageId: string) => Promise | void; onGenerateImages?: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) => Promise<{ assets: Asset[] } | null>; }) { - const [tab, setTab] = useState<"assets" | "videos">("assets"); + const [tab, setTab] = useState<"assets" | "videos">(initialTab); const [editing, setEditing] = useState(false); const [triOpen, setTriOpen] = useState(false); const imgInputRef = useRef(null); diff --git a/core/frontend/src/routes/projects.tsx b/core/frontend/src/routes/projects.tsx index 65e86c8..1b7c8ad 100644 --- a/core/frontend/src/routes/projects.tsx +++ b/core/frontend/src/routes/projects.tsx @@ -561,6 +561,11 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel ))}
+ {(query || catFilter || sourceFilter !== "all" || timeFilter !== "all" || tab !== "all") && ( + + )}