MioUI

设置页

Claude 风格的设置页区块。这个示例是真的在工作:改动会作用于整个 MioUI 网站。

设置

这里的选择会立即作用于整个网站,并保存在本机浏览器。

颜色模式

主题色

只改变强调色。背景、文字、边框和状态色保持不变。

字体

默认:标题用衬线,界面和正文用系统字体。中文始终走苹方与系统字体。

界面大小
整体缩放文字与控件

动效

减弱动效
关闭弹簧与过渡动画

实时预览

组件只读取令牌,改设置即可看到全站效果。

MioUI新

温润、克制,让内容先说话。

强调色只出现在关键操作和状态上,其余层级靠 Surface 明度与发丝边建立。

● 成功● 警告● 危险● 强调文字文档链接

包含

  • 颜色模式卡片(浅色 / 跟随系统 / 深色),带缩略图
  • 主题色选择(只改强调色)
  • 正文字体、界面大小、减弱动效
  • 实时预览卡片

源码

components/blocks/settings-panel.tsx
'use client';
import { useEffect, useState } from 'react';
import { useTheme } from 'next-themes';
import { motion } from 'motion/react';
import {
  Check,
  Sun,
  Moon,
  Desktop,
  PaintBrush,
  TextAa,
  Sparkle,
  ArrowCounterClockwise,
  MagnifyingGlass,
  PaperPlaneRight,
} from '@phosphor-icons/react';
import { ACCENTS, useSettings, type FontPref, type ScalePref } from '@/lib/settings';
import { Button } from '@/components/ui/button';
import { Segmented } from '@/components/ui/segmented';
import { Switch } from '@/components/ui/switch';
import { Card, Badge } from '@/components/ui/card';
import { SPRING_GENTLE } from '@/lib/motion';

const COPY = {
 zh: {
  appearance_l: '外观',
  type_l: '字体与大小',
  motion_l: '动效',
  fDefault: '默认',
  fSerif: '衬线阅读',
  fSans: '全无衬线',
  title: '设置',
  subtitle: '这里的选择会立即作用于整个网站,并保存在本机浏览器。',
  reset: '恢复默认',
  mode: '颜色模式',
  light: '浅色',
  system: '跟随系统',
  dark: '深色',
  accent: '主题色',
  accentDesc: '只改变强调色。背景、文字、边框和状态色保持不变。',
  font: '字体',
  fontDesc: '默认:标题用衬线,界面和正文用系统字体。中文始终走苹方与系统字体。',
  sample: 'Aa 字',
  scale: '界面大小',
  scaleDesc: '整体缩放文字与控件',
  sm: '紧凑',
  md: '标准',
  lg: '宽松',
  reduce: '减弱动效',
  reduceDesc: '关闭弹簧与过渡动画',
  preview: '实时预览',
  previewDesc: '组件只读取令牌,改设置即可看到全站效果。',
  badge: '新',
  demo: '示例',
  t1: '概览',
  t2: '组件',
  t3: '区块',
  h: '温润、克制,让内容先说话。',
  p: '强调色只出现在关键操作和状态上,其余层级靠 Surface 明度与发丝边建立。',
  search: '搜索组件…',
  send: '发送',
  b1: '主要操作',
  b2: '次要',
  b3: '描边',
  b4: '幽灵',
  b5: '删除',
  ok: '● 成功',
  warn: '● 警告',
  bad: '● 危险',
  acc: '● 强调文字',
  link: '文档链接'
 },
 en: {
  appearance_l: 'Appearance',
  type_l: 'Type & size',
  motion_l: 'Motion',
  fDefault: 'Default',
  fSerif: 'Serif reading',
  fSans: 'All sans',
  title: 'Settings',
  subtitle: 'Changes apply to the whole site instantly and are saved in this browser.',
  reset: 'Reset to defaults',
  mode: 'Color mode',
  light: 'Light',
  system: 'System',
  dark: 'Dark',
  accent: 'Accent color',
  accentDesc: 'Only the accent changes. Backgrounds, text, borders and status colors stay fixed.',
  font: 'Font',
  fontDesc: 'Default: serif headings, system sans for UI and body. Chinese always uses PingFang and system fonts.',
  sample: 'Aa',
  scale: 'Interface size',
  scaleDesc: 'Scale text and controls together',
  sm: 'Compact',
  md: 'Default',
  lg: 'Roomy',
  reduce: 'Reduce motion',
  reduceDesc: 'Turn off springs and transitions',
  preview: 'Live preview',
  previewDesc: 'Components only read tokens, so every change shows up site-wide.',
  badge: 'New',
  demo: 'Example',
  t1: 'Overview',
  t2: 'Components',
  t3: 'Blocks',
  h: 'Warm and restrained. Let the content speak.',
  p: 'The accent appears only on key actions and states; everything else is layered with surface tones and hairlines.',
  search: 'Search components…',
  send: 'Send',
  b1: 'Primary',
  b2: 'Secondary',
  b3: 'Outline',
  b4: 'Ghost',
  b5: 'Delete',
  ok: '● Success',
  warn: '● Warning',
  bad: '● Danger',
  acc: '● Accent text',
  link: 'Docs link'
 },
};

const NAV = [
  { id: 'appearance', label: 'appearance_l' as const, icon: PaintBrush },
  { id: 'type', label: 'type_l' as const, icon: TextAa },
  { id: 'motion', label: 'motion_l' as const, icon: Sparkle },
];

function Section({ id, title, desc, children }: { id: string; title: string; desc?: string; children: React.ReactNode }) {
  return (
    <section id={id} className="scroll-mt-24 border-t border-hairline py-8 first:border-t-0 first:pt-0">
      <h2 className="text-[15px] font-semibold text-text-primary">{title}</h2>
      {desc && <p className="mt-1 text-sm text-text-secondary">{desc}</p>}
      <div className="mt-5">{children}</div>
    </section>
  );
}

function ModeThumb({ mode }: { mode: 'light' | 'dark' | 'system' }) {
  const L = { bg: '#f9f9f7', panel: '#ffffff', line: 'rgba(20,20,20,.12)', text: 'rgba(20,20,20,.55)' };
  const D = { bg: '#0b0b0b', panel: '#1a1a19', line: 'rgba(255,255,255,.12)', text: 'rgba(255,255,255,.5)' };
  const half = (c: typeof L) => (
    <div className="flex h-full flex-1 flex-col gap-1.5 p-2" style={{ background: c.bg }}>
      <div className="h-1.5 w-8 rounded-full" style={{ background: c.text }} />
      <div className="flex-1 rounded-md p-1.5" style={{ background: c.panel, boxShadow: `0 0 0 1px ${c.line}` }}>
        <div className="h-1 w-10 rounded-full" style={{ background: c.line }} />
        <div className="mt-1 h-1 w-6 rounded-full" style={{ background: c.line }} />
        <div className="mt-2 h-2 w-5 rounded-full" style={{ background: 'var(--cds-accent)' }} />
      </div>
    </div>
  );
  return (
    <div className="flex h-[76px] w-full overflow-hidden rounded-lg">
      {mode === 'light' && half(L)}
      {mode === 'dark' && half(D)}
      {mode === 'system' && (
        <>
          {half(L)}
          {half(D)}
        </>
      )}
    </div>
  );
}

function ChoiceCard({
  active,
  onClick,
  children,
  label,
}: {
  active: boolean;
  onClick: () => void;
  children: React.ReactNode;
  label: React.ReactNode;
}) {
  return (
    <button onClick={onClick} aria-pressed={active} className="cds-focus group flex flex-col gap-2 rounded-xl text-left">
      <div
        className={
          'w-full rounded-xl p-1 transition-shadow duration-[120ms] ' +
          (active
            ? 'shadow-[0_0_0_1.5px_var(--cds-accent)]'
            : 'shadow-[0_0_0_1px_var(--cds-border)] group-hover:shadow-[0_0_0_1px_var(--cds-border-strong)]')
        }
      >
        {children}
      </div>
      <span className={'px-0.5 text-[13px] ' + (active ? 'font-medium text-text-primary' : 'text-text-secondary')}>{label}</span>
    </button>
  );
}

export function SettingsPanel({ lang = 'zh' }: { lang?: 'zh' | 'en' }) {
  const t = COPY[lang];
  const { settings, update, reset } = useSettings();
  const { theme, setTheme } = useTheme();
  const [mounted, setMounted] = useState(false);
  const [active, setActive] = useState('appearance');
  const [tab, setTab] = useState<'a' | 'b' | 'c'>('a');
  useEffect(() => setMounted(true), []);
  const mode = (mounted ? theme : 'system') as 'light' | 'dark' | 'system';

  const fonts: { id: FontPref; label: string; family: string }[] = [
    { id: 'default', label: t.fDefault, family: 'var(--cds-font-ui)' },
    { id: 'serif', label: t.fSerif, family: 'var(--cds-font-serif)' },
    { id: 'system', label: t.fSans, family: 'var(--cds-font-system)' },
  ];

  return (
    <div className="w-full bg-surface-1 px-6 pb-10 pt-8 text-left">
      <h1 className="font-title text-[30px] leading-tight text-text-primary">{t.title}</h1>
      <p className="mt-2 text-sm text-text-secondary">{t.subtitle}</p>

      <div className="mt-10 grid gap-10 md:grid-cols-[180px_1fr]">
        <nav className="md:self-start">
          <ul className="flex gap-1 md:flex-col">
            {NAV.map((n) => {
              const Icon = n.icon;
              const on = active === n.id;
              return (
                <li key={n.id}>
                  <a
                    href={`#${n.id}`}
                    onClick={() => setActive(n.id)}
                    className={
                      'cds-focus flex h-8 items-center gap-2 rounded-lg px-2.5 text-sm transition-colors duration-[120ms] ' +
                      (on ? 'bg-ghost-selected font-medium text-text-primary' : 'text-text-secondary hover:bg-ghost-hover hover:text-text-primary')
                    }
                  >
                    <Icon size={16} weight={on ? 'fill' : 'regular'} />
                    {t[n.label]}
                  </a>
                </li>
              );
            })}
          </ul>
          <div className="mt-4 hidden md:block">
            <Button variant="ghost" size="sm" onClick={() => { reset(); setTheme('system'); }}>
              <ArrowCounterClockwise /> {t.reset}
            </Button>
          </div>
        </nav>

        <div className="min-w-0">
          <Section id="appearance" title={t.mode}>
            <div className="grid max-w-[560px] grid-cols-3 gap-4">
              {([
                ['light', t.light, Sun],
                ['system', t.system, Desktop],
                ['dark', t.dark, Moon],
              ] as const).map(([m, label, Icon]) => (
                <ChoiceCard
                  key={m}
                  active={mode === m}
                  onClick={() => setTheme(m)}
                  label={<span className="inline-flex items-center gap-1.5"><Icon size={14} />{label}</span>}
                >
                  <ModeThumb mode={m} />
                </ChoiceCard>
              ))}
            </div>
          </Section>

          <Section title={t.accent} id="accent" desc={t.accentDesc}>
            <div className="flex flex-wrap gap-3">
              {ACCENTS.map((a) => {
                const on = settings.accent === a.id;
                return (
                  <button
                    key={a.id}
                    onClick={() => update({ accent: a.id })}
                    aria-pressed={on}
                    className={
                      'cds-focus flex h-10 items-center gap-2.5 rounded-full pl-1.5 pr-4 text-[13px] transition-shadow duration-[120ms] ' +
                      (on
                        ? 'bg-surface-2 font-medium text-text-primary shadow-[0_0_0_1.5px_var(--cds-accent)]'
                        : 'text-text-secondary shadow-[0_0_0_1px_var(--cds-border)] hover:shadow-[0_0_0_1px_var(--cds-border-strong)]')
                    }
                  >
                    <span className="relative grid size-7 place-items-center rounded-full">
                      <span className="absolute inset-0 rounded-full dark:hidden" style={{ background: a.light }} />
                      <span className="absolute inset-0 hidden rounded-full dark:block" style={{ background: a.dark }} />
                      {on && (
                        <motion.span initial={{ scale: 0.4, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={SPRING_GENTLE} className="relative">
                          <Check size={14} weight="bold" color={a.id === 'ink' ? 'var(--cds-accent-foreground)' : '#fff'} />
                        </motion.span>
                      )}
                    </span>
                    {lang === 'en' ? a.name.split(' ')[0] : a.name}
                  </button>
                );
              })}
            </div>
          </Section>

          <Section id="type" title={t.font} desc={t.fontDesc}>
            <div className="grid max-w-[560px] grid-cols-3 gap-4">
              {fonts.map((f) => (
                <ChoiceCard key={f.id} active={settings.font === f.id} onClick={() => update({ font: f.id })} label={f.label}>
                  <div className="grid h-[76px] place-items-center rounded-lg bg-surface-0">
                    <span className="text-[26px] text-text-primary" style={{ fontFamily: f.family }}>{t.sample}</span>
                  </div>
                </ChoiceCard>
              ))}
            </div>
            <div className="mt-6 flex items-center justify-between gap-4 max-w-[560px]">
              <div>
                <div className="text-sm font-medium text-text-primary">{t.scale}</div>
                <div className="text-[13px] text-text-secondary">{t.scaleDesc}</div>
              </div>
              <Segmented<ScalePref>
                ariaLabel={t.scale}
                value={settings.scale}
                onChange={(v) => update({ scale: v })}
                options={[
                  { value: 'sm', label: t.sm },
                  { value: 'md', label: t.md },
                  { value: 'lg', label: t.lg },
                ]}
              />
            </div>
          </Section>

          <Section id="motion" title={t.motion_l}>
            <div className="flex max-w-[560px] items-center justify-between gap-4">
              <div>
                <div className="text-sm font-medium text-text-primary">{t.reduce}</div>
                <div className="text-[13px] text-text-secondary">{t.reduceDesc}</div>
              </div>
              <Switch label={t.reduce} checked={settings.motion === 'reduce'} onChange={(v) => update({ motion: v ? 'reduce' : 'full' })} />
            </div>
          </Section>

          <Section id="preview" title={t.preview} desc={t.previewDesc}>
            <Card className="max-w-[640px] p-5">
              <div className="flex items-center justify-between">
                <div className="flex items-center gap-2">
                  <span className="cds-dot-breathe size-2 rounded-full bg-accent-brand" />
                  <span className="text-sm font-medium">MioUI</span>
                  <Badge>{t.badge}</Badge>
                </div>
                <Segmented
                  ariaLabel={t.demo}
                  value={tab}
                  onChange={setTab}
                  options={[
                    { value: 'a', label: t.t1 },
                    { value: 'b', label: t.t2 },
                    { value: 'c', label: t.t3 },
                  ]}
                />
              </div>
              <p className="font-title mt-5 text-[22px] leading-snug">{t.h}</p>
              <p className="mt-1.5 text-sm text-text-secondary">
                {t.p}
              </p>
              <div className="mt-5 flex items-center gap-2 rounded-[20px] bg-surface-3 py-1.5 pl-4 pr-1.5 shadow-[0_4px_20px_rgba(0,0,0,0.035),inset_0_0_0_1px_var(--cds-border)] focus-within:shadow-[0_4px_24px_rgba(0,0,0,0.08),inset_0_0_0_1px_var(--cds-border-strong)]">
                <MagnifyingGlass size={16} className="text-text-muted" />
                <input
                  placeholder={t.search}
                  className="h-8 flex-1 bg-transparent text-sm outline-none placeholder:text-text-muted"
                />
                <Button size="icon" aria-label={t.send}><PaperPlaneRight weight="fill" /></Button>
              </div>
              <div className="mt-5 flex flex-wrap gap-2">
                <Button>{t.b1}</Button>
                <Button variant="secondary">{t.b2}</Button>
                <Button variant="outline">{t.b3}</Button>
                <Button variant="ghost">{t.b4}</Button>
                <Button variant="danger">{t.b5}</Button>
              </div>
              <div className="mt-5 flex flex-wrap gap-4 text-[13px]">
                <span className="text-success">{t.ok}</span>
                <span className="text-warning">{t.warn}</span>
                <span className="text-danger">{t.bad}</span>
                <span className="text-accent-text">{t.acc}</span>
                <a className="text-accent-text underline underline-offset-4" href={`/${lang}/docs`}>{t.link}</a>
              </div>
            </Card>
          </Section>

          <div className="md:hidden">
            <Button variant="ghost" size="sm" onClick={() => { reset(); setTheme('system'); }}>
              <ArrowCounterClockwise /> {t.reset}
            </Button>
          </div>
        </div>
      </div>
    </div>
  );
}