diff --git a/frontend/src/components/reader-settings.test.tsx b/frontend/src/components/reader-settings.test.tsx
new file mode 100644
index 0000000..a208e6b
--- /dev/null
+++ b/frontend/src/components/reader-settings.test.tsx
@@ -0,0 +1,72 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { expect } from "vitest";
+import { beforeEach, describe, it } from "vitest";
+import { ReaderSettings } from "@/components/reader-settings";
+import { ThemeProvider } from "@/components/theme";
+import { DEFAULT_PREFS } from "@/lib/readerPrefs";
+
+beforeEach(() => {
+ localStorage.clear();
+});
+
+function renderSettings(props = {}) {
+ return render(
+
+ {}, ariaLabel: "页码跳转" }}
+ position="3/12 · 25%"
+ {...props}
+ />
+ ,
+ );
+}
+
+describe("ReaderSettings", () => {
+ it("常驻行渲染:滑条 aria-label 与位置文本", () => {
+ renderSettings();
+ expect(screen.getByRole("slider", { name: "页码跳转" })).toBeInTheDocument();
+ expect(screen.getByText("3/12 · 25%")).toBeInTheDocument();
+ });
+
+ it("主题卡点击 → localStorage 的 themeMode 变手动值", () => {
+ renderSettings();
+ fireEvent.click(screen.getByRole("button", { name: "夜色主题" }));
+ const stored = JSON.parse(localStorage.getItem("reader-prefs") ?? "{}");
+ expect(stored.themeMode).toBe("night");
+ });
+
+ it("「跟随全局」按钮存在且点击回 auto", () => {
+ renderSettings();
+ const follow = screen.getByRole("button", { name: "跟随全局" });
+ expect(follow).toBeInTheDocument();
+ expect(follow).toHaveAttribute("aria-pressed", "true");
+ fireEvent.click(screen.getByRole("button", { name: "米色主题" }));
+ expect(screen.getByRole("button", { name: "跟随全局" })).toHaveAttribute("aria-pressed", "false");
+ fireEvent.click(screen.getByRole("button", { name: "跟随全局" }));
+ const stored = JSON.parse(localStorage.getItem("reader-prefs") ?? "{}");
+ expect(stored.themeMode).toBe("auto");
+ });
+
+ it("showFont=false 时无 A−/A+", () => {
+ renderSettings({ showFont: false });
+ expect(screen.queryByRole("button", { name: "减小字号" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "加大字号" })).not.toBeInTheDocument();
+ });
+
+ it("「更多设置」展开/收起(aria-expanded 翻转、extra 可见性)", () => {
+ renderSettings({ extra:
额外面板
});
+ const toggle = screen.getByRole("button", { name: "更多阅读设置" });
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
+ expect(screen.queryByTestId("extra")).not.toBeInTheDocument();
+ fireEvent.click(toggle);
+ expect(toggle).toHaveAttribute("aria-expanded", "true");
+ expect(screen.getByTestId("extra")).toBeInTheDocument();
+ });
+
+ it("showFont 时 A+ 点击 → text.sizeIdx 增加", () => {
+ renderSettings({ showFont: true });
+ fireEvent.click(screen.getByRole("button", { name: "加大字号" }));
+ const stored = JSON.parse(localStorage.getItem("reader-prefs") ?? "{}");
+ expect(stored.text.sizeIdx).toBe(DEFAULT_PREFS.text.sizeIdx + 1);
+ });
+});
diff --git a/frontend/src/components/reader-settings.tsx b/frontend/src/components/reader-settings.tsx
new file mode 100644
index 0000000..a80044b
--- /dev/null
+++ b/frontend/src/components/reader-settings.tsx
@@ -0,0 +1,116 @@
+import { ChevronDown, ChevronUp } from "lucide-react";
+import { useState, type ReactNode } from "react";
+import { Button } from "@/components/ui/button";
+import { Slider } from "@/components/ui/slider";
+import { useGlobalDark } from "@/components/theme";
+import { RD_THEMES, RD_THEME_LABEL, FONT_SIZES, resolveRdTheme, useReaderPrefs, type RdTheme } from "@/lib/readerPrefs";
+
+export interface ReaderSettingsProps {
+ /** 进度滑条:value/max 页或千分比由调用方定,onChange 收整数 */
+ slider: { value: number; max: number; onChange: (v: number) => void; ariaLabel: string };
+ /** 位置文本,如 "3/12 · 25%" */
+ position?: string;
+ /** 左半区按钮(导航/上一章/下一章等),调用方给 */
+ left?: ReactNode;
+ /** 是否显示 A−/A+ 与字号(text/epub true;cbz/pdf false) */
+ showFont?: boolean;
+ /** 「更多设置」展开区内容(按格式给:CBZ 翻页/RTL/连读/预读;Text 行距/边距;EPUB 字号/行距/边距) */
+ extra?: ReactNode;
+}
+
+/** 主题色卡样式(纸 #faf8f4 / 米 #f0e6d2 / 夜 #171512,与 --rd-bg 三值一致) */
+const SWATCH: Record = { paper: "#faf8f4", sepia: "#f0e6d2", night: "#171512" };
+
+/** 统一阅读设置栏:常驻行 = 进度滑条 + 位置 + 左区 + 主题卡(含跟随全局)+ A−/A+;「更多设置」展开区按格式给。 */
+export function ReaderSettings({ slider, position, left, showFont = false, extra }: ReaderSettingsProps) {
+ const pr = useReaderPrefs();
+ const globalDark = useGlobalDark();
+ const rd = resolveRdTheme(pr.prefs.themeMode, globalDark);
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+ {left &&
{left}
}
+ {position && (
+
+ {position}
+
+ )}
+
+ {RD_THEMES.map((t) => (
+
+
+ {showFont && (
+ <>
+ pr.bumpText(-1)}
+ >
+ A−
+
+ pr.bumpText(1)}
+ >
+ A+
+
+ >
+ )}
+ {extra && (
+ setOpen((v) => !v)}
+ >
+ {open ? : }
+
+ )}
+
+
+
slider.onChange(v)}
+ aria-label={slider.ariaLabel}
+ />
+ {extra && open && {extra}
}
+
+ );
+}
diff --git a/frontend/src/components/ui/slider.tsx b/frontend/src/components/ui/slider.tsx
index 49d4c7d..82be525 100644
--- a/frontend/src/components/ui/slider.tsx
+++ b/frontend/src/components/ui/slider.tsx
@@ -14,6 +14,8 @@ function Slider({
() => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
[value, defaultValue, min, max],
);
+ // radix 的可访问 slider 角色在 Thumb 上,aria-label 提到 Root 传不进去——改下沉到每个 Thumb。
+ const { "aria-label": ariaLabel, ...rootProps } = props;
return (
))}