feat(frontend): shelf reading stats card + session tracking (B4)
This commit is contained in:
@@ -10,6 +10,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
|
|||||||
|
|
||||||
### Added / 新增
|
### Added / 新增
|
||||||
|
|
||||||
|
- The shelf page gains a reading stats card (this week's time, streak days, expandable 7-day bar chart); reading time is tracked on-device while a book is open.
|
||||||
|
- 书架页新增阅读统计卡(本周时长、连续天数、可展开的近 7 日柱状图);打开书籍期间在设备本地记录阅读时长。
|
||||||
|
|
||||||
- EPUB reader gains typography settings (font size, line height, margins) and honors the 纸/米/夜 reading themes including follow-global-theme; its toolbar moves to the unified settings bar.
|
- EPUB reader gains typography settings (font size, line height, margins) and honors the 纸/米/夜 reading themes including follow-global-theme; its toolbar moves to the unified settings bar.
|
||||||
- EPUB 阅读器新增排版设置(字号/行距/边距)并支持纸/米/夜阅读主题(含跟随全局);工具条迁入统一设置栏。
|
- EPUB 阅读器新增排版设置(字号/行距/边距)并支持纸/米/夜阅读主题(含跟随全局);工具条迁入统一设置栏。
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ test("登录 → 书架 → CBZ 阅读翻页 → 书签 → 退出", async ({ pa
|
|||||||
// 卡片用「结构 + 文本」定位:可访问名匹配会同时命中卡片的《…》操作菜单钮(含书名 aria-label)。
|
// 卡片用「结构 + 文本」定位:可访问名匹配会同时命中卡片的《…》操作菜单钮(含书名 aria-label)。
|
||||||
const card = page.locator(".group > button", { hasText: title });
|
const card = page.locator(".group > button", { hasText: title });
|
||||||
await expect(card).toBeVisible();
|
await expect(card).toBeVisible();
|
||||||
|
await expect(page.getByRole("region", { name: "阅读统计" })).toBeVisible();
|
||||||
await scanA11y(page, "shelf");
|
await scanA11y(page, "shelf");
|
||||||
await card.click();
|
await card.click();
|
||||||
await expect(page).toHaveURL(new RegExp(`/book/${bookId}$`));
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import type { Format } from "../api/types";
|
|||||||
import { FormatBadge } from "../components/format-badge";
|
import { FormatBadge } from "../components/format-badge";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import type { ReaderProps } from "../lib/useProgress";
|
import type { ReaderProps } from "../lib/useProgress";
|
||||||
|
import { createLocalStats } from "../lib/readingStats";
|
||||||
|
import { useReadingSession } from "../lib/useReadingSession";
|
||||||
|
|
||||||
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
|
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
|
||||||
cbz: lazy(() => import("../readers/CbzReader")),
|
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 bookQ = useQuery({ queryKey: ["book", id], queryFn: () => api.getBook(id), enabled: Number.isFinite(id) });
|
||||||
const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
|
const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
|
||||||
|
const stats = useMemo(() => createLocalStats(), []);
|
||||||
|
useReadingSession(stats);
|
||||||
|
|
||||||
const book = bookQ.data;
|
const book = bookQ.data;
|
||||||
const body = (() => {
|
const body = (() => {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useAuth } from "@/auth/AuthContext";
|
|||||||
import { Cover } from "@/components/Cover";
|
import { Cover } from "@/components/Cover";
|
||||||
import { toast } from "@/components/Toaster";
|
import { toast } from "@/components/Toaster";
|
||||||
import { UploadButton } from "@/components/upload-button";
|
import { UploadButton } from "@/components/upload-button";
|
||||||
|
import { ReadingStatsCard } from "@/components/reading-stats-card";
|
||||||
import { formatBadge } from "@/components/ui";
|
import { formatBadge } from "@/components/ui";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -280,6 +281,8 @@ export default function Shelf() {
|
|||||||
|
|
||||||
{lib === LIB0 && <p className="sr-only" aria-live="polite">{`当前筛选:${libName}`}</p>}
|
{lib === LIB0 && <p className="sr-only" aria-live="polite">{`当前筛选:${libName}`}</p>}
|
||||||
|
|
||||||
|
<ReadingStatsCard />
|
||||||
|
|
||||||
{(libsQ.data ?? []).length === 0 && !libsQ.isPending && !searching && (
|
{(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">
|
<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" />
|
<LibraryBig className="size-4 shrink-0" />
|
||||||
|
|||||||
Reference in New Issue
Block a user