dsh-annual-activity 1.1.3 → 1.1.5

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 +21 -12
  2. package/lib/client.js +178 -45
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,15 +1,19 @@
1
- # 📅 全年活跃记录 — dsh-annual-activity
1
+ # 📅 DSH-活跃记录 — dsh-annual-activity
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)
2
4
 
3
5
  > 一个**纯本地**的 DSH 活跃度统计面板:把你在 DSH 里每一天的「干活量」画成一张年度热力图
4
6
  > (GitHub 贡献图风格),统计口径是**本机真实会话日志**——不联网、不上报、不估算。
5
7
 
6
8
  ```
7
9
  DSH 会话头部右上角 ↓
8
- … 项目 / 会话名 ▦ ▢▯ ← ▦ 本插件入口(14 天迷你热力图)
9
- ▢▯ 是官方「打开右侧边栏」
10
+ … 项目 / 会话名 ▦ ▢▯ … ⊞
11
+ │ │ └ 官方「打开右侧边栏」(corner 槽)
12
+ │ └──────── 官方「选择打开方式」(utilities 槽,order -10)
13
+ └─────────── 本插件入口(utilities 槽,order -100 → 该槽第一个子元素)
10
14
 
11
15
  ┌──────────────────────────────────────────────────────────────────────────────┐
12
- │ 全年活跃记录 24 天活跃 · 活跃率 9% ‹ 2026 年 ▾ › ✕ │
16
+ │ DSH-活跃记录 24 天活跃 · 活跃率 9% ‹ 2026 ▾ › ✕ │
13
17
  │ ──────────────────────────────────────────────────────────────────────────── │
14
18
  │ 24 天 9% 6 周 5 天 未活跃 ▢▢▢▢▢ 活跃 │
15
19
  │ 活跃 活跃率 连登 最长连续 │
@@ -22,11 +26,15 @@
22
26
  └──────────────────────────────────────────────────────────────────────────────┘
23
27
  ```
24
28
 
25
- 入口在**会话头部右上角**:紧挨「打开右侧边栏」按钮的左边,同高同圆角(28×28)。
26
- 图标是**最近 14 天的迷你热力图**(等级越高颜色越深);按钮带常驻底色与 1px 细边
27
- (用官方 token,深浅主题都跟随),悬停加深一档。鼠标悬浮时说明浮层出现在**按钮下方**
28
- (不会遮挡按钮与会话标题),显示今日 / 今年 / 连登摘要;点击展开上面的年度面板。
29
- 有新版本时按钮右上角带一个小圆点。
29
+ 入口在**会话头部右上角的最左侧**:注册在 `conversation.session.header.utilities` 槽,
30
+ `order: -100` 让它成为该槽容器的**第一个子元素**,排在官方「选择打开方式」
31
+ (`.CAgGvG_split`,`order: -10`)之前;同高同圆角(28×28)。
32
+ 图标是**最近 14 天的迷你热力图**(等级越高颜色越深);按钮带常驻底色
33
+ (用官方 token,深浅主题都跟随),悬停加深一档,**不使用 `box-shadow` 描边**。
34
+ 鼠标悬浮时说明浮层出现在**按钮下方**(不会遮挡按钮与会话标题),显示今日 / 今年 /
35
+ 连登摘要;点击展开上面的年度面板。有新版本时按钮右上角带一个小圆点。
36
+ 挂载后会**立刻**拉取一次数据(不等面板打开),在此之前按钮显示 14 格**呼吸骨架**,
37
+ 所以任何时刻它都不会是一个空按钮。
30
38
 
31
39
  ## 安装
32
40
 
@@ -68,11 +76,11 @@ dsh plugin --profile web remove dsh-annual-activity
68
76
 
69
77
  | 区域 | 说明 |
70
78
  | --- | --- |
71
- | **头部入口** | 会话右上角、「打开右侧边栏」左侧;14 天迷你热力图字形 + 悬浮摘要;有更新时带圆点 |
72
- | **标题行** | `N 天活跃 · 活跃率 X%`,右侧是**年份切换**(`‹ 2026 年 ▾ ›`,多于一年时可下拉直选)与关闭按钮 |
79
+ | **头部入口** | 会话右上角最左侧(utilities 槽第一个子元素,官方「选择打开方式」之前);14 天迷你热力图字形 + 悬浮摘要;有更新时带圆点 |
80
+ | **标题行** | `N 天活跃 · 活跃率 X%`,右侧是**年份切换**(`‹ 2026 ▾ ›`,只显示年份数字;多于一年时可下拉直选)与关闭按钮 |
73
81
  | **统计行** | 活跃天数 / 活跃率 / **周连登** / 年内最长连续天数 |
74
82
  | **图例** | 未活跃 → 活跃 的 5 级色块,悬浮可看每级阈值 |
75
- | **热力图** | 一周一列(周一在顶),最多 53 列;今天的格子有独立描边;悬浮任一格看当天明细 |
83
+ | **热力图** | 一周一列(周一在顶),最多 53 列;**列宽自适应铺满面板宽度**(不会在右侧留白,窄到放不下才横向滚动);今天的格子有独立描边;悬浮任一格看当天明细 |
76
84
  | **底部** | 数据更新时间、时区口径、跨年累计(轮次 / tokens / 会话数)、手动 ⟳ 重扫、⚙ 设置 |
77
85
 
78
86
  关闭方式:右上角 `✕` / 点击遮罩空白处 / `Esc`。
@@ -151,6 +159,7 @@ dsh plugin --profile web remove dsh-annual-activity
151
159
  | 现象 | 原因 / 处理 |
152
160
  | --- | --- |
153
161
  | 会话右上角没有入口按钮 | 先看 `curl http://127.0.0.1:3080/activity/hello`:404 说明 Host 层没装配(`--dump-config` 确认 `activity` 行存在、没被 `disabled: true`);若 Host 正常,浏览器半区需**重启 dsh web**(客户端 bundle 在启动时注入 `__DSH_BOOT__`)。入口注册在 `conversation.session.header.utilities` 槽,只有会话页头部存在时才可见 |
162
+ | 入口按钮是个「空按钮」 | 正常情况下不该出现:挂载后**立刻**拉一次 `/activity/pull`,数据到达前画 14 格**呼吸骨架**。若骨架一直呼吸,说明拉取没回来(看 Network 里 `/activity/pull`);若骨架变成琥珀色静止,说明 `state.error` 非空——Host 路由没装配 |
154
163
  | 面板显示「host 未就绪:…(重试中…)」 | Host 路由未注册或已被禁用;入口仍在,恢复后自动接上 |
155
164
  | 数字全是 0 / 只有今天有数据 | `$DSH_HOME` 指向了别的目录。插件默认读 `$DSH_HOME/sessions`,其次 `~/.dsh/sessions`;可用 `DSH_ACTIVITY_ROOT` 覆盖 |
156
165
  | 今天色块没变深 | 面板打开时 20s 轮询一次;点底部 ⟳ 可立即重扫(有 5s 最小间隔限流) |
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);
@@ -84,6 +95,12 @@ window.__ModuleLoader__.load({
84
95
  return out;
85
96
  }
86
97
 
98
+ /**
99
+ * 数据未到时的字形骨架:固定 14 格占位(不区分「未来格」),
100
+ * 配合 .daa-glyph-loading 的呼吸动画表示「正在加载」。
101
+ */
102
+ const GLYPH_SKELETON = Array.from({ length: 14 }, () => ({ level: 0 }));
103
+
87
104
  /**
88
105
  * 把一年的数据排成 GitHub 风格的热力图网格:列 = 周(周一在顶),最多 53 列。
89
106
  * 首列前用 null 占位,使 1 月 1 日落在它真实的星期行上。
@@ -140,6 +157,33 @@ window.__ModuleLoader__.load({
140
157
  return map;
141
158
  }
142
159
 
160
+ // ==================== portal 宿主 ====================
161
+ // 面板与悬浮说明都渲染到 document.body 下的一个固定层里,避开会话头部所在
162
+ // 滚动容器的裁剪与层叠上下文。
163
+ const PORTAL_CLASS = 'daa-portal';
164
+ let portalHost = null;
165
+ function ensurePortalHost() {
166
+ if (portalHost && portalHost.isConnected !== false) return portalHost;
167
+ portalHost = document.createElement('div');
168
+ portalHost.className = PORTAL_CLASS;
169
+ portalHost.setAttribute('data-plugin', 'dsh-annual-activity');
170
+ // 内联层级 + 视口铺满:position:fixed 需要「视口大小的包含块」,
171
+ // 否则父级(头部槽位)的尺寸会成为浮层的包含块,panel 会被挤在头部区域里。
172
+ portalHost.style.position = 'fixed';
173
+ portalHost.style.top = '0';
174
+ portalHost.style.right = '0';
175
+ portalHost.style.bottom = '0';
176
+ portalHost.style.left = '0';
177
+ portalHost.style.pointerEvents = 'none';
178
+ portalHost.style.zIndex = String(Z_PORTAL); document.body.appendChild(portalHost);
179
+ return portalHost;
180
+ }
181
+ /** 把浮层内容 portal 到 body;运行时没有 react-dom 时退化为内联渲染。 */
182
+ function portalToBody(node) {
183
+ if (!ReactDOM || typeof ReactDOM.createPortal !== 'function') return node;
184
+ try { return ReactDOM.createPortal(node, ensurePortalHost()); } catch (err) { return node; }
185
+ }
186
+
143
187
  // ==================== 迷你 store:轮询 host 的只读接口 ====================
144
188
 
145
189
  function createStore() {
@@ -189,7 +233,10 @@ window.__ModuleLoader__.load({
189
233
  if (timer) return;
190
234
  // 面板打开时 20s 一次(今天的新活动很快可见),关闭时 3 分钟一次保活
191
235
  const tick = () => { void pull(); timer = setTimeout(tick, state.panelOpen ? 20000 : 180000); };
192
- timer = setTimeout(tick, state.panelOpen ? 1000 : 8000);
236
+ // 首次订阅必须**立刻**拉一次:入口按钮的字形就靠这份数据渲染,等 8s 会让
237
+ // 按钮在页面刚打开的一段时间里看起来是个空按钮(像是坏了)。延迟 0 而不是
238
+ // 直接调用,是为了避免在订阅(React effect / 渲染期)中同步 setState。
239
+ timer = setTimeout(tick, 0);
193
240
  }
194
241
 
195
242
  return {
@@ -310,11 +357,13 @@ window.__ModuleLoader__.load({
310
357
  // ==================== 小组件 ====================
311
358
 
312
359
  function HeatCell(props) {
313
- const { day, rec, isToday, size, onHover } = props;
360
+ const { day, rec, isToday, onHover } = props;
314
361
  const ref = React.useRef(null);
315
362
 
363
+ // 尺寸交给 CSS(列宽 minmax(9px,1fr) + aspect-ratio:1)——写死像素会让网格
364
+ // 与卡片内容宽对不齐,日历右侧留白。
316
365
  if (!day) {
317
- return React.createElement('div', { className: 'daa-cell daa-cell-pad', style: { width: size, height: size } });
366
+ return React.createElement('div', { className: 'daa-cell daa-cell-pad' });
318
367
  }
319
368
  const level = levelOfDay(rec);
320
369
  const cls = 'daa-cell daa-l' + level + (isToday ? ' daa-today' : '');
@@ -327,7 +376,6 @@ window.__ModuleLoader__.load({
327
376
  return React.createElement('div', {
328
377
  ref,
329
378
  className: cls,
330
- style: { width: size, height: size },
331
379
  onMouseEnter: onEnter,
332
380
  onMouseLeave: () => { if (onHover) onHover(null); },
333
381
  'aria-label': day + (rec ? ' 活跃 ' + (rec.turns || 0) + ' 轮' : ' 未活跃'),
@@ -350,7 +398,7 @@ window.__ModuleLoader__.load({
350
398
  // 贴近单元格上方,但不越出视口
351
399
  const top = Math.max(8, Math.round(y) - 8);
352
400
  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' };
401
+ const style = { left: left + 'px', top: top + 'px', zIndex: 20 };
354
402
  return React.createElement('div', { className: 'daa-tip', style },
355
403
  React.createElement('div', { className: 'daa-tip-head' }, head),
356
404
  rows.map(([k, v]) => React.createElement('div', { className: 'daa-tip-row', key: k },
@@ -386,7 +434,7 @@ window.__ModuleLoader__.load({
386
434
  title: years.length > 1 ? '切换年份' : '仅记录到这一年',
387
435
  onClick: () => years.length > 1 && setOpen(!open),
388
436
  },
389
- React.createElement('span', null, year + ' 年'),
437
+ React.createElement('span', null, String(year)),
390
438
  years.length > 1 ? React.createElement('span', { className: 'daa-year-caret' }, '▾') : null),
391
439
  React.createElement('button', {
392
440
  type: 'button', className: 'daa-year-btn', disabled: !hasNext,
@@ -398,7 +446,7 @@ window.__ModuleLoader__.load({
398
446
  key: y, type: 'button',
399
447
  className: 'daa-year-item' + (y === year ? ' on' : ''),
400
448
  onClick: () => { setOpen(false); onPick(y); },
401
- }, y + ' 年', y === currentYear ? React.createElement('span', { className: 'daa-year-badge' }, '今年') : null))) : null,
449
+ }, String(y), y === currentYear ? React.createElement('span', { className: 'daa-year-badge' }, '今年') : null))) : null,
402
450
  React.createElement('button', { type: 'button', className: 'daa-close', title: '关闭 (Esc)', onClick: onClose }, '✕'));
403
451
  }
404
452
 
@@ -412,7 +460,7 @@ window.__ModuleLoader__.load({
412
460
  // ==================== 面板 ====================
413
461
 
414
462
  function ActivityPanel(props) {
415
- const { onClose } = props;
463
+ const { onClose, summary } = props;
416
464
  const state = useStore();
417
465
  const data = state.data;
418
466
  const today = (data && data.today) || todayKey();
@@ -474,13 +522,15 @@ window.__ModuleLoader__.load({
474
522
 
475
523
  return React.createElement('div', {
476
524
  className: 'daa-backdrop',
525
+ // 内联层级:这一层是 portal 宿主里唯一的直接子元素,层级写死避免被宿主样式覆盖
526
+ style: { zIndex: Z_PORTAL },
477
527
  onMouseDown: (e) => { if (e.target === e.currentTarget) onClose(); },
478
528
  },
479
- React.createElement('div', { className: 'daa-card', role: 'dialog', 'aria-label': '全年活跃记录' },
529
+ React.createElement('div', { className: 'daa-card', role: 'dialog', 'aria-label': summary || 'DSH-活跃记录' },
480
530
  // ---- 头部 ----
481
531
  React.createElement('div', { className: 'daa-head' },
482
532
  React.createElement('div', { className: 'daa-head-l' },
483
- React.createElement('span', { className: 'daa-title' }, '全年活跃记录'),
533
+ React.createElement('span', { className: 'daa-title' }, 'DSH-活跃记录'),
484
534
  React.createElement('span', { className: 'daa-sub' },
485
535
  fmtInt(stats0.activeDays) + ' 天活跃 · 活跃率 ' + fmtPercent(stats0.rate))),
486
536
  React.createElement('div', { className: 'daa-head-r' },
@@ -502,7 +552,7 @@ window.__ModuleLoader__.load({
502
552
  React.createElement('div', { className: 'daa-grid', style: gridStyle },
503
553
  weeks.map((week, ci) => React.createElement('div', { className: 'daa-col', key: ci },
504
554
  week.map((day, ri) => React.createElement(HeatCell, {
505
- key: ri, day, rec: day ? byDay[day] : null, size: 11, isToday: day === today, onHover: setTip,
555
+ key: ri, day, rec: day ? byDay[day] : null, isToday: day === today, onHover: setTip,
506
556
  })))))),
507
557
 
508
558
  // ---- 底部 ----
@@ -605,8 +655,11 @@ window.__ModuleLoader__.load({
605
655
  const curStats = data && data.yearStats ? data.yearStats[data.currentYear] : null;
606
656
  const meta = metaState.meta;
607
657
  const updateAvailable = !!(meta && meta.updateAvailable);
658
+ // 还没拿到数据时画骨架:'loading' 呼吸、'error' 用警示色(host 路由没装配)
659
+ const pending = !data;
660
+ const glyphMode = pending ? (state.error ? 'error' : 'loading') : 'ok';
608
661
 
609
- const title = '全年活跃记录 (dsh-annual-activity)'
662
+ const title = 'DSH-活跃记录 (dsh-annual-activity)'
610
663
  + (data
611
664
  ? ':' + fmtInt(curStats ? curStats.activeDays : 0) + ' 天活跃 · 活跃率 ' + fmtPercent(curStats ? curStats.rate : 0)
612
665
  + ' · 今日 ' + (rec ? fmtInt(rec.turns) + ' 轮' : '未活跃')
@@ -626,31 +679,79 @@ window.__ModuleLoader__.load({
626
679
  ref: btnRef,
627
680
  type: 'button',
628
681
  className: 'daa-hbtn' + (open ? ' on' : ''),
629
- 'aria-label': '全年活跃记录',
630
- title,
682
+ 'aria-label': 'DSH-活跃记录' + (pending ? '(数据加载中)' : ''),
683
+ 'aria-busy': pending ? 'true' : undefined,
684
+ // 刻意不设原生 title:浏览器自带的提示框会浮在按钮上方并遮挡按钮,
685
+ // 提示改由下方自绘浮层承担(EntryTip)。
631
686
  onClick: () => setOpen(!open),
632
687
  onMouseEnter: onEnter,
633
688
  onMouseLeave: () => setTip(null),
634
689
  },
635
- React.createElement('span', { className: 'daa-glyph', 'aria-hidden': 'true' },
636
- glyphCells(byDay).map((c, i) => React.createElement('span', {
637
- key: i, className: 'daa-glyph-cell' + (c.level < 0 ? ' daa-glyph-void' : ' daa-l' + c.level),
690
+ React.createElement('span', {
691
+ className: 'daa-glyph' + (pending ? ' daa-glyph-' + glyphMode : ''),
692
+ 'aria-hidden': 'true',
693
+ },
694
+ (pending ? GLYPH_SKELETON : glyphCells(byDay)).map((c, i) => React.createElement('span', {
695
+ key: i,
696
+ className: 'daa-glyph-cell' + (pending ? '' : c.level < 0 ? ' daa-glyph-void' : ' daa-l' + c.level),
638
697
  }))),
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);
698
+ updateAvailable ? React.createElement('span', { className: 'daa-hbtn-dot', 'aria-label': '有新版本' }) : null),
699
+ tip ? portalToBody(React.createElement(EntryTip, { tip })) : null,
700
+ open ? portalToBody(React.createElement(ActivityPanel, { onClose: () => setOpen(false), summary: title })) : null);
701
+ }
702
+
703
+ /**
704
+ * 浮层位置自适应(纯函数,便于直接测试):
705
+ * - 下方空间不足时整体上移到「刚好放得下」,保证整块浮层可见;
706
+ * - 右侧越界时向左收,避免贴边被截断;
707
+ * - 视口信息缺失时退回原值。
708
+ */
709
+ function fitTipPosition(base, size, viewport, margin) {
710
+ const m = typeof margin === 'number' ? margin : 8;
711
+ let { left, top } = base;
712
+ const vw = viewport && viewport.width;
713
+ const vh = viewport && viewport.height;
714
+ const w = size && size.width;
715
+ const h = size && size.height;
716
+ if (vh && h && top + h > vh - m) top = Math.max(m, vh - m - h);
717
+ if (vw && w && left + w / 2 > vw - m) left = vw - m - w / 2;
718
+ if (left - w / 2 < m) left = m + w / 2;
719
+ return { left: Math.round(left), top: Math.round(top) };
642
720
  }
643
721
 
644
- /** 入口悬浮说明:固定定位在按钮**下方**(放在上方会遮住按钮与标题行)。 */
722
+ /** 入口悬浮说明:渲染在按钮**下方**(放到上方会盖住按钮与标题行)。
723
+ * 位置不是死偏移:挂载后按实测高度微调,保证顶部不会被头部/视口切掉。 */
645
724
  function EntryTip({ tip }) {
646
725
  const { today: rec, stats, x, y } = tip;
726
+ const tipRef = React.useRef(null);
647
727
  const rows = [
648
728
  ['今日', rec ? fmtInt(rec.turns) + ' 轮' : '未活跃'],
649
729
  ['今年', (stats ? fmtInt(stats.activeDays) : '0') + ' 天活跃 · ' + fmtPercent(stats ? stats.rate : 0)],
650
730
  ['连登', fmtInt(stats ? stats.weekStreak : 0) + ' 周'],
651
731
  ];
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' } },
732
+ const baseLeft = Math.min(Math.max(Math.round(x), 110), Math.max(110, window.innerWidth - 130));
733
+ const baseTop = Math.round(y) + TIP_GAP;
734
+
735
+ React.useLayoutEffect(() => {
736
+ const el = tipRef.current;
737
+ if (!el || typeof el.getBoundingClientRect !== 'function') return;
738
+ const rect = el.getBoundingClientRect();
739
+ const fitted = fitTipPosition(
740
+ { left: baseLeft, top: baseTop },
741
+ { width: rect.width, height: rect.height },
742
+ { width: window.innerWidth, height: window.innerHeight },
743
+ 8
744
+ );
745
+ if (fitted.top !== baseTop) el.style.top = fitted.top + 'px';
746
+ if (fitted.left !== baseLeft) el.style.left = fitted.left + 'px';
747
+ });
748
+
749
+ return React.createElement('div', {
750
+ ref: tipRef,
751
+ className: 'daa-tip daa-tip-below',
752
+ // 初值:按钮下方 TIP_GAP 像素,随后由 layout effect 按实测尺寸修正
753
+ style: { left: baseLeft + 'px', top: baseTop + 'px', zIndex: 20 },
754
+ },
654
755
  rows.map(([k, v]) => React.createElement('div', { className: 'daa-tip-row', key: k },
655
756
  React.createElement('span', { className: 'daa-tip-k' }, k),
656
757
  React.createElement('span', { className: 'daa-tip-v' }, v))));
@@ -664,36 +765,40 @@ window.__ModuleLoader__.load({
664
765
  color:var(--dsh-text-1,#1f2937);}
665
766
  /* ---------- 头部入口按钮(对齐「打开右侧边栏」:28×28 / radius 28 / 16px 图标)
666
767
  官方按钮是纯透明底,放在标题行里太「隐形」,所以这里给一个比悬停态更浅的
667
- 常驻底色 + 一层细边(同样是官方 token,深色主题自动跟随)。---------- */
768
+ 常驻底色(官方 token,深色主题自动跟随)。刻意不用 box-shadow:描边在浅色
769
+ 主题下显脏,纯色底 + 悬停加深一档已经足够区分层级,也更贴近官方按钮观感。 */
668
770
  .daa-hbtn{position:relative;box-sizing:border-box;width:28px;height:28px;flex:none;padding:6px;
669
771
  display:inline-flex;align-items:center;justify-content:center;cursor:pointer;
670
772
  background:var(--dsw-alias-interactive-bg-hover,rgba(38,49,72,.06));
671
- border:none;border-radius:28px;box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l2,rgba(0,0,0,.10));
773
+ border:none;border-radius:28px;
672
774
  color:var(--dsw-alias-label-secondary,#4b5563);
673
- transition:background .14s ease,color .14s ease,box-shadow .14s ease;}
775
+ transition:background .14s ease,color .14s ease;}
674
776
  .daa-hbtn:hover{background:var(--dsw-alias-interactive-bg-active,rgba(38,49,72,.10));
675
- box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l3,rgba(0,0,0,.16));
676
777
  color:var(--dsw-alias-label-primary,#111827);}
677
778
  .daa-hbtn.on{background:var(--dsw-alias-interactive-bg-active,rgba(38,49,72,.10));
678
- box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l3,rgba(0,0,0,.16));
679
779
  color:var(--dsw-alias-label-primary,#111827);}
680
780
  .daa-glyph{display:grid;grid-template-columns:repeat(7,2px);grid-template-rows:repeat(2,2px);gap:1px;}
681
781
  .daa-glyph-cell{width:2px;height:2px;border-radius:.5px;}
682
782
  .daa-glyph-void{background:transparent;}
783
+ /* 数据还没到时画 14 格骨架并做呼吸动画:否则「全 0 级」的浅灰几乎看不见,
784
+ 入口按钮会像个坏掉的空按钮(页面刚打开、还没点开过面板时最明显)。 */
785
+ .daa-glyph-loading{animation:daa-glyph-pulse 1.2s ease-in-out infinite;}
786
+ .daa-glyph-loading .daa-glyph-cell{background:var(--dsw-alias-border-l2,rgba(0,0,0,.14));}
787
+ /* host 路由没装配时骨架不再呼吸,改用警示色,避免一直「转圈」像是卡住了 */
788
+ .daa-glyph-error .daa-glyph-cell{background:var(--dsw-alias-state-business-primary,#f59e0b);opacity:.55;}
789
+ @keyframes daa-glyph-pulse{0%,100%{opacity:.4}50%{opacity:1}}
790
+ /* 「有新版本」圆点:不加 box-shadow 描边环,琥珀色在深浅两套底色上都够醒目 */
683
791
  .daa-hbtn-dot{position:absolute;top:2px;right:2px;width:7px;height:7px;border-radius:50%;
684
- background:var(--dsw-alias-state-business-primary,#f59e0b);
685
- 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;}
792
+ background:var(--dsw-alias-state-business-primary,#f59e0b);}
692
793
  /* ---------- 遮罩 + 卡片 ---------- */
693
794
  .daa-backdrop{position:fixed;inset:0;pointer-events:auto;background:rgba(15,23,42,.42);
694
795
  display:flex;align-items:center;justify-content:center;padding:24px;animation:daa-fade .14s ease-out;}
695
796
  @keyframes daa-fade{from{opacity:0}to{opacity:1}}
696
- .daa-card{position:relative;width:min(828px,calc(100vw - 40px));max-height:calc(100vh - 48px);overflow:auto;
797
+ /* box-sizing:border-box 是显式声明的:面板总宽就等于这里的 760px(含内边距与描边),
798
+ 不依赖宿主是否提供全局 reset。min-width:0 也是必须的:卡片是 flex item,默认
799
+ min-width:auto 会取内容的 min-content 宽度,内容偏宽时会把卡片顶得比 760px 还宽。
800
+ 改宽度时注意热力图要跟着放得下——见 .daa-grid 的自适应列宽。 */
801
+ .daa-card{position:relative;box-sizing:border-box;min-width:0;width:min(760px,calc(100vw - 40px));max-height:calc(100vh - 48px);overflow:auto;
697
802
  background:var(--dsh-surface-1,#fff);border:1px solid var(--dsh-border,#e8eaee);border-radius:14px;
698
803
  box-shadow:0 24px 64px rgba(15,23,42,.26);padding:16px 20px 12px;
699
804
  animation:daa-pop .16s cubic-bezier(.2,.9,.3,1.2);}
@@ -739,12 +844,17 @@ window.__ModuleLoader__.load({
739
844
  .daa-legend-cap{padding:0 3px;}
740
845
  .daa-legend-cell{width:11px;height:11px;border-radius:3px;display:inline-block;}
741
846
  /* ---------- 热力图 ---------- */
847
+ /* 列宽用 minmax(9px,1fr) 自适应:网格铺满卡片内容宽,最后一列的右边缘正好落在内容区
848
+ 右边界上,因此**日历右侧不会留下一条空白**(固定列宽 + 左对齐会留下
849
+ 「内容宽 − 53×列宽 − 52×间距」的空档,实测有 31~73px)。9px 是下限:窄到放不下时
850
+ 由 .daa-heat 出横向滚动条,而不是把格子压成看不清的碎片。 */
742
851
  .daa-heat{overflow-x:auto;overflow-y:hidden;padding-bottom:2px;}
743
- .daa-months{display:grid;grid-template-columns:repeat(var(--daa-cols,53),11px);gap:3px;margin-bottom:6px;min-width:max-content;}
852
+ .daa-months{display:grid;grid-template-columns:repeat(var(--daa-cols,53),minmax(9px,1fr));gap:2px;margin-bottom:6px;}
744
853
  .daa-month{font-size:11.5px;color:var(--dsh-text-3,#9aa1ab);white-space:nowrap;line-height:1;overflow:visible;}
745
- .daa-grid{display:grid;grid-template-columns:repeat(var(--daa-cols,53),11px);gap:3px;min-width:max-content;}
746
- .daa-col{display:flex;flex-direction:column;gap:3px;}
747
- .daa-cell{border-radius:3px;position:relative;background:#eef0f2;transition:transform .1s ease,box-shadow .1s ease;}
854
+ .daa-grid{display:grid;grid-template-columns:repeat(var(--daa-cols,53),minmax(9px,1fr));gap:2px;}
855
+ .daa-col{display:flex;flex-direction:column;gap:2px;}
856
+ /* 格子尺寸由列宽决定(正方形),不再写死像素——见 .daa-grid 的列定义 */
857
+ .daa-cell{width:100%;aspect-ratio:1/1;border-radius:3px;position:relative;background:#eef0f2;transition:transform .1s ease,box-shadow .1s ease;}
748
858
  .daa-cell-pad{background:transparent;}
749
859
  .daa-cell:not(.daa-cell-pad):hover{transform:scale(1.18);box-shadow:0 0 0 1.5px rgba(22,163,74,.45);}
750
860
  .daa-today{box-shadow:inset 0 0 0 1.5px #16a34a;}
@@ -762,6 +872,14 @@ window.__ModuleLoader__.load({
762
872
  .daa-tip-row{display:flex;justify-content:space-between;gap:12px;font-size:11.5px;line-height:1.7;}
763
873
  .daa-tip-k{color:var(--dsh-text-3,#9aa1ab);}
764
874
  .daa-tip-v{color:var(--dsh-text-1,#111827);font-variant-numeric:tabular-nums;}
875
+ /* 入口浮层修饰类:必须排在 .daa-tip 之后 —— 同优先级下后者会覆盖前者,
876
+ 写成「先修饰后基础」会让 translate(-50%,-100%) 生效,把浮层整体上移自身
877
+ 高度(正好是"上半部分看不到"的现象)。 */
878
+ .daa-tip-below{transform:translate(-50%,0);}
879
+ .daa-tip-below::before{content:'';position:absolute;left:50%;top:-4px;width:8px;height:8px;
880
+ margin-left:-4px;transform:rotate(45deg);background:inherit;
881
+ border-left:1px solid var(--dsh-border,#e5e7eb);border-top:1px solid var(--dsh-border,#e5e7eb);
882
+ border-top-left-radius:2px;}
765
883
  /* ---------- 底部 ---------- */
766
884
  .daa-foot{display:flex;align-items:center;gap:10px;margin-top:12px;padding-top:9px;
767
885
  border-top:1px solid var(--dsh-border,#f0f1f3);font-size:11.5px;color:var(--dsh-text-3,#9aa1ab);}
@@ -808,7 +926,6 @@ window.__ModuleLoader__.load({
808
926
  .daa-setup-btn,.daa-setup-notice{
809
927
  background:var(--dsh-surface-1,#1b1e24);border-color:var(--dsh-border,#2f343d);}
810
928
  .daa-setup{background:var(--dsh-surface-2,#171a1f);border-color:var(--dsh-border,#2f343d);}
811
- .daa-hbtn-dot{box-shadow:0 0 0 1.5px var(--dsh-surface-1,#1b1e24);}
812
929
  .daa-l0{background:#2a2f37;}
813
930
  .daa-foot,.daa-toolbar,.daa-setup-foot{border-color:var(--dsh-border,#2f343d);}
814
931
  .daa-error{background:#3a2a12;border-color:#7c5310;color:#fbbf24;}
@@ -826,14 +943,30 @@ window.__ModuleLoader__.load({
826
943
  styleEl.setAttribute("data-plugin", "dsh-annual-activity");
827
944
  styleEl.textContent = CSS;
828
945
  document.head.appendChild(styleEl);
829
- ctx.effect(() => () => { try { styleEl.remove(); } catch (e) { /* ignore */ } });
946
+
947
+ // portal 宿主随插件卸载一并移除(portal 出去的内容不在 React 树上,
948
+ // 卸载时槽位卸载会清掉内容,但宿主 div 会留下空壳,这里显式回收)。
949
+ const disposePortal = () => {
950
+ try { if (portalHost && portalHost.remove) portalHost.remove(); } catch (e) { /* ignore */ }
951
+ portalHost = null;
952
+ };
953
+ ctx.effect(() => () => {
954
+ try { styleEl.remove(); } catch (e) { /* ignore */ }
955
+ disposePortal();
956
+ });
830
957
 
831
958
  // 注册到会话头部右侧「utility」列表槽:该槽在 DOM 中先于右上角
832
959
  // corner 槽(「打开右侧边栏」按钮所在处)渲染,所以这个入口会落在
833
960
  // 那个按钮的左边、同一行内。按钮尺寸/圆角/色板都与 corner 里的
834
961
  // 官方按钮保持一致(28×28、图标 16px、interactive-bg-hover 悬停底色)。
962
+ //
963
+ // order 决定槽内顺序:renderer 按 order 升序渲染(`sort((a,b)=>a.order-b.order)`),
964
+ // 这些条目都渲染在 `[data-slot="conversation.session.header.utilities"]`
965
+ // 这个 display:contents 锚点下、成为 flex 容器的直接子元素。官方
966
+ // 「选择打开方式」的分裂按钮(.CAgGvG_split)用 -10、官方会话日志导出
967
+ // 用默认 0,所以这里取 -100,让本入口成为该容器的**第一个**子元素。
835
968
  slots.inject('conversation.session.header.utilities', () => slots.register(
836
- { name: 'conversation.session.header.utilities', id: 'annual-activity', order: 50 },
969
+ { name: 'conversation.session.header.utilities', id: 'annual-activity', order: -100 },
837
970
  () => React.createElement(HeaderEntry, null)
838
971
  ));
839
972
  }
@@ -841,7 +974,7 @@ window.__ModuleLoader__.load({
841
974
  exports.name = name;
842
975
  exports.inject = inject;
843
976
  exports.apply = apply;
844
- exports.__internal = { buildWeeks, monthLabels, fmtPercent, fmtCompact, levelOfDay, indexDays, glyphCells, FALLBACK_LEVELS, store, metaStore };
977
+ exports.__internal = { buildWeeks, monthLabels, fmtPercent, fmtCompact, levelOfDay, indexDays, glyphCells, fitTipPosition, FALLBACK_LEVELS, store, metaStore };
845
978
  return module.exports;
846
979
  }
847
980
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-annual-activity",
3
- "version": "1.1.3",
4
- "description": "全年活跃记录 (Annual Activity) — 一个纯本地的 DSH 活跃度统计面板:Host 半区扫描 $DSH_HOME/sessions 下的历史会话日志,按本地日期聚合每一天的活跃天/轮次/工具调用/Token 消耗,Client 半区以「年度热力图」面板渲染(统计行 + 5 级色块 + 月份轴 + 年份切换 + 连登统计),数据全部来自本机会话文件,不联网、不上报。",
3
+ "version": "1.1.5",
4
+ "description": "DSH-活跃记录 (Annual Activity) — 一个纯本地的 DSH 活跃度统计面板:Host 半区扫描 $DSH_HOME/sessions 下的历史会话日志,按本地日期聚合每一天的活跃天/轮次/工具调用/Token 消耗,Client 半区以「年度热力图」面板渲染(统计行 + 5 级色块 + 月份轴 + 年份切换 + 连登统计),数据全部来自本机会话文件,不联网、不上报。",
5
5
  "author": "spartaattack",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",