账户设置 · 场景文档

本页由 3 个真实组件拼成;提示词与源码都可直接复制带走。

整页提示词

复现「账户设置」整页场景(/showcase/demo/page-settings):
- 页面级规则按 /design#pages 的对应小节执行,不在此复述
- 本页由这些真实组件拼成(手写等价物不算):
- 玻璃输入框(Floating Label Input):标签在框上方,不做浮动标签动画|/showcase/component/input/glass-apple
- 玻璃滑块(UISlider):role=slider + aria-valuenow/min/max,← → 单步、PageUp/Down 十步、Home/End 到端|/showcase/component/slider/glass-apple
- 玻璃开关(iOS UISwitch):role=switch + aria-checked,Space / Enter 切换|/showcase/component/switch/glass-apple
- 骨架:视口高度固定,页头不滚,正文在容器内滚;分区页的页头与正文共用同一条内容列

本页用到的组件

清单是扫 src/pages/showcase/demo/page-settings.astro 的 import 闭包得出的, 手写等价物不会出现在这里。

一条链接装走整页

/r/scene--page-settings.json /llms/scenes/page-settings.md

page-settings.astro src/pages/showcase/demo/page-settings.astro

---
import BaseLayout from '../../../layouts/BaseLayout.astro';
import DemoChrome from '../../../components/DemoChrome.astro';
import PageHeader from '../../../components/PageHeader.astro';
import PageShell from '../../../components/PageShell.astro';
import SceneDocLink from '../../../components/SceneDocLink.astro';
import { GlassAppleSwitch } from '../../../components/switch/glass-apple/Switch';
import { GlassAppleSlider } from '../../../components/slider/glass-apple/Slider';
import SettingsProfile from './SettingsProfile.tsx';

import data from '../../../mocks/page-settings.json';
import type { SettingsSection } from '../../../mocks/types';

const { profile, sections } = data as {
  profile: { title: string; fields: { label: string; type: string; placeholder: string }[] };
  sections: SettingsSection[];
};

/** 左栏分组导航:id 与右栏每个 fieldset 一一对应 */
const groups = [
  { id: 'settings-profile', title: profile.title },
  ...sections.map((sec, i) => ({ id: `settings-${i}`, title: sec.title })),
];

const panel = 'rounded-2xl bg-[var(--color-surface)] border border-[var(--glass-border)] shadow-[var(--shadow-sm)]';
const groupTitle = 'w-full text-sm font-medium text-[var(--color-text-muted)] uppercase tracking-[0.12em] mb-3 lg:sr-only';
---
<BaseLayout title="UI Echo · 设置页复现">
  <DemoChrome />
  <PageShell demo col>
    <PageHeader
      slot="header"
      title="设置"
      description="管理账户、安全、通知和隐私选项。"
      size="sm"
    >
      <SceneDocLink id="page-settings" slot="actions" />
    </PageHeader>

    <!-- 分区页:左栏分组导航 + 右栏卡片列(Polaris app-settings 的 2fr / 5fr)。
         导航吸顶靠 ul 上的 sticky:nav 这个网格项被拉伸到整行高,sticky 才有可滑动的行程。 -->
    <div class="lg:grid lg:grid-cols-[14rem_minmax(0,1fr)] lg:gap-10">
      <nav aria-label="设置分组" class="hidden lg:block">
        <ul class="sticky top-0 space-y-1">
          {groups.map((g) => (
            <li>
              <a
                href={`#${g.id}`}
                data-scene-nav={g.id}
                class="block px-3 py-2 rounded-lg text-sm text-[var(--color-text-muted)] transition-colors hover:bg-[var(--glass-recess)] hover:text-[var(--color-text)] aria-[current=true]:bg-[var(--glass-recess)] aria-[current=true]:text-[var(--color-text)] aria-[current=true]:font-medium"
              >
                {g.title}
              </a>
            </li>
          ))}
        </ul>
      </nav>

      <div class="min-w-0">
        <SettingsProfile id={groups[0].id} title={profile.title} fields={profile.fields} client:visible />

        {sections.map((sec, i) => (
          <fieldset id={groups[i + 1].id} tabindex="-1" class="mb-8 min-w-0 scroll-mt-4 border-0 p-0 m-0 focus-visible:outline-none">
            <legend class={groupTitle}>{sec.title}</legend>
            <div class={`${panel} overflow-hidden`}>
              {sec.rows.map((row) => (
                <div class="flex items-center justify-between gap-4 px-5 py-4 border-b border-[var(--glass-border)] last:border-b-0">
                  <div class="min-w-0 flex-1">
                    <div class="text-sm font-medium">{row.label}</div>
                    <div class="text-xs text-[var(--color-text-muted)] mt-0.5">{row.desc}</div>
                  </div>

                  {row.toggle && (
                    <GlassAppleSwitch
                      defaultChecked={row.defaultOn}
                      aria-label={row.label}
                      client:visible
                    />
                  )}

                  {row.range && (
                    <GlassAppleSlider
                      className="w-40 shrink-0"
                      defaultValue={row.defaultValue}
                      unit={row.unit}
                      showValue
                      aria-label={row.label}
                      client:visible
                    />
                  )}

                  {row.cta && (
                    <button class={`
                      press-feedback shrink-0 px-3 py-1.5 rounded-lg text-xs
                      border transition-colors cursor-pointer
                      ${row.danger
                        ? 'text-[var(--color-warning-fg)] border-[var(--color-warning-bd)] hover:bg-[var(--color-warning-bg)]'
                        : 'text-[var(--color-text-muted)] border-[var(--glass-border)] bg-[var(--color-surface)] hover:text-[var(--color-text)]'}
                    `}>
                      {row.cta}
                    </button>
                  )}
                </div>
              ))}
            </div>
          </fieldset>
        ))}
      </div>
    </div>
  </PageShell>
</BaseLayout>

<script>
  /** 滚动联动:吸顶的导航得说出"你在哪一组",否则固定住也没有意义。
   *  判定用"最后一个越过顶线的分组"——分组本身很高,用可见带相交会一直卡在上一组。 */
  const links = [...document.querySelectorAll<HTMLElement>('[data-scene-nav]')];
  const scrollRoot = document.querySelector<HTMLElement>('.page-scroll');
  const sections = links
    .map((a) => document.getElementById(a.dataset.sceneNav!))
    .filter((el): el is HTMLElement => Boolean(el));

  const setCurrent = (id: string | null) => {
    for (const a of links) {
      if (a.dataset.sceneNav === id) a.setAttribute('aria-current', 'true');
      else a.removeAttribute('aria-current');
    }
  };

  const update = () => {
    if (!sections.length) return;
    // 滚到底时最后一组的顶线永远到不了上方——到底就算它,否则导航会卡在倒数第二组
    const atBottom = scrollRoot
      ? scrollRoot.scrollTop + scrollRoot.clientHeight >= scrollRoot.scrollHeight - 2
      : window.innerHeight >= document.documentElement.scrollHeight - 2;
    if (atBottom) {
      setCurrent(sections[sections.length - 1].id);
      return;
    }
    const line = (scrollRoot?.getBoundingClientRect().top ?? 0) + 96;
    let current = sections[0];
    for (const s of sections) if (s.getBoundingClientRect().top <= line) current = s;
    setCurrent(current.id);
  };

  scrollRoot?.addEventListener('scroll', update, { passive: true });
  window.addEventListener('resize', update);
  update();

  // hash 跳转不移焦点,键盘用户会被丢回页首:跳完把焦点送到该分组
  for (const a of links) {
    a.addEventListener('click', () => {
      const target = sections.find((s) => s.id === a.dataset.sceneNav);
      if (!target) return;
      setCurrent(target.id);
      window.setTimeout(() => target.focus({ preventScroll: true }), 80);
    });
  }
</script>

SettingsProfile.tsx src/pages/showcase/demo/SettingsProfile.tsx

import { useState } from 'react';
import { GlassAppleInput } from '../../../components/input/glass-apple/Input';

interface Field {
  label: string;
  type: string;
  placeholder: string;
}

const panel = 'rounded-2xl bg-[var(--color-surface)] border border-[var(--glass-border)] shadow-[var(--shadow-sm)]';

/** 文本编辑显式保存;开关与滑块仍即时生效(见 docs/standards/pages.md §4) */
export default function SettingsProfile({ id, title, fields }: { id: string; title: string; fields: Field[] }) {
  const initial = fields.map(() => '');
  const [values, setValues] = useState(initial);
  const [saved, setSaved] = useState(initial);
  const [message, setMessage] = useState('');

  const dirty = values.some((v, i) => v !== saved[i]);

  const submit = (e: React.FormEvent) => {
    e.preventDefault();
    setSaved(values);
    setMessage('已保存');
  };

  const discard = () => {
    setValues(saved);
    setMessage('已放弃更改');
  };

  return (
    <form onSubmit={submit}>
      <fieldset id={id} tabIndex={-1} className="mb-8 min-w-0 scroll-mt-4 border-0 p-0 m-0 focus-visible:outline-none">
        <legend className="w-full text-sm font-medium text-[var(--color-text-muted)] uppercase tracking-[0.12em] mb-3 lg:sr-only">
          {title}
        </legend>

        <div className={`${panel} p-6`}>
          {/* 宽卡里两个字段并排,避免卡片右侧空出一大片 */}
          <div className="grid gap-4 lg:grid-cols-2">
            {fields.map((f, i) => (
              <GlassAppleInput
                key={f.label}
                id={`settings-field-${i}`}
                label={f.label}
                type={f.type}
                placeholder={f.placeholder}
                value={values[i]}
                onChange={(e) =>
                  setValues((prev) => prev.map((v, j) => (j === i ? e.target.value : v)))
                }
              />
            ))}
          </div>

          {(dirty || message) && (
            <div className="flex flex-wrap items-center justify-between gap-3 mt-5">
              <p aria-live="polite" className="text-xs text-[var(--color-text-subtle)]">
                {dirty ? '有未保存的更改' : message}
              </p>
              {dirty && (
                <div className="flex items-center gap-2">
                  <button
                    type="button"
                    onClick={discard}
                    className="press-feedback px-3 py-1.5 rounded-lg text-xs border border-[var(--glass-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:text-[var(--color-text)] transition-colors cursor-pointer"
                  >
                    放弃
                  </button>
                  <button
                    type="submit"
                    className="press-feedback press-hover px-4 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-accent)] text-white cursor-pointer"
                  >
                    保存
                  </button>
                </div>
              )}
            </div>
          )}
        </div>
      </fieldset>
    </form>
  );
}

页面级规则(页头、工具栏、表格、设置、详情、流程、状态)只写在 页面模式 一处,这里不复述——复述就会漂移。