feat(frontend): shelf reading stats card + session tracking (B4)

This commit is contained in:
2026-09-16 17:49:12 +08:00
parent 4767ee7fec
commit 28d8587e71
6 changed files with 133 additions and 0 deletions
+1
View File
@@ -32,6 +32,7 @@ test("登录 → 书架 → CBZ 阅读翻页 → 书签 → 退出", async ({ pa
// 卡片用「结构 + 文本」定位:可访问名匹配会同时命中卡片的《…》操作菜单钮(含书名 aria-label)。
const card = page.locator(".group > button", { hasText: title });
await expect(card).toBeVisible();
await expect(page.getByRole("region", { name: "阅读统计" })).toBeVisible();
await scanA11y(page, "shelf");
await card.click();
await expect(page).toHaveURL(new RegExp(`/book/${bookId}$`));
@@ -0,0 +1,64 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect } from "vitest";
import { beforeEach, describe, it } from "vitest";
import { ReadingStatsCard } from "@/components/reading-stats-card";
import type { StatsStore } from "@/lib/readingStats";
function fakeStore(): StatsStore {
const today = new Date();
const p = (n: number) => String(n).padStart(2, "0");
const key = (d: Date) => `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
const y = new Date(today);
y.setDate(y.getDate() - 1);
const buckets: Record<string, number> = { [key(today)]: 900, [key(y)]: 300 };
return {
record() {},
weekly() {
const perDay = Array.from({ length: 7 }, (_, i) => {
const d = new Date(today);
d.setDate(d.getDate() - (6 - i));
const k = key(d);
return { day: k, seconds: buckets[k] ?? 0 };
});
return { perDay, total: perDay.reduce((s, x) => s + x.seconds, 0) };
},
streak() {
return 2;
},
};
}
beforeEach(() => localStorage.clear());
describe("ReadingStatsCard", () => {
it("本周文案含时长与连续天数", () => {
render(<ReadingStatsCard store={fakeStore()} />);
expect(screen.getByText(/本周阅读/)).toBeInTheDocument();
expect(screen.getByText(/20 分钟/)).toBeInTheDocument();
expect(screen.getByText(/连续 2 天/)).toBeInTheDocument();
});
it("展开/收起:aria-label 翻转 + 柱状图可见性 + localStorage 记忆", () => {
render(<ReadingStatsCard store={fakeStore()} />);
expect(screen.getByRole("img", { name: "近 7 日阅读时长柱状图" })).toBeInTheDocument();
const btn = screen.getByRole("button", { name: "折叠统计" });
expect(screen.queryAllByRole("img").length).toBeGreaterThan(0);
fireEvent.click(btn);
expect(screen.getByRole("button", { name: "展开统计" })).toBeInTheDocument();
expect(screen.queryByRole("img", { name: "近 7 日阅读时长柱状图" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "展开统计" }));
expect(screen.getByRole("img", { name: "近 7 日阅读时长柱状图" })).toBeInTheDocument();
});
it("注入的 store 无连续记录时不显示连续段", () => {
const s = fakeStore();
render(<ReadingStatsCard store={{ ...s, streak: () => 0 }} />);
expect(screen.queryByText(/连续/)).not.toBeInTheDocument();
});
it("7 根柱(含 0 值日)", () => {
render(<ReadingStatsCard store={fakeStore()} />);
const chart = screen.getByRole("img", { name: "近 7 日阅读时长柱状图" });
expect(chart.children.length).toBe(7);
});
});
@@ -0,0 +1,58 @@
import { useMemo, useState } from "react";
import { BarChart3, ChevronDown, ChevronUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { createLocalStats, type StatsStore } from "@/lib/readingStats";
const FOLD_KEY = "stats-card-folded";
function fmtDur(s: number): string {
const h = Math.floor(s / 3600);
const m = Math.round((s % 3600) / 60);
return h > 0 ? `${h} 小时 ${m} 分` : `${m} 分钟`;
}
/** 书架顶部阅读统计卡:本周时长 + 连续天数,展开显示近 7 日柱状图。store 可注入(测试/③ 服务端实现)。 */
export function ReadingStatsCard({ store }: { store?: StatsStore }) {
const stats = useMemo(() => store ?? createLocalStats(), [store]);
const [folded, setFolded] = useState(() => localStorage.getItem(FOLD_KEY) === "1");
const { perDay, total } = stats.weekly();
const streak = stats.streak();
const max = Math.max(1, ...perDay.map((d) => d.seconds));
return (
<section aria-label="阅读统计" className="bg-card text-card-foreground px-4 py-3 text-sm rounded-lg border">
<div className="flex items-center gap-2">
<BarChart3 className="text-muted-foreground size-4" />
<span>
本周阅读 <strong className="tabular-nums">{fmtDur(total)}</strong>
{streak > 0 && <span className="text-muted-foreground"> · 连续 {streak} 天</span>}
</span>
<Button
variant="ghost"
size="icon-xs"
className="ml-auto"
aria-label={folded ? "展开统计" : "折叠统计"}
onClick={() => {
const v = !folded;
setFolded(v);
localStorage.setItem(FOLD_KEY, v ? "1" : "0");
}}
>
{folded ? <ChevronDown className="size-4" /> : <ChevronUp className="size-4" />}
</Button>
</div>
{!folded && (
<div className="mt-2 flex h-16 items-end gap-1.5" role="img" aria-label="近 7 日阅读时长柱状图">
{perDay.map((d) => (
<div key={d.day} className="flex flex-1 flex-col items-center gap-1">
<div
className="bg-primary/70 w-full rounded-sm"
style={{ height: `${Math.max(2, (d.seconds / max) * 100)}%` }}
/>
<span className="text-[10px] text-muted-foreground tabular-nums">{d.day.slice(8)}</span>
</div>
))}
</div>
)}
</section>
);
}
+4
View File
@@ -7,6 +7,8 @@ import type { Format } from "../api/types";
import { FormatBadge } from "../components/format-badge";
import { Button } from "../components/ui/button";
import type { ReaderProps } from "../lib/useProgress";
import { createLocalStats } from "../lib/readingStats";
import { useReadingSession } from "../lib/useReadingSession";
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
cbz: lazy(() => import("../readers/CbzReader")),
@@ -27,6 +29,8 @@ export default function Reader() {
const bookQ = useQuery({ queryKey: ["book", id], queryFn: () => api.getBook(id), enabled: Number.isFinite(id) });
const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
const stats = useMemo(() => createLocalStats(), []);
useReadingSession(stats);
const book = bookQ.data;
const body = (() => {
+3
View File
@@ -17,6 +17,7 @@ import { useAuth } from "@/auth/AuthContext";
import { Cover } from "@/components/Cover";
import { toast } from "@/components/Toaster";
import { UploadButton } from "@/components/upload-button";
import { ReadingStatsCard } from "@/components/reading-stats-card";
import { formatBadge } from "@/components/ui";
import {
AlertDialog,
@@ -280,6 +281,8 @@ export default function Shelf() {
{lib === LIB0 && <p className="sr-only" aria-live="polite">{`当前筛选:${libName}`}</p>}
<ReadingStatsCard />
{(libsQ.data ?? []).length === 0 && !libsQ.isPending && !searching && (
<p className="mb-6 flex items-center gap-2 rounded-md border border-accent/30 bg-accent/10 px-3 py-2 text-sm text-accent-strong">
<LibraryBig className="size-4 shrink-0" />