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
+85 -35
View File
@@ -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":
+38 -4
View File
@@ -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> {
+24 -1
View File
@@ -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;
+5
View File
@@ -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; }
+6
View File
@@ -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; }
+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")}>
+7 -4
View File
@@ -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 */