mirror of
https://github.com/eternity4719/HowToLiveBetter.git
synced 2026-10-28 11:44:50 +08:00
1673 lines
107 KiB
HTML
1673 lines
107 KiB
HTML
<!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 => ({'&':'&','<':'<','>':'>','"':'"'}[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}&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(/<(https?:\/\/[^\s&]+)>/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>
|