"use client"
import { Settings01 } from "@/components/settings-01/settings-01"
/**
* 设置页入口:用 onSave 把设置存到你自己的后端。
* 回调返回 Promise;失败时 throw new Error("中文原因"),原因会显示在底部保存栏上。
* 想把改动实时应用到页面(例如切换主题),在 onValueChange 里处理。
*/
export default function SettingsPage() {
return (
<Settings01
onSave={async (values) => {
// TODO: 调用你的接口,例如 await fetch("/api/settings", { method: "PUT", body: JSON.stringify(values) })
console.info("save settings", values)
await new Promise((resolve) => setTimeout(resolve, 600))
}}
/>
)
}
"use client"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check, LoaderCircle, Settings } from "lucide-react"
import { useMemo, useState, type ReactNode } from "react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { flowToneStyle } from "@/components/ui/flow-diagram/tone"
import { AppearanceSection } from "./appearance-section"
import {
DEFAULT_VALUES,
SECTION_IDS,
SECTION_KEYS,
formatLabel,
resolveLabels,
type Settings01Labels,
} from "./config"
import { EditorSection } from "./editor-section"
import { NotificationsSection } from "./notifications-section"
import { PrivacySection } from "./privacy-section"
import type { SectionProps, SetValue } from "./section-layout"
import { SectionNav, SectionTabs } from "./section-nav"
import { SoundSection } from "./sound-section"
import type { Settings01SectionId, Settings01Values } from "./types"
import { useSaveAction } from "./use-save-action"
import { isSameValue } from "./utils"
const SECTIONS: Record<
Settings01SectionId,
(props: SectionProps) => ReactNode
> = {
appearance: AppearanceSection,
notifications: NotificationsSection,
sound: SoundSection,
editor: EditorSection,
privacy: PrivacySection,
}
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
// 未传回调时的示例实现:只在控制台打印,并模拟 0.8 秒的网络请求
async function demoSave(values: Settings01Values) {
console.info("[settings-01] onSave", values)
await wait(800)
}
function pickKeys(
values: Settings01Values,
keys: readonly (keyof Settings01Values)[],
) {
return Object.fromEntries(keys.map((key) => [key, values[key]]))
}
/**
* 设置页:左侧分区导航(窄屏变成顶部分段选择)+ 右侧当前分区的控制面板 + 底部保存栏。
* 值同时支持受控与非受控;保存回调返回 Promise,组件管理 loading,回调 throw 的 Error.message 会显示在保存栏上。
*/
function Settings01({
value: valueProp,
defaultValue,
defaults,
onValueChange,
section: sectionProp,
defaultSection = "appearance",
onSectionChange,
onSave = demoSave,
labels,
className,
}: {
/** 受控的全部设置值 */
value?: Settings01Values
/** 非受控时的初始值,没写的项取 `defaults` */
defaultValue?: Partial<Settings01Values>
/** 出厂默认值,逐项覆盖 config.ts 的 DEFAULT_VALUES;「已修改」「恢复默认」都以它为准 */
defaults?: Partial<Settings01Values>
/** 任何一项变化时回调,参数是变化后的全部设置 */
onValueChange?: (value: Settings01Values) => void
/** 受控的当前分区 */
section?: Settings01SectionId
defaultSection?: Settings01SectionId
onSectionChange?: (section: Settings01SectionId) => void
/** 点击「保存」时调用;失败时 throw new Error("原因"),原因会显示在保存栏 */
onSave?: (values: Settings01Values) => Promise<void>
/** 覆盖默认文案,见 config.ts 的 DEFAULT_LABELS */
labels?: Partial<Settings01Labels>
className?: string
}) {
const text = resolveLabels(labels)
const reducedMotion = useReducedMotion()
const resolvedDefaults = useMemo<Settings01Values>(
() => ({ ...DEFAULT_VALUES, ...defaults }),
[defaults],
)
const [values, setValues] = useControllableState<Settings01Values>({
prop: valueProp,
defaultProp: { ...resolvedDefaults, ...defaultValue },
onChange: onValueChange,
})
const [section, setSection] = useControllableState<Settings01SectionId>({
prop: sectionProp,
defaultProp: defaultSection,
onChange: onSectionChange,
})
// 上一次保存(或首次载入)时的值:和默认值比出「已修改」,和它比出「有未保存的更改」
const [saved, setSaved] = useState(values)
const action = useSaveAction(text.saveFailed)
const set: SetValue = (key, next) => {
setValues((previous) => ({ ...previous, [key]: next }))
action.reset()
}
const modified = (key: keyof Settings01Values) =>
!isSameValue(values[key], resolvedDefaults[key])
const counts = Object.fromEntries(
SECTION_IDS.map((id) => [id, SECTION_KEYS[id].filter(modified).length]),
) as Record<Settings01SectionId, number>
const total = SECTION_IDS.reduce((sum, id) => sum + counts[id], 0)
const dirty = (Object.keys(values) as (keyof Settings01Values)[]).some(
(key) => !isSameValue(values[key], saved[key]),
)
const pending = action.status === "pending"
const resetSection = (id: Settings01SectionId) => {
setValues((previous) => ({
...previous,
...pickKeys(resolvedDefaults, SECTION_KEYS[id]),
}))
action.reset()
}
const resetAll = () => {
setValues({ ...resolvedDefaults })
action.reset()
}
const save = async () => {
const snapshot = values
if (await action.run(() => onSave(snapshot))) setSaved(snapshot)
}
const Section = SECTIONS[section]
const sectionProps: SectionProps = {
values,
labels: text,
set,
modified,
count: counts[section],
onReset: () => resetSection(section),
getCode: () =>
JSON.stringify(pickKeys(values, SECTION_KEYS[section]), null, 2),
}
return (
<div
data-slot="settings-01"
className={cn(
"relative flex min-h-svh flex-col bg-background text-foreground",
className,
)}
// 强调色同时作为 --flow-tone,导航的选中标记等跟着它走
style={flowToneStyle(values.accent)}
>
<header className="border-b border-edge">
<div className="mx-auto flex w-full max-w-6xl items-center gap-3 px-4 py-4 sm:px-6 lg:border-x lg:border-edge">
<span
aria-hidden="true"
className="flex size-8 shrink-0 items-center justify-center rounded-md border border-edge bg-muted/50"
>
<Settings className="size-4" />
</span>
<div className="min-w-0">
<h1 className="text-base leading-tight font-semibold tracking-tight">
{text.title}
</h1>
<p className="truncate text-xs text-muted-foreground">
{text.description}
</p>
</div>
</div>
</header>
<div className="mx-auto flex w-full max-w-6xl flex-1 flex-col md:flex-row lg:border-x lg:border-edge">
<SectionNav
section={section}
onSectionChange={setSection}
counts={counts}
labels={text}
/>
<SectionTabs
section={section}
onSectionChange={setSection}
labels={text}
tone={values.accent}
/>
<main className="min-w-0 flex-1 p-4 sm:p-6">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={section}
initial={{ opacity: 0, y: reducedMotion ? 0 : 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reducedMotion ? 0 : -8 }}
transition={{ duration: 0.18 }}
>
<Section {...sectionProps} />
</motion.div>
</AnimatePresence>
</main>
</div>
<footer
aria-label={text.footerLabel}
data-slot="settings-01-footer"
className="sticky bottom-0 z-20 border-t border-edge bg-background/90 backdrop-blur"
>
<div className="mx-auto flex w-full max-w-6xl items-center justify-between gap-3 px-4 py-3 sm:px-6 lg:border-x lg:border-edge">
<div className="min-w-0 text-sm">
<p className="truncate font-medium tabular-nums">
{total > 0
? formatLabel(text.modifiedCount, { count: total })
: text.unmodified}
</p>
{/* 状态行固定占一行高度,避免保存时底栏跳动;出错时同一位置换成红色的错误原因 */}
{action.status === "error" ? (
<p
role="alert"
data-slot="settings-01-error"
className="min-h-4 truncate text-xs text-destructive"
>
{action.error}
</p>
) : (
<p
role="status"
data-slot="settings-01-status"
className="flex min-h-4 items-center gap-1 truncate text-xs text-muted-foreground"
>
{pending ? (
text.saving
) : action.status === "saved" ? (
<>
<Check className="size-3.5 shrink-0" />
{text.saved}
</>
) : dirty ? (
text.unsaved
) : (
text.upToDate
)}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
disabled={total === 0 || pending}
onClick={resetAll}
>
{text.resetAll}
</Button>
<Button disabled={!dirty || pending} onClick={save}>
{pending ? (
<LoaderCircle className="animate-spin" data-icon="inline-start" />
) : null}
{pending ? text.saving : text.save}
</Button>
</div>
</div>
</footer>
</div>
)
}
export { Settings01 }
export type { Settings01Labels, Settings01SectionId, Settings01Values }
import type { Settings01SectionId, Settings01Values } from "./types"
/**
* 设置页所有用户可见的文案。
* 含 {count}、{value}、{section} 的文案在渲染时由 formatLabel 填入实际值。
*/
const DEFAULT_LABELS = {
// 页面
title: "设置",
description: "调整外观、通知、声音与编辑器的偏好",
navLabel: "设置分区",
navSectionModified: "已修改 {count} 项",
// 分区名(导航与面板)
sectionAppearance: "外观",
sectionNotifications: "通知",
sectionSound: "声音与触感",
sectionEditor: "编辑器",
sectionPrivacy: "隐私",
// 窄屏顶部分段选择里的短名,宽度有限所以单独一份
navAppearance: "外观",
navNotifications: "通知",
navSound: "声音",
navEditor: "编辑器",
navPrivacy: "隐私",
// 分区说明
descriptionAppearance: "主题、强调色与界面尺寸,右侧预览会实时变化",
descriptionNotifications: "决定哪些事件会打扰你,以及什么时候不打扰",
descriptionSound: "音量、按键音效与触感反馈",
descriptionEditor: "缩进、行高与空白字符的显示方式",
descriptionPrivacy: "谁能看到你,以及我们收集哪些数据",
// 面板头部
panelModified: "已修改 {count} 项",
panelDefault: "全部为默认值",
copySection: "复制本分区设置",
copiedSection: "已复制",
resetSection: "恢复本分区默认值",
// 外观
groupTheme: "主题",
groupShape: "尺寸与形状",
groupMotion: "动态",
theme: "主题模式",
themeLight: "浅色",
themeDark: "深色",
themeSystem: "跟随系统",
accent: "强调色",
fontSize: "界面字号",
radius: "圆角",
density: "信息密度",
densityCompact: "紧凑",
densityComfortable: "舒适",
densitySpacious: "宽松",
reduceMotion: "减少动态效果",
reduceMotionHint: "关闭动画",
readoutPx: "{value}px",
// 预览卡片
previewTitle: "实时预览",
previewHeading: "本周回顾",
previewBody: "已完成 12 项任务,比上周多 3 项。",
previewAction: "查看详情",
previewProgress: "进度",
previewLive: "实时",
previewModeLight: "浅色",
previewModeDark: "深色",
previewModeSystem: "跟随系统",
// 通知
groupChannels: "通知渠道",
groupQuiet: "免打扰",
notifications: "允许通知",
notificationsHint: "总开关",
notifyMentions: "提及我",
notifyMentionsHint: "有人 @ 我时",
notifyReplies: "评论回复",
notifyRepliesHint: "有人回复时",
notifyEmail: "邮件摘要",
notifyEmailHint: "每天一封",
notifyUpdates: "产品动态",
notifyUpdatesHint: "新功能上线",
quietHours: "免打扰时段",
// 声音与触感
groupVolume: "音量",
groupFeedback: "反馈",
volume: "主音量",
keySound: "按键音效",
keySoundHint: "输入时发声",
soundTheme: "音色",
soundSoft: "柔和",
soundCrisp: "清脆",
soundMechanical: "机械",
haptics: "触感反馈",
hapticsHint: "设备震动",
// 编辑器
groupIndent: "缩进与行距",
groupWhitespace: "空白字符",
indent: "缩进宽度",
indentTwo: "2 空格",
indentFour: "4 空格",
indentTab: "制表符",
lineHeight: "行高",
showTabs: "显示制表符",
showTabsHint: "标出缩进",
editorPreviewTitle: "代码预览",
// 隐私
groupProfile: "个人资料",
groupData: "数据",
visibility: "资料可见范围",
visibilityPublic: "公开",
visibilityTeam: "团队",
visibilityPrivate: "仅自己",
analytics: "使用统计",
analyticsHint: "匿名数据",
crashReports: "崩溃报告",
crashReportsHint: "自动上报",
retention: "历史保留",
readoutDays: "{value} 天",
// 底部操作栏
footerLabel: "保存设置",
modifiedCount: "已修改 {count} 项",
unmodified: "全部为默认值",
unsaved: "有未保存的更改",
upToDate: "没有未保存的更改",
saving: "保存中…",
saved: "已保存",
save: "保存",
resetAll: "恢复默认",
saveFailed: "保存失败,请稍后重试",
} as const
type Settings01Labels = { [Key in keyof typeof DEFAULT_LABELS]: string }
/** 出厂默认值。组件的「已修改」判断、恢复默认和未受控时的初始值都以它为准。 */
const DEFAULT_VALUES: Settings01Values = {
theme: "system",
accent: "primary",
fontSize: 14,
radius: 10,
density: "comfortable",
reduceMotion: false,
notifications: true,
notifyMentions: true,
notifyReplies: true,
notifyEmail: false,
notifyUpdates: false,
quietHours: [22, 31],
volume: 60,
keySound: false,
soundTheme: "soft",
haptics: true,
indent: "2",
lineHeight: 1.6,
showTabs: false,
visibility: "team",
analytics: true,
crashReports: true,
retention: 90,
}
/** 分区的显示顺序 */
const SECTION_IDS: readonly Settings01SectionId[] = [
"appearance",
"notifications",
"sound",
"editor",
"privacy",
]
/** 每个分区管哪些设置项:分区计数、分区复制与分区恢复默认都按它来。 */
const SECTION_KEYS: Record<Settings01SectionId, readonly (keyof Settings01Values)[]> =
{
appearance: [
"theme",
"accent",
"fontSize",
"radius",
"density",
"reduceMotion",
],
notifications: [
"notifications",
"notifyMentions",
"notifyReplies",
"notifyEmail",
"notifyUpdates",
"quietHours",
],
sound: ["volume", "keySound", "soundTheme", "haptics"],
editor: ["indent", "lineHeight", "showTabs"],
privacy: ["visibility", "analytics", "crashReports", "retention"],
}
/** 每个分区用到的文案键:完整名称、说明与窄屏短名。 */
const SECTION_LABEL_KEYS: Record<
Settings01SectionId,
{
title: keyof Settings01Labels
description: keyof Settings01Labels
nav: keyof Settings01Labels
}
> = {
appearance: {
title: "sectionAppearance",
description: "descriptionAppearance",
nav: "navAppearance",
},
notifications: {
title: "sectionNotifications",
description: "descriptionNotifications",
nav: "navNotifications",
},
sound: {
title: "sectionSound",
description: "descriptionSound",
nav: "navSound",
},
editor: {
title: "sectionEditor",
description: "descriptionEditor",
nav: "navEditor",
},
privacy: {
title: "sectionPrivacy",
description: "descriptionPrivacy",
nav: "navPrivacy",
},
}
/** 合并用户传入的文案与默认文案。 */
function resolveLabels(labels?: Partial<Settings01Labels>): Settings01Labels {
return { ...DEFAULT_LABELS, ...labels }
}
/** 把文案里的 {key} 占位符替换成实际值,例如「已修改 {count} 项」。 */
function formatLabel(
template: string,
values: Record<string, string | number>,
): string {
return Object.entries(values).reduce(
(text, [key, value]) => text.replaceAll(`{${key}}`, String(value)),
template,
)
}
export {
DEFAULT_LABELS,
DEFAULT_VALUES,
SECTION_IDS,
SECTION_KEYS,
SECTION_LABEL_KEYS,
formatLabel,
resolveLabels,
}
export type { Settings01Labels }
type ThemeMode = "light" | "dark" | "system"
type Density = "compact" | "comfortable" | "spacious"
type SoundTheme = "soft" | "crisp" | "mechanical"
type IndentWidth = "2" | "4" | "tab"
type ProfileVisibility = "public" | "team" | "private"
/** 设置页的全部取值。键按分区归类,见 config.ts 的 SECTION_KEYS。 */
type Settings01Values = {
// 外观
theme: ThemeMode
/** 强调色,取值是 tone-picker 的色调名(primary、blue……)或任意 CSS 颜色 */
accent: string
/** 界面字号(px) */
fontSize: number
/** 圆角(px) */
radius: number
density: Density
reduceMotion: boolean
// 通知
notifications: boolean
notifyMentions: boolean
notifyReplies: boolean
notifyEmail: boolean
notifyUpdates: boolean
/** 免打扰时段 [起点, 终点],单位小时,范围 12–36(36 即次日 12:00),读数按 24 小时取模 */
quietHours: [number, number]
// 声音与触感
volume: number
keySound: boolean
soundTheme: SoundTheme
haptics: boolean
// 编辑器
indent: IndentWidth
lineHeight: number
showTabs: boolean
// 隐私
visibility: ProfileVisibility
analytics: boolean
crashReports: boolean
/** 历史记录保留天数 */
retention: number
}
type Settings01SectionId =
| "appearance"
| "notifications"
| "sound"
| "editor"
| "privacy"
export type {
Density,
IndentWidth,
ProfileVisibility,
Settings01SectionId,
Settings01Values,
SoundTheme,
ThemeMode,
}
import { DEFAULT_LABELS } from "./config"
/** 两个设置值是否相同;区间(数组)逐项比较。 */
function isSameValue(a: unknown, b: unknown): boolean {
if (Array.isArray(a) && Array.isArray(b))
return a.length === b.length && a.every((item, index) => item === b[index])
return Object.is(a, b)
}
/**
* 把小时数格式化成「22:00」。
* 免打扰滑块的范围是 12–36(跨午夜),所以先对 24 取模;用 Intl 并固定 UTC,保证服务端与客户端一致。
*/
function formatHour(hour: number, locale: string = "zh-CN"): string {
const whole = Math.floor(hour)
const minutes = Math.round((hour - whole) * 60)
return new Intl.DateTimeFormat(locale, {
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
timeZone: "UTC",
}).format(new Date(Date.UTC(1970, 0, 1, whole % 24, minutes)))
}
/** 把未知错误转成可展示的文案;没有 message 时用兜底文案。 */
function errorMessage(
error: unknown,
fallback: string = DEFAULT_LABELS.saveFailed,
) {
return error instanceof Error && error.message ? error.message : fallback
}
export { errorMessage, formatHour, isSameValue }
import type * as React from "react"
import { useId } from "react"
import { cn } from "@/lib/utils"
import {
ControlPanel,
ControlPanelActions,
ControlPanelCopy,
ControlPanelHeader,
ControlPanelReset,
ControlPanelTitle,
} from "@/components/ui/control-panel/control-panel"
import {
SECTION_LABEL_KEYS,
formatLabel,
type Settings01Labels,
} from "./config"
import type { Settings01SectionId, Settings01Values } from "./types"
type SetValue = <Key extends keyof Settings01Values>(
key: Key,
value: Settings01Values[Key],
) => void
/** 每个分区组件收到的同一组属性 */
type SectionProps = {
values: Settings01Values
labels: Settings01Labels
set: SetValue
/** 某一项是否与默认值不同,传给控件的 `modified` */
modified: (key: keyof Settings01Values) => boolean
/** 本分区有几项不是默认值 */
count: number
/** 把本分区恢复为默认值 */
onReset: () => void
/** 本分区当前取值的 JSON,复制按钮用 */
getCode: () => string
}
/**
* 分区外壳:标题与说明 + 控制面板(头部显示本分区已修改项数,带复制与恢复)。
* 有 `aside` 时在宽屏放到右侧,窄屏排在面板上方。
*/
function SectionLayout({
id,
labels,
count,
onReset,
getCode,
aside,
children,
}: Pick<SectionProps, "labels" | "count" | "onReset" | "getCode"> & {
id: Settings01SectionId
aside?: React.ReactNode
children: React.ReactNode
}) {
const headingId = useId()
const keys = SECTION_LABEL_KEYS[id]
return (
<section
id={`settings-01-${id}`}
aria-labelledby={headingId}
data-slot="settings-01-section"
data-section={id}
className="flex flex-col gap-4"
>
<header>
<h2
id={headingId}
className="text-base font-semibold tracking-tight"
>
{labels[keys.title]}
</h2>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
{labels[keys.description]}
</p>
</header>
<div
className={cn(
"grid items-start gap-6",
aside && "lg:grid-cols-[minmax(0,1fr)_16rem]",
)}
>
<ControlPanel className={cn("max-w-2xl", aside && "lg:max-w-none")}>
<ControlPanelHeader>
<ControlPanelTitle>
{count > 0
? formatLabel(labels.panelModified, { count })
: labels.panelDefault}
</ControlPanelTitle>
<ControlPanelActions>
<ControlPanelCopy
code={getCode}
label={labels.copySection}
copiedLabel={labels.copiedSection}
/>
<ControlPanelReset
label={labels.resetSection}
disabled={count === 0}
onClick={onReset}
/>
</ControlPanelActions>
</ControlPanelHeader>
{children}
</ControlPanel>
{aside ? (
<div
data-slot="settings-01-aside"
className="order-first lg:sticky lg:top-6 lg:order-none"
>
{aside}
</div>
) : null}
</div>
</section>
)
}
export { SectionLayout }
export type { SectionProps, SetValue }
import type * as React from "react"
import { Bell, Code, Palette, ShieldCheck, Volume2 } from "lucide-react"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
import {
SECTION_IDS,
SECTION_LABEL_KEYS,
formatLabel,
type Settings01Labels,
} from "./config"
import type { Settings01SectionId } from "./types"
const SECTION_ICONS: Record<
Settings01SectionId,
React.ComponentType<{ className?: string }>
> = {
appearance: Palette,
notifications: Bell,
sound: Volume2,
editor: Code,
privacy: ShieldCheck,
}
/** 宽屏左侧的分区导航:一行一个分区,右侧小圆标显示该分区已修改的项数。 */
function SectionNav({
section,
onSectionChange,
counts,
labels,
}: {
section: Settings01SectionId
onSectionChange: (section: Settings01SectionId) => void
counts: Record<Settings01SectionId, number>
labels: Settings01Labels
}) {
return (
<nav
aria-label={labels.navLabel}
data-slot="settings-01-nav"
className="hidden w-52 shrink-0 flex-col border-r border-edge md:flex"
>
{SECTION_IDS.map((id) => {
const Icon = SECTION_ICONS[id]
const active = id === section
const count = counts[id]
return (
<button
key={id}
type="button"
aria-current={active ? "page" : undefined}
data-slot="settings-01-nav-item"
onClick={() => onSectionChange(id)}
className="relative flex h-10 w-full shrink-0 cursor-pointer items-center gap-2.5 border-b border-edge px-4 text-start text-sm text-muted-foreground outline-none transition-colors before:absolute before:inset-y-0 before:left-0 before:w-0.5 hover:bg-muted/50 hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:ring-inset aria-[current=page]:bg-muted/60 aria-[current=page]:font-medium aria-[current=page]:text-foreground aria-[current=page]:before:bg-(--flow-tone)"
>
<Icon className="size-4 shrink-0" />
<span className="truncate">{labels[SECTION_LABEL_KEYS[id].title]}</span>
{count > 0 ? (
<span
data-slot="settings-01-nav-count"
title={formatLabel(labels.navSectionModified, { count })}
className="ms-auto min-w-5 rounded-full bg-foreground/10 px-1.5 text-center text-[11px] leading-5 text-foreground tabular-nums"
>
{count}
<span className="sr-only">
{formatLabel(labels.navSectionModified, { count })}
</span>
</span>
) : null}
</button>
)
})}
</nav>
)
}
/** 窄屏顶部的分段选择:用短名,五项刚好排进 375px。 */
function SectionTabs({
section,
onSectionChange,
labels,
tone,
}: {
section: Settings01SectionId
onSectionChange: (section: Settings01SectionId) => void
labels: Settings01Labels
tone: string
}) {
return (
<div
data-slot="settings-01-tabs"
className="border-b border-edge p-4 sm:px-6 md:hidden"
>
<SegmentedControl
aria-label={labels.navLabel}
tone={tone}
value={section}
onValueChange={(next) => onSectionChange(next as Settings01SectionId)}
options={SECTION_IDS.map((id) => ({
value: id,
label: labels[SECTION_LABEL_KEYS[id].nav],
}))}
/>
</div>
)
}
export { SectionNav, SectionTabs }
import type * as React from "react"
import { Monitor, Moon, Sparkles, Sun } from "lucide-react"
import { cn } from "@/lib/utils"
import { flowToneStyle } from "@/components/ui/flow-diagram/tone"
import type { Settings01Labels } from "./config"
import type { Density, Settings01Values, ThemeMode } from "./types"
const DENSITY_CLASSES: Record<Density, string> = {
compact: "gap-2 p-3",
comfortable: "gap-3 p-4",
spacious: "gap-4 p-5",
}
const MODE_ICONS: Record<ThemeMode, React.ComponentType<{ className?: string }>> =
{ light: Sun, dark: Moon, system: Monitor }
/** 预览卡片的外框:标题栏 + 内容区。纯展示,对读屏隐藏。 */
function PreviewFrame({
title,
badge,
dark = false,
className,
style,
children,
}: {
title: string
badge?: React.ReactNode
/** 在卡片内套用深色主题变量 */
dark?: boolean
className?: string
style?: React.CSSProperties
children: React.ReactNode
}) {
return (
<div
aria-hidden="true"
data-slot="settings-01-preview"
className={cn(
"overflow-hidden rounded-lg border border-edge bg-muted/40 text-foreground",
dark && "dark bg-background",
className,
)}
style={style}
>
<div className="flex h-10 items-center justify-between gap-2 border-b border-edge px-3 text-xs text-muted-foreground">
<span className="truncate">{title}</span>
{badge ? (
<span className="flex shrink-0 items-center gap-1 [&_svg]:size-3.5">
{badge}
</span>
) : null}
</div>
{children}
</div>
)
}
/**
* 外观预览:一张小卡片,随主题模式、强调色、字号、圆角、信息密度和动态效果实时变化。
* 深色用 `.dark` 变量在卡片内套用;站点本身已是深色时,浅色无法在卡片里反向还原,所以标题栏另外写出当前模式。
*/
function AppearancePreview({
values,
labels,
}: {
values: Settings01Values
labels: Settings01Labels
}) {
const ModeIcon = MODE_ICONS[values.theme]
const modeLabel = {
light: labels.previewModeLight,
dark: labels.previewModeDark,
system: labels.previewModeSystem,
}[values.theme]
return (
<PreviewFrame
title={labels.previewTitle}
dark={values.theme === "dark"}
badge={
<>
<ModeIcon />
{modeLabel}
</>
}
style={
{
...flowToneStyle(values.accent),
"--preview-radius": `${values.radius}px`,
} as React.CSSProperties
}
>
<div className="p-3">
<div
className={cn(
"flex flex-col rounded-(--preview-radius) border border-edge bg-background shadow-xs transition-[gap,padding] duration-200 motion-reduce:transition-none",
DENSITY_CLASSES[values.density],
)}
style={{ fontSize: values.fontSize }}
>
<div className="flex items-center gap-2.5">
<span className="flex size-8 shrink-0 items-center justify-center rounded-(--preview-radius) bg-(--flow-tone)/15 text-(--flow-tone)">
<Sparkles className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[1.1em] leading-tight font-medium">
{labels.previewHeading}
</p>
<p className="flex items-center gap-1.5 text-[0.8em] text-muted-foreground">
<span
className={cn(
"size-1.5 rounded-full bg-(--flow-tone)",
!values.reduceMotion && "animate-pulse motion-reduce:animate-none",
)}
/>
{labels.previewLive}
</p>
</div>
</div>
<p className="text-[1em] leading-snug text-muted-foreground">
{labels.previewBody}
</p>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between text-[0.8em] text-muted-foreground">
<span>{labels.previewProgress}</span>
<span className="tabular-nums">72%</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full w-[72%] rounded-full bg-(--flow-tone)" />
</div>
</div>
{/* 模拟按钮:高度取默认档 32px,圆角跟随预览圆角 */}
<span className="inline-flex h-8 items-center justify-center self-start rounded-(--preview-radius) border border-(--flow-tone) bg-(--flow-tone)/10 px-3 text-[0.9em] font-medium">
{labels.previewAction}
</span>
</div>
</div>
</PreviewFrame>
)
}
/** 示例代码:level 是嵌套层级 */
const SAMPLE_CODE = [
{ level: 0, text: "function greet(name) {" },
{ level: 1, text: "if (name) {" },
{ level: 2, text: "return `Hi, ${name}`" },
{ level: 1, text: "}" },
{ level: 1, text: 'return "Hi"' },
{ level: 0, text: "}" },
] as const
const INDENT_WIDTH = { "2": 2, "4": 4, tab: 4 } as const
/** 编辑器预览:缩进宽度、行高与空白字符的显示会实时变化。 */
function EditorPreview({
values,
labels,
}: {
values: Settings01Values
labels: Settings01Labels
}) {
const width = INDENT_WIDTH[values.indent]
const marker = values.indent === "tab" ? "→" : "·".repeat(width)
return (
<PreviewFrame title={labels.editorPreviewTitle}>
<pre
className="overflow-hidden bg-background p-3 font-mono text-xs"
style={{ lineHeight: values.lineHeight }}
>
{SAMPLE_CODE.map((line, index) => (
<div key={index} className="flex">
<span className="w-5 shrink-0 pe-2 text-end text-muted-foreground/60 tabular-nums select-none">
{index + 1}
</span>
{Array.from({ length: line.level }, (_, unit) => (
<span
key={unit}
className="inline-block shrink-0 overflow-hidden text-muted-foreground/50"
style={{ width: `${width}ch` }}
>
{values.showTabs ? marker : ""}
</span>
))}
<span className="whitespace-pre">{line.text}</span>
</div>
))}
</pre>
</PreviewFrame>
)
}
export { AppearancePreview, EditorPreview }
import { Monitor, Moon, Sun } from "lucide-react"
import { ControlPanelGroup } from "@/components/ui/control-panel/control-panel"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
import { ToggleSwitch } from "@/components/ui/toggle-switch/toggle-switch"
import { TonePicker } from "@/components/ui/tone-picker/tone-picker"
import { ValueSlider } from "@/components/ui/value-slider/value-slider"
import { formatLabel } from "./config"
import { AppearancePreview } from "./previews"
import { SectionLayout, type SectionProps } from "./section-layout"
import type { Density, ThemeMode } from "./types"
/** 外观:主题、强调色、字号、圆角、信息密度、动态效果,右侧是实时预览。 */
function AppearanceSection(props: SectionProps) {
const { values, labels, set, modified } = props
const tone = values.accent
return (
<SectionLayout
id="appearance"
{...props}
aside={<AppearancePreview values={values} labels={labels} />}
>
<ControlPanelGroup title={labels.groupTheme}>
<SegmentedControl
label={labels.theme}
tone={tone}
value={values.theme}
onValueChange={(next) => set("theme", next as ThemeMode)}
modified={modified("theme")}
options={[
{ value: "light", label: labels.themeLight, icon: <Sun /> },
{ value: "dark", label: labels.themeDark, icon: <Moon /> },
{ value: "system", label: labels.themeSystem, icon: <Monitor /> },
]}
/>
<TonePicker
label={labels.accent}
value={values.accent}
onValueChange={(next) => set("accent", next)}
modified={modified("accent")}
/>
</ControlPanelGroup>
<ControlPanelGroup title={labels.groupShape} columns={2}>
<ValueSlider
label={labels.fontSize}
tone={tone}
min={12}
max={20}
step={1}
editable
formatValue={(value) =>
formatLabel(labels.readoutPx, { value })
}
value={values.fontSize}
onValueChange={(next) => set("fontSize", next)}
modified={modified("fontSize")}
/>
<ValueSlider
label={labels.radius}
tone={tone}
min={0}
max={20}
step={1}
editable
formatValue={(value) =>
formatLabel(labels.readoutPx, { value })
}
value={values.radius}
onValueChange={(next) => set("radius", next)}
modified={modified("radius")}
/>
<SegmentedControl
className="sm:col-span-2"
label={labels.density}
tone={tone}
value={values.density}
onValueChange={(next) => set("density", next as Density)}
modified={modified("density")}
options={[
{ value: "compact", label: labels.densityCompact },
{ value: "comfortable", label: labels.densityComfortable },
{ value: "spacious", label: labels.densitySpacious },
]}
/>
</ControlPanelGroup>
<ControlPanelGroup title={labels.groupMotion}>
<ToggleSwitch
label={labels.reduceMotion}
description={labels.reduceMotionHint}
tone={tone}
checked={values.reduceMotion}
onCheckedChange={(next) => set("reduceMotion", next)}
modified={modified("reduceMotion")}
/>
</ControlPanelGroup>
</SectionLayout>
)
}
export { AppearanceSection }
import { ControlPanelGroup } from "@/components/ui/control-panel/control-panel"
import { ToggleSwitch } from "@/components/ui/toggle-switch/toggle-switch"
import { ValueSlider } from "@/components/ui/value-slider/value-slider"
import { SectionLayout, type SectionProps } from "./section-layout"
import { formatHour } from "./utils"
/** 一天里从中午到次日中午,免打扰通常跨午夜,所以滑块范围取 12–36 */
const QUIET_MIN = 12
const QUIET_MAX = 36
/** 通知:总开关控制其余开关的禁用态,免打扰时段是区间滑块。 */
function NotificationsSection(props: SectionProps) {
const { values, labels, set, modified } = props
const tone = values.accent
const off = !values.notifications
return (
<SectionLayout id="notifications" {...props}>
<ControlPanelGroup title={labels.groupChannels} columns={2}>
<ToggleSwitch
className="sm:col-span-2"
label={labels.notifications}
description={labels.notificationsHint}
tone={tone}
checked={values.notifications}
onCheckedChange={(next) => set("notifications", next)}
modified={modified("notifications")}
/>
<ToggleSwitch
label={labels.notifyMentions}
description={labels.notifyMentionsHint}
tone={tone}
disabled={off}
checked={values.notifyMentions}
onCheckedChange={(next) => set("notifyMentions", next)}
modified={modified("notifyMentions")}
/>
<ToggleSwitch
label={labels.notifyReplies}
description={labels.notifyRepliesHint}
tone={tone}
disabled={off}
checked={values.notifyReplies}
onCheckedChange={(next) => set("notifyReplies", next)}
modified={modified("notifyReplies")}
/>
<ToggleSwitch
label={labels.notifyEmail}
description={labels.notifyEmailHint}
tone={tone}
disabled={off}
checked={values.notifyEmail}
onCheckedChange={(next) => set("notifyEmail", next)}
modified={modified("notifyEmail")}
/>
<ToggleSwitch
label={labels.notifyUpdates}
description={labels.notifyUpdatesHint}
tone={tone}
disabled={off}
checked={values.notifyUpdates}
onCheckedChange={(next) => set("notifyUpdates", next)}
modified={modified("notifyUpdates")}
/>
</ControlPanelGroup>
<ControlPanelGroup title={labels.groupQuiet}>
{/* 区间读数较长,用矮轨让标签与读数排在轨道上方,窄屏也不会被手柄压住 */}
<ValueSlider
label={labels.quietHours}
variant="slim"
tone={tone}
min={QUIET_MIN}
max={QUIET_MAX}
step={0.5}
largeStep={3}
minStepsBetweenValues={1}
ticks={[18, 24, 30]}
disabled={off}
formatValue={formatHour}
value={values.quietHours}
onValueChange={(next) => set("quietHours", [next[0], next[1]])}
modified={modified("quietHours")}
/>
</ControlPanelGroup>
</SectionLayout>
)
}
export { NotificationsSection }
import { Volume2, VolumeX } from "lucide-react"
import { ControlPanelGroup } from "@/components/ui/control-panel/control-panel"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
import { ToggleSwitch } from "@/components/ui/toggle-switch/toggle-switch"
import { ValueSlider } from "@/components/ui/value-slider/value-slider"
import { SectionLayout, type SectionProps } from "./section-layout"
import type { SoundTheme } from "./types"
/** 声音与触感:主音量带拖动音效,音色在按键音效关闭时禁用。 */
function SoundSection(props: SectionProps) {
const { values, labels, set, modified } = props
const tone = values.accent
return (
<SectionLayout id="sound" {...props}>
<ControlPanelGroup title={labels.groupVolume}>
<ValueSlider
label={labels.volume}
tone={tone}
sound
editable
min={0}
max={100}
step={1}
startIcon={<VolumeX />}
endIcon={<Volume2 />}
value={values.volume}
onValueChange={(next) => set("volume", next)}
modified={modified("volume")}
/>
</ControlPanelGroup>
<ControlPanelGroup title={labels.groupFeedback} columns={2}>
<ToggleSwitch
label={labels.keySound}
description={labels.keySoundHint}
tone={tone}
checked={values.keySound}
onCheckedChange={(next) => set("keySound", next)}
modified={modified("keySound")}
/>
<ToggleSwitch
label={labels.haptics}
description={labels.hapticsHint}
tone={tone}
checked={values.haptics}
onCheckedChange={(next) => set("haptics", next)}
modified={modified("haptics")}
/>
<SegmentedControl
className="sm:col-span-2"
label={labels.soundTheme}
tone={tone}
disabled={!values.keySound}
value={values.soundTheme}
onValueChange={(next) => set("soundTheme", next as SoundTheme)}
modified={modified("soundTheme")}
options={[
{ value: "soft", label: labels.soundSoft },
{ value: "crisp", label: labels.soundCrisp },
{ value: "mechanical", label: labels.soundMechanical },
]}
/>
</ControlPanelGroup>
</SectionLayout>
)
}
export { SoundSection }
import { ControlPanelGroup } from "@/components/ui/control-panel/control-panel"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
import { ToggleSwitch } from "@/components/ui/toggle-switch/toggle-switch"
import { ValueSlider } from "@/components/ui/value-slider/value-slider"
import { EditorPreview } from "./previews"
import { SectionLayout, type SectionProps } from "./section-layout"
import type { IndentWidth } from "./types"
/** 编辑器:缩进、行高与制表符显示,右侧是代码预览。 */
function EditorSection(props: SectionProps) {
const { values, labels, set, modified } = props
const tone = values.accent
return (
<SectionLayout
id="editor"
{...props}
aside={<EditorPreview values={values} labels={labels} />}
>
<ControlPanelGroup title={labels.groupIndent}>
<SegmentedControl
label={labels.indent}
tone={tone}
value={values.indent}
onValueChange={(next) => set("indent", next as IndentWidth)}
modified={modified("indent")}
options={[
{ value: "2", label: labels.indentTwo },
{ value: "4", label: labels.indentFour },
{ value: "tab", label: labels.indentTab },
]}
/>
<ValueSlider
label={labels.lineHeight}
tone={tone}
editable
min={1.2}
max={2}
step={0.1}
value={values.lineHeight}
onValueChange={(next) => set("lineHeight", next)}
modified={modified("lineHeight")}
/>
</ControlPanelGroup>
<ControlPanelGroup title={labels.groupWhitespace}>
<ToggleSwitch
label={labels.showTabs}
description={labels.showTabsHint}
tone={tone}
checked={values.showTabs}
onCheckedChange={(next) => set("showTabs", next)}
modified={modified("showTabs")}
/>
</ControlPanelGroup>
</SectionLayout>
)
}
export { EditorSection }
import { ControlPanelGroup } from "@/components/ui/control-panel/control-panel"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
import { ToggleSwitch } from "@/components/ui/toggle-switch/toggle-switch"
import { ValueSlider } from "@/components/ui/value-slider/value-slider"
import { formatLabel } from "./config"
import { SectionLayout, type SectionProps } from "./section-layout"
import type { ProfileVisibility } from "./types"
/** 隐私:资料可见范围、数据收集开关与历史保留天数。 */
function PrivacySection(props: SectionProps) {
const { values, labels, set, modified } = props
const tone = values.accent
return (
<SectionLayout id="privacy" {...props}>
<ControlPanelGroup title={labels.groupProfile}>
<SegmentedControl
label={labels.visibility}
tone={tone}
value={values.visibility}
onValueChange={(next) => set("visibility", next as ProfileVisibility)}
modified={modified("visibility")}
options={[
{ value: "public", label: labels.visibilityPublic },
{ value: "team", label: labels.visibilityTeam },
{ value: "private", label: labels.visibilityPrivate },
]}
/>
</ControlPanelGroup>
<ControlPanelGroup title={labels.groupData} columns={2}>
<ToggleSwitch
label={labels.analytics}
description={labels.analyticsHint}
tone={tone}
checked={values.analytics}
onCheckedChange={(next) => set("analytics", next)}
modified={modified("analytics")}
/>
<ToggleSwitch
label={labels.crashReports}
description={labels.crashReportsHint}
tone={tone}
checked={values.crashReports}
onCheckedChange={(next) => set("crashReports", next)}
modified={modified("crashReports")}
/>
<ValueSlider
className="sm:col-span-2"
label={labels.retention}
tone={tone}
editable
min={7}
max={365}
step={1}
largeStep={30}
ticks={[30, 90, 180]}
snapToTicks
formatValue={(value) => formatLabel(labels.readoutDays, { value })}
value={values.retention}
onValueChange={(next) => set("retention", next)}
modified={modified("retention")}
/>
</ControlPanelGroup>
</SectionLayout>
)
}
export { PrivacySection }
"use client"
import { useCallback, useState } from "react"
import { DEFAULT_LABELS } from "./config"
import { errorMessage } from "./utils"
type SaveStatus = "idle" | "pending" | "saved" | "error"
/** 包装保存动作:管理 loading,成功后记为 saved,抛出的错误转成可展示的文案。 */
function useSaveAction(fallbackError: string = DEFAULT_LABELS.saveFailed) {
const [status, setStatus] = useState<SaveStatus>("idle")
const [error, setError] = useState<string | null>(null)
const run = useCallback(
async (action: () => Promise<void>) => {
setStatus("pending")
setError(null)
try {
await action()
setStatus("saved")
return true
} catch (caught) {
setError(errorMessage(caught, fallbackError))
setStatus("error")
return false
}
},
[fallbackError],
)
/** 回到空闲:设置被再次修改后,「已保存」「出错」的提示就该消失 */
const reset = useCallback(() => {
setStatus("idle")
setError(null)
}, [])
return { status, error, run, reset }
}
export { useSaveAction }
export type { SaveStatus }
Settings01 是「方条控件」系列的落地样板:数值滑块、分段选择、开关、色调选择和控制面板拼在一起,就是一个能直接安装使用的设置页。
- 五个分区:外观、通知、声音与触感、编辑器、隐私。宽屏是左侧导航,导航项右侧的小圆标显示该分区已修改的项数;窄屏(< 768px)导航变成顶部的分段选择。
- 外观:主题模式(浅色 / 深色 / 跟随系统,带图标)、强调色、界面字号(可直接输入)、圆角、信息密度、减少动态效果。右上角的预览卡片随这些设置实时变化。强调色还会染到整页控件的选中态上。
- 通知:总开关关闭时其余开关和免打扰时段一起禁用;免打扰是一个区间滑块,读数写成「22:00 – 07:00」。
- 声音与触感:主音量滑块拖动时有刻度音效;按键音效关闭时音色选择禁用。
- 编辑器:缩进宽度、行高、制表符显示,右侧的代码预览同步变化。
- 隐私:资料可见范围、数据收集开关、历史保留天数(刻度吸附 30 / 90 / 180 天)。
- 已修改:每个控件的
modified由「当前值 ≠ 默认值」得出,改过的项标签变亮;每个分区的面板头部显示本分区已修改项数,并带「复制设置」和「恢复本分区默认值」两个按钮。 - 底部保存栏:显示总的已修改项数与保存状态,右侧是「恢复默认」和「保存」。保存时按钮进入 loading,成功显示「已保存」,失败显示回调抛出的
Error.message。
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/settings-01.json会安装 app/settings/page.tsx 页面和 components/settings-01/ 下的组件,并带上它依赖的本站控件:数值滑块、分段选择、开关、色调选择、控制面板(连同 flow-diagram 提供的色调变量)。shadcn 依赖为 button,npm 依赖为 motion、lucide-react。
用法
最简单的用法是直接渲染,不传任何属性。此时设置值在组件内部维护,「保存」退回「控制台打印 + 模拟 0.8 秒请求」的示例实现,方便先看效果:
import { Settings01 } from "@/components/settings-01/settings-01"
export default function SettingsPage() {
return <Settings01 />
}接入保存
onSave 返回 Promise。组件负责按钮的 loading 状态;失败时 throw new Error("中文原因"),原因会显示在保存栏上。 已有的设置用 defaultValue(或受控的 value)传入,没传的项取默认值:
"use client"
import {
Settings01,
type Settings01Values,
} from "@/components/settings-01/settings-01"
export default function SettingsPage({
saved,
}: {
saved: Partial<Settings01Values>
}) {
return (
<Settings01
defaultValue={saved}
onSave={async (values) => {
const response = await fetch("/api/settings", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(values),
})
if (!response.ok) throw new Error("保存失败,请稍后重试")
}}
/>
)
}受控用法与实时生效
value / onValueChange 让你把设置放进自己的状态里,例如切换主题时立刻生效,不必等点「保存」:
"use client"
import { useState } from "react"
import { useTheme } from "next-themes"
import {
Settings01,
type Settings01Values,
} from "@/components/settings-01/settings-01"
import { DEFAULT_VALUES } from "@/components/settings-01/config"
export default function SettingsPage() {
const { setTheme } = useTheme()
const [values, setValues] = useState<Settings01Values>(DEFAULT_VALUES)
return (
<Settings01
value={values}
onValueChange={(next) => {
if (next.theme !== values.theme) setTheme(next.theme)
setValues(next)
}}
onSave={async (next) => {
await saveToServer(next)
}}
/>
)
}自定义默认值
defaults 逐项覆盖出厂默认值。「已修改」「恢复默认」都以它为准,没有传 defaultValue 时它也是初始值:
<Settings01 defaults={{ accent: "blue", fontSize: 15, indent: "4" }} />换成英文
labels 覆盖任意文案,含 {count}、{value} 的占位符要保留:
import { Settings01, type Settings01Labels } from "@/components/settings-01/settings-01"
const english: Partial<Settings01Labels> = {
title: "Settings",
sectionAppearance: "Appearance",
navAppearance: "Look",
save: "Save",
resetAll: "Reset to defaults",
modifiedCount: "{count} changed",
readoutPx: "{value} px",
}
export default function SettingsPage() {
return <Settings01 labels={english} />
}API
Settings01
受控的全部设置值,见下方 Settings01Values
类型Settings01Values非受控时的初始值,没写的项取 defaults
Partial<Settings01Values>出厂默认值,逐项覆盖 config.ts 的 DEFAULT_VALUES;决定「已修改」的判断和「恢复默认」的目标
Partial<Settings01Values>任何一项变化时回调,参数是变化后的全部设置(受控与非受控都会触发)
类型(value: Settings01Values) => void受控的当前分区
类型Settings01SectionId非受控时的初始分区
类型"appearance" | "notifications" | "sound" | "editor" | "privacy""appearance"切换分区时回调
类型(section: Settings01SectionId) => void点击「保存」时调用;组件管理 loading,抛出的 Error.message 显示在保存栏。未传时只在控制台打印并等待 0.8 秒
(values: Settings01Values) => Promise<void>追加到最外层容器
类型stringSettings01Values
config.ts 的 SECTION_KEYS 记录了每个分区管哪些键。
主题模式
类型"light" | "dark" | "system""system"强调色,tone-picker 的色调名(primary、blue……)或任意 CSS 颜色
string"primary"界面字号(px),12–20
类型number14圆角(px),0–20
类型number10信息密度
类型"compact" | "comfortable" | "spacious""comfortable"减少动态效果
类型booleanfalse通知总开关,关闭后其余通知项与免打扰禁用
类型booleantrue提及我时通知
类型booleantrue评论回复时通知
类型booleantrue每日邮件摘要
类型booleanfalse产品动态
类型booleanfalse免打扰时段 [起点, 终点](小时),范围 12–36:36 即次日 12:00,读数按 24 小时取模,所以 [22, 31] 读作「22:00 – 07:00」
[number, number][22, 31]主音量,0–100
类型number60按键音效
类型booleanfalse按键音色,按键音效关闭时禁用
类型"soft" | "crisp" | "mechanical""soft"触感反馈
类型booleantrue编辑器缩进宽度
类型"2" | "4" | "tab""2"编辑器行高,1.2–2,步长 0.1
类型number1.6显示制表符与缩进标记
类型booleanfalse资料可见范围
类型"public" | "team" | "private""team"匿名使用统计
类型booleantrue崩溃报告
类型booleantrue历史记录保留天数,7–365
类型number90labels
所有键都在 config.ts 的 DEFAULT_LABELS 里,传 labels={{ save: "Save" }} 这样覆盖其中任意几项。含 {count}、{value} 的文案会在渲染时填入实际值。
| 键 | 默认值 | 说明 |
|---|---|---|
title / description | 设置 / 调整外观、通知、声音与编辑器的偏好 | 页面标题(h1)与说明 |
navLabel | 设置分区 | 导航与顶部分段选择的 aria-label |
navSectionModified | 已修改 {count} 项 | 导航项圆标的提示与读屏文字 |
sectionAppearance 等 5 个 | 外观 / 通知 / 声音与触感 / 编辑器 / 隐私 | 分区标题(h2) |
navAppearance 等 5 个 | 外观 / 通知 / 声音 / 编辑器 / 隐私 | 窄屏顶部分段选择里的短名 |
descriptionAppearance 等 5 个 | 见 config.ts | 分区说明 |
panelModified / panelDefault | 已修改 {count} 项 / 全部为默认值 | 面板头部的状态 |
copySection / copiedSection / resetSection | 复制本分区设置 / 已复制 / 恢复本分区默认值 | 面板头部两个图标按钮的名称 |
group* | 见 config.ts | 各面板里的分组小标题 |
theme、themeLight、themeDark、themeSystem | 主题模式、浅色、深色、跟随系统 | 主题模式 |
accent、fontSize、radius、density、densityCompact、densityComfortable、densitySpacious、reduceMotion、reduceMotionHint | 见 config.ts | 外观其余控件 |
readoutPx / readoutDays | {value} px / {value} 天 | 字号、圆角、保留天数的读数 |
preview* | 见 config.ts | 外观预览卡片里的文字 |
notifications、notify*、quietHours 及对应 *Hint | 见 config.ts | 通知控件与它们的补充说明 |
volume、keySound、soundTheme、sound*、haptics 及对应 *Hint | 见 config.ts | 声音与触感控件 |
indent、indentTwo、indentFour、indentTab、lineHeight、showTabs、showTabsHint、editorPreviewTitle | 见 config.ts | 编辑器控件与预览标题 |
visibility、visibility*、analytics、crashReports、retention 及对应 *Hint | 见 config.ts | 隐私控件 |
footerLabel | 保存设置 | 底部保存栏的 aria-label |
modifiedCount / unmodified | 已修改 {count} 项 / 全部为默认值 | 保存栏左侧的总计 |
unsaved / upToDate / saving / saved | 有未保存的更改 / 没有未保存的更改 / 保存中… / 已保存 | 保存栏左侧第二行的保存状态 |
save / resetAll | 保存 / 恢复默认 | 保存栏的两个按钮 |
saveFailed | 保存失败,请稍后重试 | 回调抛出的错误没有 message 时的兜底提示 |
可访问性
- 页面只有一个
h1,每个分区是带aria-labelledby的section,标题是h2;控制面板里的分组是带标题的role="group"。 - 宽屏导航是
nav,当前分区带aria-current="page";圆标除了数字还有读屏文字「已修改 N 项」。窄屏顶部的分段选择是单选组,有aria-label,方向键切换。 - 所有控件沿用各自的语义:滑块是原生滑块(方向键、Home / End、PageUp / PageDown),区间滑块的两个手柄分别叫「起点」「终点」,读数同时作为
aria-valuetext;开关是role="switch";色调选择与分段选择是单选组。 - 改过的项不只靠颜色区分:标签变亮之外,面板头部、导航圆标和保存栏都有文字计数。
- 保存状态是
role="status",保存失败是role="alert",会立即朗读;保存中按钮禁用并显示「保存中…」,避免重复提交。 - 预览卡片是纯展示,对读屏隐藏;图标按钮都有中文名称,焦点环可见。
- 视图切换动画遵循
prefers-reduced-motion,开启后只保留淡入;预览里的脉冲点在「减少动态效果」打开时也会静止。
注意事项
文件结构
app/settings/page.tsx # 页面入口:把 onSave 接到你的后端
components/settings-01/
├── settings-01.tsx # 入口:状态、已修改计数、分区切换、底部保存栏
├── config.ts # ★ DEFAULT_LABELS 文案、DEFAULT_VALUES 默认值、分区与键的对应
├── types.ts # Settings01Values 等类型
├── utils.ts # 值比较、小时格式化、错误文案
├── section-layout.tsx # 分区外壳:标题、控制面板头部(复制 / 恢复)、右侧预览位
├── section-nav.tsx # 宽屏左侧导航 + 窄屏顶部分段选择
├── appearance-section.tsx # 外观
├── notifications-section.tsx # 通知
├── sound-section.tsx # 声音与触感
├── editor-section.tsx # 编辑器
├── privacy-section.tsx # 隐私
├── previews.tsx # 外观预览卡片、编辑器代码预览
└── use-save-action.ts # 保存的 loading / 成功 / 错误状态- 已修改的口径:「已修改」比的是当前值与默认值(
defaults),「有未保存的更改」比的是当前值与上次保存(或首次载入)的值;两者独立。恢复默认后,如果和上次保存的值不同,「保存」仍然可点,用来把重置存下来。 - 免打扰时段:区间滑块的两个手柄不能交叉,而免打扰通常跨午夜,所以滑块范围取 12:00 到次日 12:00(值域 12–36),读数对 24 取模。需要全天范围时改
notifications-section.tsx里的QUIET_MIN/QUIET_MAX。 - 预览里的深色:深色靠在卡片上套用
.dark变量实现,站点本身已是深色时,选「浅色」无法在卡片里反向还原,所以预览标题栏另外写出了当前模式。 - 强调色:
accent同时传给所有控件的tone,并作为--flow-tone设在最外层,导航选中标记、预览卡片都跟着变;primary跟随当前颜色主题。 - 增删设置项:在
types.ts加键、config.ts的DEFAULT_VALUES和SECTION_KEYS登记,再到对应分区文件里加控件即可,计数、复制和恢复默认会自动包含它。 - 静态导出:组件是纯客户端组件,不使用 Server Actions 或
cookies(),可用于output: "export"。