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":
|
||||
|
||||
@@ -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<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
|
||||
@@ -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(
|
||||
<div className="modal-bg show" onClick={close}>
|
||||
<div className="modal" onClick={(event) => event.stopPropagation()}>
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h"><div className="ic-m"><CheckCircle2 size={16} /></div><div className="ti">{title}<span>// SUCCESS</span></div><button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
|
||||
<div className="modal-b">{detail}</div>
|
||||
<div className="modal-f">{actions}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function Drawer({ title, open, close, children }: { title: string; open: boolean; close: () => void; children: ReactNode }) {
|
||||
useBodyScrollLock(open);
|
||||
if (!open) return null;
|
||||
|
||||
@@ -721,6 +721,8 @@ main { position: relative; background: var(--background-base); min-width: 0; }
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background var(--t-base);
|
||||
/* 余额是 <button class="stat">,浏览器默认 text-align:center 会让金额居中;强制左对齐与其他 KPI 一致 */
|
||||
text-align: left;
|
||||
}
|
||||
.stat:hover { background: var(--black-alpha-4); }
|
||||
.stat:last-child { border-right: 0; }
|
||||
@@ -832,6 +834,9 @@ main { position: relative; background: var(--background-base); min-width: 0; }
|
||||
.chip:hover { background: var(--black-alpha-4); border-color: var(--black-alpha-24); color: var(--accent-black); }
|
||||
.chip.active { border-color: var(--heat-40); color: var(--heat); background: var(--heat-12); }
|
||||
.chip svg { width: 12px; height: 12px; }
|
||||
/* 清空筛选:幽灵态(无边框/透明底),仅在有筛选时出现,点击重置全部筛选 */
|
||||
.chip.chip-clear { border-color: transparent; background: transparent; color: var(--black-alpha-48); padding: 0 8px; }
|
||||
.chip.chip-clear:hover { background: var(--black-alpha-4); color: var(--accent-black); }
|
||||
|
||||
/* ─── Toolbar ─── */
|
||||
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; flex-wrap: wrap; }
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
.product-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background .15s, border-color .15s; position: relative; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.product-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }
|
||||
.product-thumb { aspect-ratio: 1.4 / 1; }
|
||||
/* 真实主图:铺满缩略区(等比裁切),不再露白底/占位文字 */
|
||||
.product-thumb.has-real-media { position: relative; overflow: hidden; background: var(--background-lighter); }
|
||||
.product-thumb.has-real-media img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.product-body { padding: 14px 14px 12px; flex: 1; }
|
||||
.product-name { font-size: 14px; font-weight: 600; color: var(--accent-black); line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.product-cat { display: inline-flex; align-items: center; margin-top: 8px; padding: 2px 8px; background: var(--background-lighter); color: var(--black-alpha-72); border-radius: var(--r-sm); font-size: 12px; }
|
||||
@@ -18,6 +21,9 @@
|
||||
|
||||
.product-footer { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; column-gap: 8px; padding: 10px 12px; border-top: 1px solid var(--border-faint); font-size: 12px; color: #6f6f6f; background: var(--background-base); }
|
||||
.product-footer .stat { display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 3px 8px; border-radius: var(--r-sm); font-family: var(--font-mono); letter-spacing: .02em; white-space: nowrap; border: 1px solid transparent; justify-self: center; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
|
||||
/* 视频计数是按钮:点击进详情视频项目 tab,hover 提示可点 */
|
||||
.product-footer button.stat-link { background: transparent; cursor: pointer; color: inherit; }
|
||||
.product-footer button.stat-link:hover { background: var(--background-lighter); border-color: var(--border-faint); color: var(--accent-black); }
|
||||
.product-footer .stat svg { width: 14px; height: 14px; color: currentColor; flex-shrink: 0; stroke-width: 1.25; transition: color var(--t-base); }
|
||||
.product-footer .stat b { color: var(--accent-black); font-weight: 600; transition: color var(--t-base); }
|
||||
.product-footer .sep { color: #b8b8b8; font-family: var(--font-mono); flex-shrink: 0; }
|
||||
|
||||
@@ -461,7 +461,8 @@ export function ImageWorkbenchPage({
|
||||
modelConfigs,
|
||||
onBack,
|
||||
navigate,
|
||||
onGenerate
|
||||
onGenerate,
|
||||
onResume
|
||||
}: {
|
||||
mode: WorkMode;
|
||||
products: Product[];
|
||||
@@ -470,6 +471,7 @@ export function ImageWorkbenchPage({
|
||||
onBack: () => void;
|
||||
navigate?: (page: Page) => void;
|
||||
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) => Promise<{ assets: Asset[] } | null>;
|
||||
onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
|
||||
}) {
|
||||
const meta = MODE_META[mode];
|
||||
const [productId, setProductId] = useState(products[0]?.id || "");
|
||||
@@ -532,6 +534,37 @@ export function ImageWorkbenchPage({
|
||||
}
|
||||
}
|
||||
|
||||
/* 跨刷新恢复:读本地残留(由 App.generateImages 落盘),先回显已出的图,
|
||||
再对还在跑的任务继续轮询——刷新页面不再丢"生成中"与已出结果。 */
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
try {
|
||||
const raw = localStorage.getItem(`airshelf:imgwb:${mode}`);
|
||||
if (!raw) return;
|
||||
const saved = JSON.parse(raw) as { pending?: string[]; results?: Asset[]; ts?: number };
|
||||
if (saved.ts && Date.now() - saved.ts > 60 * 60 * 1000) return; // 1 小时前的残留不恢复
|
||||
if (saved.results && saved.results.length) setResults(saved.results);
|
||||
const pending = saved.pending || [];
|
||||
if (pending.length && onResume) {
|
||||
setGenerating(true);
|
||||
onResume(mode, pending)
|
||||
.then((res) => {
|
||||
if (!cancelled && res?.assets) setResults(res.assets);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setGenerating(false);
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* 残留解析失败则忽略,按初始态渲染 */
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// 仅在挂载/切换 mode 时恢复一次
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode]);
|
||||
|
||||
const hasResults = !!(results && results.length > 0);
|
||||
|
||||
/* ── 共享:生成结果网格(§4.18 .gen-card · 三 mode 统一渲染真图)── */
|
||||
@@ -1002,15 +1035,15 @@ export function ImageWorkbenchPage({
|
||||
</div>
|
||||
<div className="gen-card">{renderResultGrid()}</div>
|
||||
<div className="gen-card-actions">
|
||||
<button className="btn btn-sm" type="button">
|
||||
<button className="btn btn-sm" type="button" disabled={generating} onClick={() => runGenerate()}>
|
||||
<RefreshCw size={13} />
|
||||
重跑
|
||||
</button>
|
||||
<button className="btn btn-sm" type="button">
|
||||
<button className="btn btn-sm" type="button" onClick={() => navigate?.("library")}>
|
||||
<Check size={13} />
|
||||
采用并入资产库
|
||||
</button>
|
||||
<button className="btn btn-sm btn-ghost" type="button" title="更多">
|
||||
<button className="btn btn-sm btn-ghost" type="button" title="下载全部" onClick={() => results?.forEach((asset, i) => { const u = asset.files?.[0]?.preview_url; if (u) downloadImage(u, `${meta.title}-${i + 1}.png`); })}>
|
||||
<MoreHorizontal size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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<void>;
|
||||
onAuthed: (payload: { token: string; user: User; team: Team; remember?: boolean }) => void | Promise<void>;
|
||||
}) {
|
||||
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<FieldErrors>({});
|
||||
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({
|
||||
</div>
|
||||
</div>
|
||||
<div className="row-between">
|
||||
<label><input type="checkbox" defaultChecked /> 记住我 7 天</label>
|
||||
<label><input type="checkbox" checked={remember} onChange={(event) => setRemember(event.target.checked)} /> 记住我 7 天</label>
|
||||
<a href="#" onClick={(event) => { event.preventDefault(); showToast("重置密码", "请联系团队超管重置你的登录密码"); }}>忘记密码?</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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, Asset>): 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<unknown> | void;
|
||||
openProduct: (productId: string, tab?: "assets" | "videos") => void;
|
||||
onCreate: (payload: ProductPayload) => Promise<Product | null | undefined> | void;
|
||||
onUploadImage?: (productId: string, formData: FormData) => Promise<unknown> | void;
|
||||
onDelete?: (id: string) => Promise<unknown> | 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<string | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [target, setTarget] = useState("");
|
||||
const [bullets, setBullets] = useState<string[]>([]);
|
||||
const [bulletDraft, setBulletDraft] = useState("");
|
||||
// 新建抽屉:主图选择(选图即预览,创建商品后到详情页上传)+ 使用指南面板
|
||||
// 新建抽屉:主图选择(选图即预览,创建成功后随商品一起上传持久化)+ 使用指南面板
|
||||
const [imagePreview, setImagePreview] = useState<string>("");
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const imgInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [showGuide, setShowGuide] = useState(false);
|
||||
const [titleError, setTitleError] = useState(false);
|
||||
function pickImage(event: ChangeEvent<HTMLInputElement>) {
|
||||
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
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{(query || catFilter || dateFilter !== "all") && (
|
||||
<button className="chip chip-clear" type="button" onClick={() => { setQuery(""); setCatFilter(""); setDateFilter("all"); setOpenChip(""); }}>
|
||||
<X size={13} /> 清空筛选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="result-meta" id="result-meta">
|
||||
@@ -229,10 +263,12 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
coverUrl={resolveCoverUrl(product, assetById)}
|
||||
videoCount={projects.filter((p) => 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}
|
||||
/>
|
||||
|
||||
<SuccessModal
|
||||
open={Boolean(createdName)}
|
||||
title="商品创建成功"
|
||||
detail={`「${createdName}」已加入商品库。你可以继续创建商品,或直接为它新建一个视频项目。`}
|
||||
close={() => setCreatedName(null)}
|
||||
actions={
|
||||
<>
|
||||
<button className="btn" type="button" onClick={() => { setCreatedName(null); resetForm(); setDrawer(true); }}>继续创建商品</button>
|
||||
<button className="btn btn-primary" type="button" onClick={() => { setCreatedName(null); navigate("projectWizard"); }}>去新建项目</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 新建商品 · 右侧 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 (
|
||||
<div className={`product-card${selected ? " selected" : ""}`} data-cat={product.category} data-name={product.title} role="button" tabIndex={0} aria-pressed={editMode ? selected : undefined} onClick={onOpen} onKeyDown={(event) => event.key === "Enter" && onOpen()}>
|
||||
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="3 8 7 12 13 4" /></svg></span>
|
||||
<button className="card-del-btn" type="button" title="删除商品" onClick={(event) => { event.stopPropagation(); onDelete?.(); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg></button>
|
||||
<div className={`placeholder product-thumb${cover ? " has-mock-media" : ""}`} style={cover ? prodMock(cover) : undefined}><span className="ph-frame">{product.title} · 1200×800</span></div>
|
||||
{coverUrl ? (
|
||||
<div className="product-thumb has-real-media"><img src={coverUrl} alt={product.title} loading="lazy" /></div>
|
||||
) : (
|
||||
<div className={`placeholder product-thumb${mock ? " has-mock-media" : ""}`} style={mock ? prodMock(mock) : undefined}><span className="ph-frame">{product.title} · 1200×800</span></div>
|
||||
)}
|
||||
<div className="product-body">
|
||||
<div className="product-name">{product.title}</div>
|
||||
<div className="product-cat">{product.category || "未分类"}</div>
|
||||
@@ -397,10 +451,10 @@ export function ProductCard({ product, videoCount = 0, onOpen, editMode = false,
|
||||
素材 <b>{assetCount}</b>
|
||||
</span>
|
||||
<span className="sep">·</span>
|
||||
<span className="stat">
|
||||
<button className="stat stat-link" type="button" title="查看视频项目" onClick={(event) => { event.stopPropagation(); onOpenVideos?.(); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M16 10l6-3v10l-6-3z" /></svg>
|
||||
视频 <b>{videoCount}</b>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -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<Product>) => Promise<unknown> | void;
|
||||
onUploadImage?: (formData: FormData) => Promise<unknown> | void;
|
||||
onDeleteImage?: (imageId: string) => Promise<unknown> | 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<HTMLInputElement>(null);
|
||||
|
||||
@@ -561,6 +561,11 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{(query || catFilter || sourceFilter !== "all" || timeFilter !== "all" || tab !== "all") && (
|
||||
<button className="chip chip-clear" type="button" onClick={() => { setQuery(""); setCatFilter(""); setSourceFilter("all"); setTimeFilter("all"); setTab("all"); setOpenChip(""); }}>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg> 清空筛选
|
||||
</button>
|
||||
)}
|
||||
<span className="spacer"></span>
|
||||
<div className="view-toggle">
|
||||
<button className={view === "grid" ? "active" : ""} type="button" data-view="grid" onClick={() => setView("grid")}>
|
||||
|
||||
@@ -1104,7 +1104,8 @@ nav button svg { width: 14px; height: 14px; color: var(--ink-3); }
|
||||
.dash-side { display: flex; flex-direction: column; gap: 24px; }
|
||||
.recent-row {
|
||||
display: grid;
|
||||
grid-template-columns: 54px 1fr auto auto auto;
|
||||
/* 缩略图 | 标题(吃满剩余) | 进度条(固定) | 状态(固定列,左对齐对齐成列) | 按钮(固定) */
|
||||
grid-template-columns: 54px minmax(0, 1fr) max-content 116px 68px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px 18px;
|
||||
@@ -1114,18 +1115,20 @@ nav button svg { width: 14px; height: 14px; color: var(--ink-3); }
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
}
|
||||
.recent-row > .pill { justify-self: start; }
|
||||
.recent-row > .btn-sm { justify-self: end; }
|
||||
.recent-row:last-child { border-bottom: 0; }
|
||||
.recent-row:hover { background: var(--bg-soft); }
|
||||
.recent-row .thumb { width: 54px; height: 70px; }
|
||||
.recent-meta .name { font-weight: 600; font-size: 14px; color: var(--ink); }
|
||||
.recent-meta .sub { font-size: 12px; color: var(--ink-3); margin-top: 3px; font-family: 'JetBrains Mono', monospace; letter-spacing: .01em; }
|
||||
.shortcuts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.shortcut { background: var(--card); border: 1px solid var(--border); padding: 14px; display: flex; align-items: flex-start; gap: 11px; cursor: pointer; text-align: left; }
|
||||
.shortcut { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 14px; display: flex; align-items: flex-start; gap: 11px; cursor: pointer; text-align: left; transition: background var(--t-base), border-color var(--t-base); }
|
||||
.shortcut:hover { background: var(--bg-soft); }
|
||||
.shortcut .ic { width: 30px; height: 30px; background: var(--orange-tint); color: var(--orange); display: grid; place-items: center; border: 1px solid var(--orange-soft); flex-shrink: 0; }
|
||||
.shortcut .ic { width: 30px; height: 30px; background: var(--orange-tint); color: var(--orange); display: grid; place-items: center; border: 1px solid var(--orange-soft); border-radius: var(--r-sm); flex-shrink: 0; }
|
||||
.shortcut .t { font-size: 13px; font-weight: 600; }
|
||||
.shortcut .d { font-size: 12px; color: var(--ink-3); margin-top: 2px; font-family: 'JetBrains Mono', monospace; letter-spacing: .01em; }
|
||||
.tip { background: var(--card); border: 1px dashed var(--border); padding: 14px 16px; font-size: 13px; color: var(--ink-2); line-height: 1.6; }
|
||||
.tip { background: var(--surface); border: 1px dashed var(--border-faint); border-radius: var(--r-md); padding: 14px 16px; font-size: 13px; color: var(--ink-2); line-height: 1.6; }
|
||||
.tip strong { color: var(--ink); font-weight: 600; display: block; margin-bottom: 4px; }
|
||||
|
||||
/* Product / asset grids */
|
||||
|
||||
Reference in New Issue
Block a user