Files

1673 lines
107 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>高性价比人生指南 · 用最少的钱、时间和精力换回寿命、金钱和人身自由</title>
<meta name="description" content="按性价比算账的人生指南:767 条建议,覆盖长寿防病、意外急救、省钱理财、防骗与法律红线、失业兜底、创业风险、恋爱婚育、出国与技能。每条写明成本、收益、证据等级和原始出处,可按钱、时间、毅力三个成本维度检索。">
<meta name="keywords" content="长寿,健康,循证,总死亡率,省钱,理财,防骗,急救,法律常识,失业救助,劳动仲裁,创业风险,性价比">
<meta name="author" content="高性价比人生指南">
<meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1">
<meta name="theme-color" content="#3451b2" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#1b1b1f" media="(prefers-color-scheme: dark)">
<link rel="canonical" href="https://eternity4719.github.io/HowToLiveBetter/">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%233451b2'/%3E%3Cpath d='M17 33l10 11 20-24' fill='none' stroke='white' stroke-width='7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
<meta property="og:type" content="website">
<meta property="og:site_name" content="高性价比人生指南">
<meta property="og:locale" content="zh_CN">
<meta property="og:url" content="https://eternity4719.github.io/HowToLiveBetter/">
<meta property="og:title" content="高性价比人生指南">
<meta property="og:description" content="按性价比算账的人生指南:767 条建议,覆盖长寿防病、意外急救、省钱理财、防骗与法律红线、失业兜底、创业风险、恋爱婚育、出国与技能。每条写明成本、收益、证据等级和原始出处,可按钱、时间、毅力三个成本维度检索。">
<meta property="og:image" content="https://eternity4719.github.io/HowToLiveBetter/og.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="高性价比人生指南">
<meta name="twitter:description" content="按性价比算账的人生指南:767 条建议,覆盖长寿防病、意外急救、省钱理财、防骗与法律红线、失业兜底、创业风险、恋爱婚育、出国与技能。每条写明成本、收益、证据等级和原始出处,可按钱、时间、毅力三个成本维度检索。">
<meta name="twitter:image" content="https://eternity4719.github.io/HowToLiveBetter/og.png">
<script type="application/ld+json">
{"@context":"https://schema.org","@graph":[
{"@type":"WebSite","@id":"https://eternity4719.github.io/HowToLiveBetter/#website","url":"https://eternity4719.github.io/HowToLiveBetter/","name":"高性价比人生指南","description":"按性价比算账的人生指南:767 条建议,覆盖长寿防病、意外急救、省钱理财、防骗与法律红线、失业兜底、创业风险、恋爱婚育、出国与技能。每条写明成本、收益、证据等级和原始出处,可按钱、时间、毅力三个成本维度检索。","inLanguage":"zh-CN","potentialAction":{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https://eternity4719.github.io/HowToLiveBetter/?q={search_term_string}"},"query-input":"required name=search_term_string"}},
{"@type":"Book","@id":"https://eternity4719.github.io/HowToLiveBetter/#book","name":"高性价比人生指南","url":"https://eternity4719.github.io/HowToLiveBetter/","inLanguage":"zh-CN","bookFormat":"https://schema.org/EBook","numberOfPages":767,"license":"https://creativecommons.org/licenses/by/4.0/","abstract":"按性价比算账的人生指南:767 条建议,覆盖长寿防病、意外急救、省钱理财、防骗与法律红线、失业兜底、创业风险、恋爱婚育、出国与技能。每条写明成本、收益、证据等级和原始出处,可按钱、时间、毅力三个成本维度检索。","about":["健康","长寿","个人理财","急救","法律常识","消费者保护"],"isAccessibleForFree":true}
]}
</script>
<script>(function(){try{var s=localStorage.getItem('theme');var d=s?s==='dark':matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark')}catch(e){}})();</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{
--bg:#ffffff; --bg-alt:#f6f6f7; --bg-soft:#f6f6f7; --bg-mute:#f1f1f2; --bg-elv:#ffffff;
--divider:#e2e2e3; --gutter:#e2e2e3;
--t1:rgba(60,60,67,1); --t2:rgba(60,60,67,.78); --t3:rgba(60,60,67,.56);
--brand-1:#3451b2; --brand-2:#3a5ccc; --brand-3:#5672cd; --brand-soft:rgba(100,108,255,.14);
--green-1:#18794e; --green-soft:rgba(16,185,129,.14);
--yellow-1:#915930; --yellow-soft:rgba(234,179,8,.14);
--red-1:#b8272c; --red-soft:rgba(244,63,94,.14);
--gray-1:#565a5f; --gray-soft:rgba(142,150,170,.14);
--mark:rgba(234,179,8,.32);
--shadow:0 1px 2px rgba(0,0,0,.04),0 1px 2px rgba(0,0,0,.06);
--font:"Inter",ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans SC",sans-serif;
--mono:ui-monospace,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
--nav-h:64px; --side-w:272px;
color-scheme:light;
}
html.dark{
--bg:#1b1b1f; --bg-alt:#161618; --bg-soft:#202127; --bg-mute:#2b2b2f; --bg-elv:#202127;
--divider:#2e2e32; --gutter:#000000;
--t1:rgba(255,255,245,.86); --t2:rgba(235,235,245,.6); --t3:rgba(235,235,245,.38);
--brand-1:#a8b1ff; --brand-2:#5c73e7; --brand-3:#3e63dd; --brand-soft:rgba(100,108,255,.16);
--green-1:#3dd68c; --green-soft:rgba(16,185,129,.16);
--yellow-1:#f9b44e; --yellow-soft:rgba(234,179,8,.16);
--red-1:#f66f81; --red-soft:rgba(244,63,94,.16);
--gray-1:#a4a8ae; --gray-soft:rgba(142,150,170,.16);
--mark:rgba(234,179,8,.28);
--shadow:0 1px 2px rgba(0,0,0,.3);
color-scheme:dark;
}
*{box-sizing:border-box}
html{scroll-padding-top:calc(var(--nav-h) + 16px)}
/* 弹窗打开时 body 设了 overflow:hidden,滚动条一没,页面变宽 15px、卡片重新折行,整页抽一下(2026-10-01 用户发现)。
预留滚动条那条槽位,有没有滚动条宽度都不变 */
html{scrollbar-gutter:stable}
body{margin:0;background:var(--bg);color:var(--t1);font:14px/1.7 var(--font);-webkit-font-smoothing:antialiased}
a{color:var(--brand-1);text-decoration:none;transition:color .25s}
a:hover{color:var(--brand-2);text-decoration:underline;text-underline-offset:2px}
button{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer}
:focus-visible{outline:2px solid var(--brand-1);outline-offset:2px;border-radius:4px}
mark{background:var(--mark);color:inherit;border-radius:2px;padding:0 1px}
/* 正文里的「第 X 条」交叉引用:点一下就地看那条说什么,不用自己翻过去 */
.xref{color:var(--brand-1);cursor:pointer;border-bottom:1px dashed currentColor}
.xref:hover{color:var(--brand-2);border-bottom-style:solid}
#xref-pop{position:absolute;z-index:80;width:min(430px,calc(100vw - 28px));background:var(--bg-elv);border:1px solid var(--divider);border-radius:10px;box-shadow:var(--shadow);padding:11px 13px;font-size:13.5px;line-height:1.75}
#xref-pop .xp-h{display:flex;align-items:flex-start;gap:8px;margin-bottom:7px}
#xref-pop .xp-h b{flex:1;font-weight:600;line-height:1.5}
#xref-pop .xp-h button{flex:none;border:1px solid var(--divider);background:var(--bg-soft);color:inherit;border-radius:6px;padding:2px 9px;font-size:12px;cursor:pointer;font-family:inherit;white-space:nowrap}
#xref-pop .xp-h button:hover{border-color:var(--brand-1);color:var(--brand-1)}
#xref-pop .xp-s{opacity:.62;font-size:12px;margin-bottom:5px}
#xref-pop .xp-b{max-height:210px;overflow:auto}
#xref-pop .xp-b+.xp-b{margin-top:8px;padding-top:8px;border-top:1px solid var(--divider)}
.card.flash{animation:xflash 1.8s ease-out}
/* 节标题不是卡片,不能套卡片那块底色:一行标题填上底色像个色块,也像在说「这一行
就是目标」,而整节指路的目标是下面一整节。标题的高亮改走文字色和上沿那条分割线,
时长和 .card.flash 一样,反馈还是同一套 */
.sec-h.flash{animation:secflash-line 1.8s ease-out}
.sec-h.flash h2{animation:secflash-text 1.8s ease-out}
@keyframes secflash-line{0%,35%{border-top-color:var(--brand-1)}100%{border-top-color:var(--divider)}}
@keyframes secflash-text{0%,35%{color:var(--brand-1)}100%{color:var(--t1)}}
@keyframes xflash{0%,35%{background:var(--brand-soft)}100%{background:transparent}}
[hidden]{display:none!important}
/* nav */
.nav{position:fixed;top:0;left:0;right:0;height:var(--nav-h);z-index:30;background:var(--bg);border-bottom:1px solid var(--divider)}
.nav-in{height:100%;display:flex;align-items:center;gap:16px;padding:0 24px}
.title{display:flex;align-items:center;gap:10px;font-weight:600;font-size:16px;color:var(--t1);white-space:nowrap}
.title:hover{text-decoration:none;color:var(--t1)}
.logo{width:24px;height:24px;border-radius:6px;background:linear-gradient(135deg,var(--brand-3),var(--brand-1));display:grid;place-items:center;color:#fff;font-size:12px;font-weight:700}
.logo svg{width:15px;height:15px}
.search{flex:1;max-width:520px;margin-left:auto;position:relative}
.search input{width:100%;height:40px;padding:0 44px 0 40px;border-radius:8px;border:1px solid transparent;background:var(--bg-alt);color:var(--t1);font:inherit;font-size:14px;transition:border-color .25s,background .25s}
.search input:hover{border-color:var(--divider)}
.search input:focus{outline:0;border-color:var(--brand-1);background:var(--bg)}
.search input::placeholder{color:var(--t3)}
.search svg{position:absolute;left:12px;top:50%;transform:translateY(-50%);width:18px;height:18px;fill:none;stroke:var(--t3);stroke-width:2;pointer-events:none}
.search kbd{position:absolute;right:10px;top:50%;transform:translateY(-50%);font:500 11px/1 var(--font);color:var(--t3);border:1px solid var(--divider);border-radius:4px;padding:3px 6px;background:var(--bg-elv);pointer-events:none}
.nav-r{display:flex;align-items:center;gap:8px}
.icon-btn{width:36px;height:36px;border-radius:8px;display:grid;place-items:center;color:var(--t2);transition:background .25s,color .25s}
.icon-btn:hover{background:var(--bg-soft);color:var(--t1)}
.icon-btn svg{width:20px;height:20px;fill:currentColor}
.switch{position:relative;width:40px;height:22px;border-radius:11px;border:1px solid var(--divider);background:var(--bg-mute);transition:border-color .25s,background .25s;flex:none;margin-left:8px}
.switch:hover{border-color:var(--t3)}
.switch .knob{position:absolute;top:1px;left:1px;width:18px;height:18px;border-radius:50%;background:var(--bg-elv);box-shadow:var(--shadow);transition:transform .25s;display:grid;place-items:center}
html.dark .switch .knob{transform:translateX(18px)}
.switch svg{width:12px;height:12px;fill:var(--t2)}
.switch .sun{display:block} .switch .moon{display:none}
html.dark .switch .sun{display:none} html.dark .switch .moon{display:block}
.menu-btn{display:none}
/* sidebar */
.sidebar{position:fixed;top:var(--nav-h);bottom:0;left:0;width:var(--side-w);background:var(--bg-alt);padding:32px 32px 96px;overflow-y:auto;overscroll-behavior:contain;z-index:20}
.group{padding-bottom:20px;margin-bottom:20px;border-bottom:1px solid var(--divider)}
.group:last-of-type{border-bottom:0}
.gt{font-size:14px;font-weight:700;color:var(--t1);line-height:24px;margin-bottom:4px;display:flex;justify-content:space-between;align-items:baseline}
.gt small{font-weight:400;font-size:12px;color:var(--t3)}
.sec-links{display:flex;flex-direction:column}
.sec-link{display:flex;justify-content:space-between;align-items:flex-start;gap:8px;padding:4px 0;font-size:14px;font-weight:500;color:var(--t2);line-height:24px;text-align:left;transition:color .25s;border-radius:0}
.sec-link:hover{color:var(--t1)}
.sec-link[aria-pressed="true"]{color:var(--brand-1)}
.sec-link i{font-style:normal;font-size:12px;color:var(--t3);font-variant-numeric:tabular-nums;flex:none;min-width:1.6em;text-align:right}
/* 节名折行时:编号单独一列做悬挂缩进,第二行和节名首字对齐;条数、箭头贴第一行 */
.sec-link .sn{flex:none;width:1.6em;margin-right:-4px;font-variant-numeric:tabular-nums}
.sec-link .st{flex:1;min-width:0;text-wrap:pretty}
.sec-link.all{padding-bottom:8px;margin-bottom:4px;border-bottom:1px solid var(--divider)}
/* 章节下的条目目录:只展开选中的那节,没选节时展开正在读的那节(issue #38) */
.toc-sub{display:flex;flex-direction:column;margin:2px 0 8px;border-left:1px solid var(--divider)}
.toc-sub a{display:flex;gap:6px;padding:3px 0 3px 12px;margin-left:-1px;border-left:1px solid transparent;font-size:13px;line-height:20px;color:var(--t2);text-decoration:none;transition:color .2s}
.toc-sub a:hover{color:var(--t1);text-decoration:none}
.toc-sub a i{font-style:normal;flex:none;min-width:1.5em;color:var(--t3);font-variant-numeric:tabular-nums}
.toc-sub a span{display:-webkit-box;-webkit-line-clamp:2;line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.toc-sub a.on{color:var(--brand-1);border-left-color:var(--brand-1)}
.toc-sub a.on i{color:var(--brand-1)}
.toc-none{margin:0;padding:3px 0 3px 12px;font-size:13px;line-height:20px;color:var(--t3)}
/* 折叠箭头:点它只收起或展开目录,不切换章节筛选 */
.sec-link i{margin-left:auto}
.sec-link .fold{flex:none;display:grid;place-items:center;width:24px;height:24px;margin-right:-6px;border-radius:4px;color:var(--t3);transition:transform .2s,color .2s}
.sec-link .fold:hover{color:var(--t1);background:var(--bg-mute)}
.sec-link .fold svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2;transform:rotate(-90deg);transition:transform .2s}
.sec-link.open .fold svg{transform:none}
.chips{display:flex;flex-wrap:wrap;gap:6px;padding-top:6px}
.chip{border:1px solid var(--divider);background:var(--bg-elv);color:var(--t2);border-radius:999px;padding:3px 11px;font-size:12px;font-weight:500;line-height:18px;transition:all .2s}
.chip:hover{border-color:var(--brand-3);color:var(--t1)}
.chip[aria-pressed="true"]{background:var(--brand-soft);border-color:var(--brand-1);color:var(--brand-1)}
.toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--t2);padding:4px 0;cursor:pointer;font-weight:500}
.toggle input{accent-color:var(--brand-1);width:15px;height:15px;margin:0}
.reset{display:inline-flex;align-items:center;gap:6px;margin-top:8px;padding:6px 14px;border:1px solid var(--divider);border-radius:20px;font-size:13px;font-weight:500;color:var(--t2);background:var(--bg-elv);transition:all .25s}
.reset:hover{border-color:var(--brand-1);color:var(--brand-1)}
.doc-links{margin-top:8px;font-size:14px;color:var(--t3)}
.doc-links a{margin-right:4px}
.hint{font-size:12px;color:var(--t3);line-height:1.6;margin-top:16px}
/* 侧栏底部广告位:图片换成新广告主的即可,尺寸按 2:1 出图,源文件在 tools/ad-*.html */
.ad{margin-top:26px}
.ad img{display:block;width:100%;height:auto;border-radius:8px;border:1px solid var(--divider)}
/* 赞赏:侧栏底部一行字,点开才出二维码,图用 loading=lazy,不点不下载 */
.tip-open{margin-top:10px;padding:0;border:0;background:none;font:inherit;font-size:12px;color:var(--t3);cursor:pointer}
.tip-open:hover{color:var(--brand-1)}
#tip-mask{position:fixed;inset:0;z-index:70;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;padding:24px}
#tip-mask[hidden]{display:none}
#tip-box{background:var(--bg);border:1px solid var(--divider);border-radius:12px;box-shadow:var(--shadow);width:min(360px,100%);padding:16px;text-align:center}
#tip-box img{display:block;width:100%;height:auto;border-radius:8px}
#tip-box p{margin:12px 0;font-size:13px;line-height:1.6;color:var(--t2)}
#tip-box button{border:1px solid var(--divider);background:var(--bg-soft);color:var(--t2);border-radius:6px;padding:3px 14px;font-size:13px;cursor:pointer;font-family:inherit}
#tip-box button:hover{color:var(--t1);background:var(--bg-mute)}
.hint p{margin:4px 0}
.backdrop{display:none}
/* content */
.content{padding:calc(var(--nav-h) + 40px) 48px 128px calc(var(--side-w) + 48px)}
.doc{max-width:900px;margin:0 auto}
.doc-head{margin-bottom:28px}
.doc-head h1{font-size:32px;line-height:40px;font-weight:700;letter-spacing:-.02em;margin:0 0 8px}
.doc-head p{margin:0;color:var(--t2);font-size:16px}
.doc-head .stat{color:var(--t3);font-size:14px;margin-top:6px}
.doc-head .stat b{color:var(--brand-1);font-weight:600}
.sec-block{margin-top:40px}
.sec-h{display:flex;align-items:baseline;gap:12px;padding-top:24px;border-top:1px solid var(--divider);margin-bottom:8px}
.sec-h h2{font-size:24px;line-height:32px;font-weight:600;letter-spacing:-.02em;margin:0}
.sec-h .shown{margin-left:auto;font-size:13px;color:var(--t3);font-variant-numeric:tabular-nums}
.intro{margin:0 0 12px;color:var(--t2);font-size:14px;max-width:72ch}
.intro:last-of-type{margin-bottom:20px}
.card{background:var(--bg-soft);border:1px solid transparent;border-radius:12px;padding:22px 24px 18px;margin-bottom:16px;transition:border-color .25s,background .25s;content-visibility:auto;contain-intrinsic-size:300px;contain-intrinsic-size:auto 300px}
.card:hover{border-color:var(--divider)}
.card-h{display:flex;align-items:flex-start;gap:10px;margin-bottom:10px}
.card-h h3{margin:0;font-size:18px;line-height:28px;font-weight:600;letter-spacing:-.01em;flex:1;min-width:0;overflow-wrap:anywhere}
.card-h .idx{color:var(--t3);font-size:14px;line-height:28px;font-variant-numeric:tabular-nums;min-width:22px}
.card-h .anchor{opacity:0;color:var(--brand-1);font-size:16px;line-height:28px;font-weight:500;transition:opacity .2s}
.card:hover .anchor,.anchor:focus-visible{opacity:1}
.badges{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px}
.badge{display:inline-block;border-radius:10px;padding:0 10px;line-height:20px;font-size:12px;font-weight:600;white-space:nowrap;transform:translateY(0)}
.badge.tip{background:var(--brand-soft);color:var(--brand-1)}
.badge.A{background:var(--green-soft);color:var(--green-1)}
.badge.B{background:var(--yellow-soft);color:var(--yellow-1)}
.badge.C{background:var(--gray-soft);color:var(--gray-1)}
.badge.danger{background:var(--red-soft);color:var(--red-1)}
.badge.warn{background:var(--yellow-soft);color:var(--yellow-1)}
.badge.plain{background:var(--gray-soft);color:var(--t2);font-weight:500}
.badge.r3{background:var(--brand-soft);color:var(--brand-1)}
.badge.r2{background:var(--green-soft);color:var(--green-1)}
.badge.r1{background:var(--gray-soft);color:var(--gray-1)}
.badge.upd{background:none;box-shadow:inset 0 0 0 1px var(--brand-3);color:var(--brand-1);font-weight:500}
.chips + .chips{margin-top:6px}
.human{margin:0 0 14px;padding:10px 14px;border-left:2px solid var(--brand-1);background:var(--brand-soft);border-radius:0 6px 6px 0;font-size:15px;line-height:1.75;color:var(--t1);overflow-wrap:anywhere}
.rows{display:grid;grid-template-columns:auto 1fr;gap:8px 16px;font-size:14px;line-height:1.75}
.rows .k{color:var(--t3);font-weight:500;font-size:13px;line-height:1.75;padding-top:1px}
.rows .v{color:var(--t1);min-width:0;overflow-wrap:anywhere}
.rows .v.note{color:var(--t2)}
/* 超长的收益、备注按换话题拆成几块,一块一行,圆点挂在左边 */
.rows .v ul.chunks{margin:0;padding:0;list-style:none}
.rows .v ul.chunks li{position:relative;padding-left:14px;margin-bottom:6px}
.rows .v ul.chunks li:last-child{margin-bottom:0}
.rows .v ul.chunks li::before{content:"";position:absolute;left:2px;top:.75em;width:4px;height:4px;border-radius:50%;background:var(--t3)}
.rows .v strong,.human strong,.intro strong,.src .body strong{font-weight:600;color:var(--t1)}
.src{margin-top:14px;border-top:1px solid var(--divider);padding-top:10px}
.src summary{list-style:none;cursor:pointer;font-size:13px;font-weight:500;color:var(--t2);display:flex;align-items:center;gap:6px;user-select:none}
.src summary::-webkit-details-marker{display:none}
.src summary::before{content:"";width:6px;height:6px;border-right:1.5px solid var(--t3);border-bottom:1.5px solid var(--t3);transform:rotate(-45deg);transition:transform .2s;margin-right:2px}
.src[open] summary::before{transform:rotate(45deg)}
.src .body{padding-top:8px;font-size:13px;line-height:1.75;color:var(--t2);overflow-wrap:anywhere}
.src summary .cnt{color:var(--t3);font-weight:400}
/* 一条来源一行,序号挂在左边,长 URL 单独一行不挤正文 */
.src .body ol{margin:0;padding:0;list-style:none;counter-reset:s}
.src .body li{counter-increment:s;position:relative;padding-left:20px;margin-bottom:8px}
.src .body li:last-child{margin-bottom:0}
.src .body li::before{content:counter(s) ".";position:absolute;left:0;top:0;font-size:12px;color:var(--t3);font-variant-numeric:tabular-nums}
.src .body a.url{display:block;font-size:12px;color:var(--t3)}
.src .body a.url:hover{color:var(--brand-1)}
abbr.term{text-decoration:none;border-bottom:1px dotted var(--t3);cursor:help}
abbr.term:hover{border-bottom-color:var(--brand-1);color:var(--brand-1)}
.pop{position:absolute;z-index:40;background:var(--bg-elv);border:1px solid var(--divider);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.14);padding:10px 12px;font-size:13px;line-height:1.6;color:var(--t2)}
.pop b{display:block;color:var(--t1);font-weight:600;margin-bottom:2px}
/* 长文弹窗:点「长文」不跳 GitHub,就地打开,左侧是可点的章节目录 */
#doc-mask{position:fixed;inset:0;z-index:70;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;padding:24px}
#doc-mask[hidden]{display:none}
#doc-modal{background:var(--bg);border:1px solid var(--divider);border-radius:12px;box-shadow:var(--shadow);width:min(1000px,100%);max-height:100%;display:flex;flex-direction:column;overflow:hidden}
.dm-h{display:flex;align-items:center;gap:10px;padding:13px 16px;border-bottom:1px solid var(--divider);flex:none}
.dm-h b{font-size:15px;font-weight:600;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dm-h a,.dm-h button{flex:none;border:1px solid var(--divider);background:var(--bg-soft);color:var(--t2);border-radius:6px;padding:3px 10px;font-size:12px;cursor:pointer;font-family:inherit;text-decoration:none;line-height:1.6}
.dm-h a:hover,.dm-h button:hover{color:var(--t1);background:var(--bg-mute)}
.dm-b{display:flex;min-height:0;flex:1}
.dm-toc{width:208px;flex:none;border-right:1px solid var(--divider);overflow-y:auto;overscroll-behavior:contain;padding:12px 8px 28px;background:var(--bg-alt)}
.dm-toc a{display:block;padding:5px 10px;border-radius:6px;color:var(--t2);font-size:13px;line-height:1.5;text-decoration:none}
.dm-toc a:hover{background:var(--bg-mute);color:var(--t1)}
.dm-toc a.lv3{padding-left:22px;font-size:12.5px;color:var(--t3)}
.dm-toc a.on{color:var(--brand-1);background:var(--bg-mute);font-weight:500}
.dm-doc{position:relative;flex:1;min-width:0;overflow-y:auto;overscroll-behavior:contain;padding:20px 30px 64px;line-height:1.8}
.dm-doc h2{font-size:17px;font-weight:600;margin:26px 0 10px;padding-top:6px;border-top:1px solid var(--divider)}
.dm-doc h2:first-child{border-top:0;margin-top:4px}
.dm-doc h3{font-size:14.5px;font-weight:600;margin:18px 0 8px;color:var(--t1)}
.dm-doc p{margin:0 0 12px;color:var(--t2)}
.dm-doc ul,.dm-doc ol{margin:0 0 12px;padding-left:22px;color:var(--t2)}
.dm-doc li{margin:4px 0}
.dm-doc strong{color:var(--t1);font-weight:600}
.dm-doc code{font-family:var(--mono);font-size:12.5px;background:var(--bg-mute);padding:2px 5px;border-radius:4px}
.dm-doc a{color:var(--brand-1);text-decoration:none;word-break:break-all}
.dm-doc a:hover{text-decoration:underline}
.dm-tw{overflow-x:auto;margin:0 0 14px}
.dm-doc table{border-collapse:collapse;font-size:13px;min-width:100%}
.dm-doc th,.dm-doc td{border:1px solid var(--divider);padding:7px 10px;text-align:left;vertical-align:top;color:var(--t2)}
.dm-doc th{background:var(--bg-soft);color:var(--t1);font-weight:600;white-space:nowrap}
.dm-load{color:var(--t3);padding:24px 30px}
@media (max-width:820px){
#doc-mask{padding:0}
#doc-modal{border-radius:0;border:0;height:100%}
.dm-toc{display:none}
.dm-doc{padding:16px 18px 56px}
}
.gloss{margin-top:16px;border:1px solid var(--divider);border-radius:8px;padding:0 16px;font-size:13px;color:var(--t2)}
.gloss summary{cursor:pointer;padding:10px 0;font-weight:500;color:var(--t2);list-style:none;display:flex;align-items:center;gap:6px}
.gloss summary::-webkit-details-marker{display:none}
.gloss summary::before{content:"";width:6px;height:6px;border-right:1.5px solid var(--t3);border-bottom:1.5px solid var(--t3);transform:rotate(-45deg);transition:transform .2s;margin-right:2px}
.gloss[open] summary::before{transform:rotate(45deg)}
.gloss p{margin:0 0 8px}
.gloss dl{display:grid;grid-template-columns:auto 1fr;gap:6px 14px;margin:0 0 14px}
.gloss dt{font-weight:600;color:var(--t1);white-space:nowrap}
.gloss dd{margin:0}
@media (max-width:960px){.gloss dl{grid-template-columns:1fr;gap:2px} .gloss dd{margin-bottom:8px}}
.empty{border:1px dashed var(--divider);border-radius:12px;padding:40px 24px;text-align:center;color:var(--t2);margin-top:24px}
.empty button{margin-top:12px;padding:7px 16px;border:1px solid var(--divider);border-radius:20px;font-weight:500;background:var(--bg-elv)}
.empty button:hover{border-color:var(--brand-1);color:var(--brand-1)}
.status{color:var(--t3);padding:40px 0;text-align:center}
.status code{font-family:var(--mono);font-size:13px;background:var(--bg-mute);padding:2px 6px;border-radius:4px}
.status .bar{max-width:260px;height:3px;margin:14px auto 0;border-radius:2px;background:var(--bg-mute);overflow:hidden}
.status .bar i{display:block;height:100%;width:0;border-radius:2px;background:var(--brand-1);transition:width .18s ease-out}
.status button{margin-top:14px;border:1px solid var(--divider);background:var(--bg-soft);color:var(--t1);border-radius:6px;padding:6px 16px;font:inherit;cursor:pointer}
.status button:hover{border-color:var(--brand-1);color:var(--brand-1)}
.foot{margin-top:64px;padding-top:24px;border-top:1px solid var(--divider);color:var(--t3);font-size:13px;line-height:1.7}
@media (max-width:960px){
:root{--nav-h:56px}
.nav-in{padding:0 16px;gap:10px}
.title span{display:none}
.search{max-width:none}
.search kbd{display:none}
.menu-btn{display:grid}
.sidebar{transform:translateX(-100%);transition:transform .25s;box-shadow:none;padding:24px 24px 96px;width:min(var(--side-w),86vw)}
.sidebar.open{transform:none;box-shadow:0 8px 24px rgba(0,0,0,.2)}
.backdrop{display:block;position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:15;opacity:0;pointer-events:none;transition:opacity .25s}
.backdrop.on{opacity:1;pointer-events:auto}
.content{padding:calc(var(--nav-h) + 24px) 20px 96px}
.doc-head h1{font-size:26px;line-height:34px}
.card{padding:18px 16px 14px;border-radius:10px}
.card-h h3{font-size:16px;line-height:24px}
.card-h .idx{line-height:24px}
.human{padding:9px 12px;font-size:14px;margin-bottom:12px}
.rows{grid-template-columns:1fr;gap:4px}
.rows .k{padding-top:6px}
.rows .k:first-child{padding-top:0}
}
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
<!-- ga:start 这两行注释是离线构建的剥离标记(tools/offline/build.mjs),别删 -->
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-NTPGXCLMP6"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-NTPGXCLMP6');
</script>
<!-- ga:end -->
</head>
<body>
<noscript>
<div style="max-width:760px;margin:0 auto;padding:32px 20px;line-height:1.7">
<h1>高性价比人生指南</h1>
<p>用最少的钱、时间、精力和毅力,换回最多的寿命、金钱、时间和人身自由。全书 34 节 767 条,每条写明成本、收益、证据等级和原始出处。收益按受益人分四档算:你自己、配偶与直系亲属、朋友同事、陌生人,档次越低回报期望越小、风险越要写清。</p>
<p>本页的筛选功能需要 JavaScript。正文可直接阅读:<a href="README.md">README.md</a> 里的目录,正文在 <a href="book/">book/</a>。</p>
<ol>
<li>不要早死:交通、火灾、燃气、毒蘑菇、溺水、跌落、疫苗、癌症筛查、心理危机、家庭应急装备</li>
<li>不要慢慢死:烟酒、运动、睡眠、饮食、久坐</li>
<li>不要浪费精力:睡眠、打断、多任务、人际负债</li>
<li>不要浪费时间:沉没成本、拖延、会议、通勤</li>
<li>不要浪费钱:订阅、彩票、利息、保险、基金费率、公积金提取、车险、预付款、直播带货、医保个人账户</li>
<li>反面清单:看起来性价比高但其实不高的东西</li>
<li>没钱的时候怎么活:失业保险、劳动仲裁、救助站、低保、医保、求职</li>
<li>别把自己搭进去:交通事故、被骗止付、彩礼、婚前财产、担保、反诈、被执行与失信名单、养犬责任</li>
<li>普通人容易踩的法律红线:谣言、兼职洗钱、伪造材料骗贷、高空抛物、偷拍、赌博</li>
<li>恋爱和结婚划不划算:择偶策略、纠缠的红线、异地恋、登记流程、婚检、健康账、钱账、退出成本</li>
<li>程序员和技术人容易踩的红线:外挂、爬虫、删库、带走源码、竞业</li>
<li>创业与做生意:本钱、担保、主体选择、注册登记、许可证、纳税申报与零申报、涉税诈骗、用人、退场</li>
<li>紧急情况:心脏骤停、大出血、过敏性休克、癫痫、低血糖、触电、一氧化碳、化学品灼伤、火灾、溺水、迷路、失温、蛇咬、地震、雷击、高原病</li>
<li>账号与信息安全:二次验证、密码、SIM 卡、手机丢失、App 权限、查阅与删除权</li>
<li>租房与买房:押金、腾退、中介代收、资金监管、买卖不破租赁、交易资金专户、隔断房</li>
<li>得了慢性病之后怎么活:服药依从、门诊慢特病跨省结算、复查、长期处方、并发症筛查</li>
<li>家里有老人:意定监护、遗嘱形式、账户与话术、长期护理保险</li>
<li>养孩子划不划算:育儿补贴、产假与生育津贴、时间账、钱账</li>
<li>在职、离职和工伤:加班费、年休假、试用期;职业病危害告知与体检、粉尘噪声防护;N、代通知金、2N、留证;工伤认定时限、劳动能力鉴定、工亡待遇</li>
<li>刚出生的孩子怎么带:安全睡眠、乙肝首针、母乳与辅食、冲奶水温、发热就医红线</li>
<li>出国、旅行与境外安全:安全提醒级别、12308、领事保护的边界、境外医疗保险、境外高薪招聘骗局、境外驾照</li>
<li>怎么放松:娱乐场所和减压:安全出口、明码标价、涉毒红线、别人递的东西;运动、正念、呼吸、社交、绿地</li>
<li>学什么技能划算:教育回报率、山寨证书、培训补贴、抗自动化的维度、技能等级</li>
<li>看病:怎么少花钱少走弯路:分级诊疗与转诊、起付线连续计算、报销比例差、预留号源、异地就医、病历留存</li>
<li>人走了以后要办什么:殡葬基础项目清单、价格违法、中介备案、公积金余额与社保待遇、死者个人信息权利</li>
<li>做一个网站或平台:支付结算红线、ICP 许可与备案、直播与视听资质、平台核验与涉税报送、内容治理、实名、未成年人、通知删除、数据出境、服务器选型</li>
<li>怀孕和生产:叶酸与建册产检、破水处置、无痛分娩、剖宫产指征、生育保险、出生医学证明、新生儿筛查、参保与落户</li>
<li>别为了外形把身体搞坏:极端节食与进食障碍、医美两证、面部填充失明部位、违法减肥药、正畸当面复诊</li>
<li>遭遇重大打击之后:丧亲、重病诊断、失业、丧偶、哀伤就医、12356 与 12355、应激期不做不可逆决定</li>
<li>上学以后的孩子:按小时算的急症、被欺凌处置、每天户外 2 小时、抑郁筛查、治愈近视骗局、散瞳验光、窝沟封闭、别送戒网瘾学校</li>
<li>十八岁之后有哪几条路:十二条路法定门槛、当兵入伍、特岗教师、军队文职、自考成考、公费师范定向医学生、出国打工、创业担保贷款、骑手保障</li>
<li>出国留学:身份、打工、保险和回国认证:认证院校、居留与宽限期、打工时数上限、留学预警、OSHC、留服认证</li>
<li>残疾之后怎么活:精神障碍住院原则、照护者健康、治愈系骗局、办证待遇、康复救助、无障碍改造、残保金、行为能力与监护</li>
<li>家里的常备药别吃出事:对乙酰氨基酚别吃重复、儿童退烧禁忌、布洛芬胃损伤、2 岁以下不喂复方感冒药、奥美拉唑限期、感冒不用抗生素、头孢酒精禁忌</li>
</ol>
</div>
</noscript>
<header class="nav">
<div class="nav-in">
<button class="icon-btn menu-btn" id="menu" aria-label="打开筛选" aria-expanded="false"><svg viewBox="0 0 24 24"><path d="M4 6h16v2H4zm0 5h16v2H4zm0 5h16v2H4z"/></svg></button>
<a class="title" href="./"><span class="logo" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M6 12.5l4 4 8-9.5" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span>高性价比人生指南</span></a>
<label class="search">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5" stroke-linecap="round"/></svg>
<input id="q" type="search" placeholder="搜索条目" autocomplete="off" aria-label="搜索条目">
<kbd>/</kbd>
</label>
<div class="nav-r">
<a class="icon-btn" href="README.md" title="查看 README.md" aria-label="README"><svg viewBox="0 0 24 24"><path d="M6 2h9l5 5v15H6zm8 1.5V8h4.5zM8 12h8v1.5H8zm0 3.5h8V17H8z"/></svg></a>
<a class="icon-btn" href="https://github.com/eternity4719/HowToLiveBetter" target="_blank" rel="noopener" title="在 GitHub 上查看源仓库" aria-label="GitHub 仓库"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1.1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.8-1.6-2.7-.3-5.5-1.3-5.5-5.9 0-1.3.5-2.4 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0C17.3 5.6 18.3 6 18.3 6c.6 1.7.2 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0 0 12 .3"/></svg></a>
<button class="switch" id="theme" aria-label="切换深色模式" title="切换深色模式"><span class="knob"><svg class="sun" viewBox="0 0 24 24"><path d="M12 4a1 1 0 0 1 1 1v1a1 1 0 1 1-2 0V5a1 1 0 0 1 1-1zm0 13a1 1 0 0 1 1 1v1a1 1 0 1 1-2 0v-1a1 1 0 0 1 1-1zm8-6a1 1 0 1 1 0 2h-1a1 1 0 1 1 0-2zM5 11a1 1 0 1 1 0 2H4a1 1 0 1 1 0-2zm12.07-5.07a1 1 0 0 1 0 1.41l-.7.71a1 1 0 1 1-1.42-1.41l.71-.71a1 1 0 0 1 1.41 0zM8.05 16.95a1 1 0 0 1 0 1.41l-.7.71a1 1 0 1 1-1.42-1.41l.71-.71a1 1 0 0 1 1.41 0zm9.02 0l.7.71a1 1 0 1 1-1.41 1.41l-.71-.7a1 1 0 0 1 1.42-1.42zM6.34 5.93l.71.71a1 1 0 0 1-1.41 1.41l-.71-.7a1 1 0 0 1 1.41-1.42zM12 8a4 4 0 1 1 0 8 4 4 0 0 1 0-8z"/></svg><svg class="moon" viewBox="0 0 24 24"><path d="M12.1 22c-5.5 0-10-4.5-10-10 0-4.8 3.4-8.9 8-9.8.5-.1.9.4.7.9A8 8 0 0 0 20.9 13c.5-.2 1 .2.9.7-.9 4.7-5 8.3-9.7 8.3z"/></svg></span></button>
</div>
</div>
</header>
<div class="backdrop" id="backdrop"></div>
<div class="pop" id="pop" role="tooltip" hidden><b></b><span></span></div>
<aside class="sidebar" id="sidebar">
<div class="group">
<div class="gt">我的情况 <small>不相干的条目会藏起来</small></div>
<div class="chips" data-dim="who">
<button class="chip" data-v="怀孕" aria-pressed="false">怀孕或备孕</button>
<button class="chip" data-v="婴幼儿" aria-pressed="false">有 3 岁以下孩子</button>
<button class="chip" data-v="孩子" aria-pressed="false">有 3 到 18 岁孩子</button>
<button class="chip" data-v="老人" aria-pressed="false">家有老人</button>
<button class="chip" data-v="35岁" aria-pressed="false">35 岁以上</button>
<button class="chip" data-v="60岁" aria-pressed="false">60 岁以上</button>
<button class="chip" data-v="女性" aria-pressed="false">女性</button>
<button class="chip" data-v="无" aria-pressed="false">以上都不是</button>
</div>
</div>
<div class="group" id="g-upd" hidden>
<div class="gt">最近更新 <small>光改措辞的不算</small></div>
<div class="chips" data-dim="upd">
<button class="chip" data-v="新增" aria-pressed="false">新增的</button>
<button class="chip" data-v="改过" aria-pressed="false">改过的</button>
</div>
<div class="chips" data-dim="days">
<button class="chip" data-v="7" aria-pressed="false">7 天内</button>
<button class="chip" data-v="30" aria-pressed="false">30 天内</button>
</div>
</div>
<div class="group">
<div class="gt">章节</div>
<div class="sec-links" id="f-sec" data-dim="sec"></div>
</div>
<div class="group">
<div class="gt">性价比 <small>作者判断,同口径内可比</small></div>
<div class="chips" data-dim="ratio">
<button class="chip" data-v="极高" aria-pressed="false">极高</button>
<button class="chip" data-v="高" aria-pressed="false">高</button>
<button class="chip" data-v="一般" aria-pressed="false">一般</button>
</div>
</div>
<div class="group">
<div class="gt">换回什么 <small>口径,不跨口径比较</small></div>
<div class="chips" data-dim="lens">
<button class="chip" data-v="死亡率" aria-pressed="false">寿命</button>
<button class="chip" data-v="金钱" aria-pressed="false">钱</button>
<button class="chip" data-v="时间" aria-pressed="false">时间精力</button>
<button class="chip" data-v="自由" aria-pressed="false">人身自由</button>
</div>
</div>
<div class="group">
<div class="gt">证据等级 <small>荟萃/RCT · 有研究 · 共识</small></div>
<div class="chips" data-dim="grade">
<button class="chip" data-v="A" aria-pressed="false">A 级</button>
<button class="chip" data-v="B" aria-pressed="false">B 级</button>
<button class="chip" data-v="C" aria-pressed="false">C 级</button>
</div>
</div>
<div class="group">
<div class="gt">花钱</div>
<div class="chips" data-dim="money">
<button class="chip" data-v="0" aria-pressed="false">不花钱</button>
<button class="chip" data-v="少" aria-pressed="false">少</button>
<button class="chip" data-v="多" aria-pressed="false">多</button>
</div>
</div>
<div class="group">
<div class="gt">花时间</div>
<div class="chips" data-dim="time">
<button class="chip" data-v="少" aria-pressed="false">顺手</button>
<button class="chip" data-v="中" aria-pressed="false">几小时</button>
<button class="chip" data-v="多" aria-pressed="false">每天占用</button>
</div>
</div>
<div class="group">
<div class="gt">要毅力</div>
<div class="chips" data-dim="will">
<button class="chip" data-v="否" aria-pressed="false">不用</button>
<button class="chip" data-v="些" aria-pressed="false">一点</button>
<button class="chip" data-v="是" aria-pressed="false">很多</button>
</div>
</div>
<div class="group">
<label class="toggle"><input type="checkbox" id="f-dispute"> 只看标了争议的</label>
<label class="toggle"><input type="checkbox" id="f-todo"> 只看有待核实的</label>
<button class="reset" id="reset">清空筛选</button>
</div>
<div class="hint">
<p>章节一次只看一个,成本和等级可多选。同一组多选是「或」,不同组之间是「且」。「我的情况」勾的是你符合哪几样,没标人群的条目始终显示。</p>
<p>每节内的先后顺序不代表性价比高低,想挑划算的就用上面的性价比筛选。</p>
<p>正文与标签来自仓库 <a href="book/">book/</a> 下的 34 个文件,改正文即改这里。</p>
</div>
<div class="group ad">
<div class="gt">广告</div>
<a href="https://4.mcyyy.com" target="_blank" rel="noopener"><img src="ads/mcyyy-side.webp" alt="永恒世界 Minecraft 服务器,游戏地址 1.mcyyy.com" width="624" height="312" loading="lazy"></a>
<button type="button" class="tip-open" id="tip-open">觉得有用?请作者喝杯咖啡</button>
</div>
</aside>
<main class="content">
<div class="doc">
<div class="doc-head">
<h1>高性价比人生指南</h1>
<p>每一条都回答两个问题:花掉什么,换回什么。</p>
<p>换回的算在谁头上也分档:你自己最高,其次配偶和直系亲属,再次朋友同事,陌生人最低——最低不等于零,只是回报的指望小、要连风险一起看。</p>
<p>不用全做:这是一份备选单,不是任务清单。挑走一两条就算数,剩下的需要时再回来查——想挑省力的,左边把「花钱」选「不花钱」、「要毅力」选「不用」,剩下的就是。作者自己也没做到其中大部分。正文里带虚线的「第 X 条」可以点开,就地看那条写了什么,不用自己翻过去。</p>
<p>书里的内容可能过时,也可能有错。不少政策各地执行不一样。真去办事、看病、打官司时,以当地部门、医生和律师的说法为准。发现写错了,欢迎<a href="https://github.com/eternity4719/HowToLiveBetter/issues">提 issue</a>。</p>
<div class="stat" aria-live="polite">当前显示 <b id="cnt">–</b> 条,共 <span id="tot">–</span> 条</div>
<div class="doc-links" id="upd-line" hidden></div>
<div class="doc-links">生活自查:<a href="selfcheck.html">回答几十道是否题,看看书里哪些事你还没做到</a></div>
<div class="doc-links">长文:<a href="https://github.com/eternity4719/HowToLiveBetter/blob/main/docs/%E9%81%87%E5%88%B0%E9%99%8C%E7%94%9F%E4%BA%BA%E5%87%BA%E4%BA%8B%E8%AF%A5%E4%B8%8D%E8%AF%A5%E5%81%9C.md">遇到陌生人出事该不该停</a> · <a href="https://github.com/eternity4719/HowToLiveBetter/blob/main/docs/%E5%AE%B6%E5%BA%AD%E5%BA%94%E6%80%A5%E8%A3%85%E5%A4%87%E6%B8%85%E5%8D%95.md">家庭应急装备清单</a> · <a href="https://github.com/eternity4719/HowToLiveBetter/blob/main/docs/%E7%BB%93%E5%A9%9A%E5%88%92%E4%B8%8D%E5%88%92%E7%AE%97.md">结婚划不划算</a> · <a href="https://github.com/eternity4719/HowToLiveBetter/blob/main/docs/%E5%81%9A%E5%B9%B3%E5%8F%B0%E8%A6%81%E5%8A%9E%E5%93%AA%E4%BA%9B%E8%AF%81.md">做平台要办哪些证</a> · <a href="https://github.com/eternity4719/HowToLiveBetter/blob/main/docs/%E7%94%9F%E7%89%A9%E9%92%9F%E5%92%8C%E5%A4%9C%E7%8F%AD.md">生物钟和夜班</a> · <a href="https://github.com/eternity4719/HowToLiveBetter/blob/main/docs/%E8%A2%AB%E8%A3%81%E4%BA%86%E4%B9%8B%E5%90%8E%E5%85%88%E5%81%9A%E4%BB%80%E4%B9%88.md">被裁了之后先做什么</a> · <a href="https://github.com/eternity4719/HowToLiveBetter/blob/main/docs/%E5%AD%A9%E5%AD%90%E5%87%BA%E7%94%9F%E5%89%8D%E5%90%8E%E8%A6%81%E5%8A%9E%E7%9A%84%E4%BA%8B.md">孩子出生前后要办的事</a> · <a href="https://github.com/eternity4719/HowToLiveBetter/blob/main/docs/%E5%88%9A%E7%A1%AE%E8%AF%8A%E6%85%A2%E6%80%A7%E7%97%85%E4%B9%8B%E5%90%8E.md">刚确诊慢性病之后</a> · <a href="https://github.com/eternity4719/HowToLiveBetter/blob/main/docs/%E6%8D%A2%E5%B7%A5%E4%BD%9C%E3%80%81%E6%8D%A2%E5%9F%8E%E5%B8%82%E4%B9%8B%E5%89%8D.md">换工作、换城市之前</a> ·<a href="https://github.com/eternity4719/HowToLiveBetter/tree/main/docs/%E6%A0%B8%E5%AE%9E%E8%AE%B0%E5%BD%95">来源核实记录</a></div>
<div class="doc-links">AI 助手:<a href="https://github.com/eternity4719/HowToLiveBetter/blob/main/skills/life-decision-guide/README.md">让 Claude Code 或 Codex 照这本书回答你的问题(skill 装法)</a></div>
<details class="gloss" id="gloss"><summary>看不懂的缩写和名词</summary><p>正文里带虚线的词,点一下或把鼠标放上去就有解释。全表如下。</p><dl></dl></details>
</div>
<div id="list">
<div class="status" id="status"><span id="status-t">正在读取正文 …</span><div class="bar"><i id="status-b"></i></div></div>
<div class="empty" id="empty" hidden>
没有匹配的条目。去掉一个筛选条件,或换个更短的关键词。
<div><button id="reset2">清空筛选</button></div>
</div>
</div>
<div class="foot">数字口径以条目内标注为准:第 1、2 节是 ACM 或特定死因,第 3、4 节是精力/时间,第 5 节是金钱,第 7 节是金钱与保障,第 8、9 节是金钱与人身自由,互不换算。来源核实过程见 <a href="https://github.com/eternity4719/HowToLiveBetter/tree/main/docs/%E6%A0%B8%E5%AE%9E%E8%AE%B0%E5%BD%95">docs/核实记录</a>。正文按 <a href="https://creativecommons.org/licenses/by/4.0/deed.zh-hans">CC BY 4.0</a> 发布,转载改编请署名并附原文链接。</div>
</div>
</main>
<div id="doc-mask" hidden>
<div id="doc-modal" role="dialog" aria-modal="true" aria-labelledby="dm-t">
<div class="dm-h"><b id="dm-t"></b><a id="dm-gh" target="_blank" rel="noopener">在 GitHub 打开</a><button type="button" id="dm-x" aria-label="关闭">✕</button></div>
<div class="dm-b"><nav class="dm-toc" id="dm-toc"></nav><div class="dm-doc" id="dm-doc"></div></div>
</div>
</div>
<div id="tip-mask" hidden>
<div id="tip-box" role="dialog" aria-modal="true" aria-label="微信赞赏码">
<img src="ads/wechat-reward.png" alt="微信赞赏码" width="1037" height="1037" loading="lazy">
<p>用微信扫一扫,给不给都行,不影响任何内容。<br>在手机上看的话,先截图,再用微信扫一扫从相册选图。</p>
<button type="button" id="tip-x">关闭</button>
</div>
</div>
<div id="xref-pop" hidden>
<div class="xp-h"><b></b><button class="xp-go" type="button">跳过去</button><button class="xp-x" type="button" aria-label="关闭">✕</button></div>
</div>
<script>
/* ---------- 调试面板:地址后加 ?perf=1 打开,实测各步耗时 ---------- */
const PERF = (function(){
const off = { on:false, log(){}, mark(){} };
if (!/[?&]perf=1/.test(location.search)) return off;
const box = document.createElement('div');
box.style.cssText = 'position:fixed;right:10px;bottom:10px;z-index:99;width:300px;max-height:50vh;overflow:auto;background:rgba(0,0,0,.85);color:#8fe;font:12px/1.5 ui-monospace,Consolas,monospace;padding:8px 10px;border-radius:8px;white-space:pre-wrap;pointer-events:auto';
const rows = [];
const put = (s) => { rows.unshift(s); if (rows.length > 60) rows.pop(); box.textContent = rows.join('\n'); };
const api = {
on: true,
log(name, t0, extra){ const ms = performance.now() - t0; put(`${ms >= 16 ? '⚠' : '·'} ${name} ${ms.toFixed(1)}ms${extra ? ' ' + extra : ''}`); },
mark(s){ put('· ' + s); }
};
addEventListener('DOMContentLoaded', () => document.body.appendChild(box), {once:true});
if (document.readyState !== 'loading') document.body.appendChild(box);
// 展开/收起 <details> 的重排代价:强制同步布局来量
document.addEventListener('toggle', ev => {
if (!ev.target.matches('details')) return;
const t0 = performance.now();
void document.documentElement.scrollHeight; // 强制走一遍布局
api.log((ev.target.open ? '展开' : '收起') + '重排', t0);
}, true);
// 掉帧:连续两帧间隔超过 32ms 就记一条
let last = performance.now(), worst = 0, since = last;
const tick = (t) => { const d = t - last; last = t; if (d > worst) worst = d;
if (t - since > 1000){ if (worst > 32) put(`⚠ 最长帧 ${worst.toFixed(0)}ms`); worst = 0; since = t; }
requestAnimationFrame(tick); };
requestAnimationFrame(tick);
try { new PerformanceObserver(l => { for (const e of l.getEntries()) put(`⚠ 长任务 ${e.duration.toFixed(0)}ms`); })
.observe({entryTypes:['longtask']}); } catch (e) {}
return api;
})();
/* ---------- 解析 README ---------- */
function parseReadme(md){
const lines = md.split(/\r?\n/);
const sections = [];
let sec = null, entry = null;
const flush = () => { if (entry && sec) { sec.entries.push(entry); } entry = null; };
for (const raw of lines){
const line = raw.trimEnd();
let m;
if ((m = /^#{1,2} (\d+)\. (.+)$/.exec(line))) { flush(); sec = {n:m[1], title:m[2].trim(), intro:[], entries:[]}; sections.push(sec); continue; }
if (/^#{1,2} /.test(line)) { flush(); sec = null; continue; }
if (!sec) continue;
if ((m = /^### (\d+)\. (.+)$/.exec(line))) { flush(); entry = {sec:sec.n, n:m[1], title:m[2].trim(), cost:'', human:'', gain:'', grade:'', src:'', note:'', money:'', time:'', will:'', level:'', lens:'', who:[]}; continue; }
if ((m = /^\x3c!--\s*成本标签:\s*(.*?)\s*-->/.exec(line)) && entry){
for (const kv of m[1].split(/\s+/)){ const [k,v] = kv.split('='); if(k==='钱') entry.money=v; if(k==='时间') entry.time=v; if(k==='毅力') entry.will=v; if(k==='收益') entry.level=v; if(k==='口径') entry.lens=v; if(k==='人群') entry.who=v.split(',').map(g => g.split('+')); }
continue;
}
if (entry){
if ((m = /^- 成本:(.*)$/.exec(line))) entry.cost = m[1];
else if ((m = /^- 说人话:(.*)$/.exec(line))) entry.human = m[1];
else if ((m = /^- 收益:(.*)$/.exec(line))) entry.gain = m[1];
else if ((m = /^- 证据等级:\s*([ABC])/.exec(line))) entry.grade = m[1];
else if ((m = /^- 来源:(.*)$/.exec(line))) entry.src = m[1];
else if ((m = /^- 备注:(.*)$/.exec(line))) entry.note = m[1];
continue;
}
if (line) sec.intro.push(line); // 第一条之前的每一段导读都收,不止第一段
}
flush();
for (const s of sections) for (const e of s.entries){
e.dispute = /^争议/.test(e.note);
e.todo = /待核实|TODO/.test(e.src + e.gain + e.note + e.cost);
e.cs = (COST_W.money[e.money] ?? 0) + (COST_W.time[e.time] ?? 0) + (COST_W.will[e.will] ?? 0);
e.ratio = e.level === '大' ? (e.cs === 0 ? '极高' : (e.cs <= 2 ? '高' : '一般'))
: e.level === '中' ? (e.cs === 0 ? '高' : '一般') : '一般';
e.hay = [e.title, e.human, e.cost, e.gain, e.note, e.src, e.grade].join('\n').replace(/\*\*/g, '').replace(/\\([*_])/g, '$1').toLowerCase();
}
return sections;
}
/* ---------- 术语表 ---------- */
let GLOSS = []; // [{term, meaning, re}]
function parseGlossary(md){
const m = /^## 读懂数字[^\n]*\n([\s\S]*?)(?=^## )/m.exec(md);
if (!m) return [];
const rows = [];
for (const line of m[1].split(/\r?\n/)){
const c = /^\|\s*(.+?)\s*\|\s*(.+?)\s*\|$/.exec(line);
if (!c || c[1] === '术语' || /^-+$/.test(c[1])) continue;
for (const t of c[1].split('、')){
const term = t.trim(); if (!term) continue;
rows.push({term, meaning: c[2], latin: /^[A-Za-z0-9%. ]+$/.test(term)});
}
}
rows.sort((a,b) => b.term.length - a.term.length);
// 合并成一条正则:长词在前保证最长匹配,拉丁术语要求前后不是字母数字
const escRe = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const alt = (list) => list.map(g => escRe(g.term)).join('|');
const parts = [];
const latin = rows.filter(g => g.latin), cjk = rows.filter(g => !g.latin);
if (latin.length) parts.push(`(?<![A-Za-z0-9])(?:${alt(latin)})(?![A-Za-z0-9])`);
if (cjk.length) parts.push(`(?:${alt(cjk)})`);
GLOSS_RE = new RegExp(parts.join('|'), 'g');
GLOSS_BY = Object.fromEntries(rows.map(g => [g.term, g]));
return rows;
}
let GLOSS_RE = null, GLOSS_BY = {};
function termify(root){
if (!GLOSS_RE) return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const nodes = []; let n; while ((n = walker.nextNode())) if (!n.parentElement.closest('a,abbr')) nodes.push(n);
for (const node of nodes){
const s = node.nodeValue;
GLOSS_RE.lastIndex = 0;
if (!GLOSS_RE.test(s)) continue;
GLOSS_RE.lastIndex = 0;
const frag = document.createDocumentFragment(); let pos = 0, m;
while ((m = GLOSS_RE.exec(s))){
const g = GLOSS_BY[m[0]]; if (!g) continue;
if (m.index > pos) frag.appendChild(document.createTextNode(s.slice(pos, m.index)));
const ab = document.createElement('abbr'); ab.className = 'term'; ab.textContent = m[0]; ab.title = g.meaning; ab.tabIndex = 0; ab.dataset.term = g.term;
frag.appendChild(ab); pos = m.index + m[0].length;
}
if (pos < s.length) frag.appendChild(document.createTextNode(s.slice(pos)));
node.parentNode.replaceChild(frag, node);
}
}
function showTerm(ab){
const pop = document.getElementById('pop');
pop.querySelector('b').textContent = ab.dataset.term;
pop.querySelector('span').textContent = ab.title;
pop.hidden = false;
const r = ab.getBoundingClientRect(); const w = Math.min(360, innerWidth - 24);
let x = r.left + r.width/2 - w/2; x = Math.max(12, Math.min(x, innerWidth - w - 12));
pop.style.width = w + 'px'; pop.style.left = x + 'px';
const below = r.bottom + 8 + pop.offsetHeight < innerHeight;
pop.style.top = (below ? r.bottom + 8 : r.top - 8 - pop.offsetHeight) + scrollY + 'px';
}
/* ---------- 文本渲染:链接 + 高亮 ---------- */
const URL_RE = /<?(https?:\/\/[A-Za-z0-9\-._~:\/?#\[\]@!$&'()*+,=%]+)>?/g;
function trimUrl(u){
let t = '';
for (;;){
const ch = u.slice(-1);
const unbalanced = ch === ')' && (u.split('(').length < u.split(')').length);
if (!/[)。,,]/.test(ch) && !unbalanced) break;
t = ch + t; u = u.slice(0, -1);
}
return [u, t];
}
// Markdown 链接 [文字](地址) 或裸 URL;仓库内的相对路径指向 GitHub 上的源文件
const REPO_BLOB = 'https://github.com/eternity4719/HowToLiveBetter/blob/main/';
const MD_LINK_RE = /\[([^\]\n]+)\]\(([^)\s]+)\)/;
const BOLD_RE = /\*\*((?:\\\*|[^*\n])+)\*\*/; // 正文里的加粗,不转就会露出星号;粗体里允许 \* 这种转义星号(HLA-B\*5801)
const unescMd = (s) => s.replace(/\\([*_])/g, '$1');
const LINK_RE = new RegExp([MD_LINK_RE.source, URL_RE.source, BOLD_RE.source].join('|'), 'g');
function hrefOf(p){
if (/^(https?:|mailto:|#)/.test(p)) return p;
return REPO_BLOB + p.replace(/^(\.\.?\/)+/, '').split('/').map(encodeURIComponent).join('/');
}
function mkLink(href, text){
const a = document.createElement('a'); a.href = href; a.target = '_blank'; a.rel = 'noopener'; a.textContent = text;
return a;
}
function renderText(el, text, terms, withTerms = true, urlsLast = false){
el.textContent = '';
const held = []; // urlsLast:独占一行的链接攒到最后再放,免得它后面的尾巴被挤成孤行
let justHeld = false; // 链接被攒起来后,紧跟的纯分隔符也要丢掉,否则「、」会孤零零留在正文末尾
let last = 0; LINK_RE.lastIndex = 0; let m;
const pushText = (s) => {
if (!s) return;
if (justHeld && /^[\s、,,]+$/.test(s)) return;
justHeld = false;
xrefInto(el, unescMd(s), terms);
};
while ((m = LINK_RE.exec(text))){
pushText(text.slice(last, m.index));
last = m.index + m[0].length;
if (m[4] !== undefined){
const b = document.createElement('strong'); highlightInto(b, unescMd(m[4]), terms); el.appendChild(b); justHeld = false;
continue;
}
if (m[3]){
const [u, tail] = trimUrl(m[3]);
const a = mkLink(u, u.replace(/^https?:\/\//, '').replace(/\/$/, ''));
a.className = 'url'; // 来源里单独占一行,别的地方仍是行内链接
if (urlsLast){ held.push(a); justHeld = true; } else el.appendChild(a);
pushText(tail);
continue;
}
el.appendChild(mkLink(hrefOf(m[2]), m[1])); justHeld = false;
}
pushText(text.slice(last));
for (const a of held) el.appendChild(a);
if (withTerms) termify(el);
}
// <split-rules> 这一段和 tools/lib/split-items.mjs 是同一套规则,EPUB、PDF 用那份,网页和离线版用这份。
// 改一边要同步改另一边,`node tools/check-split.mjs` 会拿全书逐条比对两边拆出来的结果。
// 收益、备注超过 300 字的,在句号处、且下一句开始引用新的依据时拆成几块,一块一行
const GAIN_MIN_LEN = 300, GAIN_MIN_CHUNK = 25;
const GAIN_TOPIC_START = [
/^[^,。「」《》:;()的]{1,12}看(《|[^,。「」的]{0,10}法[:「(第])/,
/^《[^》]{2,40}》/,
/^(?!该|本|此|这|同|上述|但|而|所以|因此|并且|也)[一-龥]{1,12}法(第[一二三四五六七八九十百零]+条|「)/,
/^(另一|另有一|还有一|再一|又一)(项|篇)/,
/^(国内|国外|国内外)也有/,
/^案例[一二三四五六七八九十]/,
/^(同一天|同一试验|同一指南|同一研究)/,
/^(美国|英国|欧洲|世界卫生组织|WHO|中国)[^,。]{0,20}(指南|共识|建议|学会)/,
/^(先说|再说|最后说|先看|再看|最后看)/,
/^一(项|篇)[^,。]{0,20}(试验|研究|综述|分析|调查)/,
/^(芬兰|北欧|英国|美国|日本|韩国|瑞典|丹麦|挪威|德国|法国|澳大利亚|加拿大|荷兰|台湾|香港|上海|北京)[^,。]{0,25}(研究|数据库|试验|队列|调查)/,
];
function splitGain(text){
if (!text || text.length < GAIN_MIN_LEN) return [text];
const sentences = []; let buf = '', depth = 0;
for (const c of text){
if ('「『《('.includes(c)) depth++;
else if ('」』》)'.includes(c)) depth = Math.max(0, depth - 1);
buf += c;
if (c === '。' && depth === 0){ sentences.push(buf); buf = ''; }
}
if (buf) sentences.push(buf);
const chunks = [];
for (const s of sentences){
if (chunks.length && GAIN_TOPIC_START.some(re => re.test(s))) chunks.push(s);
else if (chunks.length) chunks[chunks.length - 1] += s;
else chunks.push(s);
}
const out = [];
for (const c of chunks){
if (out.length && c.length < GAIN_MIN_CHUNK) out[out.length - 1] += c;
else out.push(c);
}
if (out.length > 1 && out[0].length < GAIN_MIN_CHUNK) out.splice(0, 2, out[0] + out[1]);
return out;
}
// 来源栏是「;」串起来的若干条文献,拆成带序号的列表,一条一行
// 括号和引号里的分号不算分隔符:法条原文里有「……代理或者追认;……」,条款注里有「第五项,修改刑法第三百九十条;自 2024 年 3 月 1 日起施行」
const SRC_OPEN = '「『(', SRC_CLOSE = '」』)';
function splitSrc(text){
const parts = []; let buf = '', depth = 0;
for (let i = 0; i < text.length; i++){
const c = text[i];
if (SRC_OPEN.includes(c)) depth++;
if (SRC_CLOSE.includes(c)) depth = Math.max(0, depth - 1);
const semi = c === ';' || (c === ';' && /\s/.test(text[i-1] || '') && /\s/.test(text[i+1] || ''));
if (semi && depth === 0){ parts.push(buf); buf = ''; continue; }
buf += c;
}
parts.push(buf);
return parts.map(s => s.trim()).filter(Boolean);
}
// </split-rules>
function renderGain(el, text, terms){
const parts = splitGain(text);
if (parts.length < 2){ renderText(el, text, terms); return; }
el.textContent = '';
const ul = document.createElement('ul'); ul.className = 'chunks';
for (const p of parts){ const li = document.createElement('li'); renderText(li, p, terms); ul.appendChild(li); }
el.appendChild(ul);
}
function renderSrc(el, text, terms){
el.textContent = '';
const parts = splitSrc(text);
if (parts.length < 2){ renderText(el, text, terms, true, true); return parts.length; }
const ol = document.createElement('ol');
for (const p of parts){ const li = document.createElement('li'); renderText(li, p, terms, true, true); ol.appendChild(li); }
el.appendChild(ol);
return parts.length;
}
function highlightInto(el, s, terms){
if (!terms.length){ el.appendChild(document.createTextNode(s)); return; }
const lower = s.toLowerCase(); let i = 0;
while (i < s.length){
let best = -1, bl = 0;
for (const t of terms){ const k = lower.indexOf(t, i); if (k !== -1 && (best === -1 || k < best)) { best = k; bl = t.length; } }
if (best === -1){ el.appendChild(document.createTextNode(s.slice(i))); break; }
if (best > i) el.appendChild(document.createTextNode(s.slice(i, best)));
const mk = document.createElement('mark'); mk.textContent = s.slice(best, best+bl); el.appendChild(mk);
i = best + bl;
}
}
/* ---------- 正文里的「第 X 条」变成可点的引用 ---------- */
// 正文里到处是「见第 8 节第 16 条」「本节第 3 条」这种指路,自己翻过去很麻烦。
// 检索页本来就把全部条目读进内存了,顺手把这些引用变成可点的,点一下就地看那条写了什么。
// 只有能解析到真实条目的才变成链接——来源和备注里的法条条款号(「第 285 条」)解析不到,
// 自然就留作普通文字,不用另写规则去排除。
const ITEMS = new Map(); // '9-15' -> entry
const SECS = new Map(); // '9' -> section,给「见第 9 节」这种整节指路用
let CUR_SEC = ''; // 正在渲染哪一节,供「本节第 X 条」和省掉节号的裸条号定位
// 条号可以写成「第 13 条」「第 12、13 条」,也可以写成「第 8 到 10 条」这种范围。
// 范围那种以前不认,点不动(2026-09-20 用户在第 5 节第 31 条撞到的就是它)。
const NUMS = '[\\d、]+(?:\\s*到\\s*\\d+)?';
// 最后那一段认「见第 9 节」「走第 8、9 节的法律路径」这种不带条号的整节指路,
// 全书有 135 处,以前一律是死字(2026-09-20 用户在第 5 节撞到的「(见第 9 节)」)。
// 它必须排在最后:前面几段先吃掉「第 9 节第 3 条」,剩下的才轮到它。
const XREF_RE = new RegExp(`第\\s*(\\d+)\\s*节第\\s*(${NUMS})\\s*条|本节第\\s*(${NUMS})\\s*条|第\\s*(${NUMS})\\s*条|第\\s*([\\d、]+)\\s*节`, 'g');
// 「8 到 10」摊成 8、9、10;上限 40 是防着把法条条款号那种大范围摊成几百个键
const xrefKeys = (sec, spec) => {
const ns = [];
for (const part of String(spec).split('、')){
const r = /^\s*(\d+)\s*到\s*(\d+)\s*$/.exec(part);
if (r){
const a = +r[1], b = +r[2];
if (b >= a && b - a <= 40) for (let i = a; i <= b; i++) ns.push(i);
continue;
}
const n = part.trim(); if (n) ns.push(n);
}
return ns.map(n => `${sec}-${n}`).filter(k => ITEMS.has(k));
};
function xrefInto(el, s, terms){
XREF_RE.lastIndex = 0; let m, last = 0, any = false;
while ((m = XREF_RE.exec(s))){
// 整节指路的键写成 's9',和条目键 '9-15' 分得开
const keys = m[5] != null
? String(m[5]).split('、').map(x => x.trim()).filter(x => SECS.has(x)).map(x => 's' + x)
: xrefKeys(m[1] ?? CUR_SEC, m[2] ?? m[3] ?? m[4] ?? '');
if (!keys.length) continue;
highlightInto(el, s.slice(last, m.index), terms);
const a = document.createElement('span');
a.className = 'xref'; a.tabIndex = 0; a.dataset.keys = keys.join(',');
a.textContent = m[0];
el.appendChild(a);
last = m.index + m[0].length; any = true;
}
if (!any) { highlightInto(el, s, terms); return; }
highlightInto(el, s.slice(last), terms);
}
const POP = document.getElementById('xref-pop');
function showXref(a){
POP.querySelectorAll('.xp-b').forEach(n => n.remove());
POP.querySelector('.xp-h b').textContent = a.textContent;
const keys = a.dataset.keys.split(',');
for (const k of keys){
if (k[0] === 's'){ // 整节:给节名和条目数,点「跳过去」落到节首
const sec = SECS.get(k.slice(1)); if (!sec) continue;
const b = document.createElement('div'); b.className = 'xp-b';
const s = document.createElement('div'); s.className = 'xp-s'; s.textContent = `整节,共 ${sec.entries.length} 条`;
const t = document.createElement('strong'); t.textContent = `${sec.n}. ${sec.title}`;
b.append(s, t); POP.appendChild(b); continue;
}
const e = ITEMS.get(k); if (!e) continue;
const b = document.createElement('div'); b.className = 'xp-b';
const s = document.createElement('div'); s.className = 'xp-s'; s.textContent = `第 ${e.sec} 节第 ${e.n} 条`;
const t = document.createElement('strong'); t.textContent = e.title;
b.append(s, t);
if (e.human){ const p = document.createElement('p'); p.style.cssText = 'margin:4px 0 0'; p.textContent = e.human; b.appendChild(p); }
POP.appendChild(b);
}
POP.dataset.go = keys[0];
POP.dataset.from = a.closest('.card')?.id || ''; // 记下从哪张卡片点的,好让后退键回得来
POP.hidden = false;
const r = a.getBoundingClientRect(), vw = document.documentElement.clientWidth;
POP.style.top = (window.scrollY + r.bottom + 6) + 'px';
POP.style.left = Math.max(window.scrollX + 14,
Math.min(window.scrollX + r.left, window.scrollX + vw - POP.offsetWidth - 14)) + 'px';
}
// 滚到哪条,地址栏就停在哪条,随手复制 URL 就能把这一条发给别人。
// 必须用 replaceState:滚一下推一条历史记录,后退键就彻底废了。
let spyT = 0, spyLast = '', spyOff = false;
function spy(){
if (spyOff) return;
clearTimeout(spyT);
spyT = setTimeout(() => {
const line = innerHeight * 0.3; // 判定线取视口偏上三成,和人读的位置差不多
let hit = '';
// 还在页首读导读和筛选器时,没有「正在看的那一条」,地址栏就该是干净的。
// 否则停在最上面也会被记成 #e-1-1,一刷新就滚到第 1 节标题、导读全没了
// (2026-09-20 用户提的)。判据是第一个节块的顶还在判定线以下。
const firstBlock = BLOCKS.find(b => b.el.offsetParent)?.el;
if (firstBlock && firstBlock.getBoundingClientRect().top > line){
if (spyLast === '') return;
spyLast = ''; tocSync('');
history.replaceState(history.state, '', location.pathname + location.search);
return;
}
// 看标题:从上往下第一个没被顶栏挡住的标题,就是正在读的那条。一屏只放得下一条半,
// 按「压着视口三成处那条线」「露出得多」「中线还在屏幕里」三种判法都试过,都有被用户
// 抓到的情况(2026-10-05),最后按用户的提议只看标题。节标题算作这一节第 1 条的标题,
// 不然读节首引言和导览时会挑到上一节最后一条。屏幕里一个标题都没有(一条比一屏还高),
// 就取标题已经滚到顶栏上面的最后一条,也就是正在读的这条。
const top = Math.max(0, document.querySelector('header.nav').getBoundingClientRect().bottom);
const heads = []; // [标题上沿, 落到哪条]
for (const b of BLOCKS){
const r = b.el.getBoundingClientRect();
if (!r.height) continue;
const first = CARDS.find(c => c.el.closest('.sec-block') === b.el && c.el.getBoundingClientRect().height);
if (first) heads.push([r.top, first.el.id]);
}
for (const c of CARDS){
// 不看 card.vis:那是 CARD_IO 维护的渲染标记,滞后于滚动。直接量一次位置,
// 高度为 0 的就是被筛选藏起来的,顺带一并跳过。
if (!c.el.getBoundingClientRect().height) continue;
heads.push([c.el.querySelector('.card-h').getBoundingClientRect().top, c.el.id]);
}
heads.sort((x, y) => x[0] - y[0]);
hit = heads.find(([t]) => t >= top && t < innerHeight)?.[1]
|| heads.filter(([t]) => t < top).pop()?.[1] || '';
if (hit === spyLast) return;
spyLast = hit; tocSync(hit);
history.replaceState(history.state, '', location.pathname + location.search + (hit ? '#' + hit : ''));
}, 160);
}
addEventListener('scroll', spy, { passive: true });
// 卡片是懒渲染的,没渲染过的那些矮一截。直接滚过去会停在半路:滚动沿途的卡片被
// IntersectionObserver 补渲染、高度撑开,目标又被往下推了,跨节跳时尤其明显。
// 所以先把目标本身渲染出来,再反复校正位置,直到两次之间不再变化(或试满 10 次)。
// 整节指路落到节首,所以对齐方式要分开:条目居中看得清上下文,整节必须顶到视口
// 上沿——一整节有几十条、高好几屏,block:'center' 会把它的中点摆到视口中间,落点
// 在节当中,跳过去等于没跳(2026-09-20 用户点第 13 节撞到的)。顶部对齐靠 html 上
// 那条 scroll-padding-top 让开吸顶导航。flash 也要分开:.flash 的样式只挂在 .card
// 上,整块 section 加了不显示,闪的是节标题那一行。
function scrollToEl(el, { align = 'center', flashEl = el, spyId = el.id, renderEl = el } = {}){
spyOff = true; // 校正期间别让 spy 把地址栏改成沿途的条目
const card = CARD_OF.get(renderEl); // 滚到节块时要预渲染的是节里第一张卡片
if (card && card.key !== CUR_KEY) renderCard(card, CUR_TERMS, CUR_KEY);
let tries = 0, lastY = NaN;
const settle = () => {
el.scrollIntoView({block: align});
const y = Math.round(el.getBoundingClientRect().top);
if (++tries < 10 && y !== lastY){ lastY = y; setTimeout(settle, 50); return; }
flashEl.classList.remove('flash'); void flashEl.offsetWidth; flashEl.classList.add('flash');
spyLast = spyId; spyOff = false; // 停稳了地址栏就归这一条,再放 spy 回来
tocSync(spyId);
};
settle();
}
// 一节的第一条,滚的时候连它上面的节标题一起带进视野:整节指路落地就是这个位置,
// 刷新、把链接发给别人也该看到同一屏。不然地址栏一样、画面不一样——点过去看得见
// 「13. 紧急情况」,刷新之后标题在视口上面、直接从第一条开始(2026-09-20 用户提的)。
function scrollAnchor(el){
const block = el.closest?.('.sec-block');
return block && el.id === `e-${block.dataset.sec}-1` ? block : el;
}
function gotoItem(key, fromId){
const sec = key[0] === 's' ? key.slice(1) : '';
const id = sec ? 'sec-' + sec : `e-${key.split('-')[0]}-${key.split('-')[1]}`;
const el = document.getElementById(id);
if (!el) return;
if (!el.offsetParent){ // 目标被筛选藏起来了,先把筛选清掉再跳
state.q = ''; for (const d of DIMS) state[d].clear();
state.dispute = false; state.todo = false;
syncControls(); apply(); writeUrl();
}
// 把「从哪跳的」写进当前这条历史记录,再推一条新的,这样浏览器后退键和鼠标侧键
// 能回到原处。记的是来源卡片的 id 而不只是滚动条位置——往回走时沿途卡片会被补
// 渲染撑开,原来那个像素值早就不指向同一处了,只有按 id 重新定位才准。
history.replaceState({ xrefFrom: fromId || '', xrefY: window.scrollY }, '');
// 地址栏写的不是 #sec-13 而是节里第一条的 #e-13-1:spy 一滚就会把 #sec-13 换成
// 那个形状(刷新时也是),与其让用户看着 hash 自己变,不如从一开始就写成同一个
// 形状——分享出去的链接也就和页面自己产生的链接对得上了。
const first = sec ? document.getElementById(`e-${sec}-1`) : null;
history.pushState(null, '', '#' + (first?.id || id));
if (sec){
scrollToEl(el, { align: 'start', flashEl: el.querySelector('.sec-h') || el, spyId: first?.id || id, renderEl: first || el });
return;
}
// 条目一律居中看得清上下文,只有一节的第一条例外:滚它所在的节块、顶部对齐,
// 这样「第 13 节」和「第 13 节第 1 条」两种指路落在同一屏,闪的还是那张卡片。
const anchor = scrollAnchor(el);
scrollToEl(anchor, anchor === el ? {} : { align: 'start', flashEl: el, spyId: el.id, renderEl: el });
}
addEventListener('popstate', ev => {
POP.hidden = true;
const st = ev.state; if (!st) return;
const back = st.xrefFrom && document.getElementById(st.xrefFrom);
if (back) scrollToEl(back);
else if (typeof st.xrefY === 'number') scrollTo({ top: st.xrefY });
});
document.addEventListener('click', ev => {
const a = ev.target.closest?.('.xref');
if (a){ showXref(a); return; }
if (!ev.target.closest?.('#xref-pop')) POP.hidden = true;
});
document.addEventListener('keydown', ev => {
if (ev.key === 'Escape') POP.hidden = true;
if (ev.key === 'Enter' && ev.target.classList?.contains('xref')) showXref(ev.target);
});
POP.querySelector('.xp-x').addEventListener('click', () => { POP.hidden = true; });
POP.querySelector('.xp-go').addEventListener('click', () => {
// 从长文弹窗里点的,先关掉弹窗才看得见跳到的条目
if (!DM.mask.hidden) dmClose();
gotoItem(POP.dataset.go, POP.dataset.from); POP.hidden = true;
});
addEventListener('resize', () => { POP.hidden = true; });
const COST_W = { money:{'0':0,'少':1,'多':2}, time:{'少':0,'中':1,'多':2}, will:{'否':0,'些':1,'是':2} };
const LENS_LABEL = {'死亡率':'换寿命','金钱':'换钱','时间':'换时间精力','自由':'换人身自由'};
/* ---------- 构建 DOM ---------- */
// 成本标签里的「人群=」:逗号是或,加号是且;没写的条目人人适用。值表改了要同步 sync-stats.mjs 的 WHO
const WHO_LABEL = {'怀孕':'怀孕或备孕','婴幼儿':'家有 3 岁以下孩子','孩子':'家有 3 到 18 岁孩子','老人':'家有老人','35岁':'35 岁以上','60岁':'60 岁以上','女性':'女性'};
const LABEL = { money:{'0':'不花钱','少':'花少量钱','多':'花不少钱'}, time:{'少':'顺手','中':'花几小时','多':'每天占时间'}, will:{'否':'不用毅力','些':'要一点毅力','是':'要很多毅力'} };
let CARDS = [], BLOCKS = [];
// 侧栏条目目录:卡片 id → 目录里那一行,节号 → 该节的目录块
const TOC = new Map(), TOC_SEC = new Map();
let TOC_ON = null, TOC_SELK = '';
// 手动点过折叠箭头的节:节号 → 展开与否,优先于自动规则。换了章节筛选就作废。
const TOC_MAN = new Map();
// 选了节就展开那一节;看全部章节时跟着 spy 展开正在读的那节,读到页首就全收起。
// 高亮行滚出侧栏可视区时把侧栏拉过去,只动侧栏自己的 scrollTop,不碰页面滚动。
function tocSync(hit, { follow = true } = {}){
const selk = [...state.sec].join();
if (selk !== TOC_SELK){ TOC_MAN.clear(); TOC_SELK = selk; }
const open = state.sec.size ? [...state.sec][0] : (hit ? hit.split('-')[1] : '');
for (const [n, sub] of TOC_SEC){
const on = TOC_MAN.has(n) ? TOC_MAN.get(n) : n === open;
if (sub.hidden === on) sub.hidden = !on;
sub.previousElementSibling.classList.toggle('open', on);
}
TOC_ON?.classList.remove('on');
TOC_ON = TOC.get(hit) || null;
if (!TOC_ON) return;
TOC_ON.classList.add('on');
if (!follow || !TOC_ON.offsetParent) return; // 手动折叠不追踪滚动;隐藏的条目量不出位置
const sb = document.getElementById('sidebar');
const r = TOC_ON.getBoundingClientRect(), br = sb.getBoundingClientRect();
if (r.top < br.top + 48 || r.bottom > br.bottom - 48) sb.scrollTop += r.top - br.top - sb.clientHeight / 3;
}
// 卡片文字只在「上屏 + 搜索词变了」时重渲染,离屏的先记账,滚进来再补
let CUR_TERMS = [], CUR_KEY = '';
const CARD_OF = new WeakMap();
const CARD_IO = new IntersectionObserver(list => {
const t0 = performance.now(); let n = 0;
for (const it of list){
const card = CARD_OF.get(it.target); if (!card) continue;
card.vis = it.isIntersecting;
if (card.vis && card.key !== CUR_KEY) { n++; renderCard(card, CUR_TERMS, CUR_KEY); }
}
if (n) PERF.log('滚动补渲染', t0, n + ' 张');
}, {rootMargin:'600px 0px'});
function esc(s){ return s.replace(/[&<>"]/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[ch])); }
function build(sections){
for (const s of sections){ SECS.set(s.n, s); for (const e of s.entries) ITEMS.set(`${s.n}-${e.n}`, e); }
const list = document.getElementById('list');
const fsec = document.getElementById('f-sec');
const allBtn = document.createElement('button');
allBtn.className = 'sec-link all'; allBtn.dataset.v = ''; allBtn.setAttribute('aria-pressed','true');
allBtn.innerHTML = `<span>全部章节</span><i>${sections.reduce((n,s)=>n+s.entries.length,0)}</i>`;
fsec.appendChild(allBtn);
for (const s of sections){
const b = document.createElement('button'); b.className='sec-link'; b.dataset.v = s.n; b.setAttribute('aria-pressed','false');
b.innerHTML = `<span class="sn">${s.n}.</span><span class="st">${esc(s.title)}</span><i>${s.entries.length}</i><b class="fold" title="展开或收起本节目录"><svg viewBox="0 0 24 24"><path d="m6 9 6 6 6-6" stroke-linecap="round" stroke-linejoin="round"/></svg></b>`; fsec.appendChild(b);
const sub = document.createElement('div'); sub.className = 'toc-sub'; sub.hidden = true;
for (const e of s.entries){
const a = document.createElement('a'); a.href = `#e-${e.sec}-${e.n}`; a.dataset.go = `${e.sec}-${e.n}`; a.title = e.title;
a.innerHTML = `<i>${e.n}</i><span>${esc(e.title)}</span>`;
sub.appendChild(a); TOC.set(`e-${e.sec}-${e.n}`, a);
}
// 其他筛选把这节条目全筛没了时,展开后显示这一行,不留空白
const none = document.createElement('p'); none.className = 'toc-none'; none.hidden = true; none.textContent = '没有符合当前筛选的条目';
sub.appendChild(none);
fsec.appendChild(sub); TOC_SEC.set(s.n, sub);
const block = document.createElement('section'); block.className='sec-block'; block.dataset.sec = s.n; block.id = 'sec-'+s.n;
block.innerHTML = `<div class="sec-h"><h2>${s.n}. ${esc(s.title)}</h2><span class="shown"><span class="k"></span> / ${s.entries.length}</span></div>`;
CUR_SEC = s.n;
for (const para of s.intro){
const p = document.createElement('p'); p.className = 'intro';
renderText(p, para, [], false); block.appendChild(p);
}
for (const e of s.entries){
const id = `e-${e.sec}-${e.n}`;
const c = document.createElement('article'); c.className='card'; c.id = id;
c.innerHTML = `
<div class="card-h"><span class="idx">${e.n}</span><h3 class="t"></h3><a class="anchor" href="#${id}" aria-label="本条链接">#</a></div>
<div class="badges"></div>
<p class="human"></p>
<div class="rows">
<div class="k">成本</div><div class="v cost"></div>
<div class="k">收益</div><div class="v gain"></div>
<div class="k">备注</div><div class="v note"></div>
</div>
<details class="src"><summary>来源<span class="cnt"></span></summary><div class="body"></div></details>`;
const badges = c.querySelector('.badges');
const add = (cls, text) => { const t=document.createElement('span'); t.className='badge '+cls; t.textContent=text; badges.appendChild(t); };
if (e.ratio) { const t = document.createElement('span'); t.className = 'badge r' + ({'极高':'3','高':'2','一般':'1'}[e.ratio]);
t.textContent = '性价比 ' + e.ratio;
t.title = '作者判断:由收益量级(' + e.level + ')和三项成本合成,只在同一口径(' + LENS_LABEL[e.lens] + ')内可比,与证据等级无关';
badges.appendChild(t); }
add(e.grade, `证据 ${e.grade} 级`);
if (e.lens) add('plain', LENS_LABEL[e.lens] || e.lens);
for (const d of ['money','time','will']) if (e[d]) add('plain', LABEL[d][e[d]] || e[d]);
if (e.who.length) add('plain', '适用:' + e.who.map(g => g.map(v => WHO_LABEL[v] || v).join('且').replace(/且(\d)/g, '且 $1')).join('、'));
if (e.dispute) add('danger', '争议');
if (e.todo) add('warn', '含待核实');
e.upd = updOf(e);
if (e.upd && e.upd.age <= 30){
const [, m, d] = e.upd.date.split('-').map(Number);
add('upd', `${m} 月 ${d} 日${e.upd.kind}`);
if (e.upd.fields) badges.lastChild.title = '这次改了:' + e.upd.fields.join('、');
}
block.appendChild(c);
const card = {e, el:c, key:null, vis:false, f:{t:c.querySelector('.t'), cost:c.querySelector('.cost'), human:c.querySelector('.human'), gain:c.querySelector('.gain'), note:c.querySelector('.note'), src:c.querySelector('.src .body'), srcN:c.querySelector('.src summary .cnt'), details:c.querySelector('.src')}};
renderCard(card, [], '');
CARDS.push(card); CARD_OF.set(c, card); CARD_IO.observe(c);
}
list.appendChild(block);
BLOCKS.push({n:s.n, el:block, k:block.querySelector('.shown .k')});
}
document.getElementById('tot').textContent = CARDS.length;
}
function buildGlossary(){
const box = document.getElementById('gloss'); if (!GLOSS.length) { box.hidden = true; return; }
const dl = box.querySelector('dl');
for (const g of [...GLOSS].sort((a,b) => a.term.localeCompare(b.term, 'zh'))){
const dt = document.createElement('dt'); dt.textContent = g.term;
const dd = document.createElement('dd'); dd.textContent = g.meaning;
dl.appendChild(dt); dl.appendChild(dd);
}
}
/* ---------- 筛选状态 ---------- */
const DIMS = ['sec','ratio','lens','grade','money','time','will','who','upd','days'];
/* 最近更新:updates.json 由 tools/updates.mjs 从 git 历史生成,记着哪条哪天新增、正文哪天改过(光改措辞不算) */
let UPD = {};
const TODAY = Date.parse(new Intl.DateTimeFormat('sv-SE').format(new Date()));
const ageOf = d => Math.round((TODAY - Date.parse(d)) / 864e5);
function updOf(e){
const u = UPD[`${e.sec}-${e.n}`]; if (!u) return null;
return u.added ? {kind:'新增', date:u.added, age:ageOf(u.added)} : {kind:'改过', date:u.changed, age:ageOf(u.changed), fields:u.fields};
}
function fitsUpd(e){
if (!state.upd.size && !state.days.size) return true;
const u = e.upd; if (!u) return false;
if (state.upd.size && !state.upd.has(u.kind)) return false;
return !state.days.size || u.age <= Number([...state.days][0]);
}
// 页头那行:7 天内有更新就报 7 天的,没有就报 30 天的,都没有就不显示
function buildUpdLine(){
if (!Object.keys(UPD).length) return;
document.getElementById('g-upd').hidden = false;
const count = w => { let a = 0, c = 0; for (const {e} of CARDS) if (e.upd && e.upd.age <= w) e.upd.kind === '新增' ? a++ : c++; return [a, c]; };
let w = 7, [a, c] = count(7);
if (!a && !c) { w = 30; [a, c] = count(30); }
if (!a && !c) return;
const link = (kind, n) => `<a href="?upd=${kind}&amp;days=${w}" data-upd="${kind}" data-days="${w}">${kind} ${n} 条</a>`;
const line = document.getElementById('upd-line');
line.innerHTML = `最近 ${w} 天更新:` + [a && link('新增', a), c && link('改过', c)].filter(Boolean).join(',') + '。光改措辞的不算。';
line.hidden = false;
}
// 「我的情况」:一项都没勾就不筛;勾了「都不是」等于只看人人适用的条目。勾了 60 岁以上的自然也算 35 岁以上。
function fitsMe(e){
if (!state.who.size || !e.who.length) return true;
const has = v => state.who.has(v) || (v === '35岁' && state.who.has('60岁'));
return e.who.some(g => g.every(has));
}
const state = { q:'', sec:new Set(), ratio:new Set(), lens:new Set(), grade:new Set(), money:new Set(), time:new Set(), will:new Set(), who:new Set(), upd:new Set(), days:new Set(), dispute:false, todo:false };
function readUrl(){
const p = new URLSearchParams(location.search);
state.q = p.get('q') || '';
for (const d of DIMS) state[d] = new Set((p.get(d)||'').split(',').filter(Boolean));
state.dispute = p.get('dispute') === '1'; state.todo = p.get('todo') === '1';
}
function writeUrl(){
const p = new URLSearchParams();
if (state.q) p.set('q', state.q);
for (const d of DIMS) if (state[d].size) p.set(d, [...state[d]].join(','));
if (state.dispute) p.set('dispute','1'); if (state.todo) p.set('todo','1');
if (PERF.on) p.set('perf','1');
const qs = p.toString();
const hash = location.hash;
const validHash = hash && document.getElementById(hash.slice(1))?.offsetParent ? hash : '';
history.replaceState(history.state, '', (qs ? '?'+qs : location.pathname) + validHash);
}
function syncControls(){
document.getElementById('q').value = state.q;
document.querySelectorAll('[data-dim] [data-v]').forEach(b => b.setAttribute('aria-pressed', String(state[b.parentElement.dataset.dim].has(b.dataset.v))));
const allBtn = document.querySelector('#f-sec .all');
if (allBtn) allBtn.setAttribute('aria-pressed', String(state.sec.size === 0));
document.getElementById('f-dispute').checked = state.dispute;
document.getElementById('f-todo').checked = state.todo;
}
// 文字只在搜索词变化时重渲染;筛选按钮只切换显隐,不碰卡片内容
function renderCard(card, terms, key){
const {e, f} = card;
CUR_SEC = e.sec; // 「本节第 X 条」要按这张卡片所属的节来解析
renderText(f.t, e.title, terms);
renderText(f.cost, e.cost, terms);
f.human.hidden = !e.human;
if (e.human) renderText(f.human, e.human, terms);
renderGain(f.gain, e.gain, terms);
renderGain(f.note, e.note, terms);
const nSrc = renderSrc(f.src, e.src, terms);
f.srcN.textContent = nSrc > 1 ? ` · ${nSrc} 条` : '';
const inBody = (e.title+e.human+e.cost+e.gain+e.note).toLowerCase();
f.details.open = terms.length > 0 && terms.some(t => e.src.toLowerCase().includes(t)) && !terms.some(t => inBody.includes(t));
card.key = key;
}
function apply(){
const t0 = performance.now();
const terms = state.q.toLowerCase().split(/\s+/).filter(Boolean);
const key = terms.join(' ');
CUR_TERMS = terms; CUR_KEY = key;
let shown = 0, rerender = 0, matched = 0; const perSec = {}, tocSec = {};
for (const card of CARDS){
const {e, el} = card;
let ok = true;
if (state.ratio.size && !state.ratio.has(e.ratio)) ok = false;
if (ok && state.lens.size && !state.lens.has(e.lens)) ok = false;
if (ok && state.grade.size && !state.grade.has(e.grade)) ok = false;
if (ok && state.money.size && !state.money.has(e.money)) ok = false;
if (ok && state.time.size && !state.time.has(e.time)) ok = false;
if (ok && state.will.size && !state.will.has(e.will)) ok = false;
if (ok && !fitsMe(e)) ok = false;
if (ok && !fitsUpd(e)) ok = false;
if (ok && state.dispute && !e.dispute) ok = false;
if (ok && state.todo && !e.todo) ok = false;
if (ok && terms.length && !terms.every(t => e.hay.includes(t))) ok = false;
// 目录行不跟章节筛选走:选着第 3 节时点第 5 节的折叠箭头,展开的是第 5 节
// 符合其他筛选的条目,而不是一块空白。点进去时 gotoItem 会先清掉筛选再跳。
const a = TOC.get(el.id); if (a && a.hidden === ok) a.hidden = !ok;
if (ok){ matched++; tocSec[e.sec] = (tocSec[e.sec]||0)+1; }
if (ok && state.sec.size && !state.sec.has(e.sec)) ok = false;
if (el.hidden === ok) el.hidden = !ok;
if (ok){
shown++; perSec[e.sec] = (perSec[e.sec]||0)+1;
if (card.vis && card.key !== key) { rerender++; renderCard(card, terms, key); }
}
}
for (const b of BLOCKS){ const n = perSec[b.n]||0; if (b.el.hidden !== (n === 0)) b.el.hidden = n === 0; b.k.textContent = n; }
for (const [n, sub] of TOC_SEC){ const count = tocSec[n]||0, none = count === 0; sub.previousElementSibling.querySelector('i').textContent = count; if (sub.lastElementChild.hidden === none) sub.lastElementChild.hidden = !none; }
document.getElementById('f-sec').querySelector('.all i').textContent = matched;
document.getElementById('cnt').textContent = shown;
document.getElementById('empty').hidden = shown !== 0;
syncControls(); writeUrl(); tocSync(spyLast);
PERF.log('apply', t0, `命中 ${shown} 条,重渲染 ${rerender} 张`);
}
/* ---------- 事件 ---------- */
// 离线单文件会把 GA 片段整段剥掉,这里没有 gtag 也要能跑
const track = (name, params) => { if (typeof gtag === 'function') gtag('event', name, params); };
// 搜索词上报:搜了没命中的词就是该补的条目。results 要在 GA 后台注册成自定义指标才看得到。
let SENT_Q = '';
function reportSearch(){
const q = state.q;
if (q.length < 2 || q === SENT_Q) return;
SENT_Q = q;
track('search', { search_term: q, results: Number(document.getElementById('cnt').textContent) || 0 });
}
function wire(){
const sb = document.getElementById('sidebar'), bd = document.getElementById('backdrop'), mb = document.getElementById('menu');
const setMenu = (on) => { sb.classList.toggle('open', on); bd.classList.toggle('on', on); mb.setAttribute('aria-expanded', String(on)); };
// 筛选变了就顺手让 spy 重算一次 hash:writeUrl 是无条件把旧 hash 拼回去的,
// 而滚回顶部时页面若本来就在顶部不会触发 scroll,hash 会僵在一个已经被筛掉的
// 条目上,拼出 ?sec=8#e-11-1 这种自相矛盾的地址。
const toTop = () => { setMenu(false); scrollTo({ top: 0 }); spy(); };
document.querySelectorAll('[data-dim]').forEach(g => g.addEventListener('click', ev => {
const b = ev.target.closest('[data-v]'); if (!b) return;
const dim = g.dataset.dim, v = b.dataset.v, set = state[dim];
// 点折叠箭头,或再点一次已选中的节:只收起或展开这节的目录,筛选不动
if (dim === 'sec' && v && (ev.target.closest('.fold') || set.has(v))){
const sub = TOC_SEC.get(v);
TOC_MAN.set(v, sub.hidden); tocSync(spyLast, { follow: false });
return;
}
if (dim === 'sec'){
// 章节单选:再点一次已选中的不取消,免得双击退回全部章节;要看全部点「全部章节」
if (v === '') set.clear(); else { set.clear(); set.add(v); }
} else if (dim === 'days'){
// 时间范围单选,再点一次取消
const on = !set.has(v); set.clear(); if (on) set.add(v);
} else if (dim === 'who'){
// 「都不是」和具体情况互斥:点了一边就清掉另一边
const on = !set.has(v);
if (v === '无') set.clear(); else set.delete('无');
on ? set.add(v) : set.delete(v);
} else {
set.has(v) ? set.delete(v) : set.add(v);
}
apply(); toTop();
}));
// 条目目录:点了就地滚过去,手机上先收起抽屉
document.getElementById('f-sec').addEventListener('click', ev => {
const a = ev.target.closest('a[data-go]'); if (!a) return;
ev.preventDefault(); setMenu(false); gotoItem(a.dataset.go, spyLast);
});
document.getElementById('f-dispute').addEventListener('change', e => { state.dispute = e.target.checked; apply(); toTop(); });
document.getElementById('f-todo').addEventListener('change', e => { state.todo = e.target.checked; apply(); toTop(); });
// 上报比筛选晚得多:按 120ms 那一拍发,「高血压」会被当成「高」「高血」「高血压」三次搜索
let tm, qtm; document.getElementById('q').addEventListener('input', e => {
clearTimeout(tm); tm = setTimeout(() => { state.q = e.target.value.trim(); apply(); }, 120);
clearTimeout(qtm); qtm = setTimeout(reportSearch, 2000);
});
const reset = () => { state.q=''; for (const d of DIMS) state[d].clear(); state.dispute=false; state.todo=false; apply(); toTop(); };
// 页头「最近 N 天更新」:点了就只看这一类,别的筛选清掉,免得读者以为没几条
document.getElementById('upd-line').addEventListener('click', ev => {
const a = ev.target.closest('a[data-upd]'); if (!a) return;
ev.preventDefault();
state.q = ''; for (const d of DIMS) state[d].clear(); state.dispute = false; state.todo = false;
state.upd.add(a.dataset.upd); state.days.add(a.dataset.days);
apply(); toTop();
});
document.getElementById('reset').addEventListener('click', reset);
document.getElementById('reset2').addEventListener('click', reset);
document.addEventListener('keydown', e => {
if (!DM.mask.hidden || !TIP.hidden) return;
if (e.key === '/' && !/INPUT|TEXTAREA/.test(document.activeElement.tagName)) { e.preventDefault(); document.getElementById('q').focus(); }
if (e.key === 'Escape') { if (document.activeElement.id === 'q') document.activeElement.blur(); setMenu(false); }
});
// 术语弹层
const pop = document.getElementById('pop');
document.addEventListener('click', e => {
const ab = e.target.closest('abbr.term');
if (ab) { e.preventDefault(); showTerm(ab); return; }
if (!e.target.closest('#pop')) pop.hidden = true;
});
document.addEventListener('keydown', e => { if (e.key === 'Enter' && document.activeElement.matches?.('abbr.term')) showTerm(document.activeElement); });
addEventListener('scroll', () => { pop.hidden = true; }, {passive:true});
// 侧栏(移动端抽屉)
mb.addEventListener('click', () => setMenu(!sb.classList.contains('open')));
bd.addEventListener('click', () => setMenu(false));
// 深色模式
document.getElementById('theme').addEventListener('click', () => {
const dark = !document.documentElement.classList.contains('dark');
document.documentElement.classList.toggle('dark', dark);
try { localStorage.setItem('theme', dark ? 'dark' : 'light'); } catch (e) {}
});
}
/* ---------- 启动 ---------- */
// 一次要取 README 加三十多个正文文件,手机信号差时中间掉一个,整页就全军覆没
// (2026-09-20 用户在手机上读到一半撞到 Failed to fetch)。所以:取到的存下来,
// 重试时不重下;网络类的失败自己先退避重试几次;再不行也只报「网络中断」并给
// 一个接着读的按钮,别再让读者去查文件在不在——文件在不在和这种失败没关系。
const CORPUS_CACHE = new Map();
async function readText(path, tries = 3){
if (CORPUS_CACHE.has(path)) return CORPUS_CACHE.get(path);
for (let i = 1; ; i++){
let retry = false;
try {
// 信号差时请求可能挂着不返回,给它一个上限,超时按可重试处理
const signal = AbortSignal.timeout ? AbortSignal.timeout(20000) : undefined;
const res = await fetch(path, {cache:'no-cache', signal});
if (!res.ok){
// 4xx 重试没意义(文件真不在),5xx 和 429 是服务端一时的事,值得再来一次
retry = res.status >= 500 || res.status === 429;
throw new Error('http '+res.status+':'+path);
}
const text = await res.text();
CORPUS_CACHE.set(path, text);
return text;
} catch (err) {
// fetch 的网络失败是 TypeError,超时和中断是 DOMException
const net = retry || err instanceof TypeError || /^(TimeoutError|AbortError)$/.test(err.name);
if (!net) throw err;
if (i >= tries){
const e = new Error(`取不到 ${path}(网络中断或超时)`);
e.net = true; throw e;
}
await new Promise(r => setTimeout(r, 500 * i * i)); // 0.5s、2s 后再试
}
}
}
/* ---------- 长文弹窗 ----------
长文(docs/*.md)原来只能跳 GitHub:要离开页面,回来还得重新定位,而且 GitHub 上
的长文没有章节目录可点。这里就地渲染,左边给一份可点的目录。
拦的是「指向 docs/xxx.md 的 GitHub 链接」,不是某几个写死的入口——页头那行长文、
条目备注里 hrefOf() 转出来的相对链接,形状都是这一个,所以一处拦全部生效。 */
const DOC_PREFIX = REPO_BLOB + 'docs/';
function docPathOf(href){
if (!href || href.indexOf(DOC_PREFIX) !== 0) return null;
const rest = href.slice(DOC_PREFIX.length);
if (!/^[^/?#]+[.]md$/.test(rest)) return null; // 目录链接(核实记录)不在其内
// 链接里的中文是百分号编码的,语料和 fetch 都用原名,这里要解回来
try { return 'docs/' + decodeURIComponent(rest); } catch (e) { return 'docs/' + rest; }
}
const DM = {
mask: document.getElementById('doc-mask'), title: document.getElementById('dm-t'),
gh: document.getElementById('dm-gh'), toc: document.getElementById('dm-toc'),
doc: document.getElementById('dm-doc'), heads: [], lastFocus: null
};
function dmHref(u){
if (/^(https?:|mailto:|#)/.test(u)) return u;
return REPO_BLOB + u.replace(/^(\.\.?\/)+/, '').split('/').map(encodeURIComponent).join('/');
}
function dmInline(s){
let h = esc(s);
h = h.replace(/`([^`]+)`/g, (m, c) => '<code>' + c + '</code>');
h = h.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, t, u) => '<a href="' + esc(dmHref(u)) + '" target="_blank" rel="noopener">' + t + '</a>');
h = h.replace(/&lt;(https?:\/\/[^\s&]+)&gt;/g, (m, u) => '<a href="' + u + '" target="_blank" rel="noopener">' + u + '</a>');
h = h.replace(/\*\*((?:\\\*|[^*])+)\*\*/g, '<strong>$1</strong>');
return h.replace(/\\([*_])/g, '$1');
}
// 长文只用到标题、段落、有序无序列表和表格这几样(五篇全扫过),不引 markdown 库
function dmRender(md){
const lines = md.split('\n'); const out = []; const heads = [];
let para = [], list = null, title = '';
const flushP = () => { if (para.length){ out.push('<p>' + dmInline(para.join('')) + '</p>'); para = []; } };
const flushL = () => { if (list){ out.push('</' + list + '>'); list = null; } };
const flush = () => { flushP(); flushL(); };
for (let i = 0; i < lines.length; i++){
const ln = lines[i];
if (/^\[← 回总目录\]/.test(ln)) continue; // 回总目录那行在弹窗里没有意义
if (!ln.trim()){ flush(); continue; }
let m;
if ((m = ln.match(/^(#{1,4})\s+(.*)$/))){
flush();
const lv = m[1].length, txt = m[2].trim();
if (lv === 1){ title = txt; continue; } // 一级标题拿去做弹窗标题
const id = 'dm-h-' + heads.length;
heads.push({ id, lv, txt });
out.push('<h' + lv + ' id="' + id + '">' + dmInline(txt) + '</h' + lv + '>');
continue;
}
if (ln.startsWith('|')){
flush();
const rows = [];
while (i < lines.length && lines[i].startsWith('|')) rows.push(lines[i++]);
i--;
const cells = r => r.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
const sep = rows[1] && /^[\s|:-]+$/.test(rows[1]);
const head = sep ? cells(rows[0]) : null;
const body = rows.slice(sep ? 2 : 0).map(cells);
let t = '<div class="dm-tw"><table>';
if (head) t += '<thead><tr>' + head.map(c => '<th>' + dmInline(c) + '</th>').join('') + '</tr></thead>';
t += '<tbody>' + body.map(r => '<tr>' + r.map(c => '<td>' + dmInline(c) + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
out.push(t);
continue;
}
if ((m = ln.match(/^\s*([-*])\s+(.*)$/)) || (m = ln.match(/^\s*\d+\.\s+(.*)$/))){
const txt = m.length === 3 ? m[2] : m[1];
const want = /^\s*\d/.test(ln) ? 'ol' : 'ul';
flushP();
if (list !== want){ flushL(); out.push('<' + want + '>'); list = want; }
out.push('<li>' + dmInline(txt) + '</li>');
continue;
}
para.push(ln.trim());
}
flush();
return { html: out.join('\n'), heads, title };
}
// 长文里的「见第 16 节第 9 条」也要能点(2026-10-01 用户发现弹窗里点不了)。
// dmRender 出的是 HTML 串,这里渲染完再逐个文本节点交给 xrefInto,链接和代码里的不动。
// 长文没有「本节」,裸的「第 N 条」一律是法条,所以 CUR_SEC 置空:解析不到条目就留作普通文字。
function dmXrefs(root){
const saved = CUR_SEC; CUR_SEC = '';
const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT,
{ acceptNode: n => n.parentElement.closest('a,code') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT });
const nodes = [];
while (w.nextNode()) if (/第\s*\d/.test(w.currentNode.nodeValue)) nodes.push(w.currentNode);
for (const n of nodes){
const f = document.createDocumentFragment();
xrefInto(f, n.nodeValue, []);
n.replaceWith(f);
}
CUR_SEC = saved;
}
// 标题相对滚动容器的偏移。不用 offsetTop:它算的是到最近定位祖先的距离,
// 容器没设 position 时会算到别处去(2026-09-21 目录点了不跳就是这个原因)。
function dmTopOf(el){
return el.getBoundingClientRect().top - DM.doc.getBoundingClientRect().top + DM.doc.scrollTop;
}
function dmSpy(){
// 判定线和主页的 spy 一样取视口偏上三成。原来是顶部 90px,下一节已经占了大半屏,目录还亮着上一节
const top = DM.doc.scrollTop + Math.max(90, DM.doc.clientHeight * 0.3); let cur = DM.heads[0];
for (const h of DM.heads){ const el = document.getElementById(h.id); if (el && dmTopOf(el) <= top) cur = h; }
for (const a of DM.toc.children) a.classList.toggle('on', !!cur && a.dataset.id === cur.id);
}
let dmSpyTick = false;
DM.doc.addEventListener('scroll', () => {
POP.hidden = true; // 浮层是按页面坐标摆的,长文一滚就和引用错开了
if (dmSpyTick) return; dmSpyTick = true;
requestAnimationFrame(() => { dmSpyTick = false; dmSpy(); });
});
function dmClose(){
if (DM.mask.hidden) return;
DM.mask.hidden = true;
POP.hidden = true;
document.body.style.overflow = '';
DM.lastFocus?.focus?.({ preventScroll: true }); // 不然「跳过去」刚滚到目标,又被拽回原来那个链接
DM.lastFocus = null;
}
async function dmOpen(path, from){
DM.lastFocus = from || null;
DM.mask.hidden = false;
document.body.style.overflow = 'hidden';
DM.gh.href = dmHref(path);
DM.toc.textContent = ''; DM.heads = [];
DM.title.textContent = decodeURIComponent(path.replace(/^docs\//, '').replace(/\.md$/, ''));
DM.doc.innerHTML = '<div class="dm-load">正在读取 …</div>';
DM.doc.scrollTop = 0;
document.getElementById('dm-x').focus();
let md;
try {
md = (window.__CORPUS__ && window.__CORPUS__.docs && window.__CORPUS__.docs[path]) ?? await readText(path);
} catch (err) {
DM.doc.innerHTML = '<div class="dm-load">读不到这篇长文(' + esc(String(err.message)) + ')。'
+ '<a href="' + esc(dmHref(path)) + '" target="_blank" rel="noopener">在 GitHub 打开</a></div>';
return;
}
const r = dmRender(md);
if (r.title) DM.title.textContent = r.title;
DM.doc.innerHTML = r.html;
dmXrefs(DM.doc);
DM.heads = r.heads;
for (const h of r.heads){
const a = document.createElement('a');
a.href = 'javascript:void 0'; a.textContent = h.txt; a.dataset.id = h.id;
if (h.lv >= 3) a.className = 'lv3';
a.addEventListener('click', ev => {
ev.preventDefault();
const el = document.getElementById(h.id);
// 跟随系统的「减少动态效果」:开了就直接跳,没开才滑过去
const soft = !matchMedia('(prefers-reduced-motion: reduce)').matches;
if (el) DM.doc.scrollTo({ top: dmTopOf(el) - 12, behavior: soft ? 'smooth' : 'auto' });
});
DM.toc.appendChild(a);
}
dmSpy();
if (window.gtag) gtag('event', 'doc_open', { doc: path });
}
document.addEventListener('click', ev => {
const a = ev.target.closest?.('a[href]');
if (!a || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.button) return;
// 弹窗标题栏里那个「在 GitHub 打开」指的就是当前这篇,地址形状和被拦的一模一样,
// 不排掉就会被自己拦下来重开一遍,表现成「点了没反应」(2026-09-21 用户发现)
if (a.closest('.dm-h')) return;
const path = docPathOf(a.href);
if (!path) return;
ev.preventDefault();
dmOpen(path, a);
});
document.getElementById('dm-x').addEventListener('click', dmClose);
DM.mask.addEventListener('click', ev => { if (ev.target === DM.mask) dmClose(); });
// Esc 先关引用浮层,再按一次才关长文弹窗
document.addEventListener('keydown', ev => {
if (ev.key !== 'Escape' || DM.mask.hidden) return;
ev.stopPropagation();
if (!POP.hidden){ POP.hidden = true; return; }
dmClose();
}, true);
/* ---------- 赞赏码弹窗 ---------- */
const TIP = document.getElementById('tip-mask');
function tipClose(){
if (TIP.hidden) return;
TIP.hidden = true;
document.getElementById('tip-open').focus();
}
document.getElementById('tip-open').addEventListener('click', () => {
TIP.hidden = false;
document.getElementById('tip-x').focus();
if (window.gtag) gtag('event', 'tip_open');
});
document.getElementById('tip-x').addEventListener('click', tipClose);
TIP.addEventListener('click', ev => { if (ev.target === TIP) tipClose(); });
document.addEventListener('keydown', ev => { if (ev.key === 'Escape' && !TIP.hidden){ ev.stopPropagation(); tipClose(); } }, true);
(async function init(){
const status = document.getElementById('status');
const statusText = document.getElementById('status-t'), statusBar = document.getElementById('status-b');
const progress = (done, total) => {
statusText.textContent = `正在读取正文 ${done}/${total}`;
statusBar.style.width = (done / total * 100).toFixed(1) + '%';
};
// 离线单文件版把正文内联在 window.__CORPUS__ 里(tools/offline/build.mjs 生成),此时不发任何请求
const EMBED = window.__CORPUS__;
try {
if (!EMBED && location.protocol === 'file:') throw new Error('file');
const md = EMBED ? EMBED.readme : await readText('README.md');
const files = [...new Set(Array.from(md.matchAll(/\]\((book\/[^)]+\.md)\)/g), m => m[1]))].sort();
if (!files.length) throw new Error('empty');
// 更新记录取不到不影响读正文,只是不显示「最近更新」
const updP = EMBED ? Promise.resolve(EMBED.updates ?? null)
: fetch('updates.json', {cache:'no-cache'}).then(r => r.ok ? r.json() : null).catch(() => null);
let done = 0;
if (!EMBED) progress(0, files.length);
const parts = await Promise.all(files.map(async f => {
if (EMBED) {
const text = EMBED.parts[f];
if (text === undefined) throw new Error('离线副本里缺 '+f);
return text;
}
const text = await readText(f);
progress(++done, files.length);
return text;
}));
const t1 = performance.now();
const sections = parseReadme(parts.join('\n\n'));
if (!sections.length) throw new Error('empty');
GLOSS = parseGlossary(md);
UPD = (await updP)?.items ?? {};
PERF.log('解析正文', t1, files.length + ' 个文件');
status.remove();
const t2 = performance.now(); build(sections); PERF.log('建 DOM', t2, CARDS.length + ' 张卡');
buildGlossary(); buildUpdLine(); readUrl(); wire(); apply();
PERF.mark(`DOM 节点 ${document.getElementsByTagName('*').length},术语 ${GLOSS.length}`);
if (location.hash){
let target = document.getElementById(location.hash.slice(1));
// 别人分享过来的地址可能带着和 hash 冲突的筛选(老版本拼出来的 ?sec=8#e-11-1),
// 这时按 hash 办:点链接就是要看那一条,筛选清掉。
if (target && !target.offsetParent){
state.q = ''; for (const d of DIMS) state[d].clear();
state.dispute = false; state.todo = false;
syncControls(); apply(); writeUrl();
}
// 落到一节的第一条时滚它所在的节块,节标题跟着进视野——和点「见第 13 节」
// 跳过去看到的是同一屏,地址栏也是同一个 #e-13-1
const land = target ? scrollAnchor(target) : null;
land?.scrollIntoView();
// 卡片是 content-visibility:auto,首次定位后高度才准,下一帧再对一次
if (land) requestAnimationFrame(() => land.scrollIntoView());
}
} catch (err) {
if (err.message === 'file'){
status.innerHTML = '这个页面要通过 http 打开才能读取正文。在仓库目录运行 <code>python -m http.server</code>,然后访问 <code>http://localhost:8000/</code>;或直接用 GitHub Pages 地址。';
return;
}
if (err.net){
status.innerHTML = `加载失败:${esc(String(err.message))}`
+ '<div><button type="button" id="retry">继续加载</button></div>';
document.getElementById('retry').addEventListener('click', () => {
status.innerHTML = '<span id="status-t">正在读取正文 …</span><div class="bar"><i id="status-b"></i></div>';
init();
});
return;
}
status.innerHTML = `读取正文失败(${esc(String(err.message))})。确认 README.md 和 book/ 目录都在 index.html 旁边。`;
}
})();
</script>
</body>
</html>