mirror of
https://github.com/eternity4719/HowToLiveBetter.git
synced 2026-10-29 04:04:58 +08:00
517 lines
33 KiB
HTML
517 lines
33 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="回答一组是否题,看看书里哪些事你还没做到:哪一块漏得最多、先做哪几件、哪些一次办完就行。每条单独给出证据和出处,不汇总成「少活几年」。">
|
||
<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">
|
||
<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>
|
||
<style>
|
||
/* 配色、字体与 index.html 同一套(VitePress 默认主题) */
|
||
:root{
|
||
--bg:#ffffff; --bg-alt:#f6f6f7; --bg-soft:#f6f6f7; --bg-mute:#f1f1f2;
|
||
--divider:#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-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);
|
||
--font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans SC",sans-serif;
|
||
color-scheme:light;
|
||
}
|
||
html.dark{
|
||
--bg:#1b1b1f; --bg-alt:#161618; --bg-soft:#202127; --bg-mute:#2b2b2f;
|
||
--divider:#2e2e32;
|
||
--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-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);
|
||
color-scheme:dark;
|
||
}
|
||
*{box-sizing:border-box}
|
||
body{margin:0;background:var(--bg);color:var(--t1);font:15px/1.75 var(--font);-webkit-font-smoothing:antialiased}
|
||
a{color:var(--brand-1);text-decoration:none}
|
||
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}
|
||
[hidden]{display:none!important}
|
||
.nav{position:sticky;top:0;z-index:10;height:56px;background:var(--bg);border-bottom:1px solid var(--divider);display:flex;align-items:center;gap:12px;padding:0 24px}
|
||
.nav .title{font-weight:600;font-size:16px;color:var(--t1)}
|
||
.nav .title:hover{text-decoration:none}
|
||
.nav .sp{flex:1}
|
||
.nav .back{font-size:14px;font-weight:500}
|
||
.wrap{max-width:760px;margin:0 auto;padding:32px 24px 96px}
|
||
h1{font-size:28px;line-height:1.3;margin:0 0 12px;font-weight:600;letter-spacing:-.01em}
|
||
.lead{color:var(--t2);margin:0 0 28px}
|
||
h2{font-size:18px;font-weight:600;margin:36px 0 4px;padding-top:24px;border-top:1px solid var(--divider)}
|
||
h2 small{font-weight:400;font-size:13px;color:var(--t3);margin-left:8px}
|
||
.q{display:flex;align-items:center;gap:16px;padding:12px 0;border-top:1px solid var(--divider)}
|
||
h2+.q{border-top:0}
|
||
.q .txt{flex:1;min-width:0}
|
||
.q .txt small{display:block;color:var(--t3);font-size:13px;line-height:1.5}
|
||
.seg{display:inline-flex;flex:none;border:1px solid var(--divider);border-radius:8px;overflow:hidden;background:var(--bg-soft)}
|
||
.seg button{padding:5px 12px;font-size:14px;color:var(--t2);border-left:1px solid var(--divider);white-space:nowrap;transition:background .2s,color .2s}
|
||
.seg button:first-child{border-left:0}
|
||
.seg button:hover{color:var(--t1)}
|
||
.seg button[aria-pressed="true"]{background:var(--brand-soft);color:var(--brand-1);font-weight:600}
|
||
.seg button.no[aria-pressed="true"]{background:var(--red-soft);color:var(--red-1)}
|
||
.seg button.yes[aria-pressed="true"]{background:var(--green-soft);color:var(--green-1)}
|
||
.profile-age{width:7em;padding:6px 10px;border:1px solid var(--divider);border-radius:8px;background:var(--bg-soft);color:var(--t1);font:inherit}
|
||
.bar{position:sticky;bottom:0;margin-top:32px;padding:14px 0;background:var(--bg);border-top:1px solid var(--divider);display:flex;align-items:center;gap:16px}
|
||
.bar .prog{flex:1;color:var(--t2);font-size:14px}
|
||
.btn{border-radius:20px;padding:0 20px;line-height:38px;font-weight:600;font-size:14px;background:var(--brand-1);color:#fff;transition:background .2s}
|
||
html.dark .btn{color:#1b1b1f}
|
||
.btn:hover{background:var(--brand-2)}
|
||
.btn.ghost{background:var(--bg-mute);color:var(--t1)}
|
||
.btn.ghost:hover{background:var(--divider)}
|
||
.sum{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:8px 0 12px}
|
||
.tile{background:var(--bg-soft);border-radius:10px;padding:14px 16px}
|
||
.tile b{display:block;font-size:26px;line-height:1.2;font-variant-numeric:tabular-nums}
|
||
.tile span{font-size:13px;color:var(--t2)}
|
||
.tile.r3 b{color:var(--brand-1)} .tile.r2 b{color:var(--green-1)}
|
||
.note{font-size:13.5px;color:var(--t2);background:var(--bg-soft);border-radius:8px;padding:12px 16px;margin:20px 0}
|
||
.note b{color:var(--t1)}
|
||
.card{border:1px solid var(--divider);border-radius:12px;padding:16px 20px;margin:12px 0;background:var(--bg)}
|
||
.card .h{display:flex;gap:10px;align-items:baseline}
|
||
.card .h b{flex:1;font-size:16px;font-weight:600;line-height:1.5}
|
||
.card .h a{flex:none;font-size:13px;white-space:nowrap}
|
||
.card .human{margin:8px 0 10px;color:var(--t1)}
|
||
.card .cost{font-size:13.5px;color:var(--t2);margin-top:6px}
|
||
.card .caution{font-size:13.5px;color:var(--t2);background:var(--yellow-soft);border-radius:8px;padding:10px 12px;margin:10px 0}
|
||
.card .caution summary{cursor:pointer;font-weight:600;color:var(--t1)}
|
||
.card .caution[open] summary{margin-bottom:6px}
|
||
.card .also{margin-top:10px;padding-top:10px;border-top:1px dashed var(--divider);font-size:13.5px;color:var(--t2)}
|
||
.badges{display:flex;flex-wrap:wrap;gap:6px}
|
||
.badge{display:inline-block;border-radius:10px;padding:0 10px;line-height:20px;font-size:12px;font-weight:600;white-space:nowrap}
|
||
.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.r3{background:var(--brand-soft);color:var(--brand-1)}
|
||
.badge.r2{background:var(--green-soft);color:var(--green-1)}
|
||
.badge.r1,.badge.plain{background:var(--gray-soft);color:var(--gray-1)}
|
||
.badge.warn{background:var(--yellow-soft);color:var(--yellow-1)}
|
||
.verdict{font-size:16px;line-height:1.85;margin:0 0 20px}
|
||
.verdict b{font-weight:600}
|
||
.mods{display:flex;flex-wrap:wrap;gap:8px}
|
||
.mods button{border:1px solid var(--divider);border-radius:16px;padding:3px 14px;font-size:14px;color:var(--t2);background:var(--bg-soft)}
|
||
.mods button[aria-pressed="true"]{border-color:var(--brand-1);background:var(--brand-soft);color:var(--brand-1);font-weight:600}
|
||
.hbar{display:grid;grid-template-columns:8.5em 1fr 4.5em;align-items:center;gap:12px;padding:6px 0;font-size:14px}
|
||
.hbar .nm{color:var(--t1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.hbar .tr{height:10px;border-radius:5px;background:var(--bg-mute);overflow:hidden}
|
||
.hbar .fl{height:100%;border-radius:0 4px 4px 0;background:var(--brand-1)}
|
||
.hbar .v{color:var(--t2);text-align:right;font-variant-numeric:tabular-nums}
|
||
.top3{margin:8px 0 0;padding-left:22px}
|
||
.top3 li{margin:6px 0}
|
||
.top3 .why{color:var(--t2);font-size:13.5px}
|
||
.copy-tools{margin-top:12px}
|
||
.copy-status{margin:8px 0 0;color:var(--t2);font-size:14px}
|
||
.copy-tools label{display:block;margin:12px 0 4px;font-size:14px}
|
||
.copy-tools textarea{display:block;width:100%;padding:12px;border:1px solid var(--divider);border-radius:8px;background:var(--bg-soft);color:var(--t1);font:inherit;font-size:14px;resize:vertical}
|
||
.copy-tools button:disabled{cursor:wait;opacity:.65}
|
||
.card{scroll-margin-top:72px}
|
||
.done-list{color:var(--t2);font-size:14px;padding-left:20px}
|
||
#status{color:var(--t2);padding:40px 0;text-align:center}
|
||
@media (max-width:640px){
|
||
.nav{padding:0 16px}
|
||
.wrap{padding:24px 16px 80px}
|
||
h1{font-size:24px}
|
||
.q{flex-direction:column;align-items:stretch;gap:8px}
|
||
.seg{align-self:flex-start}
|
||
.sum{grid-template-columns:repeat(2,1fr)}
|
||
.hbar{grid-template-columns:7.5em 1fr 3.8em;gap:8px}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header class="nav">
|
||
<a class="title" href="./">高性价比人生指南</a>
|
||
<span class="sp"></span>
|
||
<a class="back" href="./">回检索页</a>
|
||
</header>
|
||
<main class="wrap">
|
||
<div id="status">正在读取正文 …</div>
|
||
|
||
<section id="ask" hidden>
|
||
<h1>生活自查</h1>
|
||
<p class="lead">回答下面的问题,看看书里哪些事你还没做到。结果会告诉你哪一块漏得最多、先做哪几件、哪些一次办完就行。每条单独给出证据和出处,不汇总成一个分数。</p>
|
||
<div id="qs"><div id="profile-qs"></div><div id="topic-qs"></div></div>
|
||
<div class="bar">
|
||
<span class="prog" id="prog"></span>
|
||
<button class="btn ghost" id="reset" type="button">清空</button>
|
||
<button class="btn" id="go" type="button">看结果</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="out" hidden>
|
||
<h1>自查结果</h1>
|
||
<p class="verdict" id="verdict"></p>
|
||
<div class="sum" id="sum"></div>
|
||
<h2>哪一块漏得最多<small>按做到的比例从低到高排</small></h2>
|
||
<div id="bars"></div>
|
||
<div id="todo"></div>
|
||
<div class="note"><b>为什么不算「少活几年」:</b>每条的数字来自不同研究、不同人群,而且抽烟、不运动、睡得差这些习惯常常一起出现。把各条的风险乘到一起会重复计算,算出来的年数会偏大,偏多少也说不清。所以每条只给它自己那份证据。</div>
|
||
<h2 id="done-h">已经做到的</h2>
|
||
<ul class="done-list" id="done"></ul>
|
||
<div class="bar">
|
||
<span class="prog">答案只存在你这台设备的浏览器里。</span>
|
||
<button class="btn ghost" id="back" type="button">改答案</button>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<script>
|
||
/* 题目表。sec + key 按「第几节、标题里含哪段字」找条目,不写条号,插条目顺延时不会指歪;
|
||
找不到就在控制台报错并跳过这题。
|
||
when(p) 按基本情况决定这题问不问;also 是没做到时顺带列出的相关条目。 */
|
||
const PROFILE = [
|
||
{id:'ageYears', text:'你的周岁年龄', type:'number'},
|
||
{id:'sex', text:'你的性别', opts:[['m','男'],['f','女']]},
|
||
{id:'kid', text:'家里有 4 岁以下的孩子吗', opts:[['y','有'],['n','没有']]},
|
||
];
|
||
const validAge = age => age !== undefined && age !== null && String(age).trim() !== ''
|
||
&& Number.isInteger(Number(age)) && Number(age) >= 0 && Number(age) <= 120;
|
||
const ageAtLeast = (p, a) => validAge(p.ageYears) && Number(p.ageYears) >= a;
|
||
const GROUPS = [
|
||
{name:'烟、酒和槟榔', mod:'身体', qs:[
|
||
{sec:2, key:'戒烟,越早越好', text:'不抽烟,或者已经戒了', also:[{sec:2, key:'先去拿药'},{sec:2, key:'戒烟门诊'}]},
|
||
{sec:2, key:'少喝或不喝酒', text:'不喝酒,或者很少喝'},
|
||
{sec:2, key:'不嚼槟榔', text:'不嚼槟榔'},
|
||
]},
|
||
{name:'吃喝', mod:'身体', qs:[
|
||
{sec:2, key:'含糖饮料', text:'基本不喝奶茶、可乐、果汁这类含糖饮料'},
|
||
{sec:2, key:'加工肉', text:'很少吃火腿、香肠、培根、午餐肉'},
|
||
{sec:2, key:'全谷物', text:'主食里有一部分是糙米、燕麦、全麦这类全谷物'},
|
||
{sec:2, key:'5 份', text:'每天吃够一斤左右的水果和蔬菜'},
|
||
{sec:2, key:'坚果', text:'每天吃一小把坚果'},
|
||
{sec:2, key:'低钠盐', text:'家里做饭用低钠盐', hint:'有肾功能不全或正在吃保钾类药物的,换盐前先问医生;不适用可以跳过'},
|
||
{sec:2, key:'滚烫', text:'热茶热汤放一放再喝,不喝滚烫的'},
|
||
{sec:2, key:'抽油烟机', text:'炒菜时从开火到做完一直开着抽油烟机', hint:'不做饭可以跳过'},
|
||
]},
|
||
{name:'动、睡和晒太阳', mod:'身体', qs:[
|
||
{sec:2, key:'7000', text:'每天走到 7000 步左右,或者每周快走累计 150 分钟以上'},
|
||
{sec:2, key:'力量训练', text:'每周做点力量训练(俯卧撑、深蹲、器械都算)'},
|
||
{sec:2, key:'别连着坐太久', text:'不会连着坐好几个小时不起身'},
|
||
{sec:2, key:'每晚睡 7', text:'每晚睡 7 小时左右,作息基本固定'},
|
||
{sec:2, key:'晒晒太阳', text:'白天会出门见见太阳,不是整天待在屋里'},
|
||
{sec:2, key:'BMI', text:'体重在正常范围(BMI 20–25)', hint:'BMI = 体重(公斤) ÷ 身高(米)²'},
|
||
{sec:2, key:'认真刷牙', text:'每天认真刷牙、清一次牙缝,缺了牙会去补'},
|
||
{sec:1, key:'平衡和腿部力量', text:'练过平衡和腿部力量,浴室楼梯装了扶手防滑', when:p => ageAtLeast(p,60)},
|
||
]},
|
||
{name:'出门和家里的安全', mod:'身体', qs:[
|
||
{sec:1, key:'系安全带', text:'坐车时前排后排都系安全带'},
|
||
{sec:1, key:'不酒驾', text:'开车不超速、不酒驾', hint:'不开车可以跳过'},
|
||
{sec:1, key:'戴头盔', text:'骑电动车、摩托车戴头盔并扣好', hint:'不骑可以跳过'},
|
||
{sec:1, key:'电动自行车不推进楼道', text:'电动车不推进楼道,不在家里充电', hint:'没有电动车可以跳过'},
|
||
{sec:1, key:'烟雾报警器', text:'家里装了烟雾报警器'},
|
||
{sec:1, key:'燃气软管', text:'燃气软管和灶具到期换过,没自己改过管道', hint:'家里不用燃气可以跳过'},
|
||
{sec:1, key:'野生蘑菇', text:'不采、不买、不吃野生蘑菇'},
|
||
{sec:1, key:'安全套', text:'和不固定的伴侣发生关系,全程用安全套', hint:'没有这种情况可以跳过'},
|
||
{sec:1, key:'安全座椅', text:'孩子坐车用安全座椅,不抱在怀里', when:p => p.kid === 'y'},
|
||
{sec:1, key:'限位器', text:'窗户和阳台装了限位器或护栏', when:p => p.kid === 'y'},
|
||
]},
|
||
{name:'吃药', mod:'身体', qs:[
|
||
{sec:34, key:'对乙酰氨基酚', text:'同时吃两种感冒药或止痛药之前,会先看成分表'},
|
||
{sec:34, key:'普通感冒别找医生要抗生素', text:'普通感冒不找医生要抗生素'},
|
||
{sec:34, key:'头孢、甲硝唑', text:'吃头孢、甲硝唑期间和停药后一周不喝酒'},
|
||
{sec:2, key:'有高血压、高血脂', text:'有高血压、高血脂的,在按医嘱规律吃药', hint:'没有这些病可以跳过'},
|
||
]},
|
||
{name:'检查和疫苗', mod:'身体', qs:[
|
||
{sec:1, key:'量血压', text:'最近一年量过血压,高了在吃药控制'},
|
||
{sec:1, key:'空腹血糖', text:'查过空腹血糖', hint:'这条针对超重的人,不适用可以跳过', when:p => ageAtLeast(p,35)},
|
||
{sec:1, key:'乙肝两对半', rev:2, text:'核对过乙肝接种史,需要补种的已按医生建议接种', hint:'接种后抗体下降,不一定需要重打;拿不准可先查原条目'},
|
||
{sec:1, key:'幽门螺杆菌', text:'查过幽门螺杆菌'},
|
||
{sec:1, key:'HPV 疫苗', text:'打过 HPV 疫苗', when:p => p.sex === 'f'},
|
||
{sec:1, key:'宫颈癌筛查', text:'做过宫颈癌筛查', when:p => p.sex === 'f' && ageAtLeast(p,30)},
|
||
{sec:1, key:'乳腺癌筛查', text:'近两年做过乳腺钼靶', when:p => p.sex === 'f' && ageAtLeast(p,40)},
|
||
{sec:1, key:'结直肠癌筛查', text:'做过结直肠癌筛查(大便潜血或肠镜)', when:p => ageAtLeast(p,45)},
|
||
{sec:1, key:'流感疫苗', text:'每年打流感疫苗', when:p => ageAtLeast(p,60)},
|
||
{sec:1, key:'带状疱疹疫苗', text:'打过带状疱疹疫苗', when:p => ageAtLeast(p,50)},
|
||
{sec:1, key:'肺炎球菌疫苗', text:'打过肺炎球菌疫苗', when:p => ageAtLeast(p,65)},
|
||
{sec:1, key:'骨密度', text:'做过骨密度检查', when:p => p.sex === 'f' && ageAtLeast(p,65)},
|
||
]},
|
||
{name:'急救常识', mod:'急救常识', qs:[
|
||
{sec:13, key:'叫不应、没正常呼吸', text:'看到有人倒地、叫不应、没正常呼吸,知道要立刻用力按压胸口,并让旁人找 AED'},
|
||
{sec:13, key:'突然嘴歪', text:'知道突然嘴歪、一侧胳膊没劲要直接打 120,不自己开车去'},
|
||
{sec:13, key:'胸口压着疼', text:'知道胸口压着疼超过 15 分钟不缓解要打 120'},
|
||
{sec:13, key:'有人噎住', text:'会给噎住的人做拍背加腹部冲击'},
|
||
{sec:13, key:'烫伤后', text:'知道烫伤先用凉的流水冲 20 分钟,不抹牙膏酱油'},
|
||
{sec:13, key:'被狗、猫咬伤', text:'被猫狗咬伤或抓破皮,会先冲洗再当天去打疫苗'},
|
||
{sec:13, key:'误服', text:'知道误服清洁剂、农药不催吐,带上瓶子去医院'},
|
||
{sec:13, key:'火灾时', text:'知道火灾时贴地走、先摸门、走楼梯不坐电梯'},
|
||
{sec:13, key:'婴儿噎住', text:'会给不满 1 岁的婴儿做拍背加按胸', when:p => p.kid === 'y'},
|
||
]},
|
||
{name:'精力和情绪', mod:'精力', qs:[
|
||
{sec:3, key:'关掉非必要通知', text:'关掉了手机上不必要的通知,工作时手机放在看不到的地方'},
|
||
{sec:3, key:'固定起床时间', text:'周末也基本按平时的时间起床'},
|
||
{sec:3, key:'下午两点以后', text:'下午两点以后不喝咖啡、浓茶'},
|
||
{sec:3, key:'到点就睡', text:'不会为了打游戏、刷短视频熬夜'},
|
||
{sec:3, key:'睡前一小时', text:'睡前一小时不看手机'},
|
||
{sec:3, key:'别人过得怎么样', text:'不会每天刷同龄人的动态'},
|
||
{sec:3, key:'49 小时', text:'每周工作时间基本不超过 50 小时', hint:'没在上班可以跳过'},
|
||
{sec:3, key:'学会拒绝', text:'不想接的请求能说出「不」'},
|
||
{sec:22, key:'定期和人见面', text:'定期和朋友家人见面,不是心情差了才找人'},
|
||
]},
|
||
{name:'钱', mod:'钱', qs:[
|
||
{sec:5, key:'自动续费', text:'关掉了不用的自动续费'},
|
||
{sec:5, key:'个税汇算', text:'每年做个税汇算,该填的专项附加扣除都填了', hint:'没有工资收入可以跳过'},
|
||
{sec:5, key:'最低还款', text:'不用信用卡最低还款,不开消费分期和消费贷'},
|
||
{sec:5, key:'先存出', text:'存了 3 到 6 个月生活费的应急金,能随时取'},
|
||
{sec:5, key:'高收益', text:'看到「保本」「高收益」「稳赚」会直接走开'},
|
||
{sec:5, key:'家庭共济', text:'在医保 App 上办了家庭共济', hint:'没有职工医保可以跳过'},
|
||
{sec:5, key:'三者险', text:'开车的话,三者险买够了', hint:'没有车可以跳过'},
|
||
{sec:5, key:'定期寿险', text:'家里有人靠你的收入过日子,你买了定期寿险', hint:'家里没人靠你收入可以跳过'},
|
||
{sec:5, key:'宽基指数', text:'长期投资的钱放在宽基指数基金,而不是主动基金或个股', hint:'没有投资可以跳过'},
|
||
]},
|
||
{name:'账号与防骗', mod:'账号与防骗', qs:[
|
||
{sec:14, key:'二次验证', text:'邮箱、支付、社交账号都开了二次验证'},
|
||
{sec:14, key:'邮箱密码单独', text:'邮箱密码单独一个,没在别的网站用过'},
|
||
{sec:14, key:'锁屏密码', text:'手机设了锁屏密码,SIM 卡设了 PIN 码'},
|
||
{sec:8, key:'通讯录里的旧号码', text:'家人视频或电话要你转账,会先挂断,用通讯录里的旧号码打回去核实'},
|
||
{sec:8, key:'不把银行卡', text:'从不把银行卡、手机卡、支付账号借给别人'},
|
||
{sec:8, key:'征信报告', text:'查过自己的征信报告'},
|
||
{sec:8, key:'签字前', text:'签字前会把纸看完,不在空白纸上签'},
|
||
]},
|
||
];
|
||
const MODS = ['身体', '急救常识', '精力', '钱', '账号与防骗'];
|
||
const modOn = m => ans['mod:' + m] !== 'off';
|
||
|
||
/* 解析:和 index.html 的 parseReadme 同一套格式,只取这里用得到的字段 */
|
||
const COST_W = { money:{'0':0,'少':1,'多':2}, time:{'少':0,'中':1,'多':2}, will:{'否':0,'些':1,'是':2} };
|
||
function parse(md){
|
||
const out = []; let sec = null, e = null; let m;
|
||
for (const raw of md.split(/\r?\n/)){
|
||
const line = raw.trimEnd();
|
||
if ((m = /^#{1,2} (\d+)\. /.exec(line))) { sec = +m[1]; e = null; continue; }
|
||
if ((m = /^### (\d+)\. (.+)$/.exec(line)) && sec) { e = {sec, n:+m[1], title:m[2].trim(), human:'', cost:'', grade:'', note:''}; out.push(e); continue; }
|
||
if (!e) continue;
|
||
if ((m = /^\x3c!--\s*成本标签:\s*(.*?)\s*-->/.exec(line))){
|
||
for (const kv of m[1].split(/\s+/)){ const [k,v] = kv.split('='); const p = {'钱':'money','时间':'time','毅力':'will','收益':'level','口径':'lens'}[k]; if (p) e[p] = v; }
|
||
}
|
||
else if ((m = /^- 说人话:(.*)$/.exec(line))) e.human = m[1];
|
||
else if ((m = /^- 成本:(.*)$/.exec(line))) e.cost = m[1];
|
||
else if ((m = /^- 证据等级:\s*([ABC])/.exec(line))) e.grade = m[1];
|
||
else if ((m = /^- 备注:(.*)$/.exec(line))) e.note = m[1];
|
||
}
|
||
for (const x of out){
|
||
x.cs = (COST_W.money[x.money] ?? 0) + (COST_W.time[x.time] ?? 0) + (COST_W.will[x.will] ?? 0);
|
||
x.ratio = x.level === '大' ? (x.cs === 0 ? '极高' : (x.cs <= 2 ? '高' : '一般'))
|
||
: x.level === '中' ? (x.cs === 0 ? '高' : '一般') : '一般';
|
||
x.dispute = /^争议/.test(x.note);
|
||
}
|
||
return out;
|
||
}
|
||
const plain = s => s.replace(/\*\*/g, '').replace(/\\([*_])/g, '$1').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
|
||
const esc = s => s.replace(/[&<>"]/g, ch => ({'&':'&','<':'<','>':'>','"':'"'}[ch]));
|
||
|
||
let ENTRIES = [];
|
||
const find = (sec, key) => {
|
||
const hit = ENTRIES.find(x => x.sec === sec && x.title.includes(key));
|
||
if (!hit) console.error(`自查题找不到条目:第 ${sec} 节,标题含「${key}」`);
|
||
return hit;
|
||
};
|
||
|
||
/* 答案:只存本机浏览器,取不到也照常能用 */
|
||
const STORE = 'selfcheck-v1';
|
||
let ans = {};
|
||
try { ans = JSON.parse(localStorage.getItem(STORE) || '{}'); } catch (e) {}
|
||
const save = () => { try { localStorage.setItem(STORE, JSON.stringify(ans)); } catch (e) {} };
|
||
const profile = () => Object.fromEntries(PROFILE.map(p => [p.id, ans['p:'+p.id]]));
|
||
// 使用新字段 ageYears,旧版年龄段答案不会被当成实际年龄;其他答案保留。
|
||
const profileDone = () => PROFILE.every(p => p.type === 'number'
|
||
? validAge(ans['p:'+p.id]) : p.opts.some(([v]) => v === ans['p:'+p.id]));
|
||
const qid = q => q.sec + ':' + q.key + (q.rev ? ':v' + q.rev : '');
|
||
const shown = (q, p) => q.entry && modOn(q.g.mod) && (!q.when || (profileDone() && q.when(p)));
|
||
const activeQs = () => { const p = profile(); return GROUPS.flatMap(g => g.qs).filter(q => shown(q, p)); };
|
||
|
||
function seg(name, opts, cur){
|
||
return `<div class="seg" role="group">` + opts.map(([v, t, cls]) =>
|
||
`<button type="button" data-k="${esc(name)}" data-v="${v}" class="${cls||''}" aria-pressed="${cur === v}">${t}</button>`).join('') + `</div>`;
|
||
}
|
||
const YN = [['y','做到了','yes'],['n','没做到','no'],['s','跳过']];
|
||
|
||
function renderAsk(updateProfile = true){
|
||
const p = profile();
|
||
let h = `<h2 style="border-top:0;padding-top:0;margin-top:0">基本情况<small>决定问哪些题</small></h2>`;
|
||
for (const f of PROFILE) {
|
||
const control = f.type === 'number'
|
||
? `<input class="profile-age" id="profile-age" type="number" inputmode="numeric" min="0" max="120" step="1" data-k="p:${f.id}" aria-label="${f.text}" value="${esc(String(ans['p:'+f.id] ?? ''))}">`
|
||
: seg('p:'+f.id, f.opts, ans['p:'+f.id]);
|
||
h += `<div class="q"><div class="txt">${f.text}</div>${control}</div>`;
|
||
}
|
||
h += `<div class="q"><div class="txt">想查哪几块<small>点一下取消,不查的整块不出题</small></div><div class="mods">`
|
||
+ MODS.map(m => `<button type="button" data-mod="${m}" aria-pressed="${modOn(m)}">${m}</button>`).join('') + `</div></div>`;
|
||
if (updateProfile) document.getElementById('profile-qs').innerHTML = h;
|
||
h = '';
|
||
for (const g of GROUPS){
|
||
const qs = g.qs.filter(q => shown(q, p));
|
||
if (!qs.length) continue;
|
||
h += `<h2>${g.name}</h2>`;
|
||
for (const q of qs) h += `<div class="q"><div class="txt">${esc(q.text)}${q.hint ? `<small>${esc(q.hint)}</small>` : ''}</div>${seg(qid(q), YN, ans[qid(q)])}</div>`;
|
||
}
|
||
document.getElementById('topic-qs').innerHTML = h;
|
||
const qs = activeQs(), n = qs.filter(q => ans[qid(q)]).length;
|
||
document.getElementById('prog').textContent = profileDone() ? `已答 ${n} / ${qs.length} 题` : '先填基本情况';
|
||
}
|
||
|
||
const RANK = {'极高':0,'高':1,'一般':2}, LV = {'大':0,'中':1,'小':2}, GR = {A:0,B:1,C:2};
|
||
function card(e, also){
|
||
const r = {'极高':'r3','高':'r2','一般':'r1'}[e.ratio];
|
||
const lens = e.lens ? `<span class="badge plain">口径 ${e.lens}</span>` : '';
|
||
const alsoH = also.length ? `<div class="also">相关做法:` + also.map(a =>
|
||
`<a href="./#e-${a.sec}-${a.n}">${esc(plain(a.title))}</a>`).join(';') + `</div>` : '';
|
||
return `<div class="card" id="c-${e.sec}-${e.n}">
|
||
<div class="h"><b>${esc(plain(e.title))}</b><a href="./#e-${e.sec}-${e.n}">第 ${e.sec} 节第 ${e.n} 条 →</a></div>
|
||
${e.note ? `<details class="caution"><summary>适用条件与注意事项</summary>${esc(plain(e.note))}</details>` : ''}
|
||
${e.human ? `<div class="human">${esc(plain(e.human))}</div>` : ''}
|
||
<div class="badges"><span class="badge ${r}">性价比 ${e.ratio}</span><span class="badge ${e.grade}">证据 ${e.grade} 级</span><span class="badge plain">收益 ${e.level}</span>${lens}${e.dispute ? '<span class="badge warn">有争议</span>' : ''}</div>
|
||
<div class="cost">要花的:${esc(plain(e.cost))}</div>${alsoH}
|
||
</div>`;
|
||
}
|
||
/* 没做到的事按「要不要改习惯」分三堆:成本标签里的毅力一项直接决定 */
|
||
const PILES = [
|
||
{will:'否', name:'一次办完就行', sub:'做一次或者记住就够,不用改习惯'},
|
||
{will:'些', name:'要改一个习惯', sub:'要忍一点不适,坚持几周就成了习惯'},
|
||
{will:'是', name:'要长期坚持', sub:'和惯性长期对着干,最难,挑一件慢慢来'},
|
||
];
|
||
const WILL = {'否':0,'些':1,'是':2};
|
||
const byValue = (a, b) => RANK[a.entry.ratio] - RANK[b.entry.ratio] || LV[a.entry.level] - LV[b.entry.level]
|
||
|| WILL[a.entry.will] - WILL[b.entry.will] || GR[a.entry.grade] - GR[b.entry.grade] || a.entry.cs - b.entry.cs;
|
||
function renderOut(){
|
||
const qs = activeQs();
|
||
const no = qs.filter(q => ans[qid(q)] === 'n').sort(byValue), yes = qs.filter(q => ans[qid(q)] === 'y');
|
||
const answered = no.length + yes.length;
|
||
|
||
// 各块做到的比例,只算答了「做到 / 没做到」的题,跳过的不进分母
|
||
const groups = GROUPS.map(g => {
|
||
const y = yes.filter(q => q.g === g).length, n = no.filter(q => q.g === g).length;
|
||
return {g, y, t:y + n, pct: y + n ? y / (y + n) : 1};
|
||
}).filter(r => r.t).sort((a, b) => a.pct - b.pct || b.t - a.t);
|
||
document.getElementById('bars').innerHTML = groups.map(r =>
|
||
`<div class="hbar" title="${esc(r.g.name)}:答了 ${r.t} 题,做到 ${r.y} 项"><span class="nm">${esc(r.g.name)}</span>`
|
||
+ `<span class="tr"><span class="fl" style="display:block;width:${(r.pct * 100).toFixed(0)}%"></span></span>`
|
||
+ `<span class="v">${r.y} / ${r.t}</span></div>`).join('');
|
||
|
||
const easy = no.filter(q => q.entry.will === '否'), free = easy.filter(q => q.entry.money === '0');
|
||
const good = no.filter(q => q.entry.ratio !== '一般'), disp = no.filter(q => q.entry.dispute);
|
||
const weak = groups.find(r => r.t >= 2 && r.pct < 1);
|
||
let v = `你答了 ${answered} 题,做到 <b>${yes.length}</b> 项,没做到 <b>${no.length}</b> 项。`;
|
||
if (answered > 0 && !no.length) v += '书里这些事你都做到了。';
|
||
else if (!answered) v += '你还没有回答任何具体题目,返回答题后查看分析。';
|
||
if (weak) v += `漏得最多的是「${esc(weak.g.name)}」,${weak.t} 项里做到 ${weak.y} 项。`;
|
||
if (easy.length) v += `没做到的里有 <b>${easy.length}</b> 项一次办完就行,不用改习惯` + (free.length ? `,其中 ${free.length} 项不花钱。` : '。');
|
||
if (disp.length) v += `有 ${disp.length} 项证据有争议,先看原条目的备注再决定。`;
|
||
document.getElementById('verdict').innerHTML = v;
|
||
|
||
document.getElementById('sum').innerHTML =
|
||
`<div class="tile"><b>${answered ? Math.round(yes.length / answered * 100) : 0}%</b><span>答过的题里做到的比例</span></div>` +
|
||
`<div class="tile"><b>${no.length}</b><span>还没做到</span></div>` +
|
||
`<div class="tile r3"><b>${good.length}</b><span>其中性价比高或极高</span></div>` +
|
||
`<div class="tile r2"><b>${easy.length}</b><span>其中一次办完就行</span></div>`;
|
||
|
||
// 先做这三件:性价比最高的排前;同档里先挑不用改习惯的
|
||
const top = no.slice(0, 3);
|
||
const topTitle = `先做这${top.length === 3 ? '三' : top.length === 2 ? '两' : '一'}件`;
|
||
const alsoOf = q => (q.also || []).map(a => find(a.sec, a.key)).filter(Boolean);
|
||
let h = '';
|
||
if (top.length) h += `<h2>${topTitle}<small>没做到的里性价比最高的</small></h2><ol class="top3">`
|
||
+ top.map(q => `<li><a href="#c-${q.entry.sec}-${q.entry.n}">${esc(plain(q.entry.title))}</a> <span class="why">性价比${q.entry.ratio},证据 ${q.entry.grade} 级,${PILES.find(p => p.will === q.entry.will)?.name ?? ''}</span></li>`).join('') + '</ol>'
|
||
+ `<div class="copy-tools">
|
||
<button class="btn" id="copy-actions" type="button">复制这几件事</button>
|
||
<p class="copy-status" id="copy-status" role="status" aria-live="polite" aria-atomic="true"></p>
|
||
<div id="copy-fallback" hidden>
|
||
<label for="copy-text">行动清单</label>
|
||
<textarea id="copy-text" rows="10" readonly aria-describedby="copy-status"></textarea>
|
||
</div>
|
||
</div>`;
|
||
for (const p of PILES){
|
||
const list = no.filter(q => q.entry.will === p.will);
|
||
if (!list.length) continue;
|
||
h += `<h2>${p.name}(${list.length} 项)<small>${p.sub}</small></h2>` + list.map(q => card(q.entry, alsoOf(q))).join('');
|
||
}
|
||
document.getElementById('todo').innerHTML = h;
|
||
if (top.length) document.getElementById('copy-text').value = `高性价比人生指南 · ${topTitle}\n\n` + top.map(({entry:e}) =>
|
||
`- [ ] ${plain(e.title)}\n`
|
||
+ ` 性价比${e.ratio},证据 ${e.grade} 级,${PILES.find(p => p.will === e.will)?.name ?? ''}${e.dispute ? ',有争议(先看原条目的备注)' : ''}\n`
|
||
+ ` 要花的:${plain(e.cost)}\n`
|
||
+ (e.note ? ` 适用条件与注意事项:${plain(e.note)}\n` : '')
|
||
+ ` https://eternity4719.github.io/HowToLiveBetter/#e-${e.sec}-${e.n}`).join('\n\n');
|
||
document.getElementById('done').innerHTML = yes.map(q =>
|
||
`<li><a href="./#e-${q.entry.sec}-${q.entry.n}">${esc(plain(q.entry.title))}</a></li>`).join('');
|
||
document.getElementById('done-h').hidden = !yes.length;
|
||
}
|
||
|
||
function show(which){
|
||
document.getElementById('ask').hidden = which !== 'ask';
|
||
document.getElementById('out').hidden = which !== 'out';
|
||
scrollTo(0, 0);
|
||
}
|
||
|
||
document.getElementById('todo').addEventListener('click', async ev => {
|
||
const b = ev.target.closest('#copy-actions'); if (!b) return;
|
||
const status = document.getElementById('copy-status'), fallback = document.getElementById('copy-fallback');
|
||
const text = document.getElementById('copy-text');
|
||
b.disabled = true;
|
||
fallback.hidden = true;
|
||
status.textContent = '正在复制…';
|
||
try {
|
||
await navigator.clipboard.writeText(text.value);
|
||
if (b.isConnected && !document.getElementById('out').hidden) status.textContent = '已复制,可以粘贴到备忘录或待办应用。';
|
||
} catch (err) {
|
||
// 等待剪贴板权限时可能已经返回改答案,不把焦点拉回旧结果。
|
||
if (!b.isConnected || document.getElementById('out').hidden) return;
|
||
fallback.hidden = false;
|
||
status.textContent = '无法自动复制,请在下方手动复制清单。';
|
||
text.focus();
|
||
text.select();
|
||
text.scrollTop = 0;
|
||
} finally {
|
||
b.disabled = false;
|
||
}
|
||
});
|
||
|
||
document.getElementById('qs').addEventListener('click', ev => {
|
||
const mb = ev.target.closest('button[data-mod]');
|
||
if (mb) { ans['mod:' + mb.dataset.mod] = modOn(mb.dataset.mod) ? 'off' : undefined; save(); renderAsk(); return; }
|
||
const b = ev.target.closest('button[data-k]'); if (!b) return;
|
||
ans[b.dataset.k] = ans[b.dataset.k] === b.dataset.v ? undefined : b.dataset.v;
|
||
save(); renderAsk();
|
||
});
|
||
document.getElementById('qs').addEventListener('input', ev => {
|
||
if (!ev.target.matches('input[data-k="p:ageYears"]')) return;
|
||
ans['p:ageYears'] = ev.target.value;
|
||
// 保留年龄输入框和基本情况按钮,避免失焦重绘吞掉紧接着的点击。
|
||
save(); renderAsk(false);
|
||
});
|
||
document.getElementById('go').addEventListener('click', () => {
|
||
if (!profileDone()) { document.getElementById('prog').textContent = '请填完整基本情况,年龄填 0 到 120 的整数'; return; }
|
||
renderOut(); show('out');
|
||
});
|
||
document.getElementById('back').addEventListener('click', () => show('ask'));
|
||
document.getElementById('reset').addEventListener('click', () => { ans = {}; save(); renderAsk(); });
|
||
|
||
(async function init(){
|
||
const status = document.getElementById('status');
|
||
try {
|
||
const EMBED = window.__CORPUS__;
|
||
const readme = EMBED ? EMBED.readme : await (await fetch('README.md', {cache:'no-cache'})).text();
|
||
const files = [...new Set(Array.from(readme.matchAll(/\]\((book\/[^)]+\.md)\)/g), m => m[1]))]
|
||
.filter(f => GROUPS.some(g => g.qs.some(q => f.startsWith(`book/${String(q.sec).padStart(2,'0')}-`))));
|
||
const parts = await Promise.all(files.map(async f => EMBED ? EMBED.parts[f] : (await fetch(f, {cache:'no-cache'})).text()));
|
||
ENTRIES = parse(parts.join('\n\n'));
|
||
for (const g of GROUPS) for (const q of g.qs) { q.g = g; q.entry = find(q.sec, q.key); }
|
||
status.remove();
|
||
renderAsk(); show('ask');
|
||
} catch (err) {
|
||
status.textContent = location.protocol === 'file:'
|
||
? '这个页面要通过 http 打开才能读取正文。'
|
||
: `读取正文失败:${err.message}`;
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|