dsh-annual-activity 1.1.2 → 1.1.4

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.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/lib/client.js +113 -18
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # 📅 全年活跃记录 — dsh-annual-activity
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/dsh-annual-activity?color=blue)](https://www.npmjs.com/package/dsh-annual-activity) [![GitHub](https://img.shields.io/badge/source-GitHub-181717?logo=github)](https://github.com/Moveharder/dsh_plugins)
4
+
3
5
  > 一个**纯本地**的 DSH 活跃度统计面板:把你在 DSH 里每一天的「干活量」画成一张年度热力图
4
6
  > (GitHub 贡献图风格),统计口径是**本机真实会话日志**——不联网、不上报、不估算。
5
7
 
package/lib/client.js CHANGED
@@ -5,6 +5,12 @@ window.__ModuleLoader__.load({
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  const React = require("react");
8
+ // 必须走 portal:入口挂在会话头部的 utilities 槽里,而那个头部位于
9
+ // `.wSkVaW_scrollBody{overflow-y:auto}` 这个滚动容器内——position:fixed 的
10
+ // 后代会被祖先滚动容器裁剪(同时被其层叠上下文困住),表现为面板被页面内容
11
+ // 遮挡、悬浮说明顶部被切掉。portal 到 document.body 才能彻底跳出来。
12
+ let ReactDOM = null;
13
+ try { ReactDOM = require("react-dom"); } catch (err) { /* 旧运行时没有 react-dom:退化为内联渲染 */ }
8
14
 
9
15
  const name = "dsh-annual-activity";
10
16
  // 依赖必须显式声明:boot 阶段各插件并发激活,inject: [] 的插件可能在 slots
@@ -26,6 +32,11 @@ window.__ModuleLoader__.load({
26
32
  const WEEKDAY_NAMES = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
27
33
  const LS_YEAR = 'dsh-annual-activity:year';
28
34
  const LS_GEAR = 'dsh-annual-activity:setup-open';
35
+ // portal 浮层的层级:必须压过页面上的内容与其它插件浮层(鲸鱼 999 / token 面板
36
+ // 1000 / 市场 toast 等),同时留出余量避免将来更高层的组件把它盖住。
37
+ const Z_PORTAL = 2147483000;
38
+ // 入口浮层与按钮底边的间距:太小会贴着标题行显得"被压住"
39
+ const TIP_GAP = 18;
29
40
 
30
41
  const pad2 = (n) => (n < 10 ? '0' + n : '' + n);
31
42
  const dayKeyOf = (y, m, d) => y + '-' + pad2(m + 1) + '-' + pad2(d);
@@ -140,6 +151,33 @@ window.__ModuleLoader__.load({
140
151
  return map;
141
152
  }
142
153
 
154
+ // ==================== portal 宿主 ====================
155
+ // 面板与悬浮说明都渲染到 document.body 下的一个固定层里,避开会话头部所在
156
+ // 滚动容器的裁剪与层叠上下文。
157
+ const PORTAL_CLASS = 'daa-portal';
158
+ let portalHost = null;
159
+ function ensurePortalHost() {
160
+ if (portalHost && portalHost.isConnected !== false) return portalHost;
161
+ portalHost = document.createElement('div');
162
+ portalHost.className = PORTAL_CLASS;
163
+ portalHost.setAttribute('data-plugin', 'dsh-annual-activity');
164
+ // 内联层级 + 视口铺满:position:fixed 需要「视口大小的包含块」,
165
+ // 否则父级(头部槽位)的尺寸会成为浮层的包含块,panel 会被挤在头部区域里。
166
+ portalHost.style.position = 'fixed';
167
+ portalHost.style.top = '0';
168
+ portalHost.style.right = '0';
169
+ portalHost.style.bottom = '0';
170
+ portalHost.style.left = '0';
171
+ portalHost.style.pointerEvents = 'none';
172
+ portalHost.style.zIndex = String(Z_PORTAL); document.body.appendChild(portalHost);
173
+ return portalHost;
174
+ }
175
+ /** 把浮层内容 portal 到 body;运行时没有 react-dom 时退化为内联渲染。 */
176
+ function portalToBody(node) {
177
+ if (!ReactDOM || typeof ReactDOM.createPortal !== 'function') return node;
178
+ try { return ReactDOM.createPortal(node, ensurePortalHost()); } catch (err) { return node; }
179
+ }
180
+
143
181
  // ==================== 迷你 store:轮询 host 的只读接口 ====================
144
182
 
145
183
  function createStore() {
@@ -350,7 +388,7 @@ window.__ModuleLoader__.load({
350
388
  // 贴近单元格上方,但不越出视口
351
389
  const top = Math.max(8, Math.round(y) - 8);
352
390
  const left = Math.min(Math.max(Math.round(x), 110), Math.max(110, (typeof window !== 'undefined' ? window.innerWidth : 1200) - 110));
353
- const style = { left: left + 'px', top: top + 'px' };
391
+ const style = { left: left + 'px', top: top + 'px', zIndex: 20 };
354
392
  return React.createElement('div', { className: 'daa-tip', style },
355
393
  React.createElement('div', { className: 'daa-tip-head' }, head),
356
394
  rows.map(([k, v]) => React.createElement('div', { className: 'daa-tip-row', key: k },
@@ -412,7 +450,7 @@ window.__ModuleLoader__.load({
412
450
  // ==================== 面板 ====================
413
451
 
414
452
  function ActivityPanel(props) {
415
- const { onClose } = props;
453
+ const { onClose, summary } = props;
416
454
  const state = useStore();
417
455
  const data = state.data;
418
456
  const today = (data && data.today) || todayKey();
@@ -474,9 +512,11 @@ window.__ModuleLoader__.load({
474
512
 
475
513
  return React.createElement('div', {
476
514
  className: 'daa-backdrop',
515
+ // 内联层级:这一层是 portal 宿主里唯一的直接子元素,层级写死避免被宿主样式覆盖
516
+ style: { zIndex: Z_PORTAL },
477
517
  onMouseDown: (e) => { if (e.target === e.currentTarget) onClose(); },
478
518
  },
479
- React.createElement('div', { className: 'daa-card', role: 'dialog', 'aria-label': '全年活跃记录' },
519
+ React.createElement('div', { className: 'daa-card', role: 'dialog', 'aria-label': summary || '全年活跃记录' },
480
520
  // ---- 头部 ----
481
521
  React.createElement('div', { className: 'daa-head' },
482
522
  React.createElement('div', { className: 'daa-head-l' },
@@ -627,7 +667,8 @@ window.__ModuleLoader__.load({
627
667
  type: 'button',
628
668
  className: 'daa-hbtn' + (open ? ' on' : ''),
629
669
  'aria-label': '全年活跃记录',
630
- title,
670
+ // 刻意不设原生 title:浏览器自带的提示框会浮在按钮上方并遮挡按钮,
671
+ // 提示改由下方自绘浮层承担(EntryTip)。
631
672
  onClick: () => setOpen(!open),
632
673
  onMouseEnter: onEnter,
633
674
  onMouseLeave: () => setTip(null),
@@ -636,21 +677,63 @@ window.__ModuleLoader__.load({
636
677
  glyphCells(byDay).map((c, i) => React.createElement('span', {
637
678
  key: i, className: 'daa-glyph-cell' + (c.level < 0 ? ' daa-glyph-void' : ' daa-l' + c.level),
638
679
  }))),
639
- updateAvailable ? React.createElement('span', { className: 'daa-hbtn-dot', title: '有新版本 v' + (meta && meta.latest) }) : null),
640
- tip ? React.createElement(EntryTip, { tip }) : null,
641
- open ? React.createElement(ActivityPanel, { onClose: () => setOpen(false) }) : null);
680
+ updateAvailable ? React.createElement('span', { className: 'daa-hbtn-dot', 'aria-label': '有新版本' }) : null),
681
+ tip ? portalToBody(React.createElement(EntryTip, { tip })) : null,
682
+ open ? portalToBody(React.createElement(ActivityPanel, { onClose: () => setOpen(false), summary: title })) : null);
683
+ }
684
+
685
+ /**
686
+ * 浮层位置自适应(纯函数,便于直接测试):
687
+ * - 下方空间不足时整体上移到「刚好放得下」,保证整块浮层可见;
688
+ * - 右侧越界时向左收,避免贴边被截断;
689
+ * - 视口信息缺失时退回原值。
690
+ */
691
+ function fitTipPosition(base, size, viewport, margin) {
692
+ const m = typeof margin === 'number' ? margin : 8;
693
+ let { left, top } = base;
694
+ const vw = viewport && viewport.width;
695
+ const vh = viewport && viewport.height;
696
+ const w = size && size.width;
697
+ const h = size && size.height;
698
+ if (vh && h && top + h > vh - m) top = Math.max(m, vh - m - h);
699
+ if (vw && w && left + w / 2 > vw - m) left = vw - m - w / 2;
700
+ if (left - w / 2 < m) left = m + w / 2;
701
+ return { left: Math.round(left), top: Math.round(top) };
642
702
  }
643
703
 
644
- /** 入口悬浮说明:固定定位在按钮**下方**(放在上方会遮住按钮与标题行)。 */
704
+ /** 入口悬浮说明:渲染在按钮**下方**(放到上方会盖住按钮与标题行)。
705
+ * 位置不是死偏移:挂载后按实测高度微调,保证顶部不会被头部/视口切掉。 */
645
706
  function EntryTip({ tip }) {
646
707
  const { today: rec, stats, x, y } = tip;
708
+ const tipRef = React.useRef(null);
647
709
  const rows = [
648
710
  ['今日', rec ? fmtInt(rec.turns) + ' 轮' : '未活跃'],
649
711
  ['今年', (stats ? fmtInt(stats.activeDays) : '0') + ' 天活跃 · ' + fmtPercent(stats ? stats.rate : 0)],
650
712
  ['连登', fmtInt(stats ? stats.weekStreak : 0) + ' 周'],
651
713
  ];
652
- const left = Math.min(Math.max(Math.round(x), 110), Math.max(110, window.innerWidth - 130));
653
- return React.createElement('div', { className: 'daa-tip daa-tip-below', style: { left: left + 'px', top: Math.round(y) + 8 + 'px' } },
714
+ const baseLeft = Math.min(Math.max(Math.round(x), 110), Math.max(110, window.innerWidth - 130));
715
+ const baseTop = Math.round(y) + TIP_GAP;
716
+
717
+ React.useLayoutEffect(() => {
718
+ const el = tipRef.current;
719
+ if (!el || typeof el.getBoundingClientRect !== 'function') return;
720
+ const rect = el.getBoundingClientRect();
721
+ const fitted = fitTipPosition(
722
+ { left: baseLeft, top: baseTop },
723
+ { width: rect.width, height: rect.height },
724
+ { width: window.innerWidth, height: window.innerHeight },
725
+ 8
726
+ );
727
+ if (fitted.top !== baseTop) el.style.top = fitted.top + 'px';
728
+ if (fitted.left !== baseLeft) el.style.left = fitted.left + 'px';
729
+ });
730
+
731
+ return React.createElement('div', {
732
+ ref: tipRef,
733
+ className: 'daa-tip daa-tip-below',
734
+ // 初值:按钮下方 TIP_GAP 像素,随后由 layout effect 按实测尺寸修正
735
+ style: { left: baseLeft + 'px', top: baseTop + 'px', zIndex: 20 },
736
+ },
654
737
  rows.map(([k, v]) => React.createElement('div', { className: 'daa-tip-row', key: k },
655
738
  React.createElement('span', { className: 'daa-tip-k' }, k),
656
739
  React.createElement('span', { className: 'daa-tip-v' }, v))));
@@ -683,12 +766,6 @@ window.__ModuleLoader__.load({
683
766
  .daa-hbtn-dot{position:absolute;top:2px;right:2px;width:7px;height:7px;border-radius:50%;
684
767
  background:var(--dsw-alias-state-business-primary,#f59e0b);
685
768
  box-shadow:0 0 0 1.5px var(--dsw-alias-bg-layer-1,#fff);}
686
- /* 入口悬浮说明:贴在按钮下方(放在上方会盖住按钮本身与会话标题) */
687
- .daa-tip-below{transform:translate(-50%,0);}
688
- .daa-tip-below::before{content:'';position:absolute;left:50%;top:-4px;width:8px;height:8px;
689
- margin-left:-4px;transform:rotate(45deg);background:inherit;
690
- border-left:1px solid var(--dsh-border,#e5e7eb);border-top:1px solid var(--dsh-border,#e5e7eb);
691
- border-top-left-radius:2px;}
692
769
  /* ---------- 遮罩 + 卡片 ---------- */
693
770
  .daa-backdrop{position:fixed;inset:0;pointer-events:auto;background:rgba(15,23,42,.42);
694
771
  display:flex;align-items:center;justify-content:center;padding:24px;animation:daa-fade .14s ease-out;}
@@ -762,6 +839,14 @@ window.__ModuleLoader__.load({
762
839
  .daa-tip-row{display:flex;justify-content:space-between;gap:12px;font-size:11.5px;line-height:1.7;}
763
840
  .daa-tip-k{color:var(--dsh-text-3,#9aa1ab);}
764
841
  .daa-tip-v{color:var(--dsh-text-1,#111827);font-variant-numeric:tabular-nums;}
842
+ /* 入口浮层修饰类:必须排在 .daa-tip 之后 —— 同优先级下后者会覆盖前者,
843
+ 写成「先修饰后基础」会让 translate(-50%,-100%) 生效,把浮层整体上移自身
844
+ 高度(正好是"上半部分看不到"的现象)。 */
845
+ .daa-tip-below{transform:translate(-50%,0);}
846
+ .daa-tip-below::before{content:'';position:absolute;left:50%;top:-4px;width:8px;height:8px;
847
+ margin-left:-4px;transform:rotate(45deg);background:inherit;
848
+ border-left:1px solid var(--dsh-border,#e5e7eb);border-top:1px solid var(--dsh-border,#e5e7eb);
849
+ border-top-left-radius:2px;}
765
850
  /* ---------- 底部 ---------- */
766
851
  .daa-foot{display:flex;align-items:center;gap:10px;margin-top:12px;padding-top:9px;
767
852
  border-top:1px solid var(--dsh-border,#f0f1f3);font-size:11.5px;color:var(--dsh-text-3,#9aa1ab);}
@@ -826,7 +911,17 @@ window.__ModuleLoader__.load({
826
911
  styleEl.setAttribute("data-plugin", "dsh-annual-activity");
827
912
  styleEl.textContent = CSS;
828
913
  document.head.appendChild(styleEl);
829
- ctx.effect(() => () => { try { styleEl.remove(); } catch (e) { /* ignore */ } });
914
+
915
+ // portal 宿主随插件卸载一并移除(portal 出去的内容不在 React 树上,
916
+ // 卸载时槽位卸载会清掉内容,但宿主 div 会留下空壳,这里显式回收)。
917
+ const disposePortal = () => {
918
+ try { if (portalHost && portalHost.remove) portalHost.remove(); } catch (e) { /* ignore */ }
919
+ portalHost = null;
920
+ };
921
+ ctx.effect(() => () => {
922
+ try { styleEl.remove(); } catch (e) { /* ignore */ }
923
+ disposePortal();
924
+ });
830
925
 
831
926
  // 注册到会话头部右侧「utility」列表槽:该槽在 DOM 中先于右上角
832
927
  // corner 槽(「打开右侧边栏」按钮所在处)渲染,所以这个入口会落在
@@ -841,7 +936,7 @@ window.__ModuleLoader__.load({
841
936
  exports.name = name;
842
937
  exports.inject = inject;
843
938
  exports.apply = apply;
844
- exports.__internal = { buildWeeks, monthLabels, fmtPercent, fmtCompact, levelOfDay, indexDays, glyphCells, FALLBACK_LEVELS, store, metaStore };
939
+ exports.__internal = { buildWeeks, monthLabels, fmtPercent, fmtCompact, levelOfDay, indexDays, glyphCells, fitTipPosition, FALLBACK_LEVELS, store, metaStore };
845
940
  return module.exports;
846
941
  }
847
942
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-annual-activity",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "全年活跃记录 (Annual Activity) — 一个纯本地的 DSH 活跃度统计面板:Host 半区扫描 $DSH_HOME/sessions 下的历史会话日志,按本地日期聚合每一天的活跃天/轮次/工具调用/Token 消耗,Client 半区以「年度热力图」面板渲染(统计行 + 5 级色块 + 月份轴 + 年份切换 + 连登统计),数据全部来自本机会话文件,不联网、不上报。",
5
5
  "author": "spartaattack",
6
6
  "type": "module",