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:
@@ -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")}>
|
||||
|
||||
Reference in New Issue
Block a user