修改部份ui
This commit is contained in:
@@ -4,6 +4,7 @@ import type { ChangeEvent } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUpRight,
|
||||
Bookmark,
|
||||
Check,
|
||||
ChevronDown,
|
||||
@@ -48,6 +49,25 @@ const MODE_LABEL: Record<string, string> = {
|
||||
cover: "平台套图",
|
||||
image: "图片创作"
|
||||
};
|
||||
const MODE_TAG: Record<string, string> = {
|
||||
model: "模特上身",
|
||||
cover: "平台套图",
|
||||
image: "自由创作",
|
||||
};
|
||||
|
||||
function formatAfTime(iso: string) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso.slice(0, 10);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString()) return `今天 ${hm}`;
|
||||
const yest = new Date(now);
|
||||
yest.setDate(now.getDate() - 1);
|
||||
if (d.toDateString() === yest.toDateString()) return `昨天 ${hm}`;
|
||||
return `${pad(d.getMonth() + 1)}.${pad(d.getDate())} ${hm}`;
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
succeeded: "已完成",
|
||||
@@ -108,36 +128,33 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
const taskImage = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
const map: Record<string, { url: string; name: string }> = {};
|
||||
for (const asset of assets) {
|
||||
const taskId = asset.origin_task;
|
||||
if (!taskId || map[taskId]) continue;
|
||||
const file = asset.files?.find((f) => f.preview_url && (f.content_type?.startsWith("image") ?? true));
|
||||
if (file?.preview_url) map[taskId] = file.preview_url;
|
||||
if (file?.preview_url) map[taskId] = { url: file.preview_url, name: asset.name || "" };
|
||||
}
|
||||
return map;
|
||||
}, [assets]);
|
||||
const cards = [
|
||||
{
|
||||
page: "modelPhoto" as Page,
|
||||
tag: "[ MODEL · TRY-ON ]",
|
||||
title: "模特上身图",
|
||||
desc: "选择模特,AI 生成商品模特上身效果图",
|
||||
cost: "≈ 20 积分 / 张"
|
||||
title: "模特上身",
|
||||
desc: "上传商品和模特参考图,生成自然统一的服装、饰品上身效果。",
|
||||
image: "/exact/assets/mock/person-linxi.png",
|
||||
},
|
||||
{
|
||||
page: "platformCover" as Page,
|
||||
tag: "[ PLATFORM · KIT ]",
|
||||
title: "平台套图",
|
||||
desc: "选择平台模板,AI 生成电商平台套图",
|
||||
cost: "≈ 20 积分 / 张"
|
||||
desc: "基于商品主图一次生成主图、卖点图、细节图与场景图。",
|
||||
image: "/exact/assets/mock/cover-earbuds.png",
|
||||
},
|
||||
{
|
||||
page: "imageOptimize" as Page,
|
||||
tag: "[ IMAGE · STUDIO ]",
|
||||
title: "图片创作",
|
||||
desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写",
|
||||
cost: "≈ 20 积分 / 张"
|
||||
desc: "使用提示词、参考图和画布比例自由生成或修改视觉素材。",
|
||||
image: "",
|
||||
}
|
||||
];
|
||||
|
||||
@@ -145,8 +162,8 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
|
||||
// 注意 mode 也被脚本 agent 复用为 auto/theme/revise,故必须用白名单过滤,别把脚本任务混进来)。
|
||||
// 每张图是一个独立 AITask,同次提交共享 batch_id → 按 batch_id 归成「批次卡」;旧图无 batch_id 则各自成单。
|
||||
type TaskBatch = {
|
||||
key: string; batchId: string | null; firstTaskId: string; label: string; mode: string;
|
||||
count: number; status: "info" | "ok" | "err"; created_at: string; cover: string;
|
||||
key: string; batchId: string | null; firstTaskId: string; label: string; title: string; mode: string;
|
||||
count: number; doneCount: number; status: "info" | "ok" | "err"; created_at: string; cover: string;
|
||||
};
|
||||
const taskBatches = useMemo<TaskBatch[]>(() => {
|
||||
const groups = new Map<string, AITask[]>();
|
||||
@@ -159,27 +176,25 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
|
||||
}
|
||||
const out: TaskBatch[] = [];
|
||||
for (const [key, tasks] of groups) {
|
||||
const first = tasks[0];
|
||||
const first = tasks[0] as AITask & { product_title?: string; prompt?: string };
|
||||
const pills = tasks.map((t) => statusPill(t.status));
|
||||
const status: "info" | "ok" | "err" = pills.some((p) => p === "info") ? "info" : pills.some((p) => p === "ok") ? "ok" : "err";
|
||||
const created = tasks.reduce((m, t) => ((t.created_at || "") > m ? (t.created_at || "") : m), "");
|
||||
const cover = tasks.map((t) => taskImage[t.id]).find(Boolean) || "";
|
||||
out.push({ key, batchId: first.batch_id || null, firstTaskId: first.id, label: MODE_LABEL[first.mode!], mode: first.mode!, count: tasks.length, status, created_at: created, cover });
|
||||
const coverHit = tasks.map((t) => taskImage[t.id]).find(Boolean);
|
||||
const doneCount = pills.filter((p) => p === "ok").length;
|
||||
const title = first.product_title || first.prompt?.slice(0, 24) || coverHit?.name || MODE_LABEL[first.mode!] || "图片任务";
|
||||
out.push({
|
||||
key, batchId: first.batch_id || null, firstTaskId: first.id,
|
||||
label: MODE_LABEL[first.mode!] || "图片创作",
|
||||
title, mode: first.mode!,
|
||||
count: tasks.length, doneCount, status, created_at: created,
|
||||
cover: coverHit?.url || "",
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
||||
return out;
|
||||
}, [aiTasks, taskImage]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const acc = { gen: 0, ok: 0, err: 0 };
|
||||
for (const b of taskBatches) {
|
||||
if (b.status === "ok") acc.ok += 1;
|
||||
else if (b.status === "err") acc.err += 1;
|
||||
else acc.gen += 1;
|
||||
}
|
||||
return acc;
|
||||
}, [taskBatches]);
|
||||
|
||||
// 任务中心筛选:状态 tab / 搜索 / 时间 / 任务类型 / 网格·列表视图 —— 全部对真实 aiTasks 生效
|
||||
const [filter, setFilter] = useState<"all" | "gen" | "ok" | "err">("all");
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -187,8 +202,8 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
|
||||
const [typeFilter, setTypeFilter] = useState("");
|
||||
const [view, setView] = useViewMode<"grid" | "list">("viewmode:tasks", "grid");
|
||||
const [openChip, setOpenChip] = useState<"" | "time" | "type">("");
|
||||
// 任务中心分页:每页 10 条,筛选/搜索变化时回第 1 页
|
||||
const TASKS_PER_PAGE = 10;
|
||||
// 任务中心分页:每页 8 条(4 列网格两行),筛选/搜索变化时回第 1 页
|
||||
const TASKS_PER_PAGE = 8;
|
||||
const [taskPage, setTaskPage] = useState(1);
|
||||
// 批次详情弹窗:点批次卡 → 按 metadata.batch_id 拉该批全部图(含未入库)→ 网格展示,点图放大
|
||||
const [openBatch, setOpenBatch] = useState<TaskBatch | null>(null);
|
||||
@@ -232,7 +247,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
|
||||
if (days > Number(timeFilter)) return false;
|
||||
}
|
||||
if (query) {
|
||||
const hay = `${batch.label} ${batch.key}`.toLowerCase();
|
||||
const hay = `${batch.label} ${batch.title} ${batch.key}`.toLowerCase();
|
||||
if (!hay.includes(query.toLowerCase())) return false;
|
||||
}
|
||||
return true;
|
||||
@@ -245,140 +260,113 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
|
||||
|
||||
return (
|
||||
<div className="asset-factory">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>图片生成</h1>
|
||||
<div className="sub">
|
||||
<span className="mono">// 一键生成</span>
|
||||
<span>·</span>
|
||||
<span>电商视觉素材,提升内容制作效率</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<header className="af-hero-head">
|
||||
<h1>图片创作</h1>
|
||||
<p>从商品与模特资产出发,快速完成电商图片生产</p>
|
||||
</header>
|
||||
|
||||
<div className="factory-hero">
|
||||
<div className="af-hero">
|
||||
{cards.map((card) => (
|
||||
<article className="factory-card with-corners" key={card.title}>
|
||||
<span className="corner-tr" aria-hidden />
|
||||
<span className="corner-bl" aria-hidden />
|
||||
<div className="factory-body">
|
||||
<div className="factory-text">
|
||||
<span className="factory-tag">{card.tag}</span>
|
||||
<div className="factory-title">{card.title}</div>
|
||||
<div className="factory-desc">{card.desc}</div>
|
||||
{/* CTA 放在 factory-text 内(对齐 asset-factory.html):按钮紧跟描述,
|
||||
而非被 factory-body 的 height:100% + margin-top:auto 顶到卡片最底部 */}
|
||||
<div className="factory-cta">
|
||||
<button className="btn btn-primary btn-lg" type="button" onClick={() => navigate(card.page)}>
|
||||
开始生成
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
<span className="cost">[ {card.cost} ]</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className={`af-hero-card${card.image ? "" : " gradient"}`} type="button" key={card.title} onClick={() => navigate(card.page)}>
|
||||
<div className="af-hero-visual">
|
||||
{card.image ? <img src={card.image} alt="" /> : null}
|
||||
</div>
|
||||
</article>
|
||||
<div className="af-hero-copy">
|
||||
<span className="af-hero-arrow" aria-hidden="true"><ArrowUpRight size={16} /></span>
|
||||
<span className="t">{card.title}</span>
|
||||
<span className="d">{card.desc}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="section-h">
|
||||
<h2>任务中心</h2>
|
||||
<span className="sub-mono">
|
||||
// {taskBatches.length} 批 · {counts.gen} 生成中 · {counts.ok} 已完成 · {counts.err} 失败
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 状态 tabs(转写自 asset-factory.html #tc-tabs) */}
|
||||
<div className="tabs" id="tc-tabs">
|
||||
<div className={`tab${filter === "all" ? " active" : ""}`} data-filter="all" role="button" tabIndex={0} onClick={() => setFilter("all")}>全部 <span className="count">{taskBatches.length}</span></div>
|
||||
<div className={`tab${filter === "gen" ? " active" : ""}`} data-filter="gen" role="button" tabIndex={0} onClick={() => setFilter("gen")}>生成中 <span className="count">{counts.gen}</span></div>
|
||||
<div className={`tab${filter === "ok" ? " active" : ""}`} data-filter="ok" role="button" tabIndex={0} onClick={() => setFilter("ok")}>已完成 <span className="count">{counts.ok}</span></div>
|
||||
<div className={`tab${filter === "err" ? " active" : ""}`} data-filter="err" role="button" tabIndex={0} onClick={() => setFilter("err")}>失败 <span className="count">{counts.err}</span></div>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<div className="search-inline">
|
||||
<Search size={14} />
|
||||
<input className="input" placeholder="搜索任务名" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
<section className="af-recent">
|
||||
<div className="af-recent-head">
|
||||
<h2>最近创作</h2>
|
||||
<p>查看图片任务状态与生成结果</p>
|
||||
</div>
|
||||
<div className={`chip-wrap${openChip === "time" ? " open" : ""}`} data-key="time">
|
||||
<button className={`chip${timeFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "time" ? "" : "time"))}>
|
||||
<span className="chip-label">{TIME_OPTS.find((o) => o.value === timeFilter)?.label !== "全部时间" ? TIME_OPTS.find((o) => o.value === timeFilter)?.label : "时间"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu">
|
||||
{TIME_OPTS.map((opt) => (
|
||||
<div className={`mi${timeFilter === opt.value ? " selected" : ""}`} key={opt.value} role="button" tabIndex={0} onClick={() => { setTimeFilter(opt.value); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{opt.label}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="af-toolbar">
|
||||
<div className="af-pills" role="tablist" aria-label="任务状态">
|
||||
<button className={`af-pill${filter === "all" ? " active" : ""}`} type="button" onClick={() => setFilter("all")}>全部</button>
|
||||
<button className={`af-pill${filter === "gen" ? " active" : ""}`} type="button" onClick={() => setFilter("gen")}>生成中</button>
|
||||
<button className={`af-pill${filter === "ok" ? " active" : ""}`} type="button" onClick={() => setFilter("ok")}>已完成</button>
|
||||
<button className={`af-pill${filter === "err" ? " active" : ""}`} type="button" onClick={() => setFilter("err")}>失败</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`chip-wrap${openChip === "type" ? " open" : ""}`} data-key="type">
|
||||
<button className={`chip${typeFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "type" ? "" : "type"))}>
|
||||
<span className="chip-label">{typeFilter || "任务类型"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu">
|
||||
<div className={`mi${!typeFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setTypeFilter(""); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部类型
|
||||
<div className="af-toolbar-right">
|
||||
<div className="af-search">
|
||||
<Search size={14} />
|
||||
<input placeholder="搜索任务名称" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</div>
|
||||
{typeOptions.length > 0 && <div className="mi-sep" />}
|
||||
{typeOptions.map((t) => (
|
||||
<div className={`mi${typeFilter === t ? " selected" : ""}`} key={t} role="button" tabIndex={0} onClick={() => { setTypeFilter(t); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{t}
|
||||
<div className={`chip-wrap${openChip === "time" ? " open" : ""}`} data-key="time">
|
||||
<button className={`chip${timeFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "time" ? "" : "time"))}>
|
||||
<span className="chip-label">{TIME_OPTS.find((o) => o.value === timeFilter)?.label || "全部时间"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu">
|
||||
{TIME_OPTS.map((opt) => (
|
||||
<div className={`mi${timeFilter === opt.value ? " selected" : ""}`} key={opt.value} role="button" tabIndex={0} onClick={() => { setTimeFilter(opt.value); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{opt.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={`chip-wrap${openChip === "type" ? " open" : ""}`} data-key="type">
|
||||
<button className={`chip${typeFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "type" ? "" : "type"))}>
|
||||
<span className="chip-label">{typeFilter || "全部任务"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu">
|
||||
<div className={`mi${!typeFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setTypeFilter(""); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部任务
|
||||
</div>
|
||||
{typeOptions.length > 0 && <div className="mi-sep" />}
|
||||
{typeOptions.map((t) => (
|
||||
<div className={`mi${typeFilter === t ? " selected" : ""}`} key={t} role="button" tabIndex={0} onClick={() => { setTypeFilter(t); setOpenChip(""); }}>
|
||||
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{t}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="af-view">
|
||||
<button type="button" className={view === "grid" ? "active" : ""} aria-label="网格" onClick={() => setView("grid")}>
|
||||
<Grid2X2 size={14} />
|
||||
</button>
|
||||
<button type="button" className={view === "list" ? "active" : ""} aria-label="列表" onClick={() => setView("list")}>
|
||||
<List size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(filter !== "all" || query || timeFilter !== "all" || typeFilter) && (
|
||||
<button className="clear-filters" type="button" onClick={() => { setFilter("all"); setQuery(""); setTimeFilter("all"); setTypeFilter(""); }}>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 4l8 8M12 4l-8 8" /></svg>
|
||||
清空筛选
|
||||
</button>
|
||||
)}
|
||||
<span className="spacer" />
|
||||
<div className="view-toggle">
|
||||
<button type="button" className={view === "grid" ? "active" : ""} data-view="grid" onClick={() => setView("grid")}>
|
||||
<Grid2X2 size={13} />
|
||||
网格
|
||||
</button>
|
||||
<button type="button" className={view === "list" ? "active" : ""} data-view="list" onClick={() => setView("list")}>
|
||||
<List size={13} />
|
||||
列表
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="result-meta">
|
||||
// 显示 {paged.length} / {visible.length} 批
|
||||
</div>
|
||||
|
||||
{tasksLoading && aiTasks.length === 0 ? (
|
||||
<SkeletonRows count={5} />
|
||||
) : aiTasks.length === 0 ? (
|
||||
<div className="task-empty">
|
||||
<div className="mono">// NO TASKS YET</div>
|
||||
<div>还没有任务,去上方选一个工序开始生成吧</div>
|
||||
<div>还没有任务,去上方选一个入口开始生成</div>
|
||||
</div>
|
||||
) : visible.length === 0 ? (
|
||||
<div className="task-empty">
|
||||
<div className="mono">// NO MATCH</div>
|
||||
<div>没有符合筛选条件的任务</div>
|
||||
</div>
|
||||
) : view === "grid" ? (
|
||||
<div className="history-grid">
|
||||
<div className="af-grid">
|
||||
{paged.map((batch) => {
|
||||
const statusLabel = batch.status === "info" ? "生成中" : batch.status === "err" ? "失败" : "已完成";
|
||||
return (
|
||||
<article className="task-card history-card" key={batch.key} role="button" tabIndex={0} title="查看本批图片"
|
||||
<article className="af-card" key={batch.key} role="button" tabIndex={0} title="查看本批图片"
|
||||
onClick={() => setOpenBatch(batch)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenBatch(batch); } }}>
|
||||
<div className={`placeholder${batch.cover ? " has-img" : ""}`}>{batch.cover ? <img src={batch.cover} alt={batch.label} loading="lazy" decoding="async" /> : <span className="ph-frame">{batch.label}</span>}</div>
|
||||
<div className="history-body">
|
||||
<div className="history-name">{batch.label}</div>
|
||||
<div className="history-type">// {batch.count} 张</div>
|
||||
<div className="history-foot">
|
||||
<span className="mono">{(batch.created_at || "").slice(0, 10)}</span>
|
||||
<span className={`pill ${batch.status}`}><span className="dot" />{statusLabel}</span>
|
||||
<div className={`af-card-visual${batch.cover ? " has-img" : ""}`}>
|
||||
{batch.cover ? <img src={batch.cover} alt={batch.title} loading="lazy" decoding="async" /> : null}
|
||||
{batch.status === "info" ? (
|
||||
<div className="af-card-prog">生成中 · {batch.doneCount}/{batch.count}张</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="af-card-body">
|
||||
<div className="af-card-name">{batch.title}</div>
|
||||
<div className="af-card-meta">{batch.status === "err" ? "生成失败" : `${formatAfTime(batch.created_at)} · ${batch.count} 张`}</div>
|
||||
<div className="af-card-foot">
|
||||
<span className={`af-tag ${batch.status}`}>{statusLabel}</span>
|
||||
<span className={`af-tag type ${batch.mode}`}>{MODE_TAG[batch.mode] || batch.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -446,6 +434,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
|
||||
)}
|
||||
|
||||
<Pager page={taskCurPage} total={visible.length} pageSize={TASKS_PER_PAGE} onChange={setTaskPage} />
|
||||
</section>
|
||||
|
||||
{/* 批次详情弹窗:展示该批生成的全部图片(参考资产库),点图放大 */}
|
||||
{openBatch && createPortal(
|
||||
|
||||
Reference in New Issue
Block a user