diff --git a/core/frontend/src/products-page.css b/core/frontend/src/products-page.css
index 4d971e0..6d72092 100644
--- a/core/frontend/src/products-page.css
+++ b/core/frontend/src/products-page.css
@@ -6,6 +6,13 @@
.products-main .result-meta { margin-bottom: 12px; display: flex; align-items: center; gap: 10px; font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; }
.chip-wrap { position: relative; }
+ /* 商品分类多选 chip · 菜单多选项右侧计数 + chip 上橙色已选计数徽标(忠实移植 products.html)
+ 注:共享 .mi 已是 flex/align-center(design-restraint),此处只补 cat 专属计数,不重写共享类 */
+ .chip-wrap[data-key="cat"] .chip-menu { min-width: 220px; }
+ .chip-wrap[data-key="cat"] .mi .cat-count { margin-left: auto; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }
+ .chip-wrap[data-key="cat"] .mi.selected .cat-count { color: var(--heat); }
+ .chip .chip-count { display: inline-flex; align-items: center; justify-content: center; height: 18px; min-width: 18px; padding: 0 5px; background: var(--heat-12); color: var(--heat); border: 1px solid var(--heat-20); border-radius: var(--r-pill); font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; letter-spacing: .02em; margin-left: 2px; }
+
.product-grid-wrap { margin: 0 -8px; padding: 2px 8px 24px; }
.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }
.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; }
@@ -39,6 +46,19 @@
.view-tog button.active { background: var(--accent-black); color: var(--accent-white); }
.view-tog button svg { width: 13px; height: 13px; }
+ /* 列表视图:网格→单列,每行横排(缩图 100×70 + 信息行 + 底栏统计)·忠实移植 products.html */
+ .product-grid.list-view { display: flex; flex-direction: column; gap: 8px; }
+ .product-grid.list-view .product-card { display: grid; grid-template-columns: 100px 1fr auto; gap: 16px; padding: 12px 16px; align-items: center; }
+ .product-grid.list-view .product-thumb { width: 100px; height: 70px; aspect-ratio: auto; margin: 0; border-radius: var(--r-sm); flex-shrink: 0; }
+ .product-grid.list-view .product-card .product-thumb { border: 0; }
+ .product-grid.list-view .product-body { padding: 0; display: flex; align-items: center; gap: 14px; }
+ .product-grid.list-view .product-name { font-size: 14px; }
+ .product-grid.list-view .product-cat { margin-top: 0; }
+ .product-grid.list-view .product-date { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em; margin-top: 0; }
+ .product-grid.list-view .product-footer { border-top: 0; padding: 0; background: transparent; column-gap: 6px; }
+ /* 列表态卡片左上 checkbox 贴合更紧 */
+ .product-grid.list-view .product-card .card-check { top: 50%; left: 6px; transform: translateY(-50%); }
+
/* 编辑模式 checkbox(默认隐藏) */
.product-card .card-check { position: absolute; top: 10px; left: 10px; width: 22px; height: 22px; border-radius: 50%; background: var(--surface); border: 2px solid var(--border-loud); display: none; place-items: center; color: var(--accent-white); z-index: 5; pointer-events: none; }
.product-card .card-check svg { width: 11px; height: 11px; visibility: hidden; }
diff --git a/core/frontend/src/projects-page.css b/core/frontend/src/projects-page.css
index 58348e5..5929c2e 100644
--- a/core/frontend/src/projects-page.css
+++ b/core/frontend/src/projects-page.css
@@ -21,9 +21,16 @@
/* ─── Grid view ─── */
.proj-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }
- .proj-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background .15s; display: flex; flex-direction: column; position: relative; }
+ .proj-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background .15s, border-color .15s; display: flex; flex-direction: column; position: relative; }
.proj-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }
.proj-card .card-thumb { aspect-ratio: 9/16; max-height: 280px; border-radius: var(--r-md) var(--r-md) 0 0; }
+
+ /* 网格卡编辑态勾选圈(默认隐藏,body.edit-mode 显示;选中橙边 + 橙底白勾)· 蓝本同 products-page */
+ .proj-card .card-check { position: absolute; top: 10px; left: 10px; width: 22px; height: 22px; border-radius: 50%; background: var(--surface); border: 2px solid var(--black-alpha-32); display: none; place-items: center; color: var(--accent-white); z-index: 5; pointer-events: none; }
+ .proj-card .card-check svg { width: 11px; height: 11px; opacity: 0; }
+
+ /* 网格卡进度条 N/5 行内对齐(.prog / .hstack / .muted-2 走全局 design-restraint) */
+ .proj-card .hstack { gap: 8px; }
.row-more { position: relative; display: inline-flex; cursor: pointer; align-items: center; color: var(--black-alpha-56); padding: 4px; }
.row-more:hover { color: var(--accent-black); }
.row-more-tip { position: absolute; top: calc(100% + 6px); right: 0; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); box-shadow: 0 4px 16px rgba(0,0,0,.08); padding: 4px; min-width: 110px; opacity: 0; pointer-events: none; transform: translateY(-2px); transition: opacity .15s, transform .15s; z-index: 12; }
@@ -35,10 +42,46 @@
.btn.active { background: var(--accent-black); color: var(--accent-white); border-color: var(--accent-black); }
.proj-card .card-body { padding: 14px; display: flex; flex-direction: column; gap: 10px; flex: 1; }
.proj-card .card-name { font-size: 14px; font-weight: 600; color: var(--accent-black); line-height: 1.4; }
- .proj-card .card-sub { font-size: 12px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; }
+ .proj-card .card-sub { font-size: 12px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; margin-top: 4px; }
.proj-card .card-foot { display: flex; align-items: center; justify-content: space-between; padding-top: 10px; border-top: 1px solid var(--border-faint); margin-top: auto; }
.proj-card .card-time { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; }
+ /* 列表行编辑态:行首勾选圈(替代行末单删)+ 选中行橙底高亮 */
+ table.t tbody tr.row-selected { background: var(--heat-12); }
+ .row-check { width: 20px; height: 20px; flex-shrink: 0; border-radius: 50%; background: var(--surface); border: 2px solid var(--black-alpha-32); display: grid; place-items: center; color: var(--accent-white); transition: background .15s, border-color .15s; }
+ .row-check svg { width: 11px; height: 11px; opacity: 0; }
+ .row-check.on { background: var(--heat); border-color: var(--heat); }
+ .row-check.on svg { opacity: 1; }
+ body.edit-mode table.t tbody tr { cursor: pointer; }
+
/* chip 下拉容器(默认收起)· chip 本体/caret 走全局 restraint .chip */
.chip-wrap { position: relative; }
}
+
+/* ─── 批量管理(忠实移植自 V1 projects.html · body.edit-mode 全局态,控件仅项目页出现) ─── */
+body.edit-mode .proj-card { cursor: pointer; }
+body.edit-mode .proj-card .card-check { display: grid; }
+body.edit-mode .proj-card.selected .card-check { background: var(--heat); border-color: var(--heat); }
+body.edit-mode .proj-card.selected .card-check svg { opacity: 1; }
+body.edit-mode .proj-card.selected { border-color: var(--heat); box-shadow: 0 0 0 1px var(--heat) inset; }
+
+/* 吸底批量操作栏 · 仅 body.edit-mode 显示(蓝本同 products-page,黑底白字胶囊,删除红) */
+.bulk-bar {
+ position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
+ background: var(--accent-black); color: var(--accent-white); border-radius: var(--r-md);
+ padding: 10px 14px 10px 18px; display: none; align-items: center; gap: 16px;
+ box-shadow: 0 8px 24px rgba(0,0,0,.18); z-index: 100; font-size: 13px;
+}
+body.edit-mode .bulk-bar { display: inline-flex; }
+.bulk-bar .ct { font-family: var(--font-mono); letter-spacing: .02em; }
+.bulk-bar .ct b { color: var(--heat); font-weight: 700; padding: 0 3px; }
+.bulk-bar .sep { width: 1px; height: 18px; background: rgba(255,255,255,.16); }
+.bulk-bar button { height: 30px; padding: 0 12px; background: transparent; border: 1px solid rgba(255,255,255,.24); border-radius: var(--r-sm); color: var(--accent-white); font-size: 12.5px; font-family: inherit; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; transition: background .15s, border-color .15s; }
+.bulk-bar button:hover { background: rgba(255,255,255,.08); }
+.bulk-bar button.danger { background: var(--accent-crimson); border-color: var(--accent-crimson); }
+.bulk-bar button.danger:hover { filter: brightness(1.06); }
+.bulk-bar button:disabled { opacity: .4; cursor: not-allowed; }
+.bulk-bar button svg { width: 12px; height: 12px; }
+.bulk-bar .clear-sel { color: rgba(255,255,255,.6); font-size: 12px; cursor: pointer; background: none; border: 0; padding: 4px 6px; }
+.bulk-bar .clear-sel:hover { color: var(--accent-white); }
+.bulk-bar .clear-sel:disabled { opacity: .4; cursor: not-allowed; }
diff --git a/core/frontend/src/routes/dashboard.tsx b/core/frontend/src/routes/dashboard.tsx
index ad6c42f..28c3bb1 100644
--- a/core/frontend/src/routes/dashboard.tsx
+++ b/core/frontend/src/routes/dashboard.tsx
@@ -31,12 +31,31 @@ export function Dashboard({ products, projects, productTotal, projectTotal, bill
const completed = projects.filter((project) => project.status === "completed").length;
const running = projects.filter((project) => !["completed", "failed"].includes(project.status)).length;
const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品";
+
+ // 日期 mono:`// 06.19 · 周四`(对齐 V1 mono 注释风,补零到两位)
+ const now = new Date();
+ const pad2 = (n: number) => String(n).padStart(2, "0");
+ const weekdays = ["日", "一", "二", "三", "四", "五", "六"];
+ const dateMono = `${pad2(now.getMonth() + 1)}.${pad2(now.getDate())} · 周${weekdays[now.getDay()]}`;
+
+ // 「总项目」delta = 本月新建数(真实,按 created_at 算)。
+ // 但 projects 仅首页分页数据;当存在未加载的项目(projCount > 已加载)时,本月数会少算 → 宁可不显示也不显假值。
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
+ const newThisMonth = projects.filter((p) => p.created_at && new Date(p.created_at).getTime() >= monthStart).length;
+ const allProjectsLoaded = projects.length >= projCount;
+ const totalDelta = allProjectsLoaded && newThisMonth > 0 ? `↑ 本月 +${newThisMonth}` : undefined;
+
+ // 余额 mini progress:真实冻结占比 = 冻结 /(可用 + 冻结);无 budget/已用字段,故不复刻 V1「已用 ¥X/¥Y」,改显真实「冻结 ¥X / 共 ¥Y」。
+ const balanceNum = Number(billing?.account.balance ?? 0);
+ const reservedNum = Number(billing?.account.reserved_balance ?? 0);
+ const fundsTotal = balanceNum + reservedNum;
+ const reservedPct = fundsTotal > 0 ? Math.min(100, Math.round((reservedNum / fundsTotal) * 100)) : 0;
return (
<>
欢迎回来{userName ? `,${userName.split("@")[0]}` : ""}
-
// {new Date().toLocaleDateString("zh-CN")}·你有 {running} 个项目 正在进行中
+
// {dateMono}·你有 {running} 个项目 正在进行中
- ++
- 最近项目
{loading && projects.length === 0 ?
: projects.slice(0, 6).map((project) =>
)}
快捷入口
[ /shortcuts ] navigate("products")} /> navigate("library")} /> navigate("account")} /> navigate("projects")} />
提示
[ FAQ ]扣费规则生成失败、超时、用户重跑 — 均不扣费。仅在你点 [ 确认通过 ] 时按 token 实际结算。
+ ++
+ 最近项目
{loading && projects.length === 0 ?
: projects.slice(0, 6).map((project) =>
)}
快捷入口
[ /shortcuts ] navigate("products")} /> navigate("library")} /> navigate("account")} /> navigate("projects")} />
提示
[ FAQ ]扣费规则生成失败、超时、用户重跑 — 均不扣费。仅在你点 [ 确认通过 ] 时按 token 实际结算。
>
);
}
-export function KpiStat({ label, badge, value, delta }: { label: string; badge: string; value: string | number; delta: string }) {
- return {label} {badge}
{value}
{delta}
;
+export function KpiStat({ label, badge, value, delta }: { label: string; badge: string; value: string | number; delta?: string }) {
+ return {label} {badge}
{value}
{delta ?
{delta}
: null}
;
}
export function Shortcut({ name, title, desc, onClick }: { name: string; title: string; desc: string; onClick: () => void }) {
diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx
index a58c7b7..5188de6 100644
--- a/core/frontend/src/routes/products.tsx
+++ b/core/frontend/src/routes/products.tsx
@@ -93,11 +93,24 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
// 创建成功弹窗:存刚创建的商品名,非空即展示「继续创建 / 去新建项目」选择
const [createdName, setCreatedName] = useState(null);
const [openChip, setOpenChip] = useState<"" | "cat" | "date">("");
- const [catFilter, setCatFilter] = useState("");
+ // 商品分类筛选改回多选:Set 存已选分类(空 = 全部),菜单含每项计数 + chip 上橙色计数徽标
+ const [catFilter, setCatFilter] = useState>(new Set());
const [dateFilter, setDateFilter] = useState<"all" | "7" | "30" | "90">("all");
+ // 视图切换:网格 / 列表(.view-tog 黑激活;列表态卡片横排)
+ const [view, setView] = useState<"grid" | "list">("grid");
+ const toggleCat = (cat: string) => setCatFilter((prev) => {
+ const next = new Set(prev);
+ next.has(cat) ? next.delete(cat) : next.add(cat);
+ return next;
+ });
// 筛选选项:商品分类来自真实 products,创建时间为固定区间
const categories = Array.from(new Set(products.map((p) => p.category).filter(Boolean))) as string[];
+ // 各分类下的商品计数(菜单右侧 mono 数字)
+ const catCounts = products.reduce((acc, p) => {
+ if (p.category) acc[p.category] = (acc[p.category] || 0) + 1;
+ return acc;
+ }, {} as Record);
const DATE_OPTS: Array<{ value: "all" | "7" | "30" | "90"; label: string }> = [
{ value: "all", label: "全部时间" },
{ value: "7", label: "近 7 天" },
@@ -124,7 +137,7 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
const filtered = products.filter((product) => {
if (deletingIds.has(product.id)) return false; // 乐观隐藏:删除中的商品立即从网格移除
const matchQuery = `${product.title} ${product.brand}`.toLowerCase().includes(query.toLowerCase());
- const matchCat = !catFilter || product.category === catFilter;
+ const matchCat = catFilter.size === 0 || (product.category ? catFilter.has(product.category) : false);
let matchDate = true;
if (dateFilter !== "all" && product.created_at) {
const days = (Date.now() - new Date(product.created_at).getTime()) / 86400000;
@@ -134,7 +147,8 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
});
// 分页:每页 10 个,搜索/筛选变化回第 1 页
const [page, setPage] = useState(1);
- useEffect(() => { setPage(1); }, [query, catFilter, dateFilter]);
+ const catFilterKey = Array.from(catFilter).sort().join("|");
+ useEffect(() => { setPage(1); }, [query, catFilterKey, dateFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PROD_PAGE_SIZE));
const curPage = Math.min(page, totalPages);
const pageItems = filtered.slice((curPage - 1) * PROD_PAGE_SIZE, curPage * PROD_PAGE_SIZE);
@@ -165,17 +179,21 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
setQuery(event.target.value)} />
-