dsh-metaphysics 0.59.1 → 0.60.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.
@@ -240,9 +240,16 @@ h3::after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--li
240
240
  header{flex-direction:column;align-items:stretch;gap:10px;margin-bottom:12px}
241
241
  header>*{min-width:0}
242
242
  header{gap:10px;padding-bottom:12px}
243
- .brand{font-size:19px;letter-spacing:.1em}
243
+ .brand{font-size:19px;letter-spacing:.1em;min-width:0}
244
+ /* 标题与「分组/平铺」同一排:换行会给窄屏多顶出一整行工具栏 */
245
+ .brand-row{flex-wrap:nowrap}
246
+ .brand{flex:1 1 auto;min-width:0}
247
+ .view-switch{flex:none}
248
+ .view-switch .seg button{white-space:nowrap}
244
249
  .brand .seal{width:34px;height:34px;border-radius:9px;font-size:17px}
245
- .brand-txt small{font-size:11.5px;line-height:1.5}
250
+ /* 一行副标题 + 省略号:窄屏下让它换行会把「分组/平铺」顶到下一排,整块头重脚轻 */
251
+ .brand-txt{min-width:0;overflow:hidden}
252
+ .brand-txt small{font-size:11.5px;line-height:1.5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
246
253
  /* 分组卡片:每组先露 4 个,其余折起来;否则三组铺完,表单要滚过一整屏才见到 */
247
254
  .view-switch{gap:6px}
248
255
  .view-switch .hint{display:none}
@@ -347,7 +354,7 @@ h3::after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--li
347
354
  <div class="wrap">
348
355
  <header>
349
356
  <div class="brand-row">
350
- <div class="brand"><span class="seal" aria-hidden="true">命</span><span class="brand-txt">命理排盘<span class="ver">v0.59.1</span><small>八字 · 紫微 · 六爻 · 梅花 · 六壬 · 奇门 · 合盘 · 塔罗 全部在本机计算</small></span></div>
357
+ <div class="brand"><span class="seal" aria-hidden="true">命</span><span class="brand-txt">命理排盘<span class="ver">v0.60.0</span><small>八字 · 紫微 · 六爻 · 梅花 · 六壬 · 奇门 · 合盘 · 塔罗 全部在本机计算</small></span></div>
351
358
  <div class="view-switch">
352
359
  <span class="hint">熟练了可以换回平铺</span>
353
360
  <span class="seg" role="group" aria-label="首屏样式">
@@ -379,6 +386,10 @@ h3::after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--li
379
386
  </div>
380
387
  </header>
381
388
 
389
+ <!-- 我的命盘(本机档案):卡片列表 + 存入条,渲染与存储都取自 panel-kit,
390
+ 样式在 PP.CSS 里;放在入口之上——回来第一件事通常是接着排上次那个人。 -->
391
+ <div id="profwrap"></div>
392
+
382
393
  <!-- 首屏分组:同一批入口按「想干什么」重排,点击走 web-ui 的 setTab;平铺视图下整块收起 -->
383
394
  <section class="start" id="start" aria-label="选择要算的事">
384
395
  <div class="recent" id="recent" hidden>
@@ -634,7 +645,7 @@ h3::after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--li
634
645
  <h3>对拍:把典籍当期望值</h3>
635
646
  <ul><li>六爻:京房八宫卦序与世应,64 卦全表;旬空六旬表</li><li>六壬:日干寄宫、中气换将;紫微:纳音定五行局、紫微天府相照</li><li>卦例:《增删卜易》9 条 +《卜筮正宗》128 条(93 成对 + 35 静卦),核本变卦 / 动爻数 / 卦宫 / 缺爻 / 旬空</li><li>奇门:20 个时辰 × 9 宫,核四柱 / 局数 / 值符 / 值使 / 星门神 / 天盘干</li></ul>
636
647
  <h3>工程验证</h3>
637
- <ul><li>测试:138 个测试文件 / 6737 条断言;七道闸(回归 · 断言基线 · 测试可失败自检 · HTML 注入面 · 工程约定 · 产物宿主形态 · 随机扫射)</li><li>可复现:seed / asOf 显式;同一输入同一输出</li><li>口径:晚子时、真太阳时、奇门三流派、闰月归月都可切换,并有字段级差异对照</li></ul>
648
+ <ul><li>测试:147 个测试文件 / 7139 条断言;七道闸(回归 · 断言基线 · 测试可失败自检 · HTML 注入面 · 工程约定 · 产物宿主形态 · 随机扫射)</li><li>可复现:seed / asOf 显式;同一输入同一输出</li><li>口径:晚子时、真太阳时、奇门三流派、闰月归月都可切换,并有字段级差异对照</li></ul>
638
649
  <h3>已知边界(不猜)</h3>
639
650
  <ul><li>六爻应期(具体哪天应验)不做;卦例只录动爻个数,不录爻位</li><li>财富/健康只给结构判断与档位,不给金额区间、不作诊断</li><li>紫微飞星已做「单一支化逐宫追到成环」与「三方四正 16 条按落宫归堆」,但都未叠大限流年四化、不折吉凶;八字交运日已能算到日/时,但未与第三方排盘案例批量对拍</li></ul>
640
651
  <p class="note">内容用于传统文化学习与自我探索,不构成医疗、投资、法律或人事决策依据。</p>
@@ -1600,13 +1611,19 @@ function termInstantJd(year, index) {
1600
1611
  function jdToParts(jdLocal) {
1601
1612
  const jdn = Math.floor(jdLocal + 0.5);
1602
1613
  const frac = jdLocal + 0.5 - jdn;
1603
- const totalMinutes = Math.round(frac * 1440);
1614
+ // 按「整分钟向下取」而不是四舍五入:23:59:45 一进位就成了 1440 分,
1615
+ // 旧写法把小时取模、日序不动,于是这一瞬时塌回「当日 00:00」——
1616
+ // 日柱退一天、partsToJd 往返回来差将近一天(晚子时换日与真太阳时跨午夜正好踩在刀口上)。
1617
+ // 向下取后 hour/minute 与 jdn 永远自洽:拆出来的那一天,就是这一刻所在的那一天。
1618
+ // 但向下取会被浮点噪声咬住:JD 里的 13:00 常算成 779.99999 分,直接 floor 就是 12:59,
1619
+ // 未时变成午时。先归到最近的整秒(输入精度到分,误差远小于一秒),再向下取整分。
1620
+ const totalMinutes = Math.min(1439, Math.floor(Math.round(frac * 86400) / 60));
1604
1621
  const { year, month, day } = fromJDN(jdn);
1605
1622
  return {
1606
1623
  year,
1607
1624
  month,
1608
1625
  day,
1609
- hour: Math.floor(totalMinutes / 60) % 24,
1626
+ hour: Math.floor(totalMinutes / 60),
1610
1627
  minute: totalMinutes % 60,
1611
1628
  };
1612
1629
  }
@@ -1733,7 +1750,7 @@ return { STEMS, BRANCHES, TERM_NAMES, termLongitude, toJDN, fromJDN, sexagenaryI
1733
1750
 
1734
1751
  __mods.lunar = (function () {
1735
1752
  // 农历(定朔):朔望月计算 + 冬至所在月为十一月 + 无中气置闰
1736
- const { toJDN, jdToParts, termInstantJd, deltaTSeconds, localOffsetFor, OFFSET_MODERN } = (__mods.calendar || {});
1753
+ const { toJDN, jdToParts, termInstantJd, deltaTSeconds, localOffsetFor } = (__mods.calendar || {});
1737
1754
 
1738
1755
  const SYNODIC = 29.530588861;
1739
1756
  const NEW_MOON_EPOCH = 2451550.09766;
@@ -1795,12 +1812,11 @@ function dayNumberOf(jd) {
1795
1812
  return Math.floor(jd + 0.5);
1796
1813
  }
1797
1814
 
1798
- // 1929 年前后的时区切换常量与换算统一放在 calendar.js(节气与农历共用)
1799
- /** 把 calendar 给出的「东八区轴」时刻换成当时的地方轴 */
1800
- function toHistoricalLocal(jdBeijing) {
1801
- const jdUt = jdBeijing - OFFSET_MODERN;
1802
- return jdUt + localOffsetFor(jdUt);
1803
- }
1815
+ // 时区切换常量与换算统一在 calendar.js:termInstantJd 与 newMoonJd 都已经落在
1816
+ // 「当时的本地时间轴」上(1929 前北京地方平时,之后东八区),两边直接比日序就行。
1817
+ // 以前这里再套一次 localOffset 把节气拉回本地轴,等于对同一个量移了两次时:
1818
+ // 净偏移约 −12 分,节气时刻一旦落在午夜前后十几分钟内就少算一天,
1819
+ // 中气归属错一个月,置闰跟着整月错位(1917 报闰三、真历表闰二;1922 报闰六、真历表闰五)。
1804
1820
 
1805
1821
  function kNear(jdDay) {
1806
1822
  return Math.round((jdDay + 0.5 - NEW_MOON_EPOCH) / SYNODIC);
@@ -1810,7 +1826,7 @@ function kNear(jdDay) {
1810
1826
  function hasZhongQi(year, startDay, endDay) {
1811
1827
  for (let y = year - 1; y <= year + 1; y++) {
1812
1828
  for (let i = 1; i < 24; i += 2) {
1813
- const jdLocal = toHistoricalLocal(termInstantJd(y, i));
1829
+ const jdLocal = termInstantJd(y, i);
1814
1830
  const d = dayNumberOf(jdLocal);
1815
1831
  if (d >= startDay && d < endDay) return { index: i, jd: jdLocal };
1816
1832
  }
@@ -1825,9 +1841,9 @@ function hasZhongQi(year, startDay, endDay) {
1825
1841
  function solarToLunar(year, month, day) {
1826
1842
  const targetDay = toJDN(year, month, day);
1827
1843
 
1828
- const wsDayThisYear = dayNumberOf(toHistoricalLocal(termInstantJd(year, 23)));
1844
+ const wsDayThisYear = dayNumberOf(termInstantJd(year, 23));
1829
1845
  const wsYear = targetDay < wsDayThisYear ? year - 1 : year;
1830
- const wsDay = dayNumberOf(toHistoricalLocal(termInstantJd(wsYear, 23)));
1846
+ const wsDay = dayNumberOf(termInstantJd(wsYear, 23));
1831
1847
 
1832
1848
  // 十一月朔:日序不晚于冬至的最后一个朔日
1833
1849
  let k = kNear(wsDay);
@@ -1837,7 +1853,7 @@ function solarToLunar(year, month, day) {
1837
1853
  // ── 置闰前提(国标口径)──
1838
1854
  // 只有「两个冬至月之间有 13 个朔望月」才需要置闰,且取其中第一个无中气的月。
1839
1855
  // 若只有 12 个月,即使某月无中气也不闰(1985 正月、2053 正月就是这种反例)。
1840
- const nextWsDay = dayNumberOf(toHistoricalLocal(termInstantJd(wsYear + 1, 23)));
1856
+ const nextWsDay = dayNumberOf(termInstantJd(wsYear + 1, 23));
1841
1857
  let kNext = k;
1842
1858
  while (dayNumberOf(newMoonJd(kNext + 1)) <= nextWsDay) kNext++;
1843
1859
  const cycleMonths = kNext - k;
@@ -2112,7 +2128,15 @@ __mods.random = (function () {
2112
2128
 
2113
2129
  /** mulberry32:32 位状态,分布够用,实现只有几行 */
2114
2130
  function mulberry32(seed) {
2115
- let a = Number(seed) >>> 0;
2131
+ const n = Number(seed);
2132
+ // 状态只有 32 位,但不能只截低 32 位(`>>> 0`):相差 2^32 的种子会跑出同一串数。
2133
+ // 拿毫秒时间戳当种子时,隔 49.7 天的两次摇卦会得到完全一样的卦——用户换了种子却看不出区别,
2134
+ // 「可复现」就退成了「可猜测」。所以把高位与小数位各折一份进状态;
2135
+ // 整数种子(0…2^31)的低 32 位仍原样参与,小种子不会莫名其妙换一卦。
2136
+ const lo = n >>> 0;
2137
+ const hi = Math.floor(Math.abs(n) / 4294967296) >>> 0;
2138
+ const frac = Math.round((n - Math.trunc(n)) * 4294967296) >>> 0;
2139
+ let a = (lo ^ Math.imul(hi + 1, 0x85ebca6b) ^ frac) >>> 0;
2116
2140
  if (!a) a = 0x9e3779b9;
2117
2141
  return function next() {
2118
2142
  a = (a + 0x6d2b79f5) >>> 0;
@@ -2181,6 +2205,26 @@ function inDstSpan(t) {
2181
2205
  return true;
2182
2206
  }
2183
2207
 
2208
+ /**
2209
+ * 换表当天绕不开的两个钟面区间:
2210
+ * - 起算日 02:00–02:59:钟表从 02:00 直接拨到 03:00,这个读数在当年根本不存在;
2211
+ * - 终止日 01:00–01:59:钟表往回拨一小时,同一读数会走两遍,对应两个真实时刻。
2212
+ * 两者都不是「算错了」,而是输入本身有歧义,需要告诉调用方我们按哪种读法折的。
2213
+ * @param {{year:number,month:number,day:number,hour:number,minute?:number}} t
2214
+ * @returns {'gap'|'twice'|null}
2215
+ */
2216
+ function dstAmbiguity(t) {
2217
+ const span = DST_SPANS[t.year];
2218
+ if (!span) return null;
2219
+ const [sy, sm, sd] = span[0].split('-').map(Number);
2220
+ const [ey, em, ed] = span[1].split('-').map(Number);
2221
+ const n = ORD(t.year, t.month, t.day);
2222
+ const minutes = t.hour * 60 + (t.minute || 0);
2223
+ if (n === ORD(sy, sm, sd) && minutes >= 120 && minutes < 180) return 'gap';
2224
+ if (n === ORD(ey, em, ed) && minutes >= 60 && minutes < 120) return 'twice';
2225
+ return null;
2226
+ }
2227
+
2184
2228
  /**
2185
2229
  * 夏令时校正:钟面时间减 1 小时(跨日正确回退)。
2186
2230
  * @param {{year:number,month:number,day:number,hour:number,minute?:number}} t
@@ -2200,10 +2244,17 @@ function applyDst(t) {
2200
2244
  day = daysInMonth(year, month);
2201
2245
  }
2202
2246
  }
2247
+ const amb = dstAmbiguity(t);
2203
2248
  return {
2204
2249
  year, month, day, hour, minute,
2205
2250
  applied: true,
2206
- note: `检出自 ${DST_SPANS[t.year][0]} 至 ${DST_SPANS[t.year][1]} 的中国夏令时:出生钟面时间已减 1 小时校正`,
2251
+ note: `检出自 ${DST_SPANS[t.year][0]} 至 ${DST_SPANS[t.year][1]} 的中国夏令时:出生钟面时间已减 1 小时校正`
2252
+ + (amb === 'gap'
2253
+ ? ';这一天 02:00–03:00 钟表直接跳过,所给钟面时间在当年并不存在——这里按「拨快后的读数」回退一小时,'
2254
+ + '若登记的本是标准时间,请传 dst:false 重排。'
2255
+ : amb === 'twice'
2256
+ ? ';这一天 01:00–02:00 因回拨走了两遍,同一钟面时间对应两个真实时刻——这里按夏令时的那一遍(回退一小时)取。'
2257
+ : ''),
2207
2258
  };
2208
2259
  }
2209
2260
 
@@ -2214,7 +2265,7 @@ function withDstNote(notes, dst) {
2214
2265
  return out;
2215
2266
  }
2216
2267
 
2217
- return { DST_SPANS, inDstSpan, applyDst, withDstNote };
2268
+ return { DST_SPANS, inDstSpan, dstAmbiguity, applyDst, withDstNote };
2218
2269
  })();
2219
2270
 
2220
2271
  __mods.shensha = (function () {
@@ -7570,8 +7621,11 @@ function buildZiwei(input) {
7570
7621
  };
7571
7622
  })();
7572
7623
 
7573
- // 流年(当年起 12 年)
7574
- const thisYear = new Date().getFullYear();
7624
+ // 流年(基准年起 12 年):基准年优先用调用方传来的 asOf,缺省才落本机当年。
7625
+ // 直接读挂钟的话,同一组参数(同一个句柄)跨年会排出不同的流年表,
7626
+ // 「同参数同盘」的承诺就只在当年成立。
7627
+ const asOfForLiunian = asOfParts(input.asOf);
7628
+ const thisYear = (asOfForLiunian && asOfForLiunian.year) || new Date().getFullYear();
7575
7629
  const liunian = [];
7576
7630
  for (let y = thisYear; y < thisYear + 12; y++) {
7577
7631
  const gz = ganzhiName((((y - 4) % 60) + 60) % 60);
@@ -9486,21 +9540,37 @@ function tenGodOfZhi(dayStem, branch) {
9486
9540
 
9487
9541
  const yearGanzhi = (year) => ganzhiName(((year - 4) % 60 + 60) % 60);
9488
9542
 
9489
- /** 把大运表补到覆盖 toAge(引擎默认只排 8 步 / 80 年)。 */
9490
- function extendDayun(list, direction, toAge) {
9491
- const out = list.slice();
9543
+ /**
9544
+ * 大运分段,按「交运那一年」切,年龄落在与 points[].age 同一条虚岁轴上。
9545
+ *
9546
+ * 不能直接用 dayun.startAge:那是周岁(三日用折一年从出生日算起),
9547
+ * 而 K 线一年一格、`age` 就是虚岁(= 年份 − 出生年 + 1,与八字流年表同一口径)。
9548
+ * 两套轴混在一起会让整条线提前一年:1990-05-12 男命 1998-08 交壬午,
9549
+ * 旧写法把 1997(还没起运)划进壬午,2007(仍在壬午)却标成癸未。
9550
+ * 交运日期本来就是同一份数据的精确表达,所以按它的年份起算;
9551
+ * 引擎只排八步,八步之外按十年一格推年份,干支仍按六十甲子顺/逆推。
9552
+ */
9553
+ function buildSegments(list, direction, birthYear, toAge) {
9554
+ const toYear = birthYear + toAge - 1;
9492
9555
  const step = direction === '逆行' ? -1 : 1;
9556
+ const years = [];
9557
+ for (const d of list) years.push({ ganZhi: d.ganzhi, startYear: Number(String(d.fromDate).slice(0, 4)) });
9493
9558
  let guard = 0;
9494
- while (out.length && out[out.length - 1].startAge <= toAge && guard < 24) {
9495
- const last = out[out.length - 1];
9496
- // last.ganzhi 是「甲辰」这样的名字,不是六十甲子序号:先换回序号再顺/逆推一步
9497
- const lastIdx = sexagenaryIndex(STEMS.indexOf(last.ganzhi[0]), BRANCHES.indexOf(last.ganzhi[1]));
9498
- const nextIdx = (lastIdx + step + 60) % 60;
9499
- const g = ganzhiName(nextIdx);
9500
- out.push({ step: last.step + 1, startAge: last.startAge + 10, ganzhi: g.name, stem: g.stem, branch: g.branch, stemTenGod: last.stemTenGod });
9559
+ while (years.length && years[years.length - 1].startYear <= toYear && guard < 24) {
9560
+ const last = years[years.length - 1];
9561
+ // ganZhi 是「甲辰」这样的名字,不是六十甲子序号:先换回序号再顺/逆推一步
9562
+ const lastIdx = sexagenaryIndex(STEMS.indexOf(last.ganZhi[0]), BRANCHES.indexOf(last.ganZhi[1]));
9563
+ const g = ganzhiName((lastIdx + step + 60) % 60);
9564
+ years.push({ ganZhi: g.name, startYear: last.startYear + 10 });
9501
9565
  guard++;
9502
9566
  }
9503
- return out;
9567
+ return years
9568
+ .map((s, i) => ({
9569
+ ganZhi: s.ganZhi,
9570
+ startAge: s.startYear - birthYear + 1,
9571
+ endAge: (years[i + 1] ? years[i + 1].startYear : s.startYear + 10) - birthYear,
9572
+ }))
9573
+ .filter((s) => s.startAge <= toAge);
9504
9574
  }
9505
9575
 
9506
9576
  /**
@@ -9536,16 +9606,9 @@ function buildTrend(input) {
9536
9606
  const scoreOf = (god) => table[god] ?? 0;
9537
9607
  const boundary = LEVEL_CUTS.some((c) => Math.abs(r.strength.score - c) <= BOUNDARY_EPS);
9538
9608
 
9539
- const dayun = extendDayun(r.dayun.list, r.dayun.direction, toAge);
9540
- const segments = dayun
9541
- .map((d, i) => ({
9542
- ganZhi: d.ganzhi,
9543
- startAge: d.startAge,
9544
- endAge: (dayun[i + 1] ? dayun[i + 1].startAge : d.startAge + 10) - 1,
9545
- }))
9546
- .filter((s) => s.startAge <= toAge);
9547
-
9548
9609
  const birthYear = Number(String(r.solarTime.civilText).slice(0, 4));
9610
+ const segments = buildSegments(r.dayun.list, r.dayun.direction, birthYear, toAge);
9611
+
9549
9612
  const points = [];
9550
9613
  let prev = 50;
9551
9614
  for (let age = 1; age <= toAge; age++) {
@@ -13855,6 +13918,11 @@ __mods.zeri = (function () {
13855
13918
  // 口径透明:这是排序引导,不是吉凶断语;未收录逐日神煞,冲煞按日支对生肖。
13856
13919
  const { fromJDN, toJDN } = (__mods.calendar || {});
13857
13920
  const { buildAlmanac, SHENGXIAO } = (__mods.almanac || {});
13921
+ const { fail } = (__mods.fail || {});
13922
+
13923
+ // 择日的入参错误一律带 E_INPUT:裸 new Error 到工具层只剩 [E_INTERNAL],
13924
+ // 模型拿到的是一句没有指向性的文案,改不了参数。
13925
+ const bad = (message) => fail('E_INPUT', message);
13858
13926
 
13859
13927
  /** 支持的事项:直接宜词 + 关联宜词(黄历措辞)。 */
13860
13928
  const TASKS = {
@@ -13957,39 +14025,50 @@ const ymd = (p) => `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day
13957
14025
  */
13958
14026
  function buildZeri(input) {
13959
14027
  const task = input.task;
13960
- if (!TASKS[task]) throw new Error(`不支持的事项「${task}」。支持:${Object.keys(TASKS).join('、')}`);
14028
+ if (!TASKS[task]) throw bad(`不支持的事项「${task}」。支持:${Object.keys(TASKS).join('、')}`);
13961
14029
  const shengXiaoRaw = typeof input.shengXiao === 'string' ? input.shengXiao.trim() : '';
13962
14030
  const shengXiao = shengXiaoRaw || null;
13963
- if (shengXiao && !SHENGXIAO.includes(shengXiao)) throw new Error(`shengXiao 须为生肖名:${SHENGXIAO.join('、')}`);
14031
+ if (shengXiao && !SHENGXIAO.includes(shengXiao)) throw bad(`shengXiao 须为生肖名:${SHENGXIAO.join('、')}`);
13964
14032
 
13965
14033
  // 候选日模式:只比用户给的这几天,不再按区间扫
13966
14034
  let candidates = null;
13967
14035
  if (input.candidates !== undefined && input.candidates !== null) {
13968
14036
  if (!Array.isArray(input.candidates)) {
13969
- throw new Error('candidates 须为日期数组,例如 ["2026-12-01","2026-12-08","2026-12-15"]');
14037
+ throw bad('candidates 须为日期数组,例如 ["2026-12-01","2026-12-08","2026-12-15"]');
13970
14038
  }
13971
- if (input.candidates.length < 2) throw new Error('candidates 至少给 2 个候选日;只想看一天请用 startDate + days: 1');
13972
- if (input.candidates.length > 12) throw new Error('candidates 最多 12 个候选日;更多请用 startDate + days 扫区间');
14039
+ if (input.candidates.length < 2) throw bad('candidates 至少给 2 个候选日;只想看一天请用 startDate + days: 1');
14040
+ if (input.candidates.length > 12) throw bad('candidates 最多 12 个候选日;更多请用 startDate + days 扫区间');
13973
14041
  const seen = new Set();
13974
14042
  candidates = [];
13975
14043
  for (const raw of input.candidates) {
13976
14044
  const p = parseYmd(raw);
13977
- if (!p) throw new Error(`候选日不是真实存在的公历日期:「${raw}」,请用 2026-12-01 这种格式`);
14045
+ if (!p) throw bad(`候选日不是真实存在的公历日期:「${raw}」,请用 2026-12-01 这种格式`);
13978
14046
  const key = ymd(p);
13979
14047
  if (seen.has(key)) continue;
13980
14048
  seen.add(key);
13981
14049
  candidates.push(p);
13982
14050
  }
13983
- if (candidates.length < 2) throw new Error('候选日去重后不足 2 天;至少要两个不同的日子才谈得上比较');
14051
+ if (candidates.length < 2) throw bad('候选日去重后不足 2 天;至少要两个不同的日子才谈得上比较');
13984
14052
  }
13985
14053
 
13986
14054
  let start = null;
13987
14055
  let days = 0;
13988
14056
  if (!candidates) {
13989
14057
  const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(String(input.startDate || '').trim());
13990
- if (!m) throw new Error('择日需要 startDate(公历 YYYY-MM-DD)——请向用户询问起算日,不要代填');
14058
+ if (!m) throw bad('择日需要 startDate(公历 YYYY-MM-DD)——请向用户询问起算日,不要代填');
13991
14059
  start = { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) };
13992
- days = Math.max(1, Math.min(60, Number.isInteger(input.days) ? input.days : 30));
14060
+ // days 以前只认 Number.isInteger,于是 "45"(模型很爱给字符串)与 45.5 都静默变成 30 天:
14061
+ // 用户要的区间被悄悄改短,而结果里只看得到实际扫了几天,看不出被改过。
14062
+ // 现在:能规范成整数的(含数字串)就规范,范围外或非数字一律报错,不猜。
14063
+ const rawDays = input.days;
14064
+ if (rawDays === undefined || rawDays === null || rawDays === '') {
14065
+ days = 30;
14066
+ } else {
14067
+ const n = Number(rawDays);
14068
+ if (!Number.isInteger(n)) throw bad(`择日的 days 要是 1–60 的整数,收到「${rawDays}」`);
14069
+ if (n < 1 || n > 60) throw bad(`择日的 days 要在 1–60 之间(${n} 超出范围);更长请用分段多次查询`);
14070
+ days = n;
14071
+ }
13993
14072
  }
13994
14073
 
13995
14074
  const spec = TASKS[task];
@@ -18841,6 +18920,8 @@ const { buildBazi } = (__mods.bazi || {});
18841
18920
 
18842
18921
  const KINDS = ['career', 'wealth', 'love', 'move', 'health', 'other'];
18843
18922
  const HOURS = [0, 1, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 23];
18923
+ /** 事件条数上限:对账用不到几百条,超限是输入异常而不是需求 */
18924
+ const MAX_EVENTS = 200;
18844
18925
 
18845
18926
  const TEN_GOD = (l) => l.stemTenGod || '';
18846
18927
  const has = (l, re) => re.test((l.relations || []).join(''));
@@ -18899,10 +18980,29 @@ function rectifyBirth(opts) {
18899
18980
  e.code = 'E_INPUT';
18900
18981
  throw e;
18901
18982
  }
18902
- const nowYear = Math.min(...clean.map((x) => x.year));
18903
- const hours = Array.isArray(opts.hours) && opts.hours.length
18904
- ? opts.hours.filter((h) => Number.isInteger(h) && h >= 0 && h <= 23)
18983
+ // 事件只用来对账,几十条已足够。不设上限的后果很具体:20 万条会原样回显成 2MB 结果,
18984
+ // 并且下面的取最小值(Math.min(...arr) 逐元素入参)直接把栈打爆。
18985
+ if (clean.length > MAX_EVENTS) {
18986
+ const e = new Error(`生时校正的事件最多 ${MAX_EVENTS} 条,收到 ${clean.length} 条——只报最近几十年里确实发生过的事即可`);
18987
+ e.code = 'E_INPUT';
18988
+ throw e;
18989
+ }
18990
+ if (clean.some((x) => x.year < 1600 || x.year > 2400)) {
18991
+ const e = new Error('事件年份要落在 1600–2400 之间(与排盘支持的年份一致)');
18992
+ e.code = 'E_INPUT';
18993
+ throw e;
18994
+ }
18995
+ const nowYear = clean.reduce((m, x) => (x.year < m ? x.year : m), clean[0].year);
18996
+ // 候选时辰同样要限:一天只有 12 个时辰,给了 1000 个就是白排 1000 张盘
18997
+ const wanted = Array.isArray(opts.hours) && opts.hours.length
18998
+ ? [...new Set(opts.hours.filter((h) => Number.isInteger(h) && h >= 0 && h <= 23))]
18905
18999
  : HOURS;
19000
+ if (!wanted.length) {
19001
+ const e = new Error('候选时辰里没有有效的小时(0–23 的整数)');
19002
+ e.code = 'E_INPUT';
19003
+ throw e;
19004
+ }
19005
+ const hours = wanted.slice(0, 24);
18906
19006
 
18907
19007
  const candidates = hours.map((hour) => {
18908
19008
  const chart = buildBazi({
@@ -19656,7 +19756,7 @@ const PREFIX = 'mc1.';
19656
19756
  * 反过来,seed / asOf / direction / 各流派开关都必须留下——少了它们就复现不出同一张盘。
19657
19757
  */
19658
19758
  const SKIP = new Set([
19659
- 'handle', 'trace', 'shadow', 'shadowVersion', 'diff', 'withInterpret', 'cross',
19759
+ 'handle', 'trace', 'shadow', 'shadowVersion', 'diff', 'withInterpret', 'detail', 'cross',
19660
19760
  'verifyPast', 'rectify', 'events', 'ask', 'followup', 'question', 'geocode', 'caliber',
19661
19761
  // 一句话改参是把原话翻成补丁:补丁已经落在其余参数里,原话本身不进句柄,
19662
19762
  // 否则同一张盘换个说法就换一个句柄,回放时还会再套一遍补丁。
@@ -19804,6 +19904,9 @@ function decodeHandle(handle) {
19804
19904
  return payload;
19805
19905
  }
19806
19906
 
19907
+ /** 不能作为增量键的名字:`out['__proto__'] = v` 走的是 setter,会把 out 的原型换掉 */
19908
+ const KEY_BLOCKED = new Set(['__proto__', 'constructor', 'prototype']);
19909
+
19807
19910
  /**
19808
19911
  * 把 handle 与增量参数并起来。没有 handle 时原样返回(深一层的浅拷贝,避免改调用方对象)。
19809
19912
  * 增量优先:{ handle, asOf: 2030 } 只覆盖 asOf,其余沿用原参数。
@@ -19816,7 +19919,8 @@ function resolveHandle(args = {}) {
19816
19919
  // 显式的 undefined 当作「没传」:否则 { handle, datetime: undefined } 会把句柄里的日期抹掉
19817
19920
  const out = { ...base };
19818
19921
  for (const [k, v] of Object.entries(rest)) {
19819
- if (v !== undefined) out[k] = v;
19922
+ if (v === undefined || KEY_BLOCKED.has(k)) continue;
19923
+ out[k] = v;
19820
19924
  }
19821
19925
  return out;
19822
19926
  }
@@ -21281,13 +21385,17 @@ function ziweiSchoolArgs(args) {
21281
21385
  return out;
21282
21386
  }
21283
21387
 
21284
- /** 解析 'YYYY-MM-DDTHH:mm' / 'YYYY-MM-DD HH:mm' / 'YYYY-MM-DD' */
21388
+ /** 解析 'YYYY-MM-DDTHH:mm' / 'YYYY-MM-DD HH:mm' / 'YYYY-MM-DD'
21389
+ * 可以带秒(用户常直接粘 ISO 串),但引擎的民用时间口径到分钟:秒只校验、不参与换算。 */
21285
21390
  function parseDateTime(text) {
21286
21391
  if (typeof text !== 'string' || !text.trim()) {
21287
21392
  throw fail('E_INPUT', '缺少 datetime(公历日期时间),例如 1990-05-12T14:30');
21288
21393
  }
21289
21394
  const m = text.trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T\s](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?$/);
21290
21395
  if (!m) throw fail('E_INPUT', `无法解析 datetime:「${text}」,请用 1990-05-12T14:30 这种格式`);
21396
+ // 秒位以前只捕获不校验:\d{1,2} 放过 0–99,于是「T03:04:99」被静默折成 03:04。
21397
+ // 与 1990-02-30 同一类问题——非法输入悄悄变成一个合法时刻,盘面照样排出,没人知道。
21398
+ if (m[6] !== undefined && Number(m[6]) > 59) throw fail('E_INPUT', `秒超出范围:${m[6]}`);
21291
21399
  const out = {
21292
21400
  year: Number(m[1]), month: Number(m[2]), day: Number(m[3]),
21293
21401
  hour: Number(m[4] ?? 0), minute: Number(m[5] ?? 0),
@@ -21722,7 +21830,7 @@ function jsonSafe(value) {
21722
21830
  // ── 影子盘:同一组归一化参数 + 同一版本 → 同一个短 ID ──────────────
21723
21831
  // 只收影响盘面的参数:地址换成有效经度后不再把原文算进去,问事/方向按体系保留。
21724
21832
  const SHADOW_IGNORE = new Set([
21725
- 'shadow', 'shadowVersion', 'diff', 'trace', 'traceError', 'withInterpret', 'cross', 'verifyPast', 'rectify', 'events',
21833
+ 'shadow', 'shadowVersion', 'diff', 'trace', 'traceError', 'withInterpret', 'detail', 'cross', 'verifyPast', 'rectify', 'events',
21726
21834
  'geocode', 'ask', 'followup', 'caliber', 'seed', 'asOf', 'handle', 'revise',
21727
21835
  ]);
21728
21836
 
@@ -21961,6 +22069,8 @@ function compute(input = {}) {
21961
22069
  args = { ...args };
21962
22070
  delete args.partner2;
21963
22071
  }
22072
+ // 经度整形 + 守卫(非有限 / 超范围 → E_RANGE;数字串 → 数字)
22073
+ args = normalizeLongitudes(args);
21964
22074
  const r = jsonSafe(computeRaw(args));
21965
22075
  // 「所问之事」以前只有六爻和塔罗会落到结果里,梅花/六壬/奇门/小六壬都丢掉了:
21966
22076
  // 于是第一屏想说「你问的是……」却拿不到那句话。这里统一补齐,已有的值不动。
@@ -22009,11 +22119,15 @@ ${r.summary}`;
22009
22119
  ${r.summary}`;
22010
22120
  }
22011
22121
  // 显式要求时才附加解析模块,避免默认输出过长;「所问之事」与「询问方向」一并传进去
22122
+ const tier = detailOf(args.detail);
22012
22123
  if (args.withInterpret) {
22013
22124
  r.interpret = interpret(r, {
22014
22125
  question: typeof args.question === 'string' ? args.question : '',
22015
22126
  direction: typeof args.direction === 'string' ? args.direction : 'general',
22016
22127
  });
22128
+ // 档位只改「回给模型的文本」,不改解析对象:面板拿到的仍是完整的 interpret。
22129
+ // 键名不用 detail / tier:合盘结果里 r.detail 是详批对象、流年行里 r.tier 是档位,都会撞。
22130
+ if (tier === 'lean') r.interpretTier = 'lean';
22017
22131
  }
22018
22132
  // 八字 × 紫微交叉验证:同一份出生信息再排另一套盘,并列两边对同一件事的说法
22019
22133
  // 交叉验证要再排另一套盘:时辰未知时紫微排不出来,这条支路直接跳过,
@@ -22109,9 +22223,13 @@ ${r.summary}`;
22109
22223
  return r;
22110
22224
  }
22111
22225
 
22112
- /** 解析与详解的紧凑文本(给模型看,不塞整块 JSON) */
22113
- function interpretText(i) {
22226
+ /** 解析与详解的紧凑文本(给模型看,不塞整块 JSON)
22227
+ * detail:'full'(默认,与历史输出逐字一致)或 'lean'(只给结论、你问的那条与三张表,
22228
+ * 长论述与「规则·出处」折成一行指针)。省的是回给模型的文本,不改盘面、不改解析对象本身。 */
22229
+ function interpretText(i, detail) {
22114
22230
  if (!i) return '';
22231
+ const lean = detail === 'lean';
22232
+ const LONG_SECTION = 300;
22115
22233
  const out = [`【${i.title || '解析与详解'}】${i.ask && i.ask.echo ? i.ask.echo : ''}`];
22116
22234
  // 直答放在最前:先回答「你问的事」,再说依据,避免读者读到最后才知道结论;
22117
22235
  // 没填问题时就不该喊「答所问」——改为「先看结论」,否则是一句假话
@@ -22124,16 +22242,27 @@ function interpretText(i) {
22124
22242
  }
22125
22243
  // 解析生成失败时不能只返回一个空壳:盘面数据仍然可用,但要让调用方知道"这段没生成出来"
22126
22244
  if (i.error) out.push(`⚠ 解析生成失败:${i.error}(上方盘面数据本身可用)`);
22127
- for (const d of i.dimensions || []) {
22245
+ const allDims = i.dimensions || [];
22246
+ // 问了方向就只给那一条:其余四条在 full 档里,模型要展开随时可以再排一次
22247
+ const dims = lean && allDims.some((d) => d && d.focused) ? allDims.filter((d) => d && d.focused) : allDims;
22248
+ for (const d of dims) {
22128
22249
  const evidence = Array.isArray(d.evidence) && d.evidence.length ? `\n 依据:${d.evidence.join(';')}${d.confidence ? ` 置信度:${d.confidence}` : ''}` : '';
22129
22250
  const plain = d.plain ? `(${d.plain})` : '';
22130
22251
  // 知识条目:「为什么这样断」+出处+可信度标注,是结论与依据之外的第三层
22131
- const know = (d.knowledge || [])
22132
- .map((k) => `\n 规则·${k.gist}〔${k.statusLabel}〕${k.detail} 出处:${k.source}${k.note ? ` 注:${k.note}` : ''}`)
22133
- .join('');
22252
+ const know = lean
22253
+ ? ((d.knowledge || []).length ? `\n 规则 ${d.knowledge.length} 条未展开(detail:"full" 给出原文与出处)` : '')
22254
+ : (d.knowledge || [])
22255
+ .map((k) => `\n 规则·${k.gist}〔${k.statusLabel}〕${k.detail} 出处:${k.source}${k.note ? ` 注:${k.note}` : ''}`)
22256
+ .join('');
22134
22257
  out.push(`${d.label}:${plain}${d.text}${d.advice ? ` 建议:${d.advice}` : ''}${evidence}${know}`);
22135
22258
  }
22136
- for (const s of i.sections || []) out.push(`${s.label}:${s.text}`);
22259
+ const secs = i.sections || [];
22260
+ const longSecs = lean ? secs.filter((s) => String(s.text || '').length > LONG_SECTION) : [];
22261
+ for (const s of secs) {
22262
+ if (lean && String(s.text || '').length > LONG_SECTION) continue;
22263
+ out.push(`${s.label}:${s.text}`);
22264
+ }
22265
+ if (longSecs.length) out.push(`(另有 ${longSecs.length} 节长论述未展开:${longSecs.map((s) => s.label).join('、')}。需要原文与出处时传 detail:"full")`);
22137
22266
  // 三张表:大运 / 流年 / 触发事件(纯文本对齐,工具输出也能直接读)
22138
22267
  for (const t of i.tables || []) {
22139
22268
  if (!Array.isArray(t.rows) || !t.rows.length) continue;
@@ -22157,7 +22286,7 @@ function interpretText(i) {
22157
22286
 
22158
22287
  /** 对照时不参与计算的参数:它们只影响解析/附加模块,不影响盘面 */
22159
22288
  // shadow / trace 是结果的派生展示,不是盘面口径;带进来会让影子盘回显把每组开关都算成「有变化」
22160
- const CALIBER_IGNORE_ARGS = ['cross', 'verifyPast', 'withInterpret', 'question', 'direction', 'ask', 'geocode', 'rectify', 'events', 'shadow', 'trace'];
22289
+ const CALIBER_IGNORE_ARGS = ['cross', 'verifyPast', 'withInterpret', 'detail', 'question', 'direction', 'ask', 'geocode', 'rectify', 'events', 'shadow', 'trace'];
22161
22290
 
22162
22291
  /**
22163
22292
  * 口径对照:同一组输入,只把某一个口径开关换成它的另一档,逐字段列出变化。
@@ -22225,15 +22354,62 @@ const JU_METHODS = ['chaibu', 'chaoshen', 'zhirun'];
22225
22354
  const TAROT_COUNTS = [1, 3, 5];
22226
22355
  const LATE_ZI_MODES = ['same-day', 'next-day'];
22227
22356
 
22357
+ /**
22358
+ * 经度守卫:入口只判 `typeof === 'number'`,于是 JSON 里的 1e999(= Infinity)
22359
+ * 会一路走到真太阳时里炸成裸 TypeError,181 这类超范围值则静默排出另一张盘。
22360
+ * 两者都比报错坏:一个把堆栈式文案交给模型,一个把错结果当成对结果说出去。
22361
+ */
22362
+ function checkLongitude(value, who = '') {
22363
+ if (value === undefined || value === null || value === '') return undefined;
22364
+ const n = Number(value);
22365
+ const tag = who ? `${who}的` : '';
22366
+ if (!Number.isFinite(n)) throw fail('E_RANGE', `${tag}经度不是有限数值:收到「${value}」。经度写十进制数字即可,例如北京 116.4`);
22367
+ if (n < -180 || n > 180) throw fail('E_RANGE', `${tag}经度要在 −180 到 180 之间(东经为正),收到 ${n}`);
22368
+ return n;
22369
+ }
22370
+
22371
+ /**
22372
+ * 把主方 / 对方 / 第三方的经度统一成数字。
22373
+ * 下游按 `typeof === 'number'` 取值,所以 "86.0"(模型常给带引号的数)会被静默丢掉,
22374
+ * 少一次真太阳时修正而结果里毫无痕迹——这里先整形,再交给校验过的值。
22375
+ */
22376
+ function normalizeLongitudes(args) {
22377
+ const self = checkLongitude(args.longitude, '');
22378
+ const p = args.partner && typeof args.partner === 'object' ? checkLongitude(args.partner.longitude, '对方') : undefined;
22379
+ const p2 = args.partner2 && typeof args.partner2 === 'object' ? checkLongitude(args.partner2.longitude, '第三方') : undefined;
22380
+ const dirty = (self !== undefined && args.longitude !== self)
22381
+ || (p !== undefined && args.partner.longitude !== p)
22382
+ || (p2 !== undefined && args.partner2.longitude !== p2);
22383
+ if (!dirty) return args;
22384
+ const out = { ...args };
22385
+ if (self !== undefined) out.longitude = self;
22386
+ if (p !== undefined) out.partner = { ...out.partner, longitude: p };
22387
+ if (p2 !== undefined) out.partner2 = { ...out.partner2, longitude: p2 };
22388
+ return out;
22389
+ }
22390
+
22391
+ /** 输出档位:缺省与 'full' 都是历史全文;'lean' 只回结论与你问的那一条,省模型上下文。
22392
+ * 其它值以前会被当成「缺省」静默放过,写了 detail:'brief' 的人以为自己省了钱,其实没有。 */
22393
+ function detailOf(value) {
22394
+ if (value === undefined || value === null || value === '' || value === 'full') return 'full';
22395
+ if (value === 'lean') return 'lean';
22396
+ throw fail('E_INPUT', `detail 只能是 'full' 或 'lean':收到「${String(value).slice(0, 24)}」`);
22397
+ }
22398
+
22228
22399
  /** asOf 只接受能解析成真实日期的字符串;非法值以前会静默回退到「此刻」 */
22229
22400
  function checkAsOf(asOf) {
22230
22401
  if (asOf === undefined || asOf === null || asOf === '') return undefined;
22231
22402
  const parts = asOfParts(asOf);
22232
22403
  if (!parts) throw fail('E_ASOF', `无法解析 asOf:「${asOf}」,请用 2026-10-22T16:00 这种格式`);
22233
- const { year, month, day } = parts;
22404
+ const { year, month, day, hour, minute } = parts;
22234
22405
  if (!(month >= 1 && month <= 12) || !(day >= 1 && day <= daysInMonth(year, month))) {
22235
22406
  throw fail('E_ASOF', `asOf 指向的日期不存在:${asOf}`);
22236
22407
  }
22408
+ // 时分以前不校验:asOf: '2026-06-01T25:99' 会被接受并原样回显,
22409
+ // 而小时又按 mod 24 折回当日子时——流日/流时静默错位,比报错更坏。
22410
+ // 与 parseDateTime 同一把尺:给了就必须在范围内,缺省才由下游补默认值。
22411
+ if (hour !== undefined && !(hour >= 0 && hour <= 23)) throw fail('E_ASOF', `asOf 的小时要在 0–23 之间:${asOf}`);
22412
+ if (minute !== undefined && !(minute >= 0 && minute <= 59)) throw fail('E_ASOF', `asOf 的分钟要在 0–59 之间:${asOf}`);
22237
22413
  return parts;
22238
22414
  }
22239
22415
 
@@ -22279,7 +22455,7 @@ function computeRaw(args = {}) {
22279
22455
  // 流月/流日/流时的基准时刻:默认本机此刻,可用 asOf 指定以便复现
22280
22456
  asOf,
22281
22457
  });
22282
- return { ...result, precision: dt.precision, summary: ziweiText(result) };
22458
+ return { ...result, ...(asOf ? { asOf } : {}), precision: dt.precision, summary: ziweiText(result) };
22283
22459
  }
22284
22460
  if (system === 'tarot') {
22285
22461
  const count = args.count === undefined || args.count === null || args.count === '' ? 3 : Number(args.count);
@@ -22938,7 +23114,7 @@ function renderText(result) {
22938
23114
  + result.trace.map((t, i) => `${i + 1}. ${t.field}|${t.result}\n 规则:${t.rule} 出处:${t.source}`).join('\n'));
22939
23115
  }
22940
23116
  if (result.interpret) {
22941
- const extra = interpretText(result.interpret);
23117
+ const extra = interpretText(result.interpret, result.interpretTier);
22942
23118
  if (extra) parts.push(extra);
22943
23119
  }
22944
23120
  if (result.cross) parts.push(`【八字 × 紫微 交叉验证】\n${crossText(result.cross)}`);
@@ -23007,7 +23183,7 @@ return { interpret, DIRECTIONS, DIRECTION_DEFAULT, BRIGHTNESS, flowOf, geocodeAd
23007
23183
  })();
23008
23184
 
23009
23185
  window.Engine = __mods.index;
23010
- window.__MP_VER = "0.59.1";
23186
+ window.__MP_VER = "0.60.0";
23011
23187
  </script>
23012
23188
  <script>
23013
23189
  // 两个 UI 共用的表单规则:宿主面板(src/app.js)与独立页(src/web-ui.js)。
@@ -23119,7 +23295,7 @@ window.__MP_VER = "0.59.1";
23119
23295
  // 共用渲染层:紫微/八字/六爻/梅花/六壬/塔罗 -> HTML 字符串。面板与独立页面共用同一份。
23120
23296
  (function () {
23121
23297
  const CSS = `
23122
- .pp,.mp-it,.mp-al,.mp-cg,.mp-cl,.mp-cli,.mp-kline,.mp-mb,.mp-mb-paper,.mp-nm,.mp-quick,.mp-cdiff,.mp-shadow,.mp-trace-card,.mp-trace-layer{--ink:#1b1b1b;--ink2:#3f3a33;--muted:#6f6a60;--label:#8a8378;--faint:#a9a296;
23298
+ .pp,.mp-it,.mp-al,.mp-cg,.mp-cl,.mp-cli,.mp-kline,.mp-mb,.mp-mb-paper,.mp-nm,.mp-quick,.mp-cdiff,.mp-shadow,.mp-trace-card,.mp-trace-layer,.mp-prof{--ink:#1b1b1b;--ink2:#3f3a33;--muted:#6f6a60;--label:#8a8378;--faint:#a9a296;
23123
23299
  --line:#c8c1b2;--dimline:#b9b2a4;--hair:#e9e2d3;
23124
23300
  /* 底色略沉作「桌面」,--card 跳到近白:原先 bg 与 card 只差 1/255,卡片感等于零。
23125
23301
  同一色相里塞 9 个几乎相等的浅色,谁都浮不起来。 */
@@ -23140,7 +23316,7 @@ window.__MP_VER = "0.59.1";
23140
23316
  /* 深色:只换变量,不动任何一条选择器。用 @media screen 限定,
23141
23317
  打印永远走浅色 —— 深色主题下直接打印会得到一整页耗墨的深底 PDF。 */
23142
23318
  @media screen and (prefers-color-scheme:dark){
23143
- .pp,.mp-it,.mp-al,.mp-cg,.mp-cl,.mp-cli,.mp-kline,.mp-mb,.mp-mb-paper,.mp-nm,.mp-quick,.mp-cdiff,.mp-shadow,.mp-trace-card,.mp-trace-layer{--ink:#eae5d9;--ink2:#d5cfc2;--muted:#aca598;--label:#a49c8e;--faint:#928b7e;
23319
+ .pp,.mp-it,.mp-al,.mp-cg,.mp-cl,.mp-cli,.mp-kline,.mp-mb,.mp-mb-paper,.mp-nm,.mp-quick,.mp-cdiff,.mp-shadow,.mp-trace-card,.mp-trace-layer,.mp-prof{--ink:#eae5d9;--ink2:#d5cfc2;--muted:#aca598;--label:#a49c8e;--faint:#928b7e;
23144
23320
  --line:#514a3e;--dimline:#5f5849;--hair:#2f2b23;
23145
23321
  /* 与浅色同理:底更沉、卡片更亮,让层级在深底上也能立起来 */
23146
23322
  --bg:#12110c;--card:#211f18;--band:#282419;--hover:#2e2a1f;--hover2:#383322;
@@ -24414,6 +24590,44 @@ window.__MP_VER = "0.59.1";
24414
24590
  .mp-hy-row{grid-template-columns:12px 30px minmax(56px,1fr) 42px 38px 28px;gap:4px;font-size:11.5px}
24415
24591
  .mp-hy-row.hy-plain{grid-template-columns:28px minmax(56px,1fr);gap:6px}
24416
24592
  }
24593
+ /* ── 我的命盘(本机档案)──
24594
+ 照市面排盘 App 的「命盘列表」做:一张卡一个人,点卡直接出盘,
24595
+ 改名/置顶/删除收在卡片右上角,hover 或键盘聚焦才出现——平时不占视觉。 */
24596
+ .mp-prof{margin:0 0 14px;font-family:system-ui,-apple-system,"Microsoft YaHei",sans-serif}
24597
+ .mp-prof-h{display:flex;align-items:center;gap:8px;margin-bottom:7px}
24598
+ .mp-prof-h b{font-size:13.5px;color:var(--ink);letter-spacing:.02em}
24599
+ .mp-prof-h .mp-prof-sub{font-size:11.5px;color:var(--muted)}
24600
+ .mp-prof-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(164px,1fr));gap:8px}
24601
+ .mp-prof-card{position:relative;border:1px solid var(--line);border-radius:12px;background:var(--card);transition:border-color .12s,box-shadow .12s}
24602
+ .mp-prof-card:hover,.mp-prof-card:focus-within{border-color:var(--red);box-shadow:0 1px 2px rgba(35,28,18,.06)}
24603
+ .mp-prof-card.pinned{border-color:var(--quan)}
24604
+ .mp-prof-use{display:flex;flex-direction:column;gap:2px;width:100%;text-align:left;background:transparent;border:none;
24605
+ padding:9px 10px;cursor:pointer;color:inherit;font:inherit;line-height:1.4}
24606
+ .mp-prof-nm{display:flex;align-items:center;gap:5px;font-size:13.5px;font-weight:600;color:var(--ink);
24607
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:52px}
24608
+ .mp-prof-nm .sx{font-size:11px;font-weight:400;color:var(--muted)}
24609
+ .mp-prof-sys{align-self:flex-start;font-size:10.5px;line-height:16px;padding:0 6px;border-radius:8px;
24610
+ background:var(--tri);color:var(--quan);border:1px solid color-mix(in srgb,var(--quan) 28%,transparent)}
24611
+ .mp-prof-bd{font-size:11.5px;color:var(--ink2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
24612
+ .mp-prof-at{font-size:11px;color:var(--faint)}
24613
+ .mp-prof-ops{position:absolute;top:6px;right:6px;display:none;gap:4px}
24614
+ .mp-prof-card:hover .mp-prof-ops,.mp-prof-card:focus-within .mp-prof-ops{display:flex}
24615
+ .mp-prof-ops button{font:inherit;font-size:10.5px;line-height:18px;padding:0 6px;border-radius:6px;cursor:pointer;
24616
+ border:1px solid var(--line);background:var(--card);color:var(--muted)}
24617
+ .mp-prof-ops button:hover{color:var(--red);border-color:var(--red)}
24618
+ .mp-prof-ops button.on{color:var(--quan);border-color:var(--quan)}
24619
+ /* 触屏没有 hover:改名/置顶/删除若只靠悬停出现,手机上永远点不到,所以常驻 */
24620
+ @media (hover:none){.mp-prof-ops{display:flex}.mp-prof-nm{padding-right:0}}
24621
+ .mp-prof-empty{margin:0;padding:10px 12px;border:1px dashed var(--line);border-radius:10px;
24622
+ font-size:12px;color:var(--muted);background:var(--band)}
24623
+ .mp-prof-bar{display:flex;align-items:center;gap:8px;margin-top:9px;flex-wrap:wrap}
24624
+ .mp-prof-bar input{font:inherit;font-size:12.5px;padding:6px 9px;border-radius:9px;border:1px solid var(--line);
24625
+ background:var(--card);color:var(--ink);min-width:0;flex:1 1 150px;max-width:220px}
24626
+ .mp-prof-bar button{font:inherit;font-size:12.5px;padding:6px 12px;border-radius:9px;cursor:pointer;
24627
+ border:1px solid var(--red);background:var(--red);color:var(--on-accent)}
24628
+ .mp-prof-bar button.ghost{background:transparent;color:var(--muted);border-color:var(--line)}
24629
+ .mp-prof-bar .mp-prof-tip{font-size:11.5px;color:var(--muted)}
24630
+ @media (max-width:360px){.mp-prof-list{grid-template-columns:repeat(auto-fill,minmax(140px,1fr))}}
24417
24631
  `;
24418
24632
  const pp = (window.__PP = window.__PP || {});
24419
24633
  pp.CSS = CSS;
@@ -24435,7 +24649,9 @@ window.__MP_VER = "0.59.1";
24435
24649
  };
24436
24650
  const POS_NAME = ['初爻', '二爻', '三爻', '四爻', '五爻', '上爻'];
24437
24651
 
24438
- const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
24652
+ // 单引号也转:本仓拼接属性一律用双引号,但「少一个字符就破功」的写法不该赌下一位
24653
+ // 贡献者守不守约定(text-node 之外的任何位置都可能被 ' 截断)。
24654
+ const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
24439
24655
 
24440
24656
  /**
24441
24657
  * 单爻线:阳一整条,阴两段;动爻描红。
@@ -26154,6 +26370,286 @@ window.__MP_VER = "0.59.1";
26154
26370
  return diff;
26155
26371
  };
26156
26372
 
26373
+ // ── 我的命盘(本机档案)────────────────────────────────────────
26374
+ // 存的是「输入」而不是结果:换台机器或清掉站点数据就没有,也不含出生时间以外的身份信息。
26375
+ // 面板与单页共用这一份实现与这一份渲染,两边看到的是同一批档案。
26376
+ const PROF_KEY = 'dsh-metaphysics/profiles-v1';
26377
+ const PROF_MAX = 24;
26378
+ const PROF_LABEL_MAX = 16;
26379
+ const PROF_ADDR_MAX = 40;
26380
+ const PROF_NOTE_MAX = 60;
26381
+ const PROF_KINDS = ['bazi', 'ziwei', 'liuyao', 'tarot', 'meihua', 'daliuren', 'hepan', 'qimen',
26382
+ 'almanac', 'zeri', 'chenggu', 'xiaoliuren', 'star', 'numerology', 'mbti', 'trend', 'fengshui', 'name', 'convert'];
26383
+ pp.PROF_KEY = PROF_KEY;
26384
+ pp.PROF_MAX = PROF_MAX;
26385
+ pp.PROF_KINDS = PROF_KINDS;
26386
+
26387
+ // 档案卡上的体系短名。面板与单页各自的 tab 标签更长(带说明),这里只要两三个字
26388
+ const PROF_KIND_LABEL = {
26389
+ bazi: '八字', ziwei: '紫微', liuyao: '六爻', tarot: '塔罗', meihua: '梅花', daliuren: '六壬',
26390
+ hepan: '合盘', qimen: '奇门', almanac: '黄历', zeri: '择日', chenggu: '称骨', xiaoliuren: '小六壬',
26391
+ star: '星座', numerology: '生命数字', mbti: '人格', trend: '人生K线', fengshui: '风水', name: '起名', convert: '农历',
26392
+ };
26393
+ pp.profKindLabel = (k) => PROF_KIND_LABEL[k] || String(k || '');
26394
+
26395
+ const isStr = (v) => typeof v === 'string';
26396
+
26397
+ /**
26398
+ * 档案来自 localStorage,等于一份用户可以自己改的外部输入:
26399
+ * 逐字段验一遍再放进列表,坏条目整条丢掉(不是把 undefined 渲染进卡片)。
26400
+ */
26401
+ function profClean(raw) {
26402
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
26403
+ if (!isStr(raw.id) || !raw.id || raw.id.length > 40) return null;
26404
+ if (!isStr(raw.kind) || PROF_KINDS.indexOf(raw.kind) < 0) return null;
26405
+ // 日期要真过一遍日历:'2020-13-45' 形如合法,但会让引擎拿一个不存在的日子排出张怪盘
26406
+ const dm = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(raw.date || ''));
26407
+ if (!dm) return null;
26408
+ const y = Number(dm[1]);
26409
+ const mo = Number(dm[2]);
26410
+ const dd = Number(dm[3]);
26411
+ if (mo < 1 || mo > 12 || dd < 1 || dd > new Date(y, mo, 0).getDate()) return null;
26412
+ const time = isStr(raw.time) && /^([01]?\d|2[0-3]):[0-5]\d$/.test(raw.time) ? raw.time : '';
26413
+ return {
26414
+ id: raw.id,
26415
+ kind: raw.kind,
26416
+ label: isStr(raw.label) ? raw.label.slice(0, PROF_LABEL_MAX) : '',
26417
+ date: raw.date,
26418
+ time: raw.hourUnknown === true ? '' : time,
26419
+ hourUnknown: raw.hourUnknown === true,
26420
+ gender: raw.gender === 'male' || raw.gender === 'female' ? raw.gender : '',
26421
+ address: isStr(raw.address) ? raw.address.slice(0, PROF_ADDR_MAX) : '',
26422
+ note: isStr(raw.note) ? raw.note.slice(0, PROF_NOTE_MAX) : '',
26423
+ pinned: raw.pinned === true,
26424
+ createdAt: Number(raw.createdAt) || 0,
26425
+ lastUsedAt: Number(raw.lastUsedAt) || 0,
26426
+ uses: Number(raw.uses) || 0,
26427
+ };
26428
+ }
26429
+
26430
+ function profReadRaw() {
26431
+ try {
26432
+ const raw = window.localStorage ? window.localStorage.getItem(PROF_KEY) : null;
26433
+ const list = raw ? JSON.parse(raw) : [];
26434
+ return Array.isArray(list) ? list : [];
26435
+ } catch (e) { return []; }
26436
+ }
26437
+
26438
+ /** 置顶在前,其余按最近排过;同一秒内按创建时间兜底,保证两次渲染顺序一致 */
26439
+ function profSort(list) {
26440
+ return list.slice().sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0)
26441
+ || (b.lastUsedAt || b.createdAt || 0) - (a.lastUsedAt || a.createdAt || 0)
26442
+ || (b.createdAt || 0) - (a.createdAt || 0));
26443
+ }
26444
+
26445
+ pp.profilesLoad = function () {
26446
+ const out = [];
26447
+ const seen = Object.create(null);
26448
+ for (const raw of profReadRaw()) {
26449
+ const p = profClean(raw);
26450
+ // 同 id 只留第一条:手改过的存储里出现重复 id 时,后一条会把前一条悄悄顶掉
26451
+ if (p && !seen[p.id]) { seen[p.id] = 1; out.push(p); }
26452
+ }
26453
+ return profSort(out);
26454
+ };
26455
+
26456
+ pp.profilesSave = function (list) {
26457
+ const arr = profSort(Array.isArray(list) ? list.slice(0, PROF_MAX) : []);
26458
+ try {
26459
+ // 没有 localStorage 就等于没写进去,不能报成功(卡片只活这一次会话)
26460
+ if (!window.localStorage) return false;
26461
+ window.localStorage.setItem(PROF_KEY, JSON.stringify(arr));
26462
+ return true;
26463
+ } catch (e) { return false; }
26464
+ };
26465
+
26466
+ // 同毫秒新建两张卡时靠这个自增序列分开;只在本页生命周期内有效
26467
+ let profSeq = 0;
26468
+ pp.profilesNewId = function () {
26469
+ const taken = Object.create(null);
26470
+ for (const p of pp.profilesLoad()) taken[p.id] = 1;
26471
+ let id;
26472
+ do { id = `p${Date.now().toString(36)}${(profSeq++).toString(36)}`; } while (taken[id]);
26473
+ return id;
26474
+ };
26475
+
26476
+ /** 新增或按 id 覆盖,返回存好后的完整列表;超出上限时丢最久没排的那张 */
26477
+ pp.profilesPut = function (entry) {
26478
+ const clean = profClean(Object.assign({
26479
+ id: pp.profilesNewId(), createdAt: Date.now(), lastUsedAt: Date.now(),
26480
+ }, entry));
26481
+ if (!clean) return { ok: false, list: pp.profilesLoad() };
26482
+ const list = pp.profilesLoad();
26483
+ const i = list.findIndex((x) => x.id === clean.id);
26484
+ if (i >= 0) {
26485
+ // 更新只换出生信息与称呼:排过几次、上次什么时候排的、置没置顶归存储管,
26486
+ // 不能因为「改个称呼」就把这张卡的历史和位置抹平
26487
+ const prev = list[i];
26488
+ clean.createdAt = prev.createdAt || clean.createdAt;
26489
+ clean.lastUsedAt = prev.lastUsedAt;
26490
+ clean.uses = prev.uses;
26491
+ clean.pinned = prev.pinned;
26492
+ list.splice(i, 1, clean);
26493
+ } else list.push(clean);
26494
+ if (list.length > PROF_MAX) {
26495
+ const byUse = list.slice().sort((a, b) => (a.lastUsedAt || a.createdAt) - (b.lastUsedAt || b.createdAt));
26496
+ const drop = byUse.find((x) => x.id !== clean.id) || byUse[0];
26497
+ const at = list.indexOf(drop);
26498
+ if (at >= 0) list.splice(at, 1);
26499
+ }
26500
+ // 写不进去就别当存成功:卡片只活在本次会话里,下次进来就没了,回执必须是失败
26501
+ if (!pp.profilesSave(list)) return { ok: false, list: pp.profilesLoad() };
26502
+ return { ok: true, list: profSort(list), item: clean };
26503
+ };
26504
+
26505
+ pp.profilesRemove = function (id) {
26506
+ const list = pp.profilesLoad().filter((x) => x.id !== id);
26507
+ // 写不进去就把存储里的真实状态还回去:否则卡片从界面消失、下次进来又复活
26508
+ if (!pp.profilesSave(list)) return pp.profilesLoad();
26509
+ return list;
26510
+ };
26511
+
26512
+ pp.profilesPin = function (id) {
26513
+ const list = pp.profilesLoad().map((x) => (x.id === id ? Object.assign({}, x, { pinned: !x.pinned }) : x));
26514
+ if (!pp.profilesSave(list)) return pp.profilesLoad();
26515
+ return profSort(list);
26516
+ };
26517
+
26518
+ /** 排过一次就记一笔:档案卡按「最近排过」排序靠它 */
26519
+ pp.profilesTouch = function (id) {
26520
+ let hit = false;
26521
+ const list = pp.profilesLoad().map((x) => {
26522
+ if (x.id !== id) return x;
26523
+ hit = true;
26524
+ return Object.assign({}, x, { lastUsedAt: Date.now(), uses: (x.uses || 0) + 1 });
26525
+ });
26526
+ if (!hit) return list;
26527
+ if (!pp.profilesSave(list)) return pp.profilesLoad();
26528
+ return profSort(list);
26529
+ };
26530
+
26531
+ const DAY_MS = 86400000;
26532
+ function profAgo(ts) {
26533
+ const n = Number(ts) || 0;
26534
+ if (!n) return '未排过';
26535
+ const d = Math.floor((Date.now() - n) / DAY_MS);
26536
+ if (d <= 0) return '今天排过';
26537
+ if (d === 1) return '昨天排过';
26538
+ if (d < 30) return `${d} 天前排过`;
26539
+ const dt = new Date(n);
26540
+ return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')} 排过`;
26541
+ }
26542
+ pp.profilesAgo = profAgo;
26543
+
26544
+ const PROF_GENDER = { male: '♂', female: '♀' };
26545
+
26546
+ /**
26547
+ * 档案列表的 HTML。交互全靠 data-mp-prof + data-id 事件委托,
26548
+ * 所以面板(React 容器)与单页(原生 DOM)用同一份标记就能接上。
26549
+ * opts: { editingId, armedDel, bar }
26550
+ * bar:档案条(称呼输入 + 存入)。必须走这个参数塞进 section 里,不能由调用方拼在外面——
26551
+ * 配色变量挂在 .mp-prof 上,拼在外面就等于拿不到 --red/--on-accent,深色下按钮糊成一片。
26552
+ */
26553
+ pp.profilesHTML = function (list, opts) {
26554
+ const o = opts || {};
26555
+ const rows = (Array.isArray(list) ? list : []).map((raw) => {
26556
+ const p = profClean(raw);
26557
+ if (!p) return '';
26558
+ const birth = [p.date, p.hourUnknown ? '时辰未知' : p.time, p.address].filter(Boolean).join(' ');
26559
+ const editing = o.editingId && p.id === o.editingId;
26560
+ const armed = o.armedDel && p.id === o.armedDel;
26561
+ return `<div class="mp-prof-card${p.pinned ? ' pinned' : ''}${editing ? ' editing' : ''}" data-id="${esc(p.id)}">
26562
+ <button type="button" class="mp-prof-use" data-mp-prof="use" data-id="${esc(p.id)}"
26563
+ title="${esc(`用「${p.label || p.kind}」的出生信息直接排盘`)}">
26564
+ <span class="mp-prof-nm">${esc(p.label || '未命名')}${p.gender ? `<i class="sx">${PROF_GENDER[p.gender] || ''}</i>` : ''}</span>
26565
+ <span class="mp-prof-sys">${esc(pp.profKindLabel ? pp.profKindLabel(p.kind) : p.kind)}</span>
26566
+ <span class="mp-prof-bd">${esc(birth)}</span>
26567
+ <span class="mp-prof-at">${esc(profAgo(p.lastUsedAt || p.createdAt))}${p.uses ? ` · ${p.uses} 次` : ''}${p.pinned ? ' · 置顶' : ''}</span>
26568
+ </button>
26569
+ <span class="mp-prof-ops">
26570
+ <button type="button" data-mp-prof="pin" data-id="${esc(p.id)}" class="${p.pinned ? 'on' : ''}"
26571
+ aria-pressed="${p.pinned ? 'true' : 'false'}" title="置顶这张档案">${p.pinned ? '已置顶' : '置顶'}</button>
26572
+ <button type="button" data-mp-prof="edit" data-id="${esc(p.id)}" title="把这张档案读回输入框,改完再存">改名</button>
26573
+ <button type="button" data-mp-prof="del" data-id="${esc(p.id)}" class="${armed ? 'on' : ''}"
26574
+ title="${armed ? '再点一次就从本机删除这张档案' : '从本机删除这张档案(点两次确认)'}">${armed ? '确认删除' : '删除'}</button>
26575
+ </span>
26576
+ </div>`;
26577
+ }).join('');
26578
+ const body = rows
26579
+ ? `<div class="mp-prof-list">${rows}</div>`
26580
+ : `<p class="mp-prof-empty">还没有存过命盘。填好出生信息,给这张盘起个称呼,点「存为我的命盘」——下次进来一点就出盘,不必重报。</p>`;
26581
+ return `<section class="mp-prof" aria-label="我的命盘">
26582
+ <div class="mp-prof-h"><b>我的命盘</b><span class="mp-prof-sub">只存本机 · 不上传${list && list.length ? ` · ${list.length}/${PROF_MAX}` : ''}</span></div>
26583
+ ${body}
26584
+ ${o.bar || ''}
26585
+ </section>`;
26586
+ };
26587
+
26588
+ /**
26589
+ * 档案条(称呼输入 + 存 / 取消)。单页用这份;面板那份是 React 容器,
26590
+ * 把输入框塞进 innerHTML 会每敲一个字整块重绘、光标被踢走,
26591
+ * 所以面板自己渲染同样这几个控件。
26592
+ */
26593
+ pp.profilesBarHTML = function (opts) {
26594
+ const o = opts || {};
26595
+ return `<div class="mp-prof-bar">
26596
+ <input class="mp-prof-name" type="text" maxlength="${PROF_LABEL_MAX}" placeholder="称呼,如「我」「妈妈」" aria-label="档案称呼" value="${esc(o.name || '')}">
26597
+ <button type="button" data-mp-prof="put"${o.canAdd === false ? ' disabled' : ''}>${esc(o.editingId ? '更新这张档案' : '存为我的命盘')}</button>
26598
+ ${o.editingId ? '<button type="button" class="ghost" data-mp-prof="cancel">取消改名</button>' : ''}
26599
+ <span class="mp-prof-tip">${esc(o.tip || (o.editingId ? '改完出生信息或称呼,点「更新这张档案」覆盖原档。' : '存的是出生时间与出生地这些输入,不是结果。'))}</span>
26600
+ </div>`;
26601
+ };
26602
+
26603
+ /**
26604
+ * 事件委托:容器上点一次,返回 {action, id} 或 null。
26605
+ * 两边(React / 原生)都靠它,避免各写一份 closest 判断。
26606
+ */
26607
+ pp.profEvent = function (target) {
26608
+ let el = target && target.closest ? target.closest('[data-mp-prof]') : null;
26609
+ if (!el) return null;
26610
+ const action = el.getAttribute('data-mp-prof');
26611
+ if (el.tagName === 'BUTTON' && el.disabled) return null;
26612
+ return { action, id: el.getAttribute('data-id') || '', name: el };
26613
+ };
26614
+
26615
+ /** 从当前输入拼一条档案(称呼取自档案输入框,缺省用体系名) */
26616
+ // 「时辰未知」只有八字与人生K线成立(紫微没有时支整盘不成立),勾选状态在体系之间
26617
+ // 是会残留的,所以在这里按体系收口,免得存出一张「紫微 · 时辰未知」的矛盾档案。
26618
+ const PROF_HU = ['bazi', 'trend'];
26619
+ // 这些体系没有时刻就排不出「这张盘」(出生类与起卦类);其余只需日期或不需时间
26620
+ const PROF_NEEDS_TIME = ['bazi', 'ziwei', 'liuyao', 'meihua', 'daliuren', 'qimen', 'hepan',
26621
+ 'chenggu', 'trend', 'fengshui', 'name', 'xiaoliuren'];
26622
+ /**
26623
+ * 这张档案能不能直接排:手改存储 / 旧版本可能留下「有日期没时刻」的卡。
26624
+ * 直接排会拿当前表单里的时刻顶上,等于排成另一个人,所以两处接线都先问这一句。
26625
+ */
26626
+ pp.profUsable = function (p) {
26627
+ if (!p || typeof p !== 'object') return false;
26628
+ if (p.hourUnknown === true || p.time) return true;
26629
+ return PROF_NEEDS_TIME.indexOf(String(p.kind || '')) < 0;
26630
+ };
26631
+ pp.profFromForm = function (form, label, id) {
26632
+ const src = form || {};
26633
+ const hu = src.hourUnknown === true && PROF_HU.indexOf(String(src.kind || '')) >= 0;
26634
+ // 没时刻就排不出「这张盘」:存下去只会变成一张点开时静默沿用上一次时刻的卡。
26635
+ // 时辰未知是显式选择,不算缺时刻。
26636
+ if (!hu && !/^([01]?\d|2[0-3]):[0-5]\d$/.test(String(src.time || '').trim())) return null;
26637
+ return profClean(Object.assign({
26638
+ id: id || pp.profilesNewId(),
26639
+ kind: String(src.kind || ''),
26640
+ label: String(label || '').trim().slice(0, PROF_LABEL_MAX) || (pp.profKindLabel ? pp.profKindLabel(src.kind) : String(src.kind || '')),
26641
+ date: String(src.date || ''),
26642
+ time: String(src.time || ''),
26643
+ hourUnknown: hu,
26644
+ gender: String(src.gender || ''),
26645
+ address: String(src.address || ''),
26646
+ pinned: false,
26647
+ createdAt: Date.now(),
26648
+ lastUsedAt: 0,
26649
+ uses: 0,
26650
+ }));
26651
+ };
26652
+
26157
26653
  /**
26158
26654
  * 推导链:入口卡 + 右侧抽屉。数据来自 Engine.compute({ trace: true }) 的 r.trace。
26159
26655
  * 每层只读展示 {field, rule, inputs, result, source},不重算、不改盘。
@@ -26597,6 +27093,9 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
26597
27093
  const TYPE_CLASS = { 主星: 'main', 吉星: 'good', 煞星: 'bad', 辅星: 'aux', 杂曜: 'aux' };
26598
27094
  const MUT_CLASS = { 禄: 'm-lu', 权: 'm-quan', 科: 'm-ke', 忌: 'm-ji' };
26599
27095
  let tab = 'ziwei';
27096
+ // 排盘代次:每次点「排盘」自增。地址解析要等网络,回来时若代次已变,说明用户换过 tab
27097
+ // 或又点了一次,这一次作废(之后所有 el() 读到的都是新 tab 的控件,接着拼会排出错盘)
27098
+ let runSeq = 0;
26600
27099
  let mhMode = 'time';
26601
27100
  let xlrMode = 'time';
26602
27101
  let fsMode = 'natal';
@@ -26680,6 +27179,122 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
26680
27179
  if (next === 'mbti') mbEnter(); else run();
26681
27180
  }
26682
27181
 
27182
+ // ── 我的命盘(本机档案)────────────────────────────────────────
27183
+ // 存的是「输入」而不是结果,点卡回填 + 直接出盘。存储、清洗、卡片渲染都取自
27184
+ // panel-kit 那一份实现:宿主面板里存的档案,在这个单页里同样看得见、同样能一点出盘。
27185
+ const PROF_TABS = ['bazi', 'ziwei', 'trend', 'chenggu'];
27186
+ let profEditing = '';
27187
+ let profArmed = '';
27188
+ let profTip = '';
27189
+ // 称呼框是裸 DOM,重渲染会整块换掉:把用户敲的字存在这里,才不会因为点别的按钮被抹掉
27190
+ let profName = '';
27191
+
27192
+ function renderProfiles() {
27193
+ const box = el('profwrap');
27194
+ const PP = window.__PP;
27195
+ if (!box || !PP || !PP.profilesHTML) return;
27196
+ const list = PP.profilesLoad();
27197
+ // 档案描述的是「一个人」,占事与合盘类没有可复用的出生信息,不给存入条
27198
+ const bar = PROF_TABS.indexOf(tab) >= 0 && PP.profilesBarHTML
27199
+ ? PP.profilesBarHTML({ editingId: profEditing, name: profName, tip: profTip }) : '';
27200
+ box.innerHTML = PP.profilesHTML(list, { editingId: profEditing, armedDel: profArmed, bar });
27201
+ }
27202
+
27203
+ /** 回填出生信息:顺序与深链还原一致(先 setTab 再填,setTab 会按体系改可见性)。返回是否真排了盘 */
27204
+ function profApply(p) {
27205
+ // 外部改坏 / 旧版本留下的「有日期没时刻」卡:别拿当前表单里的时刻顶上,那会排成另一个人
27206
+ if (!window.__PP.profUsable(p)) {
27207
+ profTip = '这张档案没有出生时刻,先补上时刻再存一次。';
27208
+ renderProfiles();
27209
+ return false;
27210
+ }
27211
+ profName = '';
27212
+ setTab(p.kind);
27213
+ recordRecent(p.kind);
27214
+ const puts = [['date', p.date], ['time', p.hourUnknown ? '' : p.time], ['gender', p.gender], ['addr', p.address]];
27215
+ for (const pair of puts) {
27216
+ const node = el(pair[0]);
27217
+ if (node && pair[1]) node.value = pair[1];
27218
+ }
27219
+ const hu = el('hourunknown');
27220
+ if (hu) hu.checked = !!p.hourUnknown;
27221
+ // 把这次回填当成「用户填的」:否则切走再切回出生类体系时会被上一次的自动值顶掉
27222
+ onManualEdit();
27223
+ run();
27224
+ return true;
27225
+ }
27226
+
27227
+ function profReadForm() {
27228
+ const PP = window.__PP;
27229
+ const v = (id) => { const n = el(id); return n ? n.value : ''; };
27230
+ const hu = el('hourunknown');
27231
+ const box = el('profwrap');
27232
+ const nameEl = box ? box.querySelector('.mp-prof-name') : null;
27233
+ return PP.profFromForm({
27234
+ kind: tab, date: v('date'), time: v('time'), gender: v('gender'), address: v('addr'),
27235
+ hourUnknown: !!(hu && hu.checked),
27236
+ }, nameEl ? nameEl.value : '', profEditing || undefined);
27237
+ }
27238
+
27239
+ function onProfClick(e) {
27240
+ const PP = window.__PP;
27241
+ if (!PP || !PP.profEvent) return;
27242
+ const ev = PP.profEvent(e.target);
27243
+ if (!ev) return;
27244
+ const of = (id) => PP.profilesLoad().find((x) => x.id === id);
27245
+ if (ev.action === 'use') {
27246
+ const p = of(ev.id);
27247
+ if (!p) return;
27248
+ // 没排出来就不算「排过一次」:否则缺时刻的坏卡会顶着「1 次」跳到列表最前
27249
+ profTip = '';
27250
+ if (!profApply(p)) { renderProfiles(); return; }
27251
+ PP.profilesTouch(p.id);
27252
+ renderProfiles();
27253
+ return;
27254
+ }
27255
+ if (ev.action === 'edit') {
27256
+ const p = of(ev.id);
27257
+ if (!p) return;
27258
+ profEditing = p.id;
27259
+ profName = p.label;
27260
+ profTip = '';
27261
+ renderProfiles();
27262
+ // 改名要连着能改出生信息:把档案读回输入框,用户调完再点「更新这张档案」
27263
+ const puts = [['date', p.date], ['time', p.hourUnknown ? '' : p.time], ['gender', p.gender], ['addr', p.address]];
27264
+ for (const pair of puts) {
27265
+ const node = el(pair[0]);
27266
+ if (node && pair[1]) node.value = pair[1];
27267
+ }
27268
+ const hu = el('hourunknown');
27269
+ if (hu) hu.checked = !!p.hourUnknown;
27270
+ return;
27271
+ }
27272
+ if (ev.action === 'cancel') { profEditing = ''; profName = ''; profTip = ''; renderProfiles(); return; }
27273
+ if (ev.action === 'pin') { PP.profilesPin(ev.id); profTip = ''; renderProfiles(); return; }
27274
+ if (ev.action === 'del') {
27275
+ // 删除点两次才算:第一次只把按钮变成「确认删除」,误触不会丢档案
27276
+ if (profArmed !== ev.id) { profArmed = ev.id; profTip = ''; renderProfiles(); return; }
27277
+ PP.profilesRemove(ev.id);
27278
+ if (profEditing === ev.id) { profEditing = ''; profName = ''; }
27279
+ profArmed = '';
27280
+ profTip = '已从本机删除这张档案卡(不影响已经排出来的盘)。';
27281
+ renderProfiles();
27282
+ return;
27283
+ }
27284
+ if (ev.action === 'put') {
27285
+ const wasEditing = profEditing;
27286
+ const entry = profReadForm();
27287
+ if (!entry) { profTip = '先填好出生日期(和时刻),再存档案。'; renderProfiles(); return; }
27288
+ const res = PP.profilesPut(entry);
27289
+ if (!res.ok) { profTip = '档案没能存进本机(存储被禁用或已满),排盘不受影响。'; renderProfiles(); return; }
27290
+ profEditing = '';
27291
+ profArmed = '';
27292
+ profName = '';
27293
+ profTip = `${wasEditing ? '已更新档案' : '已存入我的命盘'}「${res.item.label}」。`;
27294
+ renderProfiles();
27295
+ }
27296
+ }
27297
+
26683
27298
  /** 分组 / 平铺:只切显示,19 个 tab 与分组卡片两套入口始终可用 */
26684
27299
  function applyView(v) {
26685
27300
  const flat = v === 'flat';
@@ -26896,9 +27511,14 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
26896
27511
  };
26897
27512
  function bridgePackages() {
26898
27513
  if (!last || !last.r || !window.Engine || !window.Engine.askPackages) return null;
26899
- const q = el('question') ? el('question').value.trim() : '';
26900
- try { return window.Engine.askPackages(last.r, { question: q, base: pageBase() }); }
26901
- catch (e) { return null; }
27514
+ // 追问框就在按钮旁边,用户刚写下的那句必须进包;没写追问时才退回「所问之事」。
27515
+ // 只读 #question 的话,命盘类根本没有这个输入框,复制出去的「所问」永远是默认开场句。
27516
+ const follow = el('askq') ? el('askq').value.trim() : '';
27517
+ const main = el('question') ? el('question').value.trim() : '';
27518
+ try {
27519
+ // interpret 一并交出去:面板已算过「直答 / 说人话」,别的模型开场就该沿用同一口径
27520
+ return window.Engine.askPackages(last.r, { question: follow || main, interpret: last.it || null, base: pageBase() });
27521
+ } catch (e) { return null; }
26902
27522
  }
26903
27523
 
26904
27524
  // ── C3 深链:打开带 #mp= 的链接时还原同一张盘 ──────────────────
@@ -26981,6 +27601,8 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
26981
27601
  /** 用缓存的卦象重新渲染:解读按当前的问题与方向重出,卦面本身不动 */
26982
27602
  function paint() {
26983
27603
  if (!last) return;
27604
+ // 重绘会换掉整个 #out:先记下追问框里的字,否则切方向 / 刷新推导链时用户刚打的问题会凭空消失
27605
+ const askBefore = el('askq') ? el('askq').value : '';
26984
27606
  let it = null;
26985
27607
  try {
26986
27608
  it = window.Engine.interpret ? window.Engine.interpret(last.r, askArgs()) : (last.r.interpret || null);
@@ -27000,6 +27622,8 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
27000
27622
  + (last.tab === 'bazi' ? `<div id="vbox">${verifyHTML()}</div><div id="rectbox">${rectifyHTML()}</div>` : '')
27001
27623
  + exportBar();
27002
27624
  last.it = it;
27625
+ const askNow = el('askq');
27626
+ if (askNow && askBefore) askNow.value = askBefore;
27003
27627
  // 耗时行跟盘走:方向切换等重绘会把 innerHTML 整个换掉,不在这里补一次就会丢
27004
27628
  if (last.perf) {
27005
27629
  const perfHtml = window.__PP.perfHTML(last.perf);
@@ -27015,7 +27639,7 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
27015
27639
  return `<div class="askbox no-print">
27016
27640
  <input id="askq" maxlength="120" placeholder="追问:如 2027 年事业怎么样(回车提交)">
27017
27641
  <button type="button" data-act="ask">追问</button>
27018
- <button type="button" data-act="copyask" title="把这张盘和你的问题整理成一段提问,粘到任意对话窗都能用">复制提问</button>
27642
+ <button type="button" data-act="copyask" title="把这张盘和上面输入的问题整理成一段提问,粘到任意 AI 对话窗都能用">复制提问</button>
27019
27643
  <span class="hint">只回答盘面能支持的部分;找不到会说找不到</span>
27020
27644
  </div><div id="fout"></div>`;
27021
27645
  }
@@ -27151,6 +27775,8 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
27151
27775
 
27152
27776
  async function run() {
27153
27777
  const runStart = mpNow();
27778
+ const myRun = ++runSeq;
27779
+ const startTab = tab;
27154
27780
  const date = el('date').value;
27155
27781
  // 勾了「时辰未知」就不读钟点:读出来的只是占位值,用它排出来的时柱是编的
27156
27782
  const hourUnknown = !!(el('hourunknown') && el('hourunknown').checked) && (tab === 'bazi' || tab === 'trend');
@@ -27185,6 +27811,8 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
27185
27811
  const g = await window.Engine.geocodeAddress(addr, { online: el('online').checked });
27186
27812
  if (g && typeof g.lon === 'number') { args.longitude = g.lon; args.geo = g; }
27187
27813
  } catch (e) { /* 退回内置表 */ }
27814
+ // 换过 tab 或又点了一次:这一次作废,让最新那次去排(下面读的全是新 tab 的控件)
27815
+ if (myRun !== runSeq || startTab !== tab) return;
27188
27816
  }
27189
27817
  }
27190
27818
  if (tab === 'hepan') {
@@ -27467,6 +28095,7 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
27467
28095
  applyDateTime(k); // 起卦类 → 当前时刻;命盘类 → 示例默认;用户改过则保留
27468
28096
  ctxKind = k;
27469
28097
  renderTip();
28098
+ renderProfiles(); // 档案条只在命盘类体系出现,切 tab 要跟着换
27470
28099
  }
27471
28100
 
27472
28101
  function syncMhMode() {
@@ -27695,6 +28324,15 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
27695
28324
  });
27696
28325
  applyView(storeGet(VIEW_KEY) === 'flat' ? 'flat' : 'group');
27697
28326
  renderRecent();
28327
+ const profBox = el('profwrap');
28328
+ if (profBox) {
28329
+ profBox.addEventListener('click', onProfClick);
28330
+ // 只记名字、不重绘:每敲一个字重绘会把光标踢走
28331
+ profBox.addEventListener('input', (e) => {
28332
+ const nm = e.target && e.target.closest ? e.target.closest('.mp-prof-name') : null;
28333
+ if (nm) profName = nm.value;
28334
+ });
28335
+ }
27698
28336
  const mbHost = el('mbti-qs');
27699
28337
  if (mbHost) {
27700
28338
  renderMbti();
@@ -27829,7 +28467,13 @@ document.head.appendChild(Object.assign(document.createElement('style'), { textC
27829
28467
  const copyAsk = e.target && e.target.closest ? e.target.closest('[data-act="copyask"]') : null;
27830
28468
  if (copyAsk) {
27831
28469
  const pkg = bridgePackages();
27832
- if (pkg) copyPlain(copyAsk, pkg.full, '复制提问');
28470
+ // 打包失败时按钮不能毫无反应:用户会以为点了没生效,再点、再粘贴,最后贴进去一段旧内容
28471
+ if (!pkg || !pkg.full) {
28472
+ copyAsk.textContent = '暂无可复制内容';
28473
+ setTimeout(() => { copyAsk.textContent = '复制提问'; }, 1400);
28474
+ return;
28475
+ }
28476
+ copyPlain(copyAsk, pkg.full, '复制提问');
27833
28477
  return;
27834
28478
  }
27835
28479
  onExportClick(e);