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
+37 -4
View File
@@ -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>
+9 -5
View File
@@ -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>
+74 -19
View File
@@ -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);
+5
View File
@@ -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")}>