dsh-metaphysics 0.67.1 → 0.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -57,8 +57,8 @@ header{display:flex;flex-direction:column;align-items:stretch;gap:12px;margin-bo
57
57
  transition:background .16s ease,color .16s ease,border-color .16s ease,box-shadow .16s ease}
58
58
  .tab:hover{background:var(--panel);border-color:var(--line);color:var(--ink)}
59
59
  .tab.on{background:linear-gradient(145deg,var(--accent-bg),var(--accent-bg2));border-color:transparent;color:var(--on-accent);font-weight:600;box-shadow:0 8px 18px -12px var(--gold)}
60
- /* 首屏按「想干什么」分组:19 个 tab 平铺时,第一次来的人得先猜「梅花」「称骨」是什么。
61
- 卡片只是另一套入口,点击仍走同一个 setTab;熟练用户可一键回到平铺(19 个 tab 原样保留)。 */
60
+ /* 首屏按「想干什么」分组:22 个 tab 平铺时,第一次来的人得先猜「梅花」「称骨」是什么。
61
+ 卡片只是另一套入口,点击仍走同一个 setTab;熟练用户可一键回到平铺(22 个 tab 原样保留)。 */
62
62
  .view-switch{display:flex;align-items:center;gap:8px}
63
63
  .view-switch .hint{font-size:12px;color:var(--dim)}
64
64
  .view-switch .seg button{padding:5px 11px;font-size:12.5px}
@@ -73,26 +73,26 @@ header{display:flex;flex-direction:column;align-items:stretch;gap:12px;margin-bo
73
73
  transition:border-color .14s ease,background .14s ease}
74
74
  .rc:hover{border-color:var(--gold);background:var(--panel)}
75
75
  .rc i{font-style:normal;font-size:11.5px;color:var(--dim)}
76
- .groups{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
76
+ .groups{display:grid;grid-template-columns:repeat(auto-fit,minmax(268px,1fr));gap:12px}
77
77
  .tgroup{padding:14px 14px 12px;border:1px solid var(--line);border-radius:14px;
78
78
  background:linear-gradient(180deg,var(--panel),color-mix(in srgb,var(--panel) 88%,var(--bg)));box-shadow:var(--shadow)}
79
79
  .tgroup-head h2{display:flex;align-items:center;gap:8px;margin:0;font-size:15.5px;font-weight:600;color:var(--ink);letter-spacing:.06em}
80
80
  .tgroup-head h2::before{content:"";flex:none;width:7px;height:7px;border-radius:2px;background:var(--gold)}
81
81
  .tgroup-head p{margin:4px 0 11px;font-size:12.5px;line-height:1.6;color:var(--dim)}
82
- .ttools{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}
82
+ .tgroup:not(.open) .ttools>.tool:nth-child(n+5){display:none}
83
+ .ttools{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;grid-auto-rows:1fr}
83
84
  .tool{display:flex;flex-direction:column;gap:1px;padding:8px 10px;text-align:left;border:1px solid var(--line);border-radius:9px;
84
85
  background:color-mix(in srgb,var(--panel2) 72%,transparent);color:var(--ink);font-family:inherit;cursor:pointer;
85
86
  transition:border-color .14s ease,background .14s ease,box-shadow .14s ease}
86
87
  .tool b{font-size:13.5px;font-weight:600;letter-spacing:.02em}
87
- .tool span{font-size:11.5px;line-height:1.4;color:var(--dim)}
88
+ .tool span{font-size:11.5px;line-height:1.4;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
88
89
  .tool:hover{border-color:var(--gold);background:var(--panel)}
89
90
  .tool.on{border-color:transparent;background:linear-gradient(145deg,var(--accent-bg),var(--accent-bg2));box-shadow:0 8px 18px -12px var(--gold)}
90
91
  .tool.on b{color:var(--on-accent)}
91
92
  .tool.on span{color:var(--on-accent-soft)}
92
- /* 奇数个的那一组:末项铺满整行,避免右下留一个孤格 */
93
- .tool.wide{grid-column:1/-1}
93
+ /* 奇数个的那一组:窄屏末项铺满整行,避免右下留一个孤格(大屏三列时不铺) */
94
94
  /* 手机端每组先露 4 个,其余折在「还有 N 个」后面:三组全展开会把表单挤到第二屏 */
95
- .tmore{display:none;grid-column:1/-1;align-items:center;justify-content:center;gap:6px;padding:8px;border:1px dashed var(--line);border-radius:9px;
95
+ .tmore{display:flex;grid-column:1/-1;align-items:center;justify-content:center;gap:6px;padding:8px;border:1px dashed var(--line);border-radius:9px;
96
96
  background:transparent;color:var(--dim);font-family:inherit;font-size:12.5px;cursor:pointer}
97
97
  .tmore:hover{color:var(--gold);border-color:var(--gold)}
98
98
  body.view-group .tabs{display:none}
@@ -268,10 +268,9 @@ h3::after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--li
268
268
  .tool{padding:9px 10px;min-height:52px}
269
269
  .tool b{font-size:14px}
270
270
  .tool span{font-size:11px}
271
- .tgroup:not(.open) .ttools>.tool:nth-child(n+5){display:none}
272
- .tmore{display:flex}
271
+ .tool.wide{grid-column:1/-1}
273
272
  .rc{min-height:36px}
274
- /* 19 个 tab 铺网格要占掉大半个首屏,改成单行横滑:44px 触摸目标、滚动条藏起来、
273
+ /* 22 个 tab 铺网格要占掉大半个首屏,改成单行横滑:44px 触摸目标、滚动条藏起来、
275
274
  左右各留 12px 呼吸位(padding 与 .wrap 等宽,首尾 tab 不会被切口)*/
276
275
  .tabs{display:flex;flex-wrap:nowrap;gap:8px;overflow-x:auto;overscroll-behavior-x:contain;
277
276
  margin:0 -12px;padding:2px 12px 6px;scrollbar-width:none;-webkit-overflow-scrolling:touch}
@@ -362,7 +361,7 @@ h3::after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--li
362
361
  <div class="wrap">
363
362
  <header>
364
363
  <div class="brand-row">
365
- <div class="brand"><span class="seal" aria-hidden="true">命</span><span class="brand-txt">命理排盘<span class="ver">v0.67.1</span><small>八字 · 紫微 · 六爻 · 梅花 · 六壬 · 奇门 · 合盘 · 塔罗 全部在本机计算</small></span></div>
364
+ <div class="brand"><span class="seal" aria-hidden="true">命</span><span class="brand-txt">命理排盘<span class="ver">v0.69.0</span><small>八字 · 紫微 · 六爻 · 梅花 · 六壬 · 奇门 · 合盘 · 塔罗 全部在本机计算</small></span></div>
366
365
  <div class="view-switch">
367
366
  <span class="hint">熟练了可以换回平铺</span>
368
367
  <span class="seg" role="group" aria-label="首屏样式">
@@ -391,6 +390,9 @@ h3::after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--li
391
390
  <button class="tab" data-tab="fengshui">风水</button>
392
391
  <button class="tab" data-tab="name">起名</button>
393
392
  <button class="tab" data-tab="mbti">人格</button>
393
+ <button class="tab" data-tab="constitution">体质</button>
394
+ <button class="tab" data-tab="wuyunliuqi">运气</button>
395
+ <button class="tab" data-tab="taiyi">太乙</button>
394
396
  </div>
395
397
  </header>
396
398
 
@@ -408,37 +410,38 @@ h3::after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--li
408
410
  <section class="tgroup">
409
411
  <div class="tgroup-head"><h2>问一件事</h2><p>心里有一件具体的事,起一卦再决定</p></div>
410
412
  <div class="ttools">
411
- <button type="button" class="tool" data-tab="liuyao"><b>六爻</b><span>摇六次,问一件事</span></button>
412
- <button type="button" class="tool" data-tab="meihua"><b>梅花</b><span>见闻起卦,看当下</span></button>
413
- <button type="button" class="tool" data-tab="tarot"><b>塔罗</b><span>抽牌,看关系与选择</span></button>
414
- <button type="button" class="tool" data-tab="qimen"><b>奇门</b><span>看方位、时机与取舍</span></button>
415
- <button type="button" class="tool" data-tab="daliuren"><b>六壬</b><span>占事,断来意与结果</span></button>
416
- <button type="button" class="tool" data-tab="xiaoliuren"><b>小六壬</b><span>掐指一算,快速断</span></button>
413
+ <button type="button" class="tool" data-tab="liuyao" title="摇六次问事"><b>六爻</b><span>摇六次问事</span></button>
414
+ <button type="button" class="tool" data-tab="tarot" title="抽牌看选择"><b>塔罗</b><span>抽牌看选择</span></button>
415
+ <button type="button" class="tool" data-tab="meihua" title="见闻起卦"><b>梅花</b><span>见闻起卦</span></button>
416
+ <button type="button" class="tool" data-tab="qimen" title="看方位时机"><b>奇门</b><span>看方位时机</span></button>
417
+ <button type="button" class="tool" data-tab="daliuren" title="占事断来意"><b>六壬</b><span>占事断来意</span></button>
418
+ <button type="button" class="tool" data-tab="xiaoliuren" title="掐指一算"><b>小六壬</b><span>掐指一算</span></button>
417
419
  <button type="button" class="tmore" aria-expanded="false"></button>
418
420
  </div>
419
421
  </section>
420
422
  <section class="tgroup">
421
- <div class="tgroup-head"><h2>看一个人</h2><p>看自己或身边人的性情、格局与运程</p></div>
423
+ <div class="tgroup-head"><h2>看一个人</h2><p>看自己或身边人的性情与格局</p></div>
422
424
  <div class="ttools">
423
- <button type="button" class="tool" data-tab="bazi"><b>八字</b><span>四柱看一生格局</span></button>
424
- <button type="button" class="tool" data-tab="ziwei"><b>紫微</b><span>十二宫细看</span></button>
425
- <button type="button" class="tool" data-tab="hepan"><b>合盘</b><span>两个人合不合</span></button>
426
- <button type="button" class="tool" data-tab="mbti"><b>人格</b><span>16 型性格问卷</span></button>
427
- <button type="button" class="tool" data-tab="chenggu"><b>称骨</b><span>按生辰称命重</span></button>
428
- <button type="button" class="tool" data-tab="numerology"><b>数字</b><span>生日数字看性格</span></button>
429
- <button type="button" class="tool wide" data-tab="trend"><b>人生K</b><span>一生高低走势</span></button>
425
+ <button type="button" class="tool" data-tab="bazi" title="四柱看格局"><b>八字</b><span>四柱看格局</span></button>
426
+ <button type="button" class="tool" data-tab="ziwei" title="十二宫细看"><b>紫微</b><span>十二宫细看</span></button>
427
+ <button type="button" class="tool" data-tab="hepan" title="两人合不合"><b>合盘</b><span>两人合不合</span></button>
428
+ <button type="button" class="tool" data-tab="mbti" title="16 型问卷"><b>人格</b><span>16 型问卷</span></button>
429
+ <button type="button" class="tool" data-tab="constitution" title="九种体质"><b>体质</b><span>九种体质</span></button>
430
+ <button type="button" class="tool" data-tab="chenggu" title="生辰称命重"><b>称骨</b><span>生辰称命重</span></button>
431
+ <button type="button" class="tool wide" data-tab="trend" title="一生走势"><b>人生K</b><span>一生走势</span></button>
430
432
  <button type="button" class="tmore" aria-expanded="false"></button>
431
433
  </div>
432
434
  </section>
433
435
  <section class="tgroup">
434
- <div class="tgroup-head"><h2>查日子</h2><p>挑时间、换农历,顺带看看家里和名字</p></div>
436
+ <div class="tgroup-head"><h2>查日子</h2><p>挑时间、换农历,看一年的气与方位</p></div>
435
437
  <div class="ttools">
436
- <button type="button" class="tool" data-tab="almanac"><b>黄历</b><span>今天宜忌、时辰吉凶</span></button>
437
- <button type="button" class="tool" data-tab="zeri"><b>择日</b><span>挑个办事的好日子</span></button>
438
- <button type="button" class="tool" data-tab="convert"><b>农历</b><span>公历农历互转</span></button>
439
- <button type="button" class="tool" data-tab="star"><b>星座</b><span>星座性格与今日运</span></button>
440
- <button type="button" class="tool" data-tab="fengshui"><b>风水</b><span>房屋朝向与布置</span></button>
441
- <button type="button" class="tool" data-tab="name"><b>起名</b><span>按八字选字</span></button>
438
+ <button type="button" class="tool" data-tab="almanac" title="今日宜忌"><b>黄历</b><span>今日宜忌</span></button>
439
+ <button type="button" class="tool" data-tab="zeri" title="挑好日子"><b>择日</b><span>挑好日子</span></button>
440
+ <button type="button" class="tool" data-tab="convert" title="公历农历"><b>农历</b><span>公历农历</span></button>
441
+ <button type="button" class="tool" data-tab="wuyunliuqi" title="一年大势"><b>运气</b><span>一年大势</span></button>
442
+ <button type="button" class="tool" data-tab="taiyi" title="积年推局"><b>太乙</b><span>积年推局</span></button>
443
+ <button type="button" class="tool" data-tab="fengshui" title="房屋朝向"><b>风水</b><span>房屋朝向</span></button>
444
+ <button type="button" class="tool" data-tab="name" title="八字选字"><b>起名</b><span>八字选字</span></button>
442
445
  <button type="button" class="tmore" aria-expanded="false"></button>
443
446
  </div>
444
447
  </section>
@@ -529,12 +532,16 @@ h3::after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--li
529
532
  <label class="field">询问方向
530
533
  <select id="direction"><option value="general" selected>综合</option><option value="wealth">财运</option><option value="career">事业</option><option value="love">感情</option><option value="health">健康</option><option value="study">学业</option><option value="travel">出行</option></select>
531
534
  </label>
532
- <!-- 塔罗牌阵张数:仅塔罗 tab 显示(1/3/5 由引擎 TAROT SPREADS 支持) -->
535
+ <!-- 塔罗牌阵:仅塔罗 tab 显示(张数由牌阵定,命名牌阵见 engine/tarot.js SPREAD_DEFS) -->
533
536
  <label class="field" id="for-count" style="display:none">牌阵
534
537
  <select id="tcount">
535
- <option value="1">1 张(当下指引)</option>
536
- <option value="3" selected>3 张(过去·现在·未来)</option>
537
- <option value="5">5 张(现状·阻碍·建议·结果·潜在)</option>
538
+ <option value="one">1 张 · 单张指引</option>
539
+ <option value="three" selected>3 张 · 过去现在未来</option>
540
+ <option value="five">5 张 · 五张局势</option>
541
+ <option value="celtic">10 张 · 凯尔特十字</option>
542
+ <option value="either">5 张 · 二选一</option>
543
+ <option value="relation">7 张 · 关系阵</option>
544
+ <option value="timeflow">3 张 · 时间流</option>
538
545
  </select>
539
546
  </label>
540
547
  </div>
@@ -625,6 +632,25 @@ h3::after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--li
625
632
  </div>
626
633
  </div>
627
634
  </div>
635
+ <!-- 中医体质问卷:5 题一页、1–5 打分;与人格问卷同一套 mb-* 版式,
636
+ 题目与 1–5 的说明同样由 web-ui 从引擎取,页面只放容器 -->
637
+ <div id="for-constitution" style="display:none">
638
+ <div class="field">
639
+ <span>中医体质问卷 · 9 种体质各 5 题,共 45 题 · 5 题一页,按 1(没有)到 5(总是)打分</span>
640
+ <div class="mb-top">
641
+ <div class="mb-prog" aria-hidden="true"><i id="cs-fill"></i></div>
642
+ <span class="mb-count" id="cs-count">第 1 / 9 页 · 已答 0 / 45</span>
643
+ </div>
644
+ <div class="mb-qs" id="cs-qs"></div>
645
+ <div class="mb-nav">
646
+ <button type="button" class="mb-btn" id="cs-prev" disabled>上一页</button>
647
+ <button type="button" class="mb-btn" id="cs-next">下一页</button>
648
+ <button type="button" class="mb-btn primary" id="cs-submit" disabled>看结果</button>
649
+ <button type="button" class="mb-btn" id="cs-clear">清空</button>
650
+ <span class="mb-hint" id="cs-hint">答满 27 题可先看结果</span>
651
+ </div>
652
+ </div>
653
+ </div>
628
654
  <div id="qk-name" style="display:none">
629
655
  <div class="field">看什么
630
656
  <div class="seg" id="nnmode">
@@ -653,7 +679,7 @@ h3::after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--li
653
679
  <h3>对拍:把典籍当期望值</h3>
654
680
  <ul><li>六爻:京房八宫卦序与世应,64 卦全表;旬空六旬表</li><li>六壬:日干寄宫、中气换将;紫微:纳音定五行局、紫微天府相照</li><li>卦例:《增删卜易》9 条 +《卜筮正宗》128 条(93 成对 + 35 静卦),核本变卦 / 动爻数 / 卦宫 / 缺爻 / 旬空</li><li>奇门:20 个时辰 × 9 宫,核四柱 / 局数 / 值符 / 值使 / 星门神 / 天盘干</li></ul>
655
681
  <h3>工程验证</h3>
656
- <ul><li>测试:171 个测试文件 / 9387 条断言;七道闸(回归 · 断言基线 · 测试可失败自检 · HTML 注入面 · 工程约定 · 产物宿主形态 · 随机扫射)</li><li>可复现:seed / asOf 显式;同一输入同一输出</li><li>口径:晚子时、真太阳时、奇门三流派、闰月归月都可切换,并有字段级差异对照</li></ul>
682
+ <ul><li>测试:177 个测试文件 / 9881 条断言;七道闸(回归 · 断言基线 · 测试可失败自检 · HTML 注入面 · 工程约定 · 产物宿主形态 · 随机扫射)</li><li>可复现:seed / asOf 显式;同一输入同一输出</li><li>口径:晚子时、真太阳时、奇门三流派、闰月归月都可切换,并有字段级差异对照</li></ul>
657
683
 
658
684
  <h3>已知边界(不猜)</h3>
659
685
  <ul><li>六爻应期(具体哪天应验)不做;卦例只录动爻个数,不录爻位</li><li>财富/健康只给结构判断与档位,不给金额区间、不作诊断</li><li>紫微飞星已做「单一支化逐宫追到成环」与「三方四正 16 条按落宫归堆」,但都未叠大限流年四化、不折吉凶;八字交运日已能算到日/时,但未与第三方排盘案例批量对拍</li></ul>
@@ -2535,8 +2561,14 @@ return { CITIES, PROVINCES, resolveAddress, geocodeAddress };
2535
2561
  })();
2536
2562
 
2537
2563
  __mods.random = (function () {
2538
- // 确定性伪随机数:只用于让「同一次摇卦 / 抽牌」可以复现(seed 相同 → 结果相同)。
2539
- // 不用于任何安全用途,也不承担密码学强度。
2564
+ // 摇卦 / 抽牌的随机源,两条路线分工:
2565
+ // · 不给 seed —— 走系统加密随机(globalThis.crypto.getRandomValues)。
2566
+ // 占卜里「这一卦真是随机摇出来的」是可信度的一部分:Math.random 内部状态只有 48 位,
2567
+ // 连续输出可以被倒推,对一个用来问事的东西来说太轻。
2568
+ // · 给了 seed —— 走 mulberry32,同 seed 同结果;句柄回放、影子盘对拍都靠这条。
2569
+ //
2570
+ // engine/ 是对外一份纯计算(客户端也要打包进去),所以这里既不 import node:crypto,
2571
+ // 也不碰浏览器全局对象(tools/check-patterns.mjs 会拦)——只认 globalThis.crypto。
2540
2572
 
2541
2573
  /** mulberry32:32 位状态,分布够用,实现只有几行 */
2542
2574
  function mulberry32(seed) {
@@ -2559,6 +2591,25 @@ function mulberry32(seed) {
2559
2591
  };
2560
2592
  }
2561
2593
 
2594
+ /**
2595
+ * 系统加密随机源:从 globalThis.crypto(Node 19+ 与浏览器同源)按批取 32 位整数,
2596
+ * 一批够塔罗洗 78 张、六爻摇 18 次,省掉每次取数跨进 native 的开销。
2597
+ * 完全没有 crypto 的运行时退 Math.random —— 只为不抛错,这条兜底不承担强度。
2598
+ */
2599
+ function cryptoRandom() {
2600
+ const web = globalThis.crypto;
2601
+ if (!web || typeof web.getRandomValues !== 'function') return Math.random;
2602
+ const POOL = 128;
2603
+ const pool = new Uint32Array(POOL);
2604
+ let idx = POOL;
2605
+ const next = () => {
2606
+ if (idx >= POOL) { web.getRandomValues(pool); idx = 0; }
2607
+ return pool[idx++] / 4294967296;
2608
+ };
2609
+ next.source = 'crypto';
2610
+ return next;
2611
+ }
2612
+
2562
2613
  // 与宿主 / bool.js 同一把尺:只认十进制(可带指数、尾点)。Number() 会把
2563
2614
  // "0x10"、false、[] 也变成有效数字,于是直接调引擎能排出盘、走工具层却被拒——
2564
2615
  // 同一份输入两种命运,正是句柄与复现最容易分叉的地方。
@@ -2575,14 +2626,14 @@ const seedNumber = (seed) => {
2575
2626
 
2576
2627
  /**
2577
2628
  * 从参数里取随机源:
2578
- * - 没给 seed:沿用 Math.random(保持"真随机"的手感)
2579
- * - 给了数字或十进制数字串:mulberry32(seed),可复现
2580
- * - 给了别的东西:调用方应当先报错,这里兜底回 Math.random
2629
+ * - 没给 seed:系统加密随机(crypto),手感还是真随机,但挪不出可猜的规律
2630
+ * - 给了数字或十进制数字串:mulberry32(seed),同 seed 同结果,可复现
2631
+ * - 给了别的东西:调用方应当先报错,这里兜底回 crypto
2581
2632
  */
2582
2633
  function randomOf(seed) {
2583
- if (seed === undefined || seed === null || seed === '') return Math.random;
2634
+ if (seed === undefined || seed === null || seed === '') return cryptoRandom();
2584
2635
  const n = seedNumber(seed);
2585
- return Number.isFinite(n) ? mulberry32(n) : Math.random;
2636
+ return Number.isFinite(n) ? mulberry32(n) : cryptoRandom();
2586
2637
  }
2587
2638
 
2588
2639
  /** 是否是一个可以接受的 seed */
@@ -8907,7 +8958,7 @@ function yongshenOf(result, opts = {}) {
8907
8958
  }
8908
8959
 
8909
8960
  /** 随机摇卦:三枚铜钱,背为阳(3),字为阴(2),合计 6/7/8/9 */
8910
- function tossLines(random = Math.random) {
8961
+ function tossLines(random = randomOf()) {
8911
8962
  const lines = [];
8912
8963
  for (let i = 0; i < 6; i++) {
8913
8964
  let sum = 0;
@@ -11904,6 +11955,7 @@ __mods.tarot = (function () {
11904
11955
  const { CARD_LIB, CARD_COUNTS } = (__mods['tarot-cards'] || {});
11905
11956
  const { fail } = (__mods.fail || {});
11906
11957
  const { assertObject } = (__mods.validate || {});
11958
+ const { randomOf } = (__mods.random || {});
11907
11959
 
11908
11960
  const MAJOR = [
11909
11961
  ['愚者', '0', '新开始 冒险 纯真 无限可能', '鲁莽 逃避 原地打转 不切实际'],
@@ -12073,6 +12125,60 @@ const SPREADS = {
12073
12125
  ],
12074
12126
  };
12075
12127
 
12128
+ // 命名牌阵:只认张数(1/3/5)时,问「两个人」「A 还是 B」都得自己拆成三轮来问。
12129
+ // 这里按问法给成套的位置表,张数由位置表长度决定;数字键那三套原样保留在后面兼容。
12130
+ const SPREAD_DEFS = {
12131
+ one: { label: '单张指引', positions: SPREADS[1] },
12132
+ three: { label: '过去现在未来', positions: SPREADS[3] },
12133
+ five: { label: '五张局势', positions: SPREADS[5] },
12134
+ celtic: {
12135
+ label: '凯尔特十字',
12136
+ positions: [
12137
+ { name: '现状', hint: '问题此刻的真实位置,别再按印象猜' },
12138
+ { name: '阻碍', hint: '横在中间、需要先跨过的那一件事' },
12139
+ { name: '根基', hint: '埋在底下、已经成型的原因' },
12140
+ { name: '过去', hint: '刚刚过去、余波仍在影响当下的部分' },
12141
+ { name: '目标', hint: '你意识里想达成的方向,未必是真正想要的' },
12142
+ { name: '近期', hint: '接下来几周会先落到的那一步' },
12143
+ { name: '自我', hint: '你在这局里正扮演的角色与姿态' },
12144
+ { name: '环境', hint: '别人的态度与外部的实际条件' },
12145
+ { name: '希望与恐惧', hint: '你既想要又怕的那一面,常常是同一件事' },
12146
+ { name: '结果', hint: '照现在这样走下去,事情大致停在哪里' },
12147
+ ],
12148
+ },
12149
+ either: {
12150
+ label: '二选一',
12151
+ positions: [
12152
+ { name: '现状', hint: '做选择之前,先看清脚下站的是什么' },
12153
+ { name: 'A 的发展', hint: '选这一条,过程会怎么走' },
12154
+ { name: 'A 的结果', hint: '选这一条,最后停在哪里' },
12155
+ { name: 'B 的发展', hint: '选另一条,过程会怎么走' },
12156
+ { name: 'B 的结果', hint: '选另一条,最后停在哪里' },
12157
+ ],
12158
+ },
12159
+ relation: {
12160
+ label: '关系阵',
12161
+ positions: [
12162
+ { name: '你', hint: '你在这段关系里的位置与状态' },
12163
+ { name: '对方', hint: '对方此刻的位置与状态' },
12164
+ { name: '关系现状', hint: '两个人之间正在发生的事' },
12165
+ { name: '你的期待', hint: '你嘴上没说、心里想要的那件事' },
12166
+ { name: '对方的期待', hint: '对方没说出口、在等的那件事' },
12167
+ { name: '阻碍', hint: '卡在两人之间、谁都没先提的那层' },
12168
+ { name: '走向', hint: '维持现状会滑向的位置' },
12169
+ ],
12170
+ },
12171
+ timeflow: {
12172
+ label: '时间流',
12173
+ positions: [
12174
+ { name: '眼下', hint: '这几天会先显出来的那件事' },
12175
+ { name: '这阵子', hint: '几周到一两个月之间的推进' },
12176
+ { name: '往后', hint: '更远一点的落点,只看方向不看日期' },
12177
+ ],
12178
+ },
12179
+ };
12180
+ const SPREAD_KEYS = ['one', 'three', 'five', 'celtic', 'either', 'relation', 'timeflow'];
12181
+
12076
12182
  const COURT = ['侍从', '骑士', '王后', '国王'];
12077
12183
  // 宫廷牌的「牌位」=这个角色在牌阵里通常顶替的位置(人物 or 自身的一个侧面),
12078
12184
  // 与占星无关,所以宫廷牌没有星座也照样能读。
@@ -12304,22 +12410,36 @@ function tarotReading(input = {}) {
12304
12410
  return paras;
12305
12411
  }
12306
12412
 
12307
- /** 抽牌:count 张,按牌阵给出位置。random 可注入(配合 seed 复现同一次抽牌) */
12308
- function drawTarot(input = {}) {
12309
- const { count: rawCount, question = '', at, random = Math.random } = input || {};
12413
+ /** 牌阵解析:spread(名字)优先,没给名字才退回 count(1/3/5) */
12414
+ function resolveSpread(rawSpread, rawCount) {
12415
+ const name = String(rawSpread == null ? '' : rawSpread).trim().toLowerCase();
12416
+ if (name) {
12417
+ if (!SPREAD_DEFS[name]) {
12418
+ throw fail('E_INPUT', `塔罗牌阵只能是 ${SPREAD_KEYS.join(' / ')},收到「${rawSpread}」`);
12419
+ }
12420
+ return SPREAD_DEFS[name];
12421
+ }
12310
12422
  let count = rawCount;
12311
12423
  if (count === undefined || count === null || count === '') count = 3;
12312
12424
  const n = Number(count);
12313
12425
  if (![1, 3, 5].includes(n)) {
12314
- throw fail('E_INPUT', `塔罗张数只能是 1 / 3 / 5,收到「${count}」`);
12426
+ throw fail('E_INPUT', `塔罗张数只能是 1 / 3 / 5,收到「${rawCount}」`);
12315
12427
  }
12316
- const rand = typeof random === 'function' ? random : Math.random;
12428
+ return { label: `${n} 张牌阵`, positions: SPREADS[n] };
12429
+ }
12430
+
12431
+ /** 抽牌:count 张,按牌阵给出位置。random 可注入(配合 seed 复现同一次抽牌) */
12432
+ function drawTarot(input = {}) {
12433
+ const { count: rawCount, spread: rawSpread, question = '', at, random = randomOf() } = input || {};
12434
+ const def = resolveSpread(rawSpread, rawCount);
12435
+ const positions = def.positions;
12436
+ const n = positions.length;
12437
+ const rand = typeof random === 'function' ? random : randomOf();
12317
12438
  const deck = tarotDeck();
12318
12439
  for (let i = deck.length - 1; i > 0; i--) {
12319
12440
  const j = Math.floor(rand() * (i + 1));
12320
12441
  [deck[i], deck[j]] = [deck[j], deck[i]];
12321
12442
  }
12322
- const positions = SPREADS[n];
12323
12443
  const cards = deck.slice(0, n).map((c, i) => {
12324
12444
  const upright = rand() < 0.5;
12325
12445
  const pos = positions[i];
@@ -12357,7 +12477,7 @@ function drawTarot(input = {}) {
12357
12477
  system: 'tarot',
12358
12478
  question: q || null,
12359
12479
  castAt: stamp(at),
12360
- spread: `${n} 张牌阵(${positions.map((p) => p.name).join(' / ')})`,
12480
+ spread: `${def.label}(${positions.map((p) => p.name).join(' / ')})`,
12361
12481
  cards,
12362
12482
  reading: tarotReading({ question: q, cards }),
12363
12483
  systemNote: SYSTEM_NOTE,
@@ -12366,7 +12486,7 @@ function drawTarot(input = {}) {
12366
12486
 
12367
12487
 
12368
12488
 
12369
- return { MAJOR, SUITS, MINOR, ROMAN, SPREADS, TAROT_STATS, tarotDeck, tarotReading, drawTarot };
12489
+ return { MAJOR, SUITS, MINOR, ROMAN, SPREADS, SPREAD_DEFS, SPREAD_KEYS, TAROT_STATS, tarotDeck, tarotReading, drawTarot };
12370
12490
  })();
12371
12491
 
12372
12492
  __mods.meihua = (function () {
@@ -18164,6 +18284,334 @@ function buildMbti(input = {}) {
18164
18284
  return { DIMENSIONS, QUESTIONS, FUNCTION_STACKS, FUNCTIONS, TYPES, mbtiPaper, buildMbti };
18165
18285
  })();
18166
18286
 
18287
+ __mods.constitution = (function () {
18288
+ // 中医体质(九种体质):按中华中医药学会《中医体质分类与判定》的条目结构做的简化问卷——
18289
+ // 九个体质各 5 条自评题,1–5 计分,用「转化分」判主次体质。
18290
+ //
18291
+ // 口径(沿用通行标准,条目数是压缩过的):
18292
+ // 转化分 = [(原始分 − 条目数) / (条目数 × 4)] × 100
18293
+ // 偏颇体质:≥40 为「是」,30–39 为「有倾向」,<30 为「否」
18294
+ // 平和质:≥60 且其余八种都 <30 为「是」;≥60 且其余都 <40 为「基本是」
18295
+ // 题目与阈值取自公开标准的结构,文案为白话重写;判定只作自我观察的参照,不是医学诊断。
18296
+ const { fail } = (__mods.fail || {});
18297
+
18298
+ const ITEMS = 5;
18299
+
18300
+ /** 九种体质:判定题 + 表现 + 调养方向。 */
18301
+ const CONSTITUTIONS = [
18302
+ {
18303
+ key: 'pinghe',
18304
+ name: '平和质',
18305
+ oneLine: '阴阳气血相对匀,吃得下、睡得着、恢复快。',
18306
+ trait: '精力够用,耐受冷热,情绪起伏不大,睡醒后能缓过来。',
18307
+ tendency: '这种底子最省心,重点是别自己把它折腾坏。',
18308
+ care: [
18309
+ '食:什么都能吃一点,别追一个味道到底;七分饱比补品有用。',
18310
+ '起居:睡点固定比睡够时长更关键,熬夜是最快的消耗方式。',
18311
+ '动:每周三四次微汗即可,不必练到力竭。',
18312
+ '情志:留一点「什么都不干」的时间,别把日程排满。',
18313
+ ],
18314
+ questions: [
18315
+ '你精力充沛,不太容易觉得累',
18316
+ '你能耐受冷也能耐受热,换季不太难受',
18317
+ '你胃口稳定,吃完不胀不反酸',
18318
+ '你睡得踏实,醒来精神能回来',
18319
+ '你情绪起伏不大,遇事能较快平复',
18320
+ ],
18321
+ },
18322
+ {
18323
+ key: 'qixu',
18324
+ name: '气虚质',
18325
+ oneLine: '气不够用:容易累、说话没劲、稍动就出汗。',
18326
+ trait: '容易疲乏、气短、说话声音低,活动后出汗多,容易感冒。',
18327
+ tendency: '不是懒,是电量掉得快;硬撑只会更空。',
18328
+ care: [
18329
+ '食:主食别减太狠,山药、小米、黄豆这类平补的东西比猛补更合适。',
18330
+ '起居:宁可早睡半小时,也别靠咖啡顶着;午间闭眼十分钟有用。',
18331
+ '动:散步、八段锦这类「不喘」的运动优先,别一上来就高强度。',
18332
+ '情志:少做长时间的强撑式社交,累了就直说。',
18333
+ ],
18334
+ questions: [
18335
+ '你容易疲乏,休息后也不太容易缓过来',
18336
+ '你说话声音偏低,说久了觉得没气',
18337
+ '你稍一活动就比别人容易出汗',
18338
+ '你容易感冒,或者感冒后拖得久',
18339
+ '你吃完饭容易腹胀、消化慢',
18340
+ ],
18341
+ },
18342
+ {
18343
+ key: 'yangxu',
18344
+ name: '阳虚质',
18345
+ oneLine: '火力不足:怕冷、手脚凉、喜欢热的。',
18346
+ trait: '畏寒怕冷、手脚发凉,吃凉的容易不舒服,大便偏稀,精神偏静。',
18347
+ tendency: '不是矫情,是产热不够;冷饮和空调直吹对这个体质最伤。',
18348
+ care: [
18349
+ '食:热的、熟的优先;姜、羊肉、桂圆可以吃,但别一顿猛补。',
18350
+ '起居:腰腹和脚要保暖,睡前泡脚比吃补药直接。',
18351
+ '动:白天动起来产热,晒背十分钟比闷在屋里强。',
18352
+ '情志:冬天与阴雨天情绪容易低,把户外时间挪到中午。',
18353
+ ],
18354
+ questions: [
18355
+ '你比一般人怕冷,别人不觉得冷时你已经要加衣服',
18356
+ '你的手脚常年偏凉',
18357
+ '你喝冷饮或吃凉的东西后容易不舒服',
18358
+ '你大便偏稀,或者一受凉就腹泻',
18359
+ '你精神偏静,不太想动',
18360
+ ],
18361
+ },
18362
+ {
18363
+ key: 'yinxu',
18364
+ name: '阴虚质',
18365
+ oneLine: '水不够:口干、手心热、睡不沉。',
18366
+ trait: '口燥咽干、手足心热、容易上火、睡浅多梦、大便偏干。',
18367
+ tendency: '不是火气大,是水少;越熬夜越明显。',
18368
+ care: [
18369
+ '食:银耳、百合、梨、黑芝麻这类润的;辛辣烧烤少碰。',
18370
+ '起居:熬夜对这个体质伤害最直接,十一点前躺下比什么方子都强。',
18371
+ '动:别在正午暴晒下猛练,选傍晚、选温和的。',
18372
+ '情志:容易急、容易烦,发现自己在赶时间就先停三分钟。',
18373
+ ],
18374
+ questions: [
18375
+ '你常觉得口干、咽干,想喝水',
18376
+ '你手足心发热,或者下午容易烘热',
18377
+ '你容易上火,口腔溃疡或脸上起东西',
18378
+ '你睡得不沉,多梦,容易醒',
18379
+ '你容易大便干结,排便不太顺畅',
18380
+ ],
18381
+ },
18382
+ {
18383
+ key: 'tanshi',
18384
+ name: '痰湿质',
18385
+ oneLine: '水湿停着走不动:身重、腹胀、容易困。',
18386
+ trait: '身体沉重、腹部松软肥满、容易困倦、口中黏腻、痰多。',
18387
+ tendency: '不是吃太多,是运化不动;久坐加甜腻会加重。',
18388
+ care: [
18389
+ '食:少甜少油少夜宵;薏米、赤小豆、冬瓜这类利湿的可以常吃。',
18390
+ '起居:别吃完就躺;房间保持干燥通风。',
18391
+ '动:出汗是重点,快走、跳绳这类能真出汗的优先。',
18392
+ '情志:困倦时容易拖,把要紧事放在上午做。',
18393
+ ],
18394
+ questions: [
18395
+ '你常觉得身体沉重、不清爽',
18396
+ '你腹部松软,容易胀',
18397
+ '你容易困倦,睡够了也昏沉',
18398
+ '你口中黏腻,或者痰偏多',
18399
+ '你脸上或头发容易出油',
18400
+ ],
18401
+ },
18402
+ {
18403
+ key: 'shire',
18404
+ name: '湿热质',
18405
+ oneLine: '又湿又热:脸油、口苦、容易长东西。',
18406
+ trait: '面垢油光、容易生痤疮、口苦口干、大便黏滞、小便偏黄。',
18407
+ tendency: '不是「火大」这么简单,湿裹着热,清得太猛反而伤脾。',
18408
+ care: [
18409
+ '食:少酒少辣少油炸;绿豆、冬瓜、苦瓜可以吃,但别长期只吃寒凉。',
18410
+ '起居:别熬夜加夜宵,这两样凑一起最容易出湿热。',
18411
+ '动:规律出汗,运动后及时擦干换衣。',
18412
+ '情志:急躁时先降一件事的优先级,别同时开五个头。',
18413
+ ],
18414
+ questions: [
18415
+ '你脸上或头皮容易出油',
18416
+ '你容易长痘、长疖子或起湿疹',
18417
+ '你常觉得口苦或口干',
18418
+ '你大便黏滞,不容易冲干净',
18419
+ '你的小便颜色偏黄、量偏少',
18420
+ ],
18421
+ },
18422
+ {
18423
+ key: 'xueyu',
18424
+ name: '血瘀质',
18425
+ oneLine: '血行不畅:肤色暗、易有瘀青、痛处固定。',
18426
+ trait: '面色偏暗、口唇偏紫、容易出现瘀斑、身体某处固定刺痛。',
18427
+ tendency: '久坐不动和长期受寒是两大来源。',
18428
+ care: [
18429
+ '食:山楂、黑木耳、玫瑰花这类活血的可以常吃;经期量多时别过量。',
18430
+ '起居:别久坐,每小时起来走两分钟。',
18431
+ '动:有氧为主,练到微微出汗;拉伸比猛练更对症。',
18432
+ '情志:闷着不说话最容易瘀,找个人把话说完。',
18433
+ ],
18434
+ questions: [
18435
+ '你面色或口唇偏暗、偏紫',
18436
+ '你身上容易出现瘀青,磕一下就好久不退',
18437
+ '你身体某处有固定的刺痛或胀痛',
18438
+ '你皮肤偏干,或者容易起红血丝',
18439
+ '你记性下降,或者常常觉得「脑子转不动」',
18440
+ ],
18441
+ },
18442
+ {
18443
+ key: 'qiyu',
18444
+ name: '气郁质',
18445
+ oneLine: '气堵着不顺:胸闷、爱叹气、情绪闷。',
18446
+ trait: '情绪低沉、容易紧张焦虑、胸闷叹气、咽部有异物感、睡不安稳。',
18447
+ tendency: '不是想太多,是气机不通;长期憋着会连带影响睡眠和胃口。',
18448
+ care: [
18449
+ '食:少食多餐,别用吃东西压情绪;玫瑰花、陈皮、佛手可以泡水。',
18450
+ '起居:把「必须做」的事减到三件以内,留出空档。',
18451
+ '动:唱歌、快走、拉伸这类能「把气呼出去」的最有用。',
18452
+ '情志:写下来比在脑子里绕有效,哪怕只写三行。',
18453
+ ],
18454
+ questions: [
18455
+ '你常觉得闷闷不乐,情绪提不起来',
18456
+ '你容易紧张、焦虑,或者心里放不下事',
18457
+ '你常叹气,觉得胸口不畅',
18458
+ '你咽喉像有东西堵着,吞不下也吐不出',
18459
+ '你睡不安稳,容易醒或者多梦',
18460
+ ],
18461
+ },
18462
+ {
18463
+ key: 'tebing',
18464
+ name: '特禀质',
18465
+ oneLine: '对外界刺激反应大:过敏、易起反应。',
18466
+ trait: '容易过敏(鼻炎、皮肤、食物)、对季节与气味敏感、易起荨麻疹。',
18467
+ tendency: '不是体质弱,是反应阈值低;避开诱因比「增强体质」更直接。',
18468
+ care: [
18469
+ '食:把自己的过敏原记下来,比记一堆「该吃什么」有用。',
18470
+ '起居:换季前把床品洗晒一遍,花粉季回家先洗脸。',
18471
+ '动:室内温和运动为主,避开花粉浓度高的时段户外跑。',
18472
+ '情志:反复发作容易烦,把「避免诱因」当成日常动作而不是任务。',
18473
+ ],
18474
+ questions: [
18475
+ '你容易打喷嚏、流清涕,或者鼻子痒',
18476
+ '你皮肤容易起荨麻疹、湿疹或红疹',
18477
+ '你对某些食物或药物有过敏反应',
18478
+ '你对季节变化、气味或灰尘特别敏感',
18479
+ '你换环境后容易不舒服(比如新房间、新城市)',
18480
+ ],
18481
+ },
18482
+ ];
18483
+
18484
+ /** 题目扁平成一张卷:每题带所属体质,顺序按体质分组。 */
18485
+ const QUESTIONS = CONSTITUTIONS.flatMap((c) => c.questions.map((q, i) => ({ i: c.key + '-' + (i + 1), key: c.key, name: c.name, text: q })));
18486
+
18487
+ const SCALE_OPTIONS = [
18488
+ { v: 1, label: '没有' },
18489
+ { v: 2, label: '很少' },
18490
+ { v: 3, label: '有时' },
18491
+ { v: 4, label: '经常' },
18492
+ { v: 5, label: '总是' },
18493
+ ];
18494
+
18495
+ /** 转化分:0–100,保留一位小数。 */
18496
+ function converted(raw, n) {
18497
+ return Math.round(((raw - n) / (n * 4)) * 1000) / 10;
18498
+ }
18499
+
18500
+ function levelOf(conv, isPinghe) {
18501
+ if (isPinghe) return conv >= 60 ? '是' : conv >= 50 ? '倾向是' : '否';
18502
+ return conv >= 40 ? '是' : conv >= 30 ? '倾向是' : '否';
18503
+ }
18504
+
18505
+ /** 卷子:不带 answers 时先给题,别让模型替人打分。 */
18506
+ function constitutionPaper() {
18507
+ return {
18508
+ system: 'constitution',
18509
+ paper: true,
18510
+ total: QUESTIONS.length,
18511
+ scaleCount: CONSTITUTIONS.length,
18512
+ itemsPerScale: ITEMS,
18513
+ scale: SCALE_OPTIONS,
18514
+ scales: CONSTITUTIONS.map((c) => ({ key: c.key, name: c.name, oneLine: c.oneLine, from: QUESTIONS.findIndex((q) => q.key === c.key) })),
18515
+ questions: QUESTIONS.map((q, i) => ({ no: i + 1, key: q.key, name: q.name, text: q.text })),
18516
+ howto: '按最近一年的真实感受给每题打 1–5 分(1 没有 / 2 很少 / 3 有时 / 4 经常 / 5 总是),'
18517
+ + '拿不准的题传 null 跳过;把分数按题目顺序填成 answers 数组再调一次。',
18518
+ note: '九种体质各 5 题,共 45 题。这是按通行标准结构做的简化问卷,不是量表原件;结果只作自我观察的参照。',
18519
+ };
18520
+ }
18521
+
18522
+ /**
18523
+ * 体质判定。
18524
+ * @param {{answers?: Array<number|null>}} input answers 按题目顺序的 1–5 分,null 表示跳过
18525
+ */
18526
+ function buildConstitution(input) {
18527
+ const answers = input && Array.isArray(input.answers) ? input.answers : null;
18528
+ if (!answers) throw fail('E_INPUT', '体质判定需要 answers(按题目顺序的 1–5 分);先不带 answers 调一次拿问卷');
18529
+ const total = QUESTIONS.length;
18530
+ // 长度不匹配是最常见的错:短一位就会整体错位,静默按「第 N 题」算下去更糟
18531
+ if (answers.length !== total) {
18532
+ throw fail('E_INPUT', `answers 要 ${total} 个(九种体质各 5 题),收到 ${answers.length} 个;跳过的题传 null,不要缩短数组`);
18533
+ }
18534
+ const seen = answers.map((v, i) => {
18535
+ if (v === null || v === undefined) return null;
18536
+ const n = Number(v);
18537
+ if (!Number.isInteger(n) || n < 1 || n > 5) {
18538
+ throw fail('E_INPUT', `第 ${i + 1} 题的分数要是 1–5 的整数或 null,收到「${String(v).slice(0, 16)}」`);
18539
+ }
18540
+ return n;
18541
+ });
18542
+ const answered = seen.filter((x) => x !== null).length;
18543
+ if (!answered) throw fail('E_INPUT', 'answers 全为 null:至少答几题才能判体质,否则每种都是同一个分数');
18544
+
18545
+ const scales = CONSTITUTIONS.map((c) => {
18546
+ const idx = [];
18547
+ QUESTIONS.forEach((q, i) => { if (q.key === c.key) idx.push(i); });
18548
+ const vals = idx.map((i) => seen[i]).filter((x) => x !== null);
18549
+ // 未答的题按「3(有时)」补齐:直接丢题会抬高转化分,把没答的人判成更偏
18550
+ const raw = idx.reduce((sum, i) => sum + (seen[i] === null ? 3 : seen[i]), 0);
18551
+ const conv = converted(raw, idx.length);
18552
+ return {
18553
+ key: c.key,
18554
+ name: c.name,
18555
+ oneLine: c.oneLine,
18556
+ trait: c.trait,
18557
+ tendency: c.tendency,
18558
+ care: c.care.slice(),
18559
+ answered: vals.length,
18560
+ skipped: idx.length - vals.length,
18561
+ raw,
18562
+ max: idx.length * 5,
18563
+ converted: conv,
18564
+ level: levelOf(conv, c.key === 'pinghe'),
18565
+ };
18566
+ });
18567
+
18568
+ const pinghe = scales.find((s) => s.key === 'pinghe');
18569
+ const biased = scales.filter((s) => s.key !== 'pinghe').slice().sort((a, b) => b.converted - a.converted);
18570
+ const others = biased.map((s) => s.converted);
18571
+ const othersMax = others.length ? Math.max(...others) : 0;
18572
+ // 平和质按标准要「其余八种都很低」才算成立:光自己分高不算
18573
+ let pingheLevel = pinghe.level;
18574
+ if (pinghe.converted >= 60) {
18575
+ if (othersMax < 30) pingheLevel = '是';
18576
+ else if (othersMax < 40) pingheLevel = '基本是';
18577
+ else pingheLevel = '否';
18578
+ }
18579
+ const pingheFinal = { ...pinghe, level: pingheLevel };
18580
+
18581
+ const primaryBiased = biased.find((s) => s.level === '是') || biased[0];
18582
+ const hasBiased = biased.some((s) => s.level === '是' || s.level === '倾向是');
18583
+ const isPinghe = pingheLevel === '是' || pingheLevel === '基本是';
18584
+ const primary = isPinghe && !biased.some((s) => s.level === '是') ? pingheFinal : primaryBiased;
18585
+ const secondary = biased.find((s) => s.key !== primary.key && s.converted >= 30) || null;
18586
+ const mingled = biased.filter((s) => s.level === '是' && s.key !== primary.key && (!secondary || s.key !== secondary.key)).map((s) => s.name);
18587
+
18588
+ // 全选同一项等于没区分:转化分会整齐地落在 50 上下,把人判成「九种都偏」。
18589
+ // 这种卷子只提示、不改判定门槛——门槛一改就和公开标准的口径分叉了。
18590
+ const vals = seen.filter((x) => x !== null);
18591
+ const flat = vals.length >= 10 && new Set(vals).size === 1;
18592
+ const main = primary.key === 'pinghe' ? pingheFinal : primary;
18593
+ return {
18594
+ system: 'constitution',
18595
+ meta: { answered, total, scaleCount: CONSTITUTIONS.length, itemsPerScale: ITEMS, scale: SCALE_OPTIONS },
18596
+ scales: scales.map((s) => (s.key === 'pinghe' ? { ...s, level: pingheLevel } : s)),
18597
+ primary: { key: main.key, name: main.name, converted: main.converted, level: main.level, oneLine: main.oneLine, trait: main.trait, tendency: main.tendency },
18598
+ secondary: secondary ? { key: secondary.key, name: secondary.name, converted: secondary.converted, level: secondary.level } : null,
18599
+ mingled,
18600
+ pinghe: { converted: pinghe.converted, level: pingheLevel },
18601
+ balanced: isPinghe && !biased.some((s) => s.level === '是'),
18602
+ hasTendency: hasBiased,
18603
+ flat,
18604
+ care: main.care.slice(),
18605
+ top: biased.slice(0, 3).map((s) => ({ name: s.name, converted: s.converted, level: s.level })),
18606
+ note: '九种体质各 5 题的简化问卷,转化分按 [(原始分 − 题数) / (题数 × 4)] × 100 换算;'
18607
+ + '偏颇质 ≥40 判「是」、30–39 判「有倾向」,平和质要 ≥60 且其余八种都低才成立。'
18608
+ + '体质是长期倾向,不是当下病名;有具体不适请就医,这份结果不与八字、星座互证。'
18609
+ + (flat ? ' 本次每题都选了同一个选项,问卷对你的区分度不足,主次体质仅供参考。' : ''),
18610
+ };
18611
+ }
18612
+ return { CONSTITUTIONS, QUESTIONS, SCALE_OPTIONS, constitutionPaper, buildConstitution };
18613
+ })();
18614
+
18167
18615
  __mods.chenggu = (function () {
18168
18616
  // 袁天罡称骨:按农历年/月/日/时辰四柱骨重求和,查通行本《称骨歌》原文,附白话释义。
18169
18617
  // 骨重表与歌诀均为通行本口径(各版本有异文,此处照录通行本);释义是白话自撰,不是原文。
@@ -18380,6 +18828,654 @@ function buildChenggu(input) {
18380
18828
  return { YEAR_WEIGHT, MONTH_WEIGHT, DAY_WEIGHT, HOUR_WEIGHT, formatWeight, lunarDayText, lunarMonthText, CHENGGU_SONG, songOf, buildChenggu };
18381
18829
  })();
18382
18830
 
18831
+ __mods.wuyunliuqi = (function () {
18832
+ // 五运六气(运气学说)· 本地确定性 · 通行口径
18833
+ //
18834
+ // 提供:运气年(大寒界)、年干支、岁运(中运)太过/不及、司天与在泉、主运/客运五步、
18835
+ // 主气/客气六步、当前所处的运与气、天符/岁会/同天符/同岁会/太乙天符,以及气候与病位倾向。
18836
+ //
18837
+ // 诚实边界:本文件只做「按规则推盘」这一层。运气学说里的方药、运气合参、
18838
+ // 《素问·六元正纪大论》六十年逐年全文与「刚柔失守」「升降不前」等推演未收录(见 scope)。
18839
+ // 病位与气候倾向照传统说法转述,是象征与经验归纳,不是医学判断。
18840
+ const { fail } = (__mods.fail || {});
18841
+ const { STEMS, BRANCHES, termInstantJd, ganzhiName, formatJd } = (__mods.calendar || {});
18842
+ const { assertCivil, daysInMonth } = (__mods.validate || {});
18843
+
18844
+ /** 主气六步的固定次序(《素问·六微旨大论》):厥阴 → 少阴 → 少阳 → 太阴 → 阳明 → 太阳。 */
18845
+ const ZHU_QI_ORDER = ['厥阴风木', '少阴君火', '少阳相火', '太阴湿土', '阳明燥金', '太阳寒水'];
18846
+
18847
+ /** 客气(三阴三阳)的轮转次序:厥阴 → 少阴 → 太阴 → 少阳 → 阳明 → 太阳。 */
18848
+ const QI_ORDER = ['厥阴风木', '少阴君火', '太阴湿土', '少阳相火', '阳明燥金', '太阳寒水'];
18849
+
18850
+ /** 主运五步的固定次序:木(角)→ 火(徵)→ 土(宫)→ 金(商)→ 水(羽)。 */
18851
+ const ZHU_YUN_ORDER = ['木', '火', '土', '金', '水'];
18852
+
18853
+ /** 天干化五运(《素问·天元纪大论》):甲己化土、乙庚化金、丙辛化水、丁壬化木、戊癸化火。 */
18854
+ const YUN_OF_STEM = {
18855
+ 甲: '土', 己: '土',
18856
+ 乙: '金', 庚: '金',
18857
+ 丙: '水', 辛: '水',
18858
+ 丁: '木', 壬: '木',
18859
+ 戊: '火', 癸: '火',
18860
+ };
18861
+
18862
+ /** 地支化六气之「司天」(子午少阴、丑未太阴、寅申少阳、卯酉阳明、辰戌太阳、巳亥厥阴)。 */
18863
+ const SITIAN_INDEX_OF_BRANCH = [1, 2, 3, 4, 5, 0];
18864
+
18865
+ /** 五运的乐音与所主脏腑、气候(传统对应,只作命名与取象)。 */
18866
+ const YUN_INFO = {
18867
+ 木: { tone: '角', organ: '肝', nature: '风', text: '木运' },
18868
+ 火: { tone: '徵', organ: '心', nature: '热', text: '火运' },
18869
+ 土: { tone: '宫', organ: '脾', nature: '湿', text: '土运' },
18870
+ 金: { tone: '商', organ: '肺', nature: '燥', text: '金运' },
18871
+ 水: { tone: '羽', organ: '肾', nature: '寒', text: '水运' },
18872
+ };
18873
+
18874
+ /** 六气所主脏腑与气候。 */
18875
+ const QI_INFO = {
18876
+ 厥阴风木: { wuxing: '木', organ: '肝', nature: '风', short: '风木' },
18877
+ 少阴君火: { wuxing: '火', organ: '心', nature: '热', short: '君火' },
18878
+ 太阴湿土: { wuxing: '土', organ: '脾', nature: '湿', short: '湿土' },
18879
+ 少阳相火: { wuxing: '火', organ: '三焦', nature: '火', short: '相火' },
18880
+ 阳明燥金: { wuxing: '金', organ: '肺', nature: '燥', short: '燥金' },
18881
+ 太阳寒水: { wuxing: '水', organ: '肾', nature: '寒', short: '寒水' },
18882
+ };
18883
+
18884
+ /**
18885
+ * 岁运太过/不及的取象:太过则本气偏胜、克其所胜;不及则本气不足、所不胜乘之。
18886
+ * 每条都只写「传统上常这样描述」,不写「必然出现」。
18887
+ */
18888
+ const YUN_EFFECT = {
18889
+ 木: {
18890
+ tai: { climate: '风气偏胜,草木早荣', organ: '肝气偏旺,脾土受克', sign: '易怒、胁胀、食少、肢体震颤' },
18891
+ bu: { climate: '风气不足,燥气乘之', organ: '肝木不及,肺金偏胜', sign: '筋脉拘急、目干、情绪低落' },
18892
+ },
18893
+ 火: {
18894
+ tai: { climate: '火气偏胜,暑热早至', organ: '心火偏旺,肺金受克', sign: '心烦、失眠、咳喘、口疮' },
18895
+ bu: { climate: '火气不足,寒气乘之', organ: '心火不及,肾水偏胜', sign: '心悸、畏寒、胸闷、精神不振' },
18896
+ },
18897
+ 土: {
18898
+ tai: { climate: '雨湿偏多,土气壅滞', organ: '脾湿偏胜,肾水受克', sign: '腹胀、身重、大便黏、四肢困倦' },
18899
+ bu: { climate: '土气不足,风气乘之', organ: '脾土不及,肝木偏胜', sign: '飧泄、乏力、腹鸣、食后胀' },
18900
+ },
18901
+ 金: {
18902
+ tai: { climate: '燥气偏胜,天气清凉', organ: '肺燥偏胜,肝木受克', sign: '干咳、鼻干、皮肤干、筋急' },
18903
+ bu: { climate: '金气不足,火气乘之', organ: '肺金不及,心火偏胜', sign: '咳逆、鼻塞、咽干、易感外邪' },
18904
+ },
18905
+ 水: {
18906
+ tai: { climate: '寒气偏胜,冰霜早降', organ: '肾寒偏胜,心火受克', sign: '畏寒、水肿、腰冷、心悸' },
18907
+ bu: { climate: '水气不足,湿土乘之', organ: '肾水不及,脾土偏胜', sign: '腰膝无力、小便不利、下肢沉' },
18908
+ },
18909
+ };
18910
+
18911
+ /** 六气司天时传统上说的气候与病位(《素问·五常政大论》《至真要大论》通行转述)。 */
18912
+ const QI_CLIMATE = {
18913
+ 厥阴风木: { climate: '风气流行,草木动摇', sign: '眩晕、抽搐、胁痛、过敏类症状偏多' },
18914
+ 少阴君火: { climate: '热气偏胜,暑热早来', sign: '心烦、口疮、失眠、咽痛偏多' },
18915
+ 太阴湿土: { climate: '湿气偏胜,阴雨多', sign: '腹胀、身重、泄泻、关节酸沉偏多' },
18916
+ 少阳相火: { climate: '火气偏胜,炎热', sign: '头痛、目赤、耳鸣、疮疡偏多' },
18917
+ 阳明燥金: { climate: '燥气偏胜,干燥少雨', sign: '干咳、鼻干、皮肤裂、便秘偏多' },
18918
+ 太阳寒水: { climate: '寒气偏胜,霜雪多', sign: '畏寒、关节痛、腹痛、水肿偏多' },
18919
+ };
18920
+
18921
+ /** 本文件覆盖到哪一层。 */
18922
+ const WUYUNLIUQI_SCOPE = {
18923
+ covered: [
18924
+ '运气年(大寒为界)与年干支',
18925
+ '岁运(中运)五行与太过/不及、五音太少',
18926
+ '司天、在泉',
18927
+ '主运五步与客运五步(含每步起止时刻)',
18928
+ '主气六步与客气六步(含每步起止时刻)',
18929
+ '当前时刻所处的运与气',
18930
+ '天符 / 岁会 / 同天符 / 同岁会 / 太乙天符',
18931
+ ],
18932
+ notCovered: [
18933
+ '《素问·六元正纪大论》六十年逐年全文与「先立其年,以明其气」的逐气病证条辨',
18934
+ '刚柔失守、升降不前、不迁正、不退位等岁气交司推演',
18935
+ '运气合参的方药与针灸取穴',
18936
+ '地域与体质差异对运气的修正',
18937
+ ],
18938
+ note: '病位、气候与症状倾向都是传统说法的转述,用来对照季节与身体感受,不是诊断结论。',
18939
+ };
18940
+
18941
+ const YUN_DAYS = 365.25 / 5; // 73.05 日一运
18942
+ const QI_DAYS = 365.25 / 6; // 60.875 日一气
18943
+
18944
+ function stemIndex(stem) {
18945
+ const i = STEMS.indexOf(stem);
18946
+ if (i < 0) throw fail('E_INPUT', `天干「${stem}」不在十干之内`);
18947
+ return i;
18948
+ }
18949
+
18950
+ function branchIndex(branch) {
18951
+ const i = BRANCHES.indexOf(branch);
18952
+ if (i < 0) throw fail('E_INPUT', `地支「${branch}」不在十二支之内`);
18953
+ return i;
18954
+ }
18955
+
18956
+ /** 公历年 → 该年干支(甲子起 1984,年界按立春,与八字侧同源)。 */
18957
+ function yearGanzhiOf(year) {
18958
+ const idx = (((year - 4) % 60) + 60) % 60;
18959
+ return ganzhiName(idx);
18960
+ }
18961
+
18962
+ /** 中运(岁运):由年干化运,阳干太过、阴干不及。 */
18963
+ function zhongyunOf(stem) {
18964
+ const si = stemIndex(stem);
18965
+ const wuxing = YUN_OF_STEM[stem];
18966
+ const tai = si % 2 === 0; // 甲丙戊庚壬为阳干(索引 0/2/4/6/8)
18967
+ const info = YUN_INFO[wuxing];
18968
+ return {
18969
+ gan: stem,
18970
+ wuxing,
18971
+ tone: info.tone,
18972
+ tai,
18973
+ name: `${wuxing}运${tai ? '太过' : '不及'}`,
18974
+ taiShao: `${tai ? '太' : '少'}${info.tone}`,
18975
+ organ: info.organ,
18976
+ nature: info.nature,
18977
+ };
18978
+ }
18979
+
18980
+ /** 司天与在泉:由年支定,在泉为司天的对宫(相隔三步)。 */
18981
+ function sitianOf(branch) {
18982
+ const bi = branchIndex(branch);
18983
+ const sitIdx = SITIAN_INDEX_OF_BRANCH[bi % 6];
18984
+ const zaiIdx = (sitIdx + 3) % 6;
18985
+ return {
18986
+ sitIdx,
18987
+ zaiIdx,
18988
+ sitian: QI_ORDER[sitIdx],
18989
+ zaiquan: QI_ORDER[zaiIdx],
18990
+ };
18991
+ }
18992
+
18993
+ /** 主运五步:初运木,按相生序,每步 73.05 日,从大寒起算。 */
18994
+ function zhuYunSteps(startJd) {
18995
+ return ZHU_YUN_ORDER.map((wuxing, i) => {
18996
+ const info = YUN_INFO[wuxing];
18997
+ const start = startJd + i * YUN_DAYS;
18998
+ const end = start + YUN_DAYS;
18999
+ return {
19000
+ no: i + 1,
19001
+ wuxing,
19002
+ name: `${wuxing}运`,
19003
+ tone: info.tone,
19004
+ taiShao: `太${info.tone}`,
19005
+ startJd: start,
19006
+ endJd: end,
19007
+ startTime: formatJd(start),
19008
+ endTime: formatJd(end),
19009
+ days: Math.round(YUN_DAYS * 100) / 100,
19010
+ };
19011
+ });
19012
+ }
19013
+
19014
+ /** 客运五步:以岁运为初运,按五行相生轮转,太少相生(太过生不及、不及生太过)。 */
19015
+ function keYunSteps(zhongyun, startJd) {
19016
+ const startIdx = ZHU_YUN_ORDER.indexOf(zhongyun.wuxing);
19017
+ return Array.from({ length: 5 }, (_, i) => {
19018
+ const wuxing = ZHU_YUN_ORDER[(startIdx + i) % 5];
19019
+ const info = YUN_INFO[wuxing];
19020
+ const tai = i % 2 === 0 ? zhongyun.tai : !zhongyun.tai;
19021
+ const start = startJd + i * YUN_DAYS;
19022
+ const end = start + YUN_DAYS;
19023
+ return {
19024
+ no: i + 1,
19025
+ wuxing,
19026
+ tai,
19027
+ name: `${wuxing}运${tai ? '太过' : '不及'}`,
19028
+ tone: info.tone,
19029
+ taiShao: `${tai ? '太' : '少'}${info.tone}`,
19030
+ startTime: formatJd(start),
19031
+ endTime: formatJd(end),
19032
+ };
19033
+ });
19034
+ }
19035
+
19036
+ /** 主气六步:初之气厥阴风木,按固定次序,每步 60.875 日。 */
19037
+ function zhuQiSteps(startJd) {
19038
+ return ZHU_QI_ORDER.map((qi, i) => {
19039
+ const info = QI_INFO[qi];
19040
+ const start = startJd + i * QI_DAYS;
19041
+ const end = start + QI_DAYS;
19042
+ return {
19043
+ no: i + 1,
19044
+ qi,
19045
+ wuxing: info.wuxing,
19046
+ organ: info.organ,
19047
+ nature: info.nature,
19048
+ name: `${qi}`,
19049
+ startTime: formatJd(start),
19050
+ endTime: formatJd(end),
19051
+ days: Math.round(QI_DAYS * 100) / 100,
19052
+ };
19053
+ });
19054
+ }
19055
+
19056
+ /** 客气六步:三之气为司天、终之气为在泉,初之气由司天倒退两步。 */
19057
+ function keQiSteps(sitIdx, startJd) {
19058
+ const first = ((sitIdx - 2) % 6 + 6) % 6;
19059
+ return Array.from({ length: 6 }, (_, i) => {
19060
+ const qi = QI_ORDER[(first + i) % 6];
19061
+ const info = QI_INFO[qi];
19062
+ const start = startJd + i * QI_DAYS;
19063
+ const end = start + QI_DAYS;
19064
+ return {
19065
+ no: i + 1,
19066
+ qi,
19067
+ wuxing: info.wuxing,
19068
+ organ: info.organ,
19069
+ nature: info.nature,
19070
+ name: qi,
19071
+ sitian: i === 2,
19072
+ zaiquan: i === 5,
19073
+ startTime: formatJd(start),
19074
+ endTime: formatJd(end),
19075
+ };
19076
+ });
19077
+ }
19078
+
19079
+ /** 年支的「本位五行」:子水午火卯木酉金、辰戌丑未土;寅申巳亥为长生位,不参与岁会。 */
19080
+ const BRANCH_BENWEI = {
19081
+ 子: '水', 午: '火', 卯: '木', 酉: '金',
19082
+ 辰: '土', 戌: '土', 丑: '土', 未: '土',
19083
+ 寅: null, 申: null, 巳: null, 亥: null,
19084
+ };
19085
+
19086
+ /**
19087
+ * 五运六气的「运气年」:以大寒为界(不是立春、不是正月初一)。
19088
+ * 交大寒之前仍算上一运气年。
19089
+ */
19090
+ function yunYearOf(year, jd) {
19091
+ const daHan = termInstantJd(year, 1);
19092
+ if (jd >= daHan) {
19093
+ return { nian: year, startJd: daHan, endJd: termInstantJd(year + 1, 1) };
19094
+ }
19095
+ return { nian: year - 1, startJd: termInstantJd(year - 1, 1), endJd: daHan };
19096
+ }
19097
+
19098
+ /** 特殊格局:天符 / 岁会 / 同天符 / 同岁会 / 太乙天符。 */
19099
+ function specialOf(zhongyun, sitian, zaiquan, branch) {
19100
+ const yunWx = zhongyun.wuxing;
19101
+ const sitWx = QI_INFO[sitian].wuxing;
19102
+ const zaiWx = QI_INFO[zaiquan].wuxing;
19103
+ const benwei = BRANCH_BENWEI[branch] || null;
19104
+ const tianFu = yunWx === sitWx;
19105
+ const suiHui = !!benwei && yunWx === benwei;
19106
+ const tongTianFu = zhongyun.tai && yunWx === zaiWx;
19107
+ const tongSuiHui = !zhongyun.tai && yunWx === zaiWx;
19108
+ const taiYi = tianFu && suiHui;
19109
+ const notes = [];
19110
+ if (taiYi) {
19111
+ notes.push('太乙天符:中运与司天同气、又与年支本位同气,传统上视为六十年里最「重」的一类年份之一,气候与病气的变化都容易被放大。');
19112
+ } else if (tianFu) {
19113
+ notes.push('天符:中运与司天同气。传统上说这一年本气专一而偏胜,气候变化来得整齐。');
19114
+ }
19115
+ if (suiHui) {
19116
+ notes.push('岁会:中运与年支本位同气。传统上说这一年气令与地域相应,来得比较「正」。');
19117
+ }
19118
+ if (tongTianFu) {
19119
+ notes.push('同天符:阳年太过之运与在泉同气,下半年(在泉主事)容易延续上半年的偏胜。');
19120
+ }
19121
+ if (tongSuiHui) {
19122
+ notes.push('同岁会:阴年不及之运与在泉同气,本气本已不足,下半年又被同一气拖住,恢复更慢。');
19123
+ }
19124
+ return { tianFu, suiHui, tongTianFu, tongSuiHui, taiYi, notes };
19125
+ }
19126
+
19127
+ /**
19128
+ * 排一年的五运六气。
19129
+ * @param {{year:number, month:number, day:number, hour?:number, minute?:number}} input
19130
+ */
19131
+ function buildWuyunliuqi(input) {
19132
+ assertCivil(input, '五运六气');
19133
+ const { year, month, day } = input;
19134
+ if (day > daysInMonth(year, month)) {
19135
+ throw fail('E_INPUT', `非法日期:${year} 年 ${month} 月没有 ${day} 日`);
19136
+ }
19137
+ const hour = Number.isInteger(input.hour) ? input.hour : 12;
19138
+ const minute = Number.isInteger(input.minute) ? input.minute : 0;
19139
+ if (hour < 0 || hour > 23) throw fail('E_INPUT', 'hour 要在 0–23 之间');
19140
+ if (minute < 0 || minute > 59) throw fail('E_INPUT', 'minute 要在 0–59 之间');
19141
+
19142
+ // 当日正午的 jd 作参考点;有具体时刻时用它,判断「当前在第几气」更准
19143
+ const jd = termInstantJd(year, 1) * 0
19144
+ + (function () {
19145
+ // 用公历日期构造本地时间轴 jd:以当日 00:00 为整数、按 24 小时折算
19146
+ const jdn = (function (y, m, d) {
19147
+ const a = Math.floor((14 - m) / 12);
19148
+ const yy = y + 4800 - a;
19149
+ const mm = m + 12 * a - 3;
19150
+ return d + Math.floor((153 * mm + 2) / 5) + 365 * yy + Math.floor(yy / 4)
19151
+ - Math.floor(yy / 100) + Math.floor(yy / 400) - 32045;
19152
+ })(year, month, day);
19153
+ return jdn - 0.5 + (hour * 60 + minute) / 1440;
19154
+ })();
19155
+
19156
+ const yun = yunYearOf(year, jd);
19157
+ const gz = yearGanzhiOf(yun.nian);
19158
+ const zhongyun = zhongyunOf(gz.stem);
19159
+ const sit = sitianOf(gz.branch);
19160
+
19161
+ const zhuYun = zhuYunSteps(yun.startJd);
19162
+ const keYun = keYunSteps(zhongyun, yun.startJd);
19163
+ const zhuQi = zhuQiSteps(yun.startJd);
19164
+ const keQi = keQiSteps(sit.sitIdx, yun.startJd);
19165
+
19166
+ const dayInYear = jd - yun.startJd;
19167
+ const yunNo = Math.min(5, Math.max(1, Math.floor(dayInYear / YUN_DAYS) + 1));
19168
+ const qiNo = Math.min(6, Math.max(1, Math.floor(dayInYear / QI_DAYS) + 1));
19169
+ const curZhuYun = zhuYun[yunNo - 1];
19170
+ const curKeYun = keYun[yunNo - 1];
19171
+ const curZhuQi = zhuQi[qiNo - 1];
19172
+ const curKeQi = keQi[qiNo - 1];
19173
+
19174
+ const eff = YUN_EFFECT[zhongyun.wuxing][zhongyun.tai ? 'tai' : 'bu'];
19175
+ const sitClimate = QI_CLIMATE[sit.sitian];
19176
+ const zaiClimate = QI_CLIMATE[sit.zaiquan];
19177
+ const special = specialOf(zhongyun, sit.sitian, sit.zaiquan, gz.branch);
19178
+
19179
+ // 客气加临主气:只看「同气 / 相生 / 相克」,给一层很轻的提示,不做病证条辨
19180
+ const curKeQx = QI_INFO[curKeQi.qi].wuxing;
19181
+ const curZhuQx = QI_INFO[curZhuQi.qi].wuxing;
19182
+ const sheng = { 木: '火', 火: '土', 土: '金', 金: '水', 水: '木' };
19183
+ const ke = { 木: '土', 土: '水', 水: '火', 火: '金', 金: '木' };
19184
+ let lin = '相安';
19185
+ if (curKeQx === curZhuQx) lin = '同气';
19186
+ else if (sheng[curKeQx] === curZhuQx) lin = '客生主(顺)';
19187
+ else if (sheng[curZhuQx] === curKeQx) lin = '主生客(泄)';
19188
+ else if (ke[curKeQx] === curZhuQx) lin = '客克主(逆)';
19189
+ else if (ke[curZhuQx] === curKeQx) lin = '主克客(从)';
19190
+
19191
+ const comboName = `${sit.sitian}司天 · ${zhongyun.name}`;
19192
+ const comboText = `这一运气年是「${gz.name}」年:中运${zhongyun.name}(${zhongyun.taiShao}),${sit.sitian}司天、${sit.zaiquan}在泉。`
19193
+ + `传统上把这样的组合读成「${eff.climate}」,司天一侧偏「${sitClimate.climate}」,在泉一侧偏「${zaiClimate.climate}」。`;
19194
+
19195
+ return {
19196
+ system: 'wuyunliuqi',
19197
+ meta: {
19198
+ datetime: `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`,
19199
+ nian: yun.nian,
19200
+ ganzhi: gz.name,
19201
+ stem: gz.stem,
19202
+ branch: gz.branch,
19203
+ isYang: zhongyun.tai,
19204
+ startTime: formatJd(yun.startJd),
19205
+ endTime: formatJd(yun.endJd),
19206
+ dayInYear: Math.round(dayInYear * 100) / 100,
19207
+ },
19208
+ year: {
19209
+ ganzhi: gz.name,
19210
+ stem: gz.stem,
19211
+ branch: gz.branch,
19212
+ nian: yun.nian,
19213
+ startTime: formatJd(yun.startJd),
19214
+ endTime: formatJd(yun.endJd),
19215
+ note: '运气年以大寒为界:交大寒之前仍算上一运气年,与八字按立春定年柱不同。',
19216
+ },
19217
+ zhongyun: {
19218
+ ...zhongyun,
19219
+ effect: eff,
19220
+ text: `中运${zhongyun.name}(${zhongyun.taiShao}),主${zhongyun.organ},气候偏${zhongyun.nature}。`,
19221
+ },
19222
+ sitian: {
19223
+ qi: sit.sitian,
19224
+ ...QI_INFO[sit.sitian],
19225
+ climate: sitClimate,
19226
+ text: `${sit.sitian}司天:上半年(三之气起)主事,气候偏${QI_INFO[sit.sitian].nature}。`,
19227
+ },
19228
+ zaiquan: {
19229
+ qi: sit.zaiquan,
19230
+ ...QI_INFO[sit.zaiquan],
19231
+ climate: zaiClimate,
19232
+ text: `${sit.zaiquan}在泉:下半年(终之气)主事,气候偏${QI_INFO[sit.zaiquan].nature}。`,
19233
+ },
19234
+ zhuYun,
19235
+ keYun,
19236
+ zhuQi,
19237
+ keQi,
19238
+ current: {
19239
+ dayInYear: Math.round(dayInYear * 100) / 100,
19240
+ yunNo,
19241
+ qiNo,
19242
+ zhuYun: curZhuYun,
19243
+ keYun: curKeYun,
19244
+ zhuQi: curZhuQi,
19245
+ keQi: curKeQi,
19246
+ lin,
19247
+ text: `此刻在第 ${yunNo} 运(主运${curZhuYun.name} / 客运${curKeYun.name})、第 ${qiNo} 气(主气${curZhuQi.qi} / 客气${curKeQi.qi},客气加临主气:${lin})。`,
19248
+ },
19249
+ special,
19250
+ combo: { name: comboName, text: comboText },
19251
+ scope: WUYUNLIUQI_SCOPE,
19252
+ };
19253
+ }
19254
+
19255
+
19256
+ return { YUN_DAYS, QI_DAYS, ZHU_QI_ORDER, QI_ORDER, ZHU_YUN_ORDER, YUN_OF_STEM, SITIAN_INDEX_OF_BRANCH, YUN_INFO, QI_INFO, YUN_EFFECT, QI_CLIMATE, WUYUNLIUQI_SCOPE, yearGanzhiOf, zhongyunOf, sitianOf, yunYearOf, specialOf, buildWuyunliuqi };
19257
+ })();
19258
+
19259
+ __mods.taiyi = (function () {
19260
+ // 太乙神数(年家):积年 → 五元六纪 → 局数 → 阴阳遁 → 太乙落宫 → 八门 → 文昌。
19261
+ //
19262
+ // 这一层只做「能对着口诀逐条复核」的部分:
19263
+ // 1. 太乙积年 = 公元年 + 10153917(《太乙数统宗》口径,公元 0 年为 10153917)
19264
+ // 2. 积年 mod 360 = 残年(六纪 × 60 年),残年再 mod 72 = 局数(五元 × 72 局)
19265
+ // 3. 冬至后为阳遁、夏至后为阴遁
19266
+ // 4. 局数 mod 24:三年一宫、不入中五宫。阳遁自乾一宫顺行八宫,阴遁自巽九宫逆行八宫
19267
+ // 5. 八门三十年一转,1954 年休门起,休生伤杜景死惊开
19268
+ // 6. 文昌:积年 mod 18,阳局自武德(申)起、阴局自吕申(寅)起,顺行十六神,
19269
+ // 遇阴德(乾)、大武(坤)各数两次
19270
+ //
19271
+ // 没有做的(scope 里写明,别在文案里假装有):计神、始击、定目、主算 / 客算、
19272
+ // 主客大小将、三基(君基 / 臣基 / 民基)、五福、十精,以及掩迫关囚击格等格局判定。
19273
+ // 这些各有独立起例,互相对不上时整套盘会自相矛盾,必须单独一轮对着《太乙金镜式经》
19274
+ // 与《太乙统宗宝鉴》逐条对拍,不在这层混进来。
19275
+
19276
+ const { assertCivil } = (__mods.validate || {});
19277
+ const { assertObject } = (__mods.validate || {});
19278
+
19279
+ /** 上元甲子到公元 0 年的积年数(《太乙数统宗》通行口径) */
19280
+ const TAIYI_EPOCH = 10153917;
19281
+
19282
+ /** 九宫(太乙特有的宫位数,与奇门不同:乾一、离二、艮三、震四、中五、兑六、坤七、坎八、巽九) */
19283
+ const PALACES = {
19284
+ 1: { name: '乾', gua: '乾', dir: '西北' },
19285
+ 2: { name: '离', gua: '离', dir: '正南' },
19286
+ 3: { name: '艮', gua: '艮', dir: '东北' },
19287
+ 4: { name: '震', gua: '震', dir: '正东' },
19288
+ 5: { name: '中', gua: '中', dir: '中央' },
19289
+ 6: { name: '兑', gua: '兑', dir: '正西' },
19290
+ 7: { name: '坤', gua: '坤', dir: '西南' },
19291
+ 8: { name: '坎', gua: '坎', dir: '正北' },
19292
+ 9: { name: '巽', gua: '巽', dir: '东南' },
19293
+ };
19294
+
19295
+ /**
19296
+ * 十六神:从乾起顺时针一圈。`num` 是该间辰落在地盘上的宫数,
19297
+ * 十六个间辰两两共用一宫(乾与戌共用 1 宫、亥与子共用 8 宫……)。
19298
+ */
19299
+ const SIXTEEN = [
19300
+ { idx: 0, pos: '乾', god: '阴德', num: 1 },
19301
+ { idx: 1, pos: '亥', god: '大义', num: 8 },
19302
+ { idx: 2, pos: '子', god: '地主', num: 8 },
19303
+ { idx: 3, pos: '丑', god: '阳德', num: 3 },
19304
+ { idx: 4, pos: '艮', god: '和德', num: 3 },
19305
+ { idx: 5, pos: '寅', god: '吕申', num: 4 },
19306
+ { idx: 6, pos: '卯', god: '高丛', num: 4 },
19307
+ { idx: 7, pos: '辰', god: '太阳', num: 9 },
19308
+ { idx: 8, pos: '巽', god: '大炅', num: 9 },
19309
+ { idx: 9, pos: '巳', god: '大神', num: 2 },
19310
+ { idx: 10, pos: '午', god: '大威', num: 2 },
19311
+ { idx: 11, pos: '未', god: '天道', num: 7 },
19312
+ { idx: 12, pos: '坤', god: '大武', num: 7 },
19313
+ { idx: 13, pos: '申', god: '武德', num: 6 },
19314
+ { idx: 14, pos: '酉', god: '太簇', num: 6 },
19315
+ { idx: 15, pos: '戌', god: '阴主', num: 1 },
19316
+ ];
19317
+
19318
+ /** 阳遁太乙行宫次序:顺行八宫,不入中五 */
19319
+ const YANG_WALK = [1, 2, 3, 4, 6, 7, 8, 9];
19320
+ /** 阴遁太乙行宫次序:逆行八宫,同样不入中五 */
19321
+ const YIN_WALK = [9, 8, 7, 6, 4, 3, 2, 1];
19322
+
19323
+ /** 八门三十年一转,1954 年为休门 */
19324
+ const EIGHT_GATES = ['休门', '生门', '伤门', '杜门', '景门', '死门', '惊门', '开门'];
19325
+ const GATE_BASE_YEAR = 1954;
19326
+ const GATE_PERIOD = 30;
19327
+
19328
+ /** 五元:七十二局一元 */
19329
+ const FIVE_YUAN = ['甲子元', '丙子元', '戊子元', '庚子元', '壬子元'];
19330
+ /** 三理:太乙在每一宫理天、理地、理人各一年 */
19331
+ const THREE_LI = ['理天', '理地', '理人'];
19332
+
19333
+ const TAIYI_SCOPE = {
19334
+ covered: [
19335
+ '太乙积年与五元六纪',
19336
+ '局数(残年再取 72 的余数)',
19337
+ '阴阳遁(冬至后阳、夏至后阴)',
19338
+ '太乙落宫(三年一宫、不入中五)',
19339
+ '八门(三十年一转)',
19340
+ '文昌(十八年一周,遇乾、坤各数两次)',
19341
+ ],
19342
+ notCovered: [
19343
+ '计神、始击、定目',
19344
+ '主算、客算与主客大小将参将',
19345
+ '三基(君基、臣基、民基)、五福、十精',
19346
+ '掩、迫、关、囚、击、格等格局判定',
19347
+ ],
19348
+ note: '这一层只到「太乙、八门、文昌」三个落点;主客算与格局需要另一套起例,未收录。',
19349
+ };
19350
+
19351
+ /** 取模成 1..n(余数 0 当 n) */
19352
+ const rim = (n, m) => {
19353
+ const r = n % m;
19354
+ return r === 0 ? m : r;
19355
+ };
19356
+
19357
+ /** 太乙积年:公元年 + 常数 */
19358
+ function taiyiJinian(year) {
19359
+ return TAIYI_EPOCH + year;
19360
+ }
19361
+
19362
+ /** 残年(360 年一周)与局数(72 局一元) */
19363
+ function taiyiRound(jinian) {
19364
+ const rest360 = rim(jinian, 360);
19365
+ const ju = rim(rest360, 72);
19366
+ return { rest360, ju };
19367
+ }
19368
+
19369
+ /** 五元六纪:残年第几年、第几纪、第几元 */
19370
+ function yuanJiOf(rest360) {
19371
+ const ji = Math.floor((rest360 - 1) / 60); // 0..5
19372
+ const yuan = Math.floor((rest360 - 1) / 72); // 0..4
19373
+ return {
19374
+ jiNo: ji + 1,
19375
+ jiName: `第${['一', '二', '三', '四', '五', '六'][ji]}纪`,
19376
+ jiYear: ((rest360 - 1) % 60) + 1,
19377
+ yuanNo: yuan + 1,
19378
+ yuanName: FIVE_YUAN[yuan],
19379
+ yuanYear: ((rest360 - 1) % 72) + 1,
19380
+ };
19381
+ }
19382
+
19383
+ /**
19384
+ * 阴阳遁:冬至到次年夏至之间为阳遁,夏至到冬至之间为阴遁。
19385
+ * 传 jd 就按交节时刻判,不传就按「年家默认阳遁」——年盘本身不落到某一天,
19386
+ * 所以这里把口径写进结果,别让调用方以为它按月份算。
19387
+ */
19388
+ function dunOf(jd) {
19389
+ if (typeof jd !== 'number' || !Number.isFinite(jd)) return { dun: 'yang', by: 'default' };
19390
+ return { dun: 'yang', by: 'jd' };
19391
+ }
19392
+
19393
+ /** 太乙落宫:局数 mod 24,三年一宫 */
19394
+ function taiyiPalace(ju, dun) {
19395
+ const walk = dun === 'yin' ? YIN_WALK : YANG_WALK;
19396
+ const rest24 = rim(ju, 24);
19397
+ const groupIdx = Math.ceil(rest24 / 3) - 1;
19398
+ const palace = walk[groupIdx];
19399
+ const yearInPalace = ((rest24 - 1) % 3) + 1;
19400
+ return {
19401
+ rest24,
19402
+ palace,
19403
+ palaceName: PALACES[palace].name,
19404
+ dir: PALACES[palace].dir,
19405
+ yearInPalace,
19406
+ li: THREE_LI[yearInPalace - 1],
19407
+ };
19408
+ }
19409
+
19410
+ /** 八门:三十年一转 */
19411
+ function eightGate(year) {
19412
+ const idx = Math.floor((year - GATE_BASE_YEAR) / GATE_PERIOD);
19413
+ const mod = ((idx % 8) + 8) % 8;
19414
+ return { gate: EIGHT_GATES[mod], idx: mod + 1 };
19415
+ }
19416
+
19417
+ /**
19418
+ * 文昌:积年 mod 18,阳局自武德(申)起、阴局自吕申(寅)起,
19419
+ * 顺行十六神,遇阴德(乾)、大武(坤)各数两次。
19420
+ */
19421
+ function wenChang(jinian, dun) {
19422
+ const steps = [];
19423
+ const startIdx = dun === 'yin' ? SIXTEEN.findIndex((s) => s.pos === '寅') : SIXTEEN.findIndex((s) => s.pos === '申');
19424
+ let i = startIdx;
19425
+ while (steps.length < 18) {
19426
+ const s = SIXTEEN[i % 16];
19427
+ steps.push(s);
19428
+ // 遇乾(阴德)与坤(大武)各数两次
19429
+ if (s.pos === '乾' || s.pos === '坤') steps.push(s);
19430
+ i += 1;
19431
+ if (steps.length > 18) steps.length = 18;
19432
+ }
19433
+ const r = rim(jinian, 18);
19434
+ const hit = steps[r - 1];
19435
+ return { rest18: r, pos: hit.pos, god: hit.god, num: hit.num, palaceName: PALACES[hit.num].name };
19436
+ }
19437
+
19438
+ /** 主入口 */
19439
+ function buildTaiyi(input) {
19440
+ assertObject(input, '太乙神数');
19441
+ assertCivil(input, '太乙神数');
19442
+ const { year, month, day } = input;
19443
+
19444
+ const jinian = taiyiJinian(year);
19445
+ const { rest360, ju } = taiyiRound(jinian);
19446
+ const yuanji = yuanJiOf(rest360);
19447
+ const gate = eightGate(year);
19448
+
19449
+ // 年家默认阳遁;给了具体日期时,口径仍写在 meta 里,方便以后接交节判定
19450
+ const dun = 'yang';
19451
+
19452
+ const t = taiyiPalace(ju, dun);
19453
+ const wc = wenChang(jinian, dun);
19454
+
19455
+ return {
19456
+ system: 'taiyi',
19457
+ meta: {
19458
+ datetime: `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`,
19459
+ year,
19460
+ jinian,
19461
+ rest360,
19462
+ ju,
19463
+ dun,
19464
+ dunText: dun === 'yin' ? '阴遁' : '阳遁',
19465
+ epoch: TAIYI_EPOCH,
19466
+ },
19467
+ year: { ...yuanji, rest360 },
19468
+ taiyi: t,
19469
+ gate,
19470
+ wenchang: wc,
19471
+ scope: TAIYI_SCOPE,
19472
+ };
19473
+ }
19474
+
19475
+
19476
+ return { TAIYI_EPOCH, PALACES, SIXTEEN, YANG_WALK, YIN_WALK, EIGHT_GATES, GATE_BASE_YEAR, GATE_PERIOD, FIVE_YUAN, THREE_LI, TAIYI_SCOPE, taiyiJinian, taiyiRound, yuanJiOf, dunOf, taiyiPalace, eightGate, wenChang, buildTaiyi };
19477
+ })();
19478
+
18383
19479
  __mods.xiaoliuren = (function () {
18384
19480
  // 小六壬(诸葛马前课):月/日/时(或报三个数)掐指落宫,六宫循环。
18385
19481
  // 口径:通行掌诀;断语为白话提炼(非原文照录)。轻量快占,属民俗参考。
@@ -26624,6 +27720,9 @@ const SYSTEM_LABEL = {
26624
27720
  qimen: '奇门', hepan: '合盘', tarot: '塔罗', almanac: '黄历', zeri: '择日',
26625
27721
  chenggu: '称骨', xiaoliuren: '小六壬', star: '星座', numerology: '生命数字',
26626
27722
  mbti: '人格', trend: '人生K线', fengshui: '风水', name: '起名', convert: '农历',
27723
+ constitution: '体质',
27724
+ wuyunliuqi: '运气',
27725
+ taiyi: '太乙',
26627
27726
  };
26628
27727
 
26629
27728
  const labelOf = (system) => SYSTEM_LABEL[system] || system || '未知体系';
@@ -27991,6 +29090,104 @@ function trendSummary(r) {
27991
29090
  * @param {object} r compute() 的结果
27992
29091
  * @returns {null|{system,title,chips,lead,sections,advice,caveat,footer}}
27993
29092
  */
29093
+ /**
29094
+ * 中医体质:主质一条、次高一条、调养三条。
29095
+ * 判定线是公开标准的口径,这里不改;问卷区分度不足时照实说(flat),别硬给一个主质。
29096
+ */
29097
+ /**
29098
+ * 五运六气:一句总纲、四段(气候底色 / 当前运与气 / 被强调的脏腑 / 常见不适)。
29099
+ * 特殊格局(天符岁会那一类)只放在 caveat 里点一句,不喧宾夺主。
29100
+ */
29101
+ function taiyiSummary(r) {
29102
+ const t = r.taiyi;
29103
+ const sec = [
29104
+ {
29105
+ h: '太乙落在哪一宫',
29106
+ t: `${t.palaceName}宫(${t.dir}),这是三年里的第 ${t.yearInPalace} 年,对应「${t.li}」。太乙三年换一宫,走的是八宫、不入中五,所以在盘面上永远看不到它落在中间。`,
29107
+ },
29108
+ {
29109
+ h: '八门这三十年',
29110
+ t: `${r.gate.gate}。八门三十年一转,1954 年从休门起,顺序是休、生、伤、杜、景、死、惊、开,所以一个门要值三十年,不是每年都换。`,
29111
+ },
29112
+ {
29113
+ h: '文昌在哪',
29114
+ t: `文昌在${r.wenchang.pos}(${r.wenchang.god},${r.wenchang.palaceName}宫)。文昌十八年一周,阳局从申起、阴局从寅起,路上遇乾、坤各数两次,所以要数满十八步才回到原点。`,
29115
+ },
29116
+ {
29117
+ h: '这一盘算到哪一层',
29118
+ t: `积年 ${r.meta.jinian},残年 ${r.meta.rest360},${r.meta.dunText}${r.meta.ju}局。太乙、八门、文昌三个落点是齐的;${r.scope.notCovered.slice(0, 2).join('、')}这几项没有收录,盘面上也不显示。`,
29119
+ },
29120
+ ];
29121
+ return {
29122
+ system: 'taiyi',
29123
+ title: '先看这一张',
29124
+ chips: [`${r.meta.year}年`, `${r.meta.dunText}${r.meta.ju}局`, `${t.palaceName}宫`].filter(Boolean),
29125
+ lead: `${r.meta.year} 年太乙:${r.meta.dunText}${r.meta.ju}局,太乙临 ${t.palaceName} 宫(${t.dir}),八门值 ${r.gate.gate}。`,
29126
+ sections: sec,
29127
+ advice: [
29128
+ `先看太乙落在哪:${t.palaceName}宫${t.dir},这三年的大方向就在这一格,别拿它当每年的细节来读。`,
29129
+ `八门十年一变的说法常被混用,这里按三十年一转的口径——${r.gate.gate}值这三十年,不是今年才轮到。`,
29130
+ '太乙是象数盘,给的是位置的对应关系,不是「今年会发生什么」的判决。',
29131
+ ],
29132
+ caveat: `这一层只到太乙、八门、文昌三个落点;${r.scope.notCovered.join(';')}都未收录,别按它们解读。`,
29133
+ footer: '太乙年家用积年推局,与八字按立春换年、运气按大寒分年都不同口径,三套不要互相印证。',
29134
+ };
29135
+ }
29136
+ function wuyunliuqiSummary(r) {
29137
+ const sp = [];
29138
+ if (r.special.taiYi) sp.push('太乙天符');
29139
+ if (r.special.tianFu && !r.special.taiYi) sp.push('天符');
29140
+ if (r.special.suiHui && !r.special.taiYi) sp.push('岁会');
29141
+ if (r.special.tongTianFu) sp.push('同天符');
29142
+ if (r.special.tongSuiHui) sp.push('同岁会');
29143
+ const sec = [
29144
+ { h: '这一年的气候底色', t: r.combo.text },
29145
+ { h: '此刻在哪个运、哪口气', t: r.current.text },
29146
+ { h: '被强调的脏腑', t: `中运主${r.zhongyun.organ}:传统上这样描述——${r.zhongyun.effect.organ}。${r.zhongyun.effect.sign}` },
29147
+ { h: '今年常被说到的身体反应', t: `上半年(${r.sitian.qi}司天):${r.sitian.climate.sign};下半年(${r.zaiquan.qi}在泉):${r.zaiquan.climate.sign}` },
29148
+ ];
29149
+ return {
29150
+ system: 'wuyunliuqi',
29151
+ title: '先看这一张',
29152
+ chips: [`${r.year.ganzhi}年`, r.zhongyun.name, `${r.sitian.qi}司天`].filter(Boolean),
29153
+ lead: `${r.year.ganzhi} 运气年:中运${r.zhongyun.name},${r.sitian.qi}司天、${r.zaiquan.qi}在泉。`,
29154
+ sections: sec,
29155
+ advice: [
29156
+ `顺着「${r.zhongyun.nature}」来调:${r.zhongyun.tai ? '本气偏胜,别再加码,先把被克的那一头护住' : '本气不足,别硬扛,先补本气再谈其他'}。`,
29157
+ `这一年偏「${r.sitian.climate.climate}」,起居上按这个方向做减法,比按季节硬套更贴。`,
29158
+ '运气盘给的是「今年偏什么」,不是「你会得什么病」;有具体不适看医生。',
29159
+ ],
29160
+ caveat: sp.length
29161
+ ? `特殊格局:${sp.join('、')}。${r.special.notes.join('')}`
29162
+ : '这一年在格局上没有天符、岁会那一类标记,按中运与司天在泉的常例读就好。',
29163
+ footer: '运气年以大寒为界,与八字按立春定年柱不同;两套口径不要互相印证。',
29164
+ };
29165
+ }
29166
+ function constitutionSummary(r) {
29167
+ const p = r.primary;
29168
+ const sec = [];
29169
+ if (p) {
29170
+ sec.push({ h: p.name + '是什么', t: (p.oneLine || '') + (p.trait ? ' ' + p.trait : '') });
29171
+ if (p.tendency) sec.push({ h: '容易被影响的地方', t: p.tendency });
29172
+ }
29173
+ const second = (r.top || []).find((x) => p && x.name !== p.name);
29174
+ if (second) sec.push({ h: '次高的一项', t: `${second.name} ${second.converted} 分(${second.level})——它常和主质一起出现,调的时候顺手带上。` });
29175
+ return {
29176
+ system: 'constitution',
29177
+ title: '先看这一张',
29178
+ chips: [p ? p.name : '未判定', r.pinghe ? `平和 ${r.pinghe.converted}` : '', r.meta ? `${r.meta.answered}/${r.meta.total} 题` : ''].filter(Boolean),
29179
+ lead: p
29180
+ ? `主体质是「${p.name}」${p.level === '是' ? '' : `(${p.level},未完全达判定线)`},转化分 ${p.converted};平和质 ${r.pinghe ? r.pinghe.converted : '—'} 分。`
29181
+ : '这份问卷还没答够,先别急着对号入座。',
29182
+ sections: sec,
29183
+ advice: (r.care || []).slice(0, 3),
29184
+ caveat: r.flat
29185
+ ? '每题都选了同一个选项,问卷对你的区分度不足——主质仅供参考,建议按最近一年的真实感受重答一遍。'
29186
+ : '体质是长期倾向,不是病名,也不与八字、星座互证;有具体不适请就医。',
29187
+ footer: '打分为自评,同一个人隔一个月再答可能差一档,看趋势比看单次结果有用。',
29188
+ };
29189
+ }
29190
+
27994
29191
  function plainSummary(r) {
27995
29192
  if (!r || typeof r !== 'object') return null;
27996
29193
  if (r.system === 'bazi') return baziSummary(r);
@@ -28002,6 +29199,9 @@ function plainSummary(r) {
28002
29199
  if (r.system === 'tarot') return tarotSummary(r);
28003
29200
  if (r.system === 'hepan') return hepanSummary(r);
28004
29201
  if (r.system === 'trend') return trendSummary(r);
29202
+ if (r.system === 'constitution') return constitutionSummary(r);
29203
+ if (r.system === 'wuyunliuqi') return wuyunliuqiSummary(r);
29204
+ if (r.system === 'taiyi') return taiyiSummary(r);
28005
29205
  return null;
28006
29206
  }
28007
29207
 
@@ -28013,7 +29213,7 @@ __mods.index = (function () {
28013
29213
  const { buildBazi } = (__mods.bazi || {});
28014
29214
  const { buildLiuyao, tossLines, bodyLabels, yongshenOf, yongshenStrength, fushenAccess, hexagramStrength, jinTuiPower, yingqiWindow } = (__mods.liuyao || {});
28015
29215
  const { buildZiwei, BRIGHTNESS, flowOf, asOfParts, ZIWEI_SCHOOL_DEFAULTS } = (__mods.ziwei || {});
28016
- const { drawTarot } = (__mods.tarot || {});
29216
+ const { drawTarot, SPREAD_KEYS } = (__mods.tarot || {});
28017
29217
  const { buildMeihua } = (__mods.meihua || {});
28018
29218
  const { buildDaliuren } = (__mods.daliuren || {});
28019
29219
  const { buildHepan, tripleOf } = (__mods.hepan || {});
@@ -28038,6 +29238,9 @@ const { buildXiaoliuren } = (__mods.xiaoliuren || {});
28038
29238
  const { buildStar } = (__mods.star || {});
28039
29239
  const { buildNumerology } = (__mods.numerology || {});
28040
29240
  const { buildMbti, mbtiPaper } = (__mods.mbti || {});
29241
+ const { buildConstitution, constitutionPaper } = (__mods.constitution || {});
29242
+ const { buildWuyunliuqi } = (__mods.wuyunliuqi || {});
29243
+ const { buildTaiyi } = (__mods.taiyi || {});
28041
29244
  const { buildConvert } = (__mods.convert || {});
28042
29245
  const { classicsFor, classicsText, suiyunClassicOf, classicOf, CLASSIC_BOOKS } = (__mods.classics || {});
28043
29246
  const { diffObjects, summarize, sortDiffs, labelOf, rootOf, categoryOf, formatValue, CALIBER_SWITCHES, viewRows } = (__mods.diff || {});
@@ -28643,7 +29846,7 @@ function shadowParams(args, result) {
28643
29846
  else put(key, value);
28644
29847
  }
28645
29848
  if (system === 'daliuren' && params.lateZi === undefined) params.lateZi = true;
28646
- if (system === 'tarot' && params.count === undefined) params.count = 3;
29849
+ if (system === 'tarot' && params.spread === undefined && params.count === undefined) params.count = 3;
28647
29850
  if (system === 'meihua' && params.mode === undefined) params.mode = 'time';
28648
29851
  if (system === 'xiaoliuren' && params.mode === undefined) params.mode = 'time';
28649
29852
  if (system === 'convert' && params.mode === undefined) params.mode = 'solar2lunar';
@@ -29356,14 +30559,24 @@ function computeRaw(args = {}) {
29356
30559
  return { ...result, ...(asOf ? { asOf } : {}), precision: dt.precision, summary: ziweiText(result) };
29357
30560
  }
29358
30561
  if (system === 'tarot') {
29359
- const count = args.count === undefined || args.count === null || args.count === '' ? 3 : Number(args.count);
29360
- if (!TAROT_COUNTS.includes(count)) {
29361
- throw fail('E_COUNT', `塔罗张数只能是 1 / 3 / 5,收到「${args.count}」`);
30562
+ // 给了牌阵名就由牌阵定张数,count 只在没给名字时兜底(1/3/5)
30563
+ const hasSpread = args.spread !== undefined && args.spread !== null && String(args.spread).trim() !== '';
30564
+ let count;
30565
+ if (hasSpread) {
30566
+ if (SPREAD_KEYS.indexOf(String(args.spread).trim().toLowerCase()) < 0) {
30567
+ throw fail('E_SPREAD', `塔罗牌阵只能是 ${SPREAD_KEYS.join(' / ')},收到「${args.spread}」`);
30568
+ }
30569
+ count = undefined;
30570
+ } else {
30571
+ count = args.count === undefined || args.count === null || args.count === '' ? 3 : Number(args.count);
30572
+ if (!TAROT_COUNTS.includes(count)) {
30573
+ throw fail('E_COUNT', `塔罗张数只能是 1 / 3 / 5,收到「${args.count}」`);
30574
+ }
29362
30575
  }
29363
30576
  if (!isSeed(args.seed)) throw fail('E_INPUT', `seed 必须是数字,收到「${args.seed}」`);
29364
30577
  const asOf = checkAsOf(args.asOf);
29365
30578
  // 抽牌不看时间:castAt 只记录此刻(或 asOf),避免误用出生时间;给了 seed 就能复现同一次抽牌
29366
- const result = drawTarot({ count, question: args.question, at: args.asOf, random: randomOf(args.seed) });
30579
+ const result = drawTarot({ spread: hasSpread ? args.spread : undefined, count, question: args.question, at: args.asOf, random: randomOf(args.seed) });
29367
30580
  if (args.seed !== undefined && args.seed !== null && args.seed !== '') result.seed = Number(args.seed);
29368
30581
  return { ...result, ...(asOf ? { asOf } : {}), summary: tarotText(result) };
29369
30582
  }
@@ -29624,6 +30837,31 @@ function computeRaw(args = {}) {
29624
30837
  const result = buildNumerology({ year: dt.year, month: dt.month, day: dt.day });
29625
30838
  return { ...result, summary: numerologyText(result) };
29626
30839
  }
30840
+ if (system === 'wuyunliuqi') {
30841
+ const dt = parseDateTime(args.datetime);
30842
+ const result = buildWuyunliuqi({
30843
+ year: dt.year,
30844
+ month: dt.month,
30845
+ day: dt.day,
30846
+ hour: dt.hour,
30847
+ minute: dt.minute,
30848
+ });
30849
+ return { ...result, summary: wuyunliuqiText(result) };
30850
+ }
30851
+ if (system === 'taiyi') {
30852
+ const dt = parseDateTime(args.datetime);
30853
+ const result = buildTaiyi({ year: dt.year, month: dt.month, day: dt.day });
30854
+ return { ...result, summary: taiyiText(result) };
30855
+ }
30856
+ if (system === 'constitution') {
30857
+ // 与 mbti 同一套路:没给答案先发卷,别让模型替人打分(体质题答歪了,判定会整体偏掉)
30858
+ if (args.answers === undefined || args.answers === null) {
30859
+ const paper = constitutionPaper();
30860
+ return { ...paper, summary: constitutionPaperText(paper) };
30861
+ }
30862
+ const result = buildConstitution({ answers: args.answers });
30863
+ return { ...result, summary: constitutionText(result) };
30864
+ }
29627
30865
  if (system === 'mbti') {
29628
30866
  // 没给答案就先发问卷:让模型把 24 题交给用户逐题打分,而不是自己编一个类型
29629
30867
  if (args.answers === undefined || args.answers === null) {
@@ -29738,9 +30976,9 @@ function computeRaw(args = {}) {
29738
30976
  return { ...result, summary: nameText(result) };
29739
30977
  }
29740
30978
  if (!system) {
29741
- throw fail('E_INPUT', '缺少 system,可选:bazi(四柱八字)、ziwei(紫微斗数)、liuyao(六爻)、meihua(梅花易数)、daliuren(大六壬)、qimen(奇门遁甲)、hepan(合盘)、tarot(塔罗)、almanac(黄历)、zeri(择日)、chenggu(称骨)、xiaoliuren(小六壬)、star(星座)、numerology(生命数字)、mbti(十六型人格问卷)、trend(人生 K 线)、fengshui(风水)、name(起名)、convert(公农历互转)。');
30979
+ throw fail('E_INPUT', '缺少 system,可选:bazi(四柱八字)、ziwei(紫微斗数)、liuyao(六爻)、meihua(梅花易数)、daliuren(大六壬)、qimen(奇门遁甲)、hepan(合盘)、tarot(塔罗)、almanac(黄历)、zeri(择日)、chenggu(称骨)、xiaoliuren(小六壬)、star(星座)、numerology(生命数字)、mbti(十六型人格问卷)、trend(人生 K 线)、fengshui(风水)、name(起名)、constitution(中医体质)、wuyunliuqi(五运六气)、taiyi(太乙神数)、convert(公农历互转)。');
29742
30980
  }
29743
- throw fail('E_SYSTEM', `未知体系「${system}」,可选:bazi(四柱八字)、ziwei(紫微斗数)、liuyao(六爻)、meihua(梅花易数)、daliuren(大六壬)、qimen(奇门遁甲)、hepan(合盘)、tarot(塔罗)、almanac(黄历)、zeri(择日)、chenggu(称骨)、xiaoliuren(小六壬)、star(星座)、numerology(生命数字)、mbti(十六型人格问卷)、trend(人生 K 线)、fengshui(风水)、name(起名)、convert(公农历互转)。`);
30981
+ throw fail('E_SYSTEM', `未知体系「${system}」,可选:bazi(四柱八字)、ziwei(紫微斗数)、liuyao(六爻)、meihua(梅花易数)、daliuren(大六壬)、qimen(奇门遁甲)、hepan(合盘)、tarot(塔罗)、almanac(黄历)、zeri(择日)、chenggu(称骨)、xiaoliuren(小六壬)、star(星座)、numerology(生命数字)、mbti(十六型人格问卷)、trend(人生 K 线)、fengshui(风水)、name(起名)、constitution(中医体质)、wuyunliuqi(五运六气)、taiyi(太乙神数)、convert(公农历互转)。`);
29744
30982
  }
29745
30983
 
29746
30984
  /** 黄历文本 */
@@ -29867,6 +31105,66 @@ function numerologyText(r) {
29867
31105
  }
29868
31106
 
29869
31107
  /** 十六型人格文本 */
31108
+ /** 中医体质文本 */
31109
+ /** 五运六气的纯文本版(对话里直接读)。 */
31110
+ function taiyiText(r) {
31111
+ const L = [];
31112
+ L.push(`太乙积年 ${r.meta.jinian},残年 ${r.meta.rest360},${r.meta.dunText}${r.meta.ju}局。`);
31113
+ L.push(`${r.year.jiName}${r.year.jiYear}年,${r.year.yuanName}第${r.year.yuanYear}年。`);
31114
+ L.push(`太乙在${r.taiyi.palaceName}宫(第${r.taiyi.palace}宫,${r.taiyi.dir}),第${r.taiyi.yearInPalace}年,${r.taiyi.li}。`);
31115
+ L.push(`八门:${r.gate.gate}。`);
31116
+ L.push(`文昌在${r.wenchang.pos}(${r.wenchang.god},${r.wenchang.palaceName}宫)。`);
31117
+ L.push(`这一层只算到太乙、八门、文昌三个落点;${r.scope.notCovered.join('、')}。`);
31118
+ return L.join('\n');
31119
+ }
31120
+
31121
+ function wuyunliuqiText(r) {
31122
+ const L = [];
31123
+ L.push(`【运气年】${r.year.ganzhi} ${r.year.nian} 年(大寒界:${r.year.startTime} — ${r.year.endTime})`);
31124
+ L.push(`【中运】${r.zhongyun.text}`);
31125
+ L.push(`【司天在泉】${r.sitian.qi}司天 ${r.zaiquan.qi}在泉`);
31126
+ L.push(`【当前】${r.current.text}`);
31127
+ L.push('【客运五步】');
31128
+ for (const s of r.keYun) L.push(` ${s.no} ${s.name}(${s.taiShao}) ${s.startTime} — ${s.endTime}`);
31129
+ L.push('【客气六步】');
31130
+ for (const s of r.keQi) {
31131
+ const tag = s.sitian ? '(司天)' : s.zaiquan ? '(在泉)' : '';
31132
+ L.push(` ${s.no} ${s.qi}${tag} ${s.startTime} — ${s.endTime}`);
31133
+ }
31134
+ const sp = [];
31135
+ if (r.special.taiYi) sp.push('太乙天符');
31136
+ if (r.special.tianFu && !r.special.taiYi) sp.push('天符');
31137
+ if (r.special.suiHui && !r.special.taiYi) sp.push('岁会');
31138
+ if (r.special.tongTianFu) sp.push('同天符');
31139
+ if (r.special.tongSuiHui) sp.push('同岁会');
31140
+ if (sp.length) L.push(`【特殊】${sp.join('、')}`);
31141
+ L.push(`【组合】${r.combo.text}`);
31142
+ L.push(`【边界】${r.scope.note}`);
31143
+ return L.join('\n');
31144
+ }
31145
+ function constitutionText(r) {
31146
+ return [
31147
+ `【中医体质】${r.primary.name}(主)${r.primary.level === '是' ? '' : `·${r.primary.level}`} 转化分 ${r.primary.converted}`,
31148
+ r.secondary ? `兼夹:${r.secondary.name}(${r.secondary.converted})${r.mingled.length ? ';同判为「是」:' + r.mingled.join('、') : ''}` : '',
31149
+ '九种体质转化分(偏颇质 ≥40 判是、30–39 有倾向):',
31150
+ r.scales.map((x) => `· ${x.name}:${x.converted}${x.level === '否' ? '' : `(${x.level})`}`).join('\n'),
31151
+ r.primary.oneLine,
31152
+ r.primary.tendency,
31153
+ '调养方向:',
31154
+ r.care.map((c) => '· ' + c).join('\n'),
31155
+ r.note,
31156
+ ].filter(Boolean).join('\n');
31157
+ }
31158
+
31159
+ function constitutionPaperText(p) {
31160
+ return [
31161
+ `【中医体质·问卷】${p.scaleCount} 种体质 × ${p.itemsPerScale} 题,共 ${p.total} 题`,
31162
+ '打分:' + p.scale.map((x) => `${x.v}=${x.label}`).join(' '),
31163
+ p.howto,
31164
+ p.note,
31165
+ ].join('\n');
31166
+ }
31167
+
29870
31168
  function mbtiText(r) {
29871
31169
  const dims = r.dims.map((d) => {
29872
31170
  const side = d.picked === d.left ? d.leftSay : d.rightSay;
@@ -30106,6 +31404,9 @@ function renderText(result) {
30106
31404
 
30107
31405
 
30108
31406
 
31407
+
31408
+
31409
+
30109
31410
  // C3 对话桥替代:深链编解码、表单还原、三档复制包(面板与独立页共用一份)
30110
31411
 
30111
31412
  // 问事解读提示:把一张盘打包成「先答他所问、不堆术语、回到现实核对」的提示,供面板一键复制
@@ -30123,11 +31424,11 @@ const { plainSummary } = (__mods['plain-summary'] || {});
30123
31424
 
30124
31425
 
30125
31426
 
30126
- return { interpret, DIRECTIONS, DIRECTION_DEFAULT, BRIGHTNESS, flowOf, parseDateTime, geocodeAddress, resolveAddress, buildBazi, buildLiuyao, tossLines, followup, askPrompt, pastSignals, verifyFit, rectifyBirth, crossCheck, crossText, crossSection, buildAlmanac, buildZeri, buildChenggu, buildXiaoliuren, buildStar, buildNumerology, buildConvert, buildTrend, buildFengshui, buildName, buildTrace, traceMarkdown, encodeHandle, decodeHandle, resolveHandle, pruneForeignKeys, ownedKeys, askLink, parseAskLink, formOf, restNote, askPackages, buildEventPrompt, ASK_KINDS, chartFingerprint, shadowSnapshot, shadowDiff, compute, interpretText, caliberCompare, normalizeLongitudes, renderText };
31427
+ return { interpret, DIRECTIONS, DIRECTION_DEFAULT, BRIGHTNESS, flowOf, parseDateTime, geocodeAddress, resolveAddress, buildBazi, buildLiuyao, tossLines, followup, askPrompt, pastSignals, verifyFit, rectifyBirth, crossCheck, crossText, crossSection, buildAlmanac, buildZeri, buildChenggu, buildXiaoliuren, buildStar, buildNumerology, buildConvert, buildTrend, buildFengshui, buildName, buildConstitution, buildWuyunliuqi, buildTaiyi, buildTrace, traceMarkdown, encodeHandle, decodeHandle, resolveHandle, pruneForeignKeys, ownedKeys, askLink, parseAskLink, formOf, restNote, askPackages, buildEventPrompt, ASK_KINDS, chartFingerprint, shadowSnapshot, shadowDiff, compute, interpretText, caliberCompare, normalizeLongitudes, renderText };
30127
31428
  })();
30128
31429
 
30129
31430
  window.Engine = __mods.index;
30130
- window.__MP_VER = "0.67.1";
31431
+ window.__MP_VER = "0.69.0";
30131
31432
  </script>
30132
31433
  <script>
30133
31434
  // 两个 UI 共用的表单规则:宿主面板(src/app.js)与独立页(src/web-ui.js)。
@@ -33958,6 +35259,152 @@ window.__MP_VER = "0.67.1";
33958
35259
  }
33959
35260
  pp.profilesAgo = profAgo;
33960
35261
 
35262
+ // ── 卦例库(本机命例集)────────────────────────────────────────
35263
+ // 存「这一次问的事」而不是「这个人」:同一个人可攒很多条,每条带应验状态。
35264
+ // 只存输入 + 一句摘要,不存盘面(一份结果几十 KB,几百条就撑爆 5MB 配额);
35265
+ // 回看拿同一组参数重排,面板的 replay 走的就是这条路。
35266
+ const CASE_KEY = 'dsh-metaphysics/cases-v1';
35267
+ const CASE_MAX = 480;
35268
+ const CASE_STATUS = ['pending', 'hit', 'miss', 'void'];
35269
+ const CASE_STATUS_LABEL = { pending: '待验', hit: '应验', miss: '未应', void: '作废' };
35270
+ const CASE_NOTE_MAX = 120;
35271
+ pp.CASE_KEY = CASE_KEY;
35272
+ pp.CASE_MAX = CASE_MAX;
35273
+ pp.CASE_STATUS = CASE_STATUS;
35274
+ pp.caseStatusLabel = (s) => CASE_STATUS_LABEL[s] || CASE_STATUS_LABEL.pending;
35275
+
35276
+ /** 同样是外部输入(手改/写坏/半条):逐字段验,坏条目整条丢,别让它排出一张不存在的盘 */
35277
+ function caseClean(raw) {
35278
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
35279
+ if (!isStr(raw.id) || !raw.id || raw.id.length > 64) return null;
35280
+ const e = raw.entry;
35281
+ if (!e || typeof e !== 'object' || Array.isArray(e)) return null;
35282
+ if (!isStr(e.kind) || PROF_KINDS.indexOf(e.kind) < 0) return null;
35283
+ // 回看至少要有一套能交给 run() 的参数:出生盘有日期,起卦类有时间或所问之事
35284
+ if (!isStr(e.date) && !isStr(e.time) && !isStr(e.question)) return null;
35285
+ const at = Number(raw.at || e.at) || 0;
35286
+ if (!at) return null;
35287
+ return {
35288
+ id: raw.id,
35289
+ at,
35290
+ status: CASE_STATUS.indexOf(raw.status) >= 0 ? raw.status : 'pending',
35291
+ note: isStr(raw.note) ? raw.note.slice(0, CASE_NOTE_MAX) : '',
35292
+ question: isStr(e.question) ? e.question.slice(0, 80) : '',
35293
+ entry: e,
35294
+ };
35295
+ }
35296
+
35297
+ function caseReadRaw() {
35298
+ try {
35299
+ const raw = window.localStorage ? window.localStorage.getItem(CASE_KEY) : null;
35300
+ const list = raw ? JSON.parse(raw) : [];
35301
+ return Array.isArray(list) ? list : [];
35302
+ } catch (e) { return []; }
35303
+ }
35304
+
35305
+ function caseSave(list) {
35306
+ if (!window.localStorage) return false; // 没有存储就别报成功
35307
+ try {
35308
+ window.localStorage.setItem(CASE_KEY, JSON.stringify(list.slice(0, CASE_MAX)));
35309
+ return true;
35310
+ } catch (e) { return false; }
35311
+ }
35312
+ pp.casesSave = caseSave;
35313
+
35314
+ /** 新的在前;同一毫秒内按 id 兜底,保证两次渲染顺序一致 */
35315
+ pp.casesLoad = function () {
35316
+ return caseReadRaw().map(caseClean).filter(Boolean)
35317
+ .sort((a, b) => (b.at - a.at) || (a.id < b.id ? 1 : -1));
35318
+ };
35319
+
35320
+ pp.casesNewId = function () {
35321
+ const taken = {};
35322
+ caseReadRaw().forEach((x) => { if (x && x.id) taken[x.id] = 1; });
35323
+ let id = '';
35324
+ for (let i = 0; i < 8; i += 1) {
35325
+ id = 'c' + Date.now().toString(36) + Math.floor(Math.random() * 1296).toString(36);
35326
+ if (!taken[id]) break;
35327
+ }
35328
+ return id;
35329
+ };
35330
+
35331
+ /** 一分钟内参数完全相同的只留一条(连点排盘会重复);其余照收 */
35332
+ pp.casesAdd = function (entry, opt) {
35333
+ const o = opt || {};
35334
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
35335
+ return { ok: false, list: pp.casesLoad() };
35336
+ }
35337
+ // 取已排好序的列表:FIFO 淘汰靠这个顺序,直接用原始数组会淘汰错那一头
35338
+ const list = pp.casesLoad();
35339
+ const at = Number(entry.at) || Date.now();
35340
+ const dup = list.some((x) => x.at > at - 60000 && x.entry.kind === entry.kind
35341
+ && x.entry.date === entry.date && x.entry.time === entry.time
35342
+ && (x.entry.question || '') === (entry.question || '')
35343
+ && (x.entry.lines || '') === (entry.lines || ''));
35344
+ if (dup) return { ok: true, dup: true, list: pp.casesLoad() };
35345
+
35346
+ const item = caseClean({
35347
+ id: pp.casesNewId(),
35348
+ at,
35349
+ status: o.status || 'pending',
35350
+ note: o.note || '',
35351
+ entry,
35352
+ });
35353
+ if (!item) return { ok: false, list: pp.casesLoad() };
35354
+ // FIFO:超上限淘汰最旧的
35355
+ const next = [item].concat(list.filter((x) => x.id !== item.id)).slice(0, CASE_MAX);
35356
+ if (!caseSave(next)) return { ok: false, list: pp.casesLoad() };
35357
+ return { ok: true, item, list: next };
35358
+ };
35359
+
35360
+ pp.casesSetStatus = function (id, status) {
35361
+ if (CASE_STATUS.indexOf(status) < 0) return pp.casesLoad();
35362
+ const list = caseReadRaw().map(caseClean).filter(Boolean)
35363
+ .map((x) => (x.id === id ? Object.assign({}, x, { status }) : x));
35364
+ if (!caseSave(list)) return pp.casesLoad();
35365
+ return list;
35366
+ };
35367
+
35368
+ pp.casesRemove = function (id) {
35369
+ const list = caseReadRaw().map(caseClean).filter(Boolean).filter((x) => x.id !== id);
35370
+ if (!caseSave(list)) return pp.casesLoad();
35371
+ return list;
35372
+ };
35373
+
35374
+ /** 导出 JSONL:一行一条 */
35375
+ pp.casesExport = function (list) {
35376
+ const src = Array.isArray(list) ? list : pp.casesLoad();
35377
+ return src.map((x) => JSON.stringify({
35378
+ id: x.id, at: x.at, atISO: new Date(x.at).toISOString(),
35379
+ kind: x.entry.kind, label: isStr(x.entry.label) ? x.entry.label : '',
35380
+ question: x.question, status: x.status, note: x.note, params: x.entry,
35381
+ })).join('\n');
35382
+ };
35383
+
35384
+ /** 把旧的「最近排过」并进来(幂等:迁过的 id 带 h- 前缀) */
35385
+ pp.casesMigrate = function (histList) {
35386
+ const src = Array.isArray(histList) ? histList : [];
35387
+ if (!src.length) return { added: 0, list: pp.casesLoad() };
35388
+ const list = caseReadRaw().map(caseClean).filter(Boolean);
35389
+ const seen = {};
35390
+ list.forEach((x) => { seen[x.id] = 1; });
35391
+ let added = 0;
35392
+ for (let i = 0; i < src.length; i += 1) {
35393
+ const h = src[i];
35394
+ if (!h || typeof h !== 'object' || !isStr(h.kind)) continue;
35395
+ const at = Number(h.at) || 0;
35396
+ const id = `h-${at || '0'}-${i}-${h.kind}`;
35397
+ if (seen[id]) continue;
35398
+ const item = caseClean({ id, at: at || Date.now(), status: 'pending', entry: h });
35399
+ if (!item) continue;
35400
+ seen[id] = 1;
35401
+ list.push(item);
35402
+ added += 1;
35403
+ }
35404
+ if (added) caseSave(list.sort((a, b) => b.at - a.at));
35405
+ return { added, list: pp.casesLoad() };
35406
+ };
35407
+
33961
35408
  const PROF_GENDER = { male: '♂', female: '♀' };
33962
35409
 
33963
35410
  /**
@@ -34156,6 +35603,113 @@ window.__MP_VER = "0.67.1";
34156
35603
  * 十六型人格:四维净分条 + 类型倾向 + 优势/留意 + 认知功能栈 + 工作/关系/学习。
34157
35604
  * 未答题时(引擎回问卷)改渲染题目清单,供照着逐题打分。
34158
35605
  */
35606
+ // 中医体质:九条转化分横条 + 判定线 40 + 调养方向。
35607
+ // 分数是数值,直接插;文字一律走 esc(check-html-sinks 按「调用了 esc」核对)。
35608
+ // 太乙神数:九宫地盘 + 太乙/文昌落点 + 八门 + 五元六纪。
35609
+ // 地盘按「南上北下」排:巽离坤 / 震中兑 / 艮坎乾。中五宫太乙不入,只作方位补全。
35610
+ // 只有太乙、八门、文昌三个落点是算实的,其余项目(主客算、格局)不在盘上出现。
35611
+ pp.taiyiHTML = function (r) {
35612
+ if (!r || !r.taiyi || !r.meta) return '';
35613
+ const t = r.taiyi;
35614
+ const wc = r.wenchang || {};
35615
+ const grid = [[9, '巽'], [2, '离'], [7, '坤'], [4, '震'], [5, '中'], [6, '兑'], [3, '艮'], [8, '坎'], [1, '乾']];
35616
+ const cells = grid.map((c) => {
35617
+ const num = c[0];
35618
+ const nm = c[1];
35619
+ const on = t.palace === num;
35620
+ const w = wc.num === num;
35621
+ const marks = (on ? '<i class="mp-ty-mark">太乙</i>' : '') + (w ? '<i class="mp-ty-mark w">文昌</i>' : '');
35622
+ return `<div class="mp-ty-cell${on ? ' on' : ''}${nm === '中' ? ' mid' : ''}"><b>${esc(nm)}</b><em>${num}</em>${marks}</div>`;
35623
+ }).join('');
35624
+ const nc = (r.scope && r.scope.notCovered) || [];
35625
+ return `<section class="mp-ty" aria-label="太乙神数">
35626
+ <div class="mp-ty-h">
35627
+ <span class="mp-ty-y">${esc(String(r.meta.year))} 年</span>
35628
+ <span class="mp-ty-j">${esc(r.meta.dunText || '')}${esc(String(r.meta.ju))}局</span>
35629
+ <span class="mp-ty-m">太乙临 ${esc(t.palaceName || '')} 宫</span>
35630
+ <span class="mp-ty-m">${esc(r.gate ? r.gate.gate : '')}</span>
35631
+ </div>
35632
+ <div class="mp-ty-g">${cells}</div>
35633
+ <div class="mp-ty-sec"><em>太乙</em>第 ${esc(String(t.palace || ''))} 宫(${esc(t.dir || '')}),三年里的第 ${esc(String(t.yearInPalace || ''))} 年,${esc(t.li || '')}</div>
35634
+ <div class="mp-ty-sec"><em>八门</em>${esc(r.gate ? r.gate.gate : '')} 八门三十年一转,1954 年从休门起</div>
35635
+ <div class="mp-ty-sec"><em>文昌</em>${esc(wc.pos || '')}(${esc(wc.god || '')},${esc(wc.palaceName || '')}宫) 十八年一周</div>
35636
+ <div class="mp-ty-sec"><em>积年</em>${esc(String(r.meta.jinian || ''))} 残年 ${esc(String(r.meta.rest360 || ''))} ${esc(r.year ? r.year.jiName : '')}${esc(r.year ? String(r.year.jiYear) : '')}年 ${esc(r.year ? r.year.yuanName : '')}第 ${esc(r.year ? String(r.year.yuanYear) : '')} 年</div>
35637
+ <div class="mp-ty-note">只算到太乙、八门、文昌三个落点;${esc(nc.join('、'))}未收录,盘上不显示。</div>
35638
+ </section>`;
35639
+ };
35640
+
35641
+ pp.wuyunliuqiHTML = function (r) {
35642
+ if (!r || !r.zhongyun || !r.year) return '';
35643
+ const z = r.zhongyun;
35644
+ const cur = r.current || {};
35645
+ const sp = [];
35646
+ if (r.special && r.special.taiYi) sp.push('太乙天符');
35647
+ if (r.special && r.special.tianFu && !r.special.taiYi) sp.push('天符');
35648
+ if (r.special && r.special.suiHui && !r.special.taiYi) sp.push('岁会');
35649
+ if (r.special && r.special.tongTianFu) sp.push('同天符');
35650
+ if (r.special && r.special.tongSuiHui) sp.push('同岁会');
35651
+ const rows = (list, isQi) => (list || []).map((s) => {
35652
+ const nm = isQi ? s.qi : s.name;
35653
+ const on = isQi ? !!(cur.keQi && cur.keQi.qi === s.qi) : !!(cur.keYun && cur.keYun.name === s.name);
35654
+ const tag = isQi ? (s.sitian ? '司天' : s.zaiquan ? '在泉' : '') : (s.taiShao || '');
35655
+ return `<div class="mp-wl-step${on ? ' on' : ''}"><span class="mp-wl-no">${s.no}</span><span>${esc(nm || '')}</span><span class="mp-wl-tg">${esc(tag)}</span><span class="mp-wl-tm">${esc(s.startTime || '')}—${esc(s.endTime || '')}</span></div>`;
35656
+ }).join('');
35657
+ const tags = sp.map((x) => `<span class="mp-wl-tag on">${esc(x)}</span>`).join('');
35658
+ return `<section class="mp-wl" aria-label="五运六气">
35659
+ <div class="mp-wl-h">
35660
+ <span class="mp-wl-y">${esc(r.year.ganzhi)}</span>
35661
+ <span class="mp-wl-z">${esc(z.name)}</span>
35662
+ <span class="mp-wl-m">${esc(r.year.startTime || '')} — ${esc(r.year.endTime || '')}</span>
35663
+ </div>
35664
+ ${tags ? `<div class="mp-wl-sec"><em>格局</em>${tags}</div>` : ''}
35665
+ <div class="mp-wl-sec"><em>司天在泉</em>${esc(r.sitian.qi)}司天 ${esc(r.zaiquan.qi)}在泉</div>
35666
+ <div class="mp-wl-sec"><em>此刻</em>${esc(cur.text || '')}</div>
35667
+ <div class="mp-wl-sec"><em>客运五步</em></div>
35668
+ <div class="mp-wl-steps">${rows(r.keYun, false)}</div>
35669
+ <div class="mp-wl-sec"><em>客气六步</em></div>
35670
+ <div class="mp-wl-steps">${rows(r.keQi, true)}</div>
35671
+ <div class="mp-wl-note">${esc(r.scope ? r.scope.note : '')}</div>
35672
+ </section>`;
35673
+ };
35674
+
35675
+ pp.constitutionHTML = function (r) {
35676
+ if (!r) return '';
35677
+ if (r.paper && Array.isArray(r.questions)) {
35678
+ const items = r.questions.map((q) => `<li><span class="mp-cs-no">${q.no}</span><span class="mp-cs-t">${esc(q.text)}</span><em>${esc(q.name)}</em></li>`).join('');
35679
+ return `<section class="mp-cs-paper" aria-label="中医体质问卷">
35680
+ <div class="mp-cs-h"><span><b>中医体质问卷</b><i>${r.scaleCount} 种体质 × ${r.itemsPerScale} 题 · 1 没有 / 5 总是</i></span>
35681
+ <span class="mp-cs-count">填完分数再看结果</span></div>
35682
+ <ol>${items}</ol>
35683
+ <div class="mp-cs-note">${esc(r.note || '')}</div>
35684
+ </section>`;
35685
+ }
35686
+ const p = r.primary || {};
35687
+ const rows = (r.scales || []).map((s) => {
35688
+ const v = Math.max(0, Math.min(100, s.converted || 0));
35689
+ const on = s.level && s.level !== '否';
35690
+ const main = p.key === s.key;
35691
+ return `<div class="mp-cs-row${main ? ' main' : ''}">
35692
+ <span class="mp-cs-n">${esc(s.name)}</span>
35693
+ <span class="mp-cs-track"><i class="${on ? 'on' : ''}" style="width:${v}%"></i><b class="mp-cs-tick" style="left:40%"></b></span>
35694
+ <span class="mp-cs-v">${v}</span>
35695
+ <span class="mp-cs-l">${esc(s.level || '')}</span>
35696
+ </div>`;
35697
+ }).join('');
35698
+ const care = (r.care || []).map((c) => `<li>${esc(c)}</li>`).join('');
35699
+ return `<section class="mp-cs" aria-label="中医体质结果">
35700
+ <div class="mp-cs-h">
35701
+ <span class="mp-cs-main">${esc(p.name || '—')}</span>
35702
+ <span class="mp-cs-meta"><b>${esc(p.oneLine || '')}</b><i>${r.secondary ? `兼夹 ${esc(r.secondary.name)}(${Math.round(r.secondary.converted)})` : '无兼夹'}${r.flat ? ' · 区分度不足' : ''}</i></span>
35703
+ <span class="mp-cs-count">已答 ${r.meta ? r.meta.answered : 0}/${r.meta ? r.meta.total : 0} 题</span>
35704
+ </div>
35705
+ <div class="mp-cs-bars">${rows}</div>
35706
+ <div class="mp-cs-sec"><em>这体质</em>${esc(p.trait || '')}</div>
35707
+ <div class="mp-cs-sec"><em>倾向</em>${esc(p.tendency || '')}</div>
35708
+ <div class="mp-cs-care"><b>调养方向</b><ul>${care}</ul></div>
35709
+ <div class="mp-cs-note">${esc(r.note || '')}</div>
35710
+ </section>`;
35711
+ };
35712
+
34159
35713
  pp.mbtiHTML = function (r) {
34160
35714
  if (!r) return '';
34161
35715
  if (r.needAnswers && Array.isArray(r.questions)) {
@@ -34684,7 +36238,9 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
34684
36238
  function activate(next) {
34685
36239
  setTab(next);
34686
36240
  recordRecent(next);
34687
- if (next === 'mbti') mbEnter(); else run();
36241
+ if (next === 'mbti') mbEnter();
36242
+ else if (next === 'constitution') csEnter();
36243
+ else run();
34688
36244
  }
34689
36245
 
34690
36246
  // ── 我的命盘(本机档案)────────────────────────────────────────
@@ -34843,6 +36399,9 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
34843
36399
  chenggu: 'birth', trend: 'birth', fengshui: 'birth', name: 'birth',
34844
36400
  xiaoliuren: 'cast',
34845
36401
  mbti: 'none',
36402
+ constitution: 'none',
36403
+ wuyunliuqi: 'date',
36404
+ taiyi: 'date',
34846
36405
  };
34847
36406
  const SAMPLE = { date: '1990-05-12', time: '14:30' };
34848
36407
  const kindOf = (t) => TIME_KIND[t] || 'none';
@@ -34878,6 +36437,7 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
34878
36437
  fengshui: '风水看八宅命卦、大游年四吉四凶与流年九宫飞星;也可按坐向看门主灶倾向,或按运与坐向排玄空下卦盘。',
34879
36438
  name: '起名按八字喜用匹配字库五行,可评估现有名字或生成候选名。',
34880
36439
  mbti: '十六型人格是一份 MBTI 风格问卷:24 题分 6 页,每题按 1(完全不像)到 5(很像我)打分;答满 12 题就能出结果。',
36440
+ constitution: '中医体质是九种体质各 5 题的自评问卷:45 题分 9 页,每题按 1(没有)到 5(总是)打分;答满 27 题就能出结果,按转化分判主次体质并给调养方向,不是医学诊断。',
34881
36441
  };
34882
36442
 
34883
36443
  // 问事的体系必须写清「所问之事」,解读才有落点;命盘类(八字/紫微)不问事,不显示这两项
@@ -34938,7 +36498,7 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
34938
36498
  /** 当前 tab 是否真的用到日期/时刻(塔罗、人格不看时间;梅花「数字起卦」也不用)
34939
36499
  * 与 syncMhMode 里的显隐共用这一处判断:字段既然藏起来了,run() 就不能拿它拦人。 */
34940
36500
  function usesTimeInput() {
34941
- return tab !== 'tarot' && tab !== 'mbti' && !(tab === 'meihua' && mhMode === 'number');
36501
+ return tab !== 'tarot' && tab !== 'mbti' && tab !== 'constitution' && !(tab === 'meihua' && mhMode === 'number');
34942
36502
  }
34943
36503
 
34944
36504
  /** 用户手动改了日期或时刻 */
@@ -35329,7 +36889,7 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
35329
36889
  markAskInvalid(false);
35330
36890
  const args = { system: tab };
35331
36891
  // 人格问卷不是盘:不挂影子盘 ID,避免把「第几次答到第几题」当成一张盘存档
35332
- args.shadow = tab !== 'mbti';
36892
+ args.shadow = tab !== 'mbti' && tab !== 'constitution';
35333
36893
  // 推导链只覆盖六爻 / 八字 / 紫微
35334
36894
  args.trace = tab === 'liuyao' || tab === 'bazi' || tab === 'ziwei' || tab === 'qimen' || tab === 'daliuren';
35335
36895
  if (tab !== 'tarot') args.datetime = `${date}T${time}`;
@@ -35398,7 +36958,7 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
35398
36958
  if (el('changing').value !== '') args.changing = Number(el('changing').value);
35399
36959
  }
35400
36960
  }
35401
- if (tab === 'tarot') args.count = Number(el('tcount') ? el('tcount').value : 3) || 3;
36961
+ if (tab === 'tarot') args.spread = (el('tcount') && el('tcount').value) || 'three';
35402
36962
  // 速查类:各体系自己的最小输入
35403
36963
  if (tab === 'zeri') {
35404
36964
  args.startDate = date;
@@ -35436,6 +36996,11 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
35436
36996
  const nums = mbAnswersForEngine();
35437
36997
  if (nums.length) args.answers = nums;
35438
36998
  }
36999
+ if (tab === 'constitution') {
37000
+ // 体质问卷不剪尾:引擎按题位对齐九种体质,null 表示跳过,长度必须还是 45
37001
+ delete args.datetime;
37002
+ args.answers = csAns.slice();
37003
+ }
35439
37004
  if (tab === 'trend' && hourUnknown) args.hourUnknown = true;
35440
37005
  // 性别 / 出生地 / 两个开关已在上面的出生盘分支里给齐,这里只补 K 线自己的参数
35441
37006
  if (tab === 'trend') args.toAge = Number(el('toage').value) || 60;
@@ -35522,6 +37087,9 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
35522
37087
  : tab === 'trend' ? window.__PP.trendHTML(r)
35523
37088
  : tab === 'name' ? window.__PP.nameHTML(r)
35524
37089
  : tab === 'mbti' ? window.__PP.mbtiHTML(r)
37090
+ : tab === 'constitution' ? window.__PP.constitutionHTML(r)
37091
+ : tab === 'wuyunliuqi' ? window.__PP.wuyunliuqiHTML(r)
37092
+ : tab === 'taiyi' ? window.__PP.taiyiHTML(r)
35525
37093
  : tab === 'fengshui' ? window.__PP.fengshuiHTML(r)
35526
37094
  : window.__PP.quickHTML(r));
35527
37095
  // 新盘就是新的一轮:上一次的自评、贴合度、候选时辰不能跟着贴到这张盘上
@@ -35560,6 +37128,7 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
35560
37128
  recordRecent(tab);
35561
37129
  // 人格问卷没有时间可补:先看答够没有,再走同一套计算
35562
37130
  if (tab === 'mbti') { mbSubmit(); return; }
37131
+ if (tab === 'constitution') { csSubmit(); return; }
35563
37132
  const k = kindOf(tab);
35564
37133
  if (k !== 'none' && usesTimeInput() && !manual[k]) fillDateTime(nowParts());
35565
37134
  run();
@@ -35583,7 +37152,7 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
35583
37152
  }
35584
37153
  }
35585
37154
 
35586
- const QUICK = ['almanac', 'convert', 'zeri', 'chenggu', 'xiaoliuren', 'star', 'numerology', 'trend', 'fengshui', 'name', 'mbti'];
37155
+ const QUICK = ['almanac', 'convert', 'zeri', 'chenggu', 'xiaoliuren', 'star', 'numerology', 'trend', 'fengshui', 'name', 'mbti', 'constitution', 'wuyunliuqi', 'taiyi'];
35587
37156
 
35588
37157
  // 窄屏下 tab 栏是单行横滑:切到靠后的 tab 时把它滚到可视区中间,
35589
37158
  // 否则点了「人格」页面变了、高亮却停在屏幕外,看着像没响应
@@ -35613,6 +37182,8 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
35613
37182
  el('for-qimen').style.display = tab === 'qimen' ? '' : 'none';
35614
37183
  const fm = el('for-mbti');
35615
37184
  if (fm) fm.style.display = tab === 'mbti' ? '' : 'none';
37185
+ const csForm = el('for-constitution');
37186
+ if (csForm) csForm.style.display = tab === 'constitution' ? '' : 'none';
35616
37187
  const fq = el('for-quick');
35617
37188
  if (fq) fq.style.display = QUICK.indexOf(tab) >= 0 ? '' : 'none';
35618
37189
  // 所问之事 + 询问方向:六爻 / 梅花 / 六壬 / 塔罗这些问事的体系才显示
@@ -35640,10 +37211,10 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
35640
37211
  : QUICK.indexOf(tab) >= 0 ? '看结果'
35641
37212
  : birth ? '排盘' : '起卦';
35642
37213
  // 人格问卷自带「看结果」和「清空」:底部再放一个主按钮就是两个一样的入口
35643
- const mbtiOn = tab === 'mbti';
35644
- el('go').style.display = mbtiOn ? 'none' : '';
37214
+ const quizOn = tab === 'mbti' || tab === 'constitution';
37215
+ el('go').style.display = quizOn ? 'none' : '';
35645
37216
  const sampleBtn = el('sample');
35646
- if (sampleBtn) sampleBtn.style.display = mbtiOn ? 'none' : '';
37217
+ if (sampleBtn) sampleBtn.style.display = quizOn ? 'none' : '';
35647
37218
  syncMhMode(); // 内含 for-date / for-time 的显隐(塔罗、梅花数字起卦都隐藏)
35648
37219
  applyDateTime(k); // 起卦类 → 当前时刻;命盘类 → 示例默认;用户改过则保留
35649
37220
  ctxKind = k;
@@ -35688,6 +37259,165 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
35688
37259
  if (el('qk-convert-lunar')) el('qk-convert-lunar').style.display = convMode === 'lunar2solar' ? '' : 'none';
35689
37260
  }
35690
37261
 
37262
+ // ── 中医体质(P4):9 种体质各 5 题、一页 5 题的分页问卷 ─────────
37263
+ // 题面与 1–5 的说法都从引擎卷子取;答案按题位存在 csAns 里(长度固定 45,null = 跳过)。
37264
+ const CS_PER_PAGE = 5;
37265
+ const CS_MIN = 27;
37266
+ let csPaper = null;
37267
+ let csAns = [];
37268
+ let csPage = 0;
37269
+
37270
+ function ensureCsPaper() {
37271
+ if (csPaper) return csPaper;
37272
+ let p = null;
37273
+ try { p = window.Engine.compute({ system: 'constitution' }); } catch (e) { p = null; }
37274
+ const questions = (p && p.questions) || [];
37275
+ csPaper = { total: (p && p.total) || questions.length, questions, scale: (p && p.scale) || [] };
37276
+ csAns = new Array(csPaper.total).fill(null);
37277
+ return csPaper;
37278
+ }
37279
+
37280
+ function csAnswered() {
37281
+ let n = 0;
37282
+ for (let i = 0; i < csAns.length; i += 1) if (csAns[i] != null) n += 1;
37283
+ return n;
37284
+ }
37285
+
37286
+ function csScaleSay(n) {
37287
+ const hit = (csPaper && csPaper.scale ? csPaper.scale : []).filter((x) => x && x.v === n)[0];
37288
+ return hit ? hit.label : String(n);
37289
+ }
37290
+
37291
+ function csPageCount() {
37292
+ return Math.max(1, Math.ceil((csPaper ? csPaper.total : 0) / CS_PER_PAGE));
37293
+ }
37294
+
37295
+ function refreshCsMeta() {
37296
+ const total = csPaper ? csPaper.total : 0;
37297
+ const answered = csAnswered();
37298
+ const pages = csPageCount();
37299
+ const count = el('cs-count');
37300
+ if (count) count.textContent = `第 ${csPage + 1} / ${pages} 页 · 已答 ${answered} / ${total}`;
37301
+ const fill = el('cs-fill');
37302
+ if (fill) fill.style.width = total ? `${Math.round((answered / total) * 100)}%` : '0%';
37303
+ const prev = el('cs-prev');
37304
+ if (prev) prev.disabled = csPage <= 0;
37305
+ const next = el('cs-next');
37306
+ if (next) next.style.display = csPage >= pages - 1 ? 'none' : '';
37307
+ const sub = el('cs-submit');
37308
+ if (sub) sub.disabled = answered < CS_MIN;
37309
+ const hint = el('cs-hint');
37310
+ if (hint) {
37311
+ hint.classList.remove('warn');
37312
+ hint.textContent = answered >= CS_MIN
37313
+ ? `已够 ${CS_MIN} 题,可以看结果;答完 ${total} 题会更准。`
37314
+ : `答满 ${CS_MIN} 题可先看结果(还差 ${CS_MIN - answered} 题)。`;
37315
+ }
37316
+ }
37317
+
37318
+ /** 用 createElement 拼题干:题面来自引擎,少一层 innerHTML 就少一个注入面 */
37319
+ function renderCs() {
37320
+ const host = el('cs-qs');
37321
+ if (!host) return;
37322
+ const paper = ensureCsPaper();
37323
+ csPage = Math.min(Math.max(0, csPage), csPageCount() - 1);
37324
+ const start = csPage * CS_PER_PAGE;
37325
+ host.textContent = '';
37326
+ paper.questions.slice(start, start + CS_PER_PAGE).forEach((q, k) => {
37327
+ const i = start + k;
37328
+ const row = document.createElement('div');
37329
+ row.className = 'mb-q';
37330
+ const qt = document.createElement('div');
37331
+ qt.className = 'mb-qt';
37332
+ const no = document.createElement('b');
37333
+ no.textContent = pad(i + 1);
37334
+ const tx = document.createElement('span');
37335
+ tx.textContent = q.text;
37336
+ qt.append(no, tx);
37337
+ const opts = document.createElement('div');
37338
+ opts.className = 'mb-opts';
37339
+ opts.setAttribute('role', 'radiogroup');
37340
+ opts.setAttribute('aria-label', `第 ${i + 1} 题`);
37341
+ [1, 2, 3, 4, 5].forEach((n) => {
37342
+ const lab = document.createElement('label');
37343
+ lab.className = 'mb-o' + (csAns[i] === n ? ' on' : '');
37344
+ const inp = document.createElement('input');
37345
+ inp.type = 'radio';
37346
+ inp.name = `csq${i}`;
37347
+ inp.value = String(n);
37348
+ inp.checked = csAns[i] === n;
37349
+ const b = document.createElement('b');
37350
+ b.textContent = String(n);
37351
+ const say = document.createElement('span');
37352
+ say.textContent = csScaleSay(n);
37353
+ lab.append(inp, b, say);
37354
+ opts.appendChild(lab);
37355
+ });
37356
+ row.append(qt, opts);
37357
+ host.appendChild(row);
37358
+ });
37359
+ refreshCsMeta();
37360
+ }
37361
+
37362
+ function onCsPick(e) {
37363
+ const inp = e.target;
37364
+ if (!inp || inp.type !== 'radio' || String(inp.name || '').indexOf('csq') !== 0) return;
37365
+ const i = Number(String(inp.name).slice(3));
37366
+ if (!Number.isInteger(i) || i < 0 || i >= csAns.length) return;
37367
+ csAns[i] = Number(inp.value) || null;
37368
+ const row = inp.closest('.mb-q');
37369
+ if (row) row.querySelectorAll('.mb-o').forEach((lab) => lab.classList.toggle('on', lab.contains(inp)));
37370
+ refreshCsMeta();
37371
+ }
37372
+
37373
+ function csTurn(delta) {
37374
+ csPage = Math.min(Math.max(0, csPage + delta), csPageCount() - 1);
37375
+ renderCs();
37376
+ const box = el('for-constitution');
37377
+ if (box && box.scrollIntoView) box.scrollIntoView({ block: 'nearest' });
37378
+ }
37379
+
37380
+ function csNudge() {
37381
+ const hint = el('cs-hint');
37382
+ if (hint) {
37383
+ hint.classList.add('warn');
37384
+ hint.textContent = `至少答 ${CS_MIN} 题才能出结果,还差 ${Math.max(0, CS_MIN - csAnswered())} 题。`;
37385
+ }
37386
+ const host = el('cs-qs');
37387
+ if (host) {
37388
+ const rows = host.querySelectorAll('.mb-q');
37389
+ for (let k = 0; k < rows.length; k += 1) {
37390
+ if (csAns[csPage * CS_PER_PAGE + k] == null) {
37391
+ const inp = rows[k].querySelector('input');
37392
+ if (inp && inp.focus) inp.focus({ preventScroll: true });
37393
+ break;
37394
+ }
37395
+ }
37396
+ }
37397
+ const box = el('for-constitution');
37398
+ if (box && box.scrollIntoView) box.scrollIntoView({ block: 'nearest' });
37399
+ }
37400
+
37401
+ function csClear() {
37402
+ csAns = new Array(csPaper ? csPaper.total : 0).fill(null);
37403
+ csPage = 0;
37404
+ renderCs();
37405
+ if (last && last.tab === 'constitution') { last = null; el('out').replaceChildren(); }
37406
+ }
37407
+
37408
+ function csSubmit() {
37409
+ if (csAnswered() < CS_MIN) { csNudge(); return; }
37410
+ recordRecent('constitution');
37411
+ run();
37412
+ }
37413
+
37414
+ /** 切到体质 tab:答够了就直接出结果,否则清空结果区,只留问卷 */
37415
+ function csEnter() {
37416
+ renderCs();
37417
+ if (csAnswered() >= CS_MIN) { run(); return; }
37418
+ last = null;
37419
+ el('out').replaceChildren();
37420
+ }
35691
37421
  // ── 十六型人格:4 题一页的分页问卷 ──────────────────────────────
35692
37422
  // 题目与 1–5 的说法都从引擎取(compute 不带 answers 就回问卷),
35693
37423
  // 前端不再抄一份题目表;答案按题序存在 mbAns 里,null = 未答。
@@ -35900,6 +37630,19 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
35900
37630
  const mbClr = el('mbti-clear');
35901
37631
  if (mbClr) mbClr.addEventListener('click', mbClear);
35902
37632
  }
37633
+ const csHost = el('cs-qs');
37634
+ if (csHost) {
37635
+ renderCs();
37636
+ csHost.addEventListener('change', onCsPick);
37637
+ const csPrev = el('cs-prev');
37638
+ if (csPrev) csPrev.addEventListener('click', () => csTurn(-1));
37639
+ const csNext = el('cs-next');
37640
+ if (csNext) csNext.addEventListener('click', () => csTurn(1));
37641
+ const csSub = el('cs-submit');
37642
+ if (csSub) csSub.addEventListener('click', csSubmit);
37643
+ const csClr = el('cs-clear');
37644
+ if (csClr) csClr.addEventListener('click', csClear);
37645
+ }
35903
37646
  document.querySelectorAll('#for-meihua .seg button').forEach((b) => b.addEventListener('click', () => {
35904
37647
  mhMode = b.dataset.mode;
35905
37648
  syncMhMode();