diff --git a/frontend/src/components/ui.ts b/frontend/src/components/ui.ts index dbbad4f..29a98f7 100644 --- a/frontend/src/components/ui.ts +++ b/frontend/src/components/ui.ts @@ -1,5 +1,7 @@ import type { Format } from "../api/types"; +export { formatSize } from "../lib/format"; // deprecated: phase-2 随阅读器清除 + export const btn = "inline-flex items-center gap-1.5 rounded-lg bg-white/[0.06] px-3 py-1.5 text-sm ring-1 ring-white/10 transition-colors hover:bg-white/[0.11] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-white/[0.15] disabled:cursor-not-allowed disabled:opacity-50"; export const btnPrimary = @@ -27,12 +29,3 @@ export const FORMAT_BADGE: Record = { export function formatBadge(fmt: Format, extra = ""): string { return `rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ring-1 ${FORMAT_BADGE[fmt]} ${extra}`; } - -export function formatSize(bytes: number): string { - if (bytes <= 0) return ""; - const units = ["B", "KB", "MB", "GB"]; - let i = 0; - let n = bytes; - for (; n >= 1024 && i < units.length - 1; i++) n /= 1024; - return `${n >= 10 || i === 0 ? Math.round(n) : n.toFixed(1)} ${units[i]}`; -} diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts new file mode 100644 index 0000000..5ac9eff --- /dev/null +++ b/frontend/src/lib/format.ts @@ -0,0 +1,8 @@ +export function formatSize(bytes: number): string { + if (bytes <= 0) return ""; + const units = ["B", "KB", "MB", "GB"]; + let i = 0; + let n = bytes; + for (; n >= 1024 && i < units.length - 1; i++) n /= 1024; + return `${n >= 10 || i === 0 ? Math.round(n) : n.toFixed(1)} ${units[i]}`; +} diff --git a/frontend/src/lib/shelf.ts b/frontend/src/lib/shelf.ts new file mode 100644 index 0000000..c39c525 --- /dev/null +++ b/frontend/src/lib/shelf.ts @@ -0,0 +1,19 @@ +import type { Book } from "../api/types"; +import { groupByDir } from "./group"; + +export type SortKey = "added" | "title" | "progress"; +export const SORT_LABEL: Record = { added: "最近入库", title: "标题", progress: "阅读进度" }; +export type GroupMode = "flat" | "dir"; + +export function sortBooks(books: Book[], key: SortKey): Book[] { + const a = [...books]; + if (key === "title") return a.sort((x, y) => x.title.localeCompare(y.title, "zh")); + if (key === "progress") return a.sort((x, y) => y.percent - x.percent || (x.added_at < y.added_at ? 1 : x.added_at > y.added_at ? -1 : 0)); + return a.sort((x, y) => (x.added_at < y.added_at ? 1 : x.added_at > y.added_at ? -1 : 0)); +} + +/** 页面分组视图:flat 一段无标题;dir 沿用「根目录置顶、目录名 locale 排序」。 */ +export function sections(books: Book[], key: SortKey, mode: GroupMode): { label: string; books: Book[] }[] { + if (mode === "flat") return [{ label: "", books: sortBooks(books, key) }]; + return groupByDir(sortBooks(books, key)).map((g) => ({ label: g.dir, books: g.books })); +} diff --git a/frontend/test/format.test.ts b/frontend/test/format.test.ts new file mode 100644 index 0000000..5c8dc82 --- /dev/null +++ b/frontend/test/format.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { formatSize } from "../src/lib/format"; + +describe("formatSize", () => { + it("renders human sizes across units", () => { + expect(formatSize(0)).toBe(""); + expect(formatSize(512)).toBe("512 B"); + expect(formatSize(2048)).toBe("2.0 KB"); + expect(formatSize(15 * 1024 * 1024)).toBe("15 MB"); + expect(formatSize(3.2 * 1024 ** 3)).toBe("3.2 GB"); + }); + + it("returns empty for non-positive and keeps one decimal below 10", () => { + expect(formatSize(-1)).toBe(""); + expect(formatSize(1024)).toBe("1.0 KB"); + expect(formatSize(10 * 1024)).toBe("10 KB"); + }); +}); diff --git a/frontend/test/shelf.test.ts b/frontend/test/shelf.test.ts new file mode 100644 index 0000000..5cd36c5 --- /dev/null +++ b/frontend/test/shelf.test.ts @@ -0,0 +1,25 @@ +import { expect, it } from "vitest"; +import type { Book } from "../src/api/types"; +import { sections, sortBooks } from "../src/lib/shelf"; + +const b = (over: Partial): Book => + ({ id: 1, path: "p", title: "t", format: "cbz", size: 1, pages: 1, percent: 0, added_at: "2026-01-01T00:00:00Z", library_id: 1, state: "ready", cover_url: "", page_url_fmt: "", pages_url: "", error: "", ...over }) as Book; + +it("sortBooks: added 新→旧 / title zh 本地化 / progress 高→低且并列按 added", () => { + const old = b({ id: 1, added_at: "2026-01-01T00:00:00Z", title: "b" }); + const nw = b({ id: 2, added_at: "2026-02-01T00:00:00Z", title: "a" }); + expect(sortBooks([old, nw], "added").map((x) => x.id)).toEqual([2, 1]); + expect(sortBooks([old, nw], "title").map((x) => x.id)).toEqual([2, 1]); + expect(sortBooks([b({ id: 3, percent: 0.2 }), b({ id: 4, percent: 0.9 })], "progress").map((x) => x.id)).toEqual([4, 3]); + const tie = [b({ id: 5, percent: 0.5, added_at: "2026-01-01T00:00:00Z" }), b({ id: 6, percent: 0.5, added_at: "2026-03-01T00:00:00Z" })]; + expect(sortBooks(tie, "progress").map((x) => x.id)).toEqual([6, 5]); +}); + +it("sections: flat 单段无标题;dir 按目录分组且组内排序、根目录置顶", () => { + const books = [b({ id: 1, path: "x/a.cbz" }), b({ id: 2, path: "b.cbz", title: "B" }), b({ id: 3, path: "x/c.cbz", title: "A" })]; + // 容器 Intl zh 实测序 A < B < t (localeCompare("zh")),故 title 升序为 id3,id2,id1 + expect(sections(books, "title", "flat")).toEqual([{ label: "", books: [books[2], books[1], books[0]] }]); + const g = sections(books, "title", "dir"); + expect(g.map((s) => s.label)).toEqual(["", "x"]); + expect(g[1].books.map((x) => x.title)).toEqual(["A", "t"]); // x 组内按 title 排序 +});