设置页(分区保存 + 危险区双确认)
后台的「首选项」形状:一个页面塞多组不相关的开关和输入 —— 个人资料、账号、通知、危险区。左侧锚点定位,右侧逐区保存,破坏性操作走双确认。个人页、项目偏好、团队设置都是这一副骨架。
试这几下:
- 改「个人资料」里的姓名 —— 该分区的保存 / 还原从灰变亮,左侧锚点旁亮起脏标
●;其它分区不受牵连 - 依次改两个分区再只保存其中一个 —— 只清那一区的脏,另一区照旧脏、锚点圆点还在
- 点左侧锚点 —— 平滑滚到对应分区;页面滚动时锚点自己跟着高亮切换
- 保存后按钮回灰、
●消失,toast 报明是哪一分区 - 危险区点删除账户 —— 先弹确认框,再让你输入用户名
chenhe,输错「永久删除」按钮就是不启用
规矩
- 一区一份状态、一份已保存快照、一个保存按钮:脏 = 当前值对已保存快照的浅 diff;
保存让快照吃掉当前值,还原反过来。demo 每区用useSection(initial)自封装出这三件套;真实版每区一个useMutation,脏由表单库(form.formState.isDirty)负责,保存成功后失效该资源的查询键 + toast - 保存 / 还原按钮同
dirty门控:写入是分区级别的原子提交,避免「A 区在改、B 区半保存」的模糊状态;蓝本 Invariants 的「Save 后 toast」在 demo 里报明是哪一分区已保存 - 破坏性操作双确认:先弹确认框说清后果,再让输入完整用户名精确匹配才启用红色主按钮;输错就禁用,不给「差不多就点」的机会。真实版把用户名换成当前用户的登录名从 session 取
- 锚点导航靠
IntersectionObserver:观察带id的四个<section>,rootMargin圈住视口中间一条带,谁进带里谁 active;点击锚点用scrollIntoView({ behavior: "smooth" })并短暂锁定 IO 防过渡帧覆盖点击目标。真实版若有粘性顶栏,用scroll-margin-top让锚点跳转不被顶栏遮挡
蓝本:
add-settings-page.md(open-dashboard @aa9815f,MIT,Invariants 已消化进上面「规矩」)
demo 源码:assets/demo-settings-page.html(自包含、未压缩)
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>形状 demo:设置页</title>
<!--
demo-settings-page.html —— 「设置页」形状的最小可玩实现(蓝本:open-dashboard 的 control-page + profile 双模板 + add-settings-page)。
复现的不变量:
- 分区保存:每个分区一份状态 + 一份已保存快照 + 各自的「保存 / 还原」按钮;仅在该分区脏时启用;保存后清脏 + toast
- 脏检测 = 当前值 vs 已保存快照的浅 diff;「还原」把当前值还原成已保存快照
- 破坏性操作双确认:先弹二次确认框,再让输入完整用户名精确匹配才启用「永久删除」
- 锚点导航:IntersectionObserver 观察四个 <section id="…">,滚过谁就高亮谁;点击锚点用 scrollIntoView 平滑跳转,并短暂锁定 IO 防止过渡帧覆盖点击目标
运行时:/vendor 的 React 18 UMD + htm(免构建),样式共用同级 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
.settings { display: grid; grid-template-columns: 130px 1fr; gap: 16px; align-items: start; }
@media (max-width: 560px) { .settings { grid-template-columns: 1fr; } }
.anchors { position: sticky; top: 10px; display: flex; flex-direction: column; gap: 2px;
padding: 6px; background: var(--card); border: 1px solid var(--border); border-radius: 10px; }
.anchor { text-align: left; padding: 6px 10px; border-radius: 6px; border: 0; background: transparent;
color: var(--muted); font: inherit; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
.anchor:hover { color: var(--fg); }
.anchor.on { background: color-mix(in srgb, var(--primary) 12%, transparent); color: var(--primary); font-weight: 550; }
.anchor .dot { color: var(--primary); font-size: 11px; }
.sections { display: flex; flex-direction: column; gap: 14px; }
.sec { padding: 14px 16px; scroll-margin-top: 12px; display: flex; flex-direction: column; gap: 10px; }
.sec-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; flex-wrap: wrap; }
.sec-head h2 { margin: 0; font-size: 15px; }
.sec-hint { margin: 2px 0 0; font-size: 12px; color: var(--muted); }
.sec-actions { display: flex; gap: 6px; }
.fields { display: flex; flex-direction: column; gap: 10px; }
.field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
.field input, .field select, .field textarea { color: var(--fg); }
.toggle-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
.toggle-text { display: flex; flex-direction: column; gap: 2px; }
.toggle-title { color: var(--fg); font-size: 13px; font-weight: 550; }
.toggle-desc { font-size: 12px; color: var(--muted); }
.toggle-row input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--primary); }
.sec.danger-sec { border-color: var(--danger); }
.btn.danger-btn { color: var(--danger); border-color: var(--danger); }
.btn.danger-solid { background: var(--danger); border-color: var(--danger); color: #fff; }
.modal code { padding: 0 4px; background: var(--border); border-radius: 4px; font-size: 12px; }
</style>
</head>
<body>
<div id="root"></div>
<script src="/vendor/react.production.min.js"></script>
<script src="/vendor/react-dom.production.min.js"></script>
<script src="/vendor/htm.umd.js"></script>
<script>
"use strict";
const { useState, useEffect, useRef } = React;
const html = htm.bind(React.createElement);
const SECTIONS = [
{ id: "profile", label: "个人资料" },
{ id: "account", label: "账号" },
{ id: "notify", label: "通知" },
{ id: "danger", label: "危险区" },
];
const INITIAL_PROFILE = {
fullName: "陈禾",
jobTitle: "产品设计师",
location: "上海",
bio: "沿海城市的一片瓦,做设计的一双手。",
};
const INITIAL_ACCOUNT = {
email: "chenhe@corp.cn",
language: "zh-CN",
timezone: "Asia/Shanghai",
};
const INITIAL_NOTIFY = {
emailAlerts: true,
pushAlerts: false,
digest: "weekly",
};
const USERNAME = "chenhe"; // 危险区二次确认要精确匹配的用户名
// 分区脏 = 当前值对已保存快照的浅 diff。真实版脏由表单库 form.formState.isDirty 负责。
function shallowDirty(a, b) { return Object.keys(a).some(k => a[k] !== b[k]); }
// 每个分区:一份当前值、一份已保存快照、脏、保存、还原。保存成功后 saved := values,清脏。
function useSection(initial) {
const [values, setValues] = useState(initial);
const [saved, setSaved] = useState(initial);
return {
values, saved,
dirty: shallowDirty(values, saved),
set: (k, v) => setValues(vs => ({ ...vs, [k]: v })),
save: () => setSaved(values),
discard: () => setValues(saved),
};
}
function SectionActions({ dirty, onSave, onDiscard }) {
return html`
<div class="sec-actions">
<button type="button" class="btn sm" disabled=${!dirty} onClick=${onDiscard}>还原</button>
<button type="button" class="btn sm primary" disabled=${!dirty} onClick=${onSave}>保存</button>
</div>`;
}
// 双确认删除:第一层打开弹窗,第二层要求输入完整用户名精确匹配才启用主按钮。
function DangerZone({ onDeleted }) {
const [open, setOpen] = useState(false);
const [typed, setTyped] = useState("");
const match = typed === USERNAME;
const close = () => { setOpen(false); setTyped(""); };
return html`
<section id="danger" class="card sec danger-sec">
<div class="sec-head">
<div>
<h2>危险区</h2>
<p class="sec-hint">这里的操作不可撤销,请三思。</p>
</div>
</div>
<div class="alert danger" role="alert">
<p><strong>删除账户</strong> —— 立即清除账号、订阅与全部历史记录。此操作无法撤销。</p>
</div>
<div class="sec-actions">
<button type="button" class="btn sm danger-btn" onClick=${() => setOpen(true)}>删除账户…</button>
</div>
${open && html`
<div class="overlay" onClick=${e => { if (e.target === e.currentTarget) close(); }}>
<div class="modal" role="dialog" aria-modal="true" aria-label="删除账户二次确认">
<h2>删除账户?</h2>
<p style=${{ margin: 0 }}>这将永久删除账户 <strong>${USERNAME}</strong> 及其全部数据。</p>
<label>
为确认删除,请输入用户名 <code>${USERNAME}</code>
<input value=${typed} placeholder=${USERNAME} autoFocus
onChange=${e => setTyped(e.target.value)} />
</label>
${typed && !match && html`<p class="err">用户名不匹配,无法删除</p>`}
<div class="actions">
<button type="button" class="btn" onClick=${close}>取消</button>
<button type="button" class="btn danger-solid" disabled=${!match}
onClick=${() => { close(); onDeleted(); }}>永久删除</button>
</div>
</div>
</div>`}
</section>`;
}
function App() {
const profile = useSection(INITIAL_PROFILE);
const account = useSection(INITIAL_ACCOUNT);
const notify = useSection(INITIAL_NOTIFY);
const [active, setActive] = useState("profile");
const [toast, setToast] = useState("");
const clickLockUntil = useRef(0); // 点击锚点后短暂锁定 IO,防止平滑滚动的过渡帧覆盖目标
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(""), 2200);
return () => clearTimeout(t);
}, [toast]);
useEffect(() => {
const els = SECTIONS.map(s => document.getElementById(s.id)).filter(Boolean);
// rootMargin 圈住视口中间一条带;谁进带里谁就是当前分区
const io = new IntersectionObserver(entries => {
if (Date.now() < clickLockUntil.current) return;
const hit = entries.filter(e => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
if (hit) setActive(hit.target.id);
}, { rootMargin: "-30% 0px -55% 0px", threshold: 0 });
els.forEach(el => io.observe(el));
return () => io.disconnect();
}, []);
const jump = id => {
const el = document.getElementById(id);
if (!el) return;
setActive(id);
clickLockUntil.current = Date.now() + 700;
el.scrollIntoView({ behavior: "smooth", block: "start" });
};
const doSave = (sec, label) => { sec.save(); setToast(`${label}已保存`); };
const isDirty = id =>
(id === "profile" && profile.dirty) ||
(id === "account" && account.dirty) ||
(id === "notify" && notify.dirty);
return html`
<div class="page">
<header class="head">
<div>
<h1>设置</h1>
<p class="sub">左侧锚点定位 · 逐区保存 · 危险区二次确认</p>
</div>
</header>
<div class="settings">
<aside class="anchors" aria-label="设置分区">
${SECTIONS.map(s => html`
<button key=${s.id} type="button" class=${"anchor" + (active === s.id ? " on" : "")}
onClick=${() => jump(s.id)}>
<span>${s.label}</span>
${isDirty(s.id) && html`<span class="dot" aria-label="有未保存的改动">●</span>`}
</button>`)}
</aside>
<div class="sections">
<section id="profile" class="card sec">
<div class="sec-head">
<div><h2>个人资料</h2><p class="sec-hint">展示在你名字旁边的公开信息</p></div>
<${SectionActions} dirty=${profile.dirty}
onSave=${() => doSave(profile, "个人资料")}
onDiscard=${profile.discard} />
</div>
<div class="fields">
<label class="field">姓名
<input value=${profile.values.fullName}
onChange=${e => profile.set("fullName", e.target.value)} />
</label>
<div class="row2">
<label class="field">职位
<input value=${profile.values.jobTitle}
onChange=${e => profile.set("jobTitle", e.target.value)} />
</label>
<label class="field">所在地
<input value=${profile.values.location}
onChange=${e => profile.set("location", e.target.value)} />
</label>
</div>
<label class="field">简介
<textarea rows="2" value=${profile.values.bio}
onChange=${e => profile.set("bio", e.target.value)}></textarea>
</label>
</div>
</section>
<section id="account" class="card sec">
<div class="sec-head">
<div><h2>账号</h2><p class="sec-hint">邮箱、语言与时区</p></div>
<${SectionActions} dirty=${account.dirty}
onSave=${() => doSave(account, "账号")}
onDiscard=${account.discard} />
</div>
<div class="fields">
<label class="field">邮箱
<input type="email" value=${account.values.email}
onChange=${e => account.set("email", e.target.value)} />
</label>
<div class="row2">
<label class="field">语言
<select value=${account.values.language}
onChange=${e => account.set("language", e.target.value)}>
<option value="zh-CN">简体中文</option>
<option value="en-US">English (US)</option>
<option value="ja-JP">日本語</option>
</select>
</label>
<label class="field">时区
<select value=${account.values.timezone}
onChange=${e => account.set("timezone", e.target.value)}>
<option value="Asia/Shanghai">Asia/Shanghai (UTC+8)</option>
<option value="Asia/Tokyo">Asia/Tokyo (UTC+9)</option>
<option value="Europe/Berlin">Europe/Berlin (UTC+1)</option>
<option value="America/Los_Angeles">America/Los_Angeles (UTC-8)</option>
</select>
</label>
</div>
</div>
</section>
<section id="notify" class="card sec">
<div class="sec-head">
<div><h2>通知</h2><p class="sec-hint">决定我们能不能打扰你</p></div>
<${SectionActions} dirty=${notify.dirty}
onSave=${() => doSave(notify, "通知")}
onDiscard=${notify.discard} />
</div>
<div class="fields">
<div class="toggle-row">
<div class="toggle-text">
<span class="toggle-title">邮件通知</span>
<span class="toggle-desc">账号、账单与安全相关的邮件</span>
</div>
<input type="checkbox" aria-label="邮件通知" checked=${notify.values.emailAlerts}
onChange=${e => notify.set("emailAlerts", e.target.checked)} />
</div>
<div class="toggle-row">
<div class="toggle-text">
<span class="toggle-title">推送通知</span>
<span class="toggle-desc">桌面与移动端的实时提醒</span>
</div>
<input type="checkbox" aria-label="推送通知" checked=${notify.values.pushAlerts}
onChange=${e => notify.set("pushAlerts", e.target.checked)} />
</div>
<label class="field">摘要频率
<select value=${notify.values.digest}
onChange=${e => notify.set("digest", e.target.value)}>
<option value="daily">每日</option>
<option value="weekly">每周</option>
<option value="never">从不</option>
</select>
</label>
</div>
</section>
<${DangerZone} onDeleted=${() => setToast(`账户 ${USERNAME} 已删除(demo)`)} />
</div>
</div>
${toast && html`<div class="toast">${toast}</div>`}
</div>`;
}
ReactDOM.createRoot(document.getElementById("root")).render(html`<${App} />`);
</script>
</body>
</html>