@wangnanshan/dsh-nanshan-publish-manager 1.0.2 → 1.0.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 (4) hide show
  1. package/README.md +7 -5
  2. package/client.js +199 -20
  3. package/index.js +74 -0
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  把本机开发的 DSH 组件,按 DeepSeek Harness 官方推荐的 npm 包形式发布给其他人安装。
4
4
 
5
5
  - **一个组件清单页**:卡片形式展示本机所有可发布的 DSH 组件(`dsh.bundle` / `dsh.client`),包含版本、发布状态、预检查结果和最近一次发布。
6
- - **一个组件详情页**:点击卡片进入,用「流程化板块」完成发布——版本与标签 → 发布目标与选项 → 发布预检查 → 确认与执行,右侧常驻该组件的**发布记录列表**。
6
+ - **一个组件详情页**:点击卡片进入,用「流程化板块」完成发布——版本与标签 → 发布目标与选项 → 发布预检查 → 确认与执行,右侧常驻该组件的 **NPM信息查询**、**发布记录列表**。
7
7
 
8
8
  界面采用现代极简的开发者工具控制台风格(Linear / Vercel / Raycast 一类的浅色克制审美),跟随 Harness 的浅色 / 深色主题。插件图标、侧栏入口和页面标识使用 3D 液态玻璃黑色调;搜索、勾选、关闭等功能图标保持线性,以保证小尺寸下的可读性。
9
9
 
@@ -84,8 +84,9 @@ DSH 组件就是一个 npm 包:
84
84
  1. **版本与标签** —— 目标版本(写回 `package.json`)、npm tag、`public` / `restricted`。
85
85
  2. **发布目标与选项** —— registry(默认读取你的 `~/.npmrc`)、可选构建脚本(`npm run <script>`)、自动运行发布预检查、先演练、自动写入版本号、发布备注。
86
86
  3. **发布预检查** —— 运行发布预检查;有阻断项时正式发布按钮不可用。
87
- 4. **确认与执行** —— 选择发布验证方式,勾选确认,先「仅演练」(`npm publish --dry-run`,临时写入的目标版本会在演练后还原)或「正式发布」(带最终确认弹窗);本机会话方式还可以在弹窗里选「在终端发布」。
87
+ 4. **确认与执行** —— 选择发布验证方式,勾选确认,先「仅演练」(`npm publish --dry-run`,临时写入的目标版本会在演练后还原)或「正式发布」(带最终确认弹窗);本机会话方式的最终弹窗按钮为:左侧「再想想」,右侧「采用终端发布(推荐)」和「采用 OTP 发布」。
88
88
  5. **发布记录** —— 每次演练 / 发布 / 失败都会写入本机记录,包含版本、tag、access、验证方式、耗时、预检查摘要和命令输出;终端发布会在记录里标记「终端」。
89
+ 6. **NPM信息查询** —— 右侧栏显示当前包名、近 7 天每日下载量柱状图(数据来自 npm downloads API)和「NPM查看该组件」入口。
89
90
 
90
91
  ## 发布验证方式
91
92
 
@@ -101,13 +102,13 @@ npm 已在 **2025-12-09 永久撤销全部经典 Token**,本插件不再提供
101
102
 
102
103
  Granular Access Token 可以在 npm 网站 Settings → Tokens 或 `npm token create` 创建;写令牌最长 90 天,无人值守发布建议开启 Bypass 2FA。
103
104
 
104
- 页面内的 OTP 只支持 TOTP(身份验证器 6 位数字)。如果账号使用安全密钥 / 网页型 2FA,npm 需要在交互式终端里打开浏览器授权;这种情况下可以直接在确认弹窗点「在终端发布」,或创建一个开启 Bypass 2FA 的 Granular Access Token 后使用「细粒度令牌」方式。
105
+ 页面内的 OTP 只支持 TOTP(身份验证器 6 位数字)。如果账号使用安全密钥 / 网页型 2FA,npm 需要在交互式终端里打开浏览器授权;这种情况下可以直接在确认弹窗点「采用终端发布(推荐)」,或创建一个开启 Bypass 2FA 的 Granular Access Token 后使用「细粒度令牌」方式。
105
106
 
106
107
  npm 凭据**不经过插件的存储层**:本机会话使用你自己的 `~/.npmrc`;Granular Token 的临时 npmrc 在发布后删除;OTP 只作用于一次子进程。三者都不会写入发布记录。
107
108
 
108
109
  ### 在系统终端发布
109
110
 
110
- 在「本机登录会话」方式下,最终确认弹窗提供「在终端发布」:
111
+ 在「本机登录会话」方式下,最终确认弹窗提供「采用终端发布(推荐)」:
111
112
 
112
113
  - macOS 打开 Terminal.app;
113
114
  - Windows 打开 `cmd.exe`;
@@ -149,7 +150,7 @@ OTP 不是插件强制每次都要填写的字段:
149
150
 
150
151
  1. 确认验证码来自**当前登录账号**(`npm whoami` 显示 `wangnanshan`)绑定的身份验证器,并且是刚生成的;旧码不能复用。
151
152
  2. 确认系统时间自动同步。TOTP 依赖本机时钟;当前机器与 npm registry 的偏差约 0.6 秒,属于正常范围。
152
- 3. 如果账号的 2FA 方式是**安全密钥 / 网页认证**而不是身份验证器 TOTP,页面内输入 6 位码不会成功。请在确认弹窗点「在终端发布」,让 npm 在系统终端里打开浏览器授权;或使用下面的令牌方案。
153
+ 3. 如果账号的 2FA 方式是**安全密钥 / 网页认证**而不是身份验证器 TOTP,页面内输入 6 位码不会成功。请在确认弹窗点「采用终端发布(推荐)」,让 npm 在系统终端里打开浏览器授权;或使用下面的令牌方案。
153
154
  4. 使用 **Granular Access Token** 时,必须在创建令牌时勾选 **Bypass 2FA**(`npm token create --bypass-2fa`);只粘贴一个未开启 Bypass 2FA 的令牌仍会返回 EOTP。
154
155
  5. 如果配置了构建脚本,构建会先于发布执行;构建较慢时验证码可能过期,建议使用开启 Bypass 2FA 的令牌。
155
156
 
@@ -225,5 +226,6 @@ HTTP 接口(仅本机、同源写入):
225
226
  - 发布使用本机 npm 配置,不支持在页面里输入 token;OTP 只用于单次发布,不落盘。
226
227
  - 系统终端发布支持 macOS Terminal、Windows `cmd.exe` 和 Linux 常见终端;Windows 的 `npm.cmd` 已做启动适配,但当前环境主要在 macOS 验证。
227
228
  - 终端发布最多等待 15 分钟;关闭终端或未完成验证会超时,不会写入成功记录;终端输出不写入插件记录。
229
+ - 近 7 天下载量来自 npm downloads API,统计通常滞后约 1 天;新包或未发布包可能暂无数据。
228
230
  - 页面直接执行 `npm publish`,请在发布前用「仅演练」确认打包内容。
229
231
  - `npm publish` 会执行组件 `package.json` 中的 publish 生命周期脚本;请只发布你信任的组件。
package/client.js CHANGED
@@ -29,7 +29,8 @@ window.__ModuleLoader__.load({
29
29
  'action.dryRun': '仅演练',
30
30
  'action.dryRunning': '演练中…',
31
31
  'action.publish': '正式发布',
32
- 'action.publishTerminal': '在终端发布',
32
+ 'action.publishTerminal': '采用终端发布(推荐)',
33
+ 'action.reconsider': '再想想',
33
34
  'action.terminalWaiting': '等待终端…',
34
35
  'action.publishing': '发布中…',
35
36
  'action.cancel': '再想想',
@@ -65,6 +66,15 @@ window.__ModuleLoader__.load({
65
66
  'badge.host': 'Host',
66
67
  'badge.bundlePatch': '含 patch',
67
68
  'detail.records': '发布记录',
69
+ 'npmInfo.title': 'NPM信息查询',
70
+ 'npmInfo.open': 'NPM查看该组件',
71
+ 'npmInfo.hint': '在 npm 网站查看版本、README 与安装信息;未发布时可能显示 404。',
72
+ 'npmInfo.last7': '近 7 天下载',
73
+ 'npmInfo.chartLabel': '近 7 天每日下载量柱形图',
74
+ 'npmInfo.loading': '查询中…',
75
+ 'npmInfo.empty': '暂无下载数据(新包或未发布)',
76
+ 'npmInfo.unavailable': '下载数据暂时不可用',
77
+ 'npmInfo.source': '数据来源:npm downloads API',
68
78
  'detail.synopsis': '发布状态',
69
79
  'detail.noRecords': '还没有发布记录',
70
80
  'detail.noRecordsHint': '完成第一次演练或正式发布后,记录会按时间出现在这里。',
@@ -241,8 +251,9 @@ window.__ModuleLoader__.load({
241
251
  'modal.publishWarning': '请确认版本号、tag 与可见范围都正确;公开包会立刻被其他人安装到。',
242
252
  'modal.dryFirstHint': '本次设置启用了「发布前先演练」,你还没有在本次会话里演练;确认后仍会直接正式发布。',
243
253
  'modal.publishOk': '确认发布',
254
+ 'modal.otpPublish': '采用 OTP 发布',
244
255
  'modal.otpHint': '请填写刚生成的验证码;一次性、约 30 秒有效。',
245
- 'modal.terminalHint': '也可以打开系统终端完成发布:npm 会在终端里询问 OTP 或打开浏览器完成 2FA,插件会等待结果并写入记录。',
256
+ 'modal.terminalHint': '推荐用系统终端发布:npm 会在终端里询问 OTP 或打开浏览器完成 2FA,插件等待结果并写入记录。也可以填写上方 OTP,改用「采用 OTP 发布」。',
246
257
  'modal.buildBeforeOtp': '当前配置了构建脚本:发布前会先执行构建,耗时较长时验证码可能过期;建议使用开启 Bypass 2FA 的 Granular Token。',
247
258
  'toast.settingsSaved': '发布设置已保存',
248
259
  'toast.checkDone': '预检查完成',
@@ -354,7 +365,8 @@ window.__ModuleLoader__.load({
354
365
  'action.dryRun': 'Dry run',
355
366
  'action.dryRunning': 'Dry running…',
356
367
  'action.publish': 'Publish',
357
- 'action.publishTerminal': 'Publish in terminal',
368
+ 'action.publishTerminal': 'Publish in terminal (recommended)',
369
+ 'action.reconsider': 'Reconsider',
358
370
  'action.terminalWaiting': 'Waiting for terminal…',
359
371
  'action.publishing': 'Publishing…',
360
372
  'action.cancel': 'Not yet',
@@ -390,6 +402,15 @@ window.__ModuleLoader__.load({
390
402
  'badge.host': 'Host',
391
403
  'badge.bundlePatch': 'patch',
392
404
  'detail.records': 'Publish history',
405
+ 'npmInfo.title': 'npm information',
406
+ 'npmInfo.open': 'View this component on npm',
407
+ 'npmInfo.hint': 'View versions, README and install info on npm; a 404 means it is not published yet.',
408
+ 'npmInfo.last7': 'Downloads · last 7 days',
409
+ 'npmInfo.chartLabel': 'Daily downloads over the last 7 days',
410
+ 'npmInfo.loading': 'Loading…',
411
+ 'npmInfo.empty': 'No download data yet (new or unpublished package)',
412
+ 'npmInfo.unavailable': 'Download data is temporarily unavailable',
413
+ 'npmInfo.source': 'Source: npm downloads API',
393
414
  'detail.synopsis': 'Release status',
394
415
  'detail.noRecords': 'No publish records yet',
395
416
  'detail.noRecordsHint': 'After the first dry run or publish, entries appear here in order.',
@@ -566,8 +587,9 @@ window.__ModuleLoader__.load({
566
587
  'modal.publishWarning': 'Double-check the version, tag, and access. A public package becomes installable immediately.',
567
588
  'modal.dryFirstHint': '“Dry run first” is enabled but no dry run has happened in this session; confirming publishes directly.',
568
589
  'modal.publishOk': 'Publish',
590
+ 'modal.otpPublish': 'Publish with OTP',
569
591
  'modal.otpHint': 'Enter a freshly generated code; single-use and valid for about 30 seconds.',
570
- 'modal.terminalHint': 'You can also publish in the system terminal: npm handles OTP or web 2FA there, and the plugin waits and records the result.',
592
+ 'modal.terminalHint': 'Recommended: publish in the system terminal. npm handles OTP or web 2FA there, and the plugin records the result. You can also enter an OTP above and use “Publish with OTP”.',
571
593
  'modal.buildBeforeOtp': 'A build script is configured: the build runs before publishing and may outlast the OTP; a Granular Access Token with Bypass 2FA is recommended.',
572
594
  'toast.settingsSaved': 'Release settings saved',
573
595
  'toast.checkDone': 'Pre-check finished',
@@ -693,7 +715,7 @@ window.__ModuleLoader__.load({
693
715
  .np__title{margin:0;font-size:23px;font-weight:600;line-height:1.25;letter-spacing:-.02em}
694
716
  .np__subtitle{margin:4px 0 0;max-width:680px;font-size:13px;line-height:1.6;color:var(--np-ink-soft)}
695
717
  .np__actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
696
- .np-btn{appearance:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;height:32px;padding:0 12px;border:1px solid var(--np-line-strong);border-radius:8px;background:var(--np-sheet);color:var(--np-ink);font:inherit;font-size:13px;font-weight:500;line-height:1;cursor:pointer;box-shadow:var(--np-shadow);transition:background-color .14s ease,border-color .14s ease,color .14s ease,opacity .14s ease;white-space:nowrap}
718
+ .np-btn{appearance:none;display:inline-flex;text-decoration:none;align-items:center;justify-content:center;gap:6px;height:32px;padding:0 12px;border:1px solid var(--np-line-strong);border-radius:8px;background:var(--np-sheet);color:var(--np-ink);font:inherit;font-size:13px;font-weight:500;line-height:1;cursor:pointer;box-shadow:var(--np-shadow);transition:background-color .14s ease,border-color .14s ease,color .14s ease,opacity .14s ease;white-space:nowrap}
697
719
  .np-btn:hover:not(:disabled){background:var(--np-sheet2);border-color:var(--np-line-strong)}
698
720
  .np-btn:active:not(:disabled){background:var(--np-sheet2)}
699
721
  .np-btn:disabled{opacity:.45;cursor:not-allowed;box-shadow:none}
@@ -852,7 +874,24 @@ window.__ModuleLoader__.load({
852
874
  .np-result__close:focus-visible{outline:2px solid var(--np-bad);outline-offset:2px}
853
875
  .np-result__cmd{padding:7px 11px;border-bottom:1px solid var(--np-line);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px;line-height:1.55;color:var(--np-ink-soft);word-break:break-all}
854
876
  .np-log{margin:0;padding:10px 12px;max-height:260px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px;line-height:1.55;color:var(--np-ink-soft)}
877
+ .np-downloads{display:flex;flex-direction:column;gap:8px;padding-top:2px}
878
+ .np-downloads__head{display:flex;align-items:baseline;justify-content:space-between;gap:8px;font-size:11.5px;color:var(--np-ink-soft)}
879
+ .np-downloads__head strong{font-size:14px;font-weight:600;color:var(--np-ink);font-variant-numeric:tabular-nums}
880
+ .np-downloads__loading{font-size:11px;color:var(--np-ink-soft)}
881
+ .np-bars{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));align-items:end;gap:5px;height:96px;padding-top:14px}
882
+ .np-bar{display:flex;flex-direction:column;align-items:center;justify-content:flex-end;gap:3px;min-width:0}
883
+ .np-bar__value{font-size:9px;line-height:1;color:var(--np-ink-soft);font-variant-numeric:tabular-nums;white-space:nowrap}
884
+ .np-bar__track{display:flex;align-items:flex-end;width:100%;max-width:18px;height:62px;overflow:hidden;border-radius:3px;background:var(--np-sheet2)}
885
+ .np-bar__fill{display:block;width:100%;min-height:2px;border-radius:3px 3px 0 0;background:var(--np-accent);opacity:.9}
886
+ .np-bar__label{font-size:9px;line-height:1;color:var(--np-ink-soft);white-space:nowrap}
887
+ .np-downloads__hint{font-size:10px;line-height:1.5;color:var(--np-ink-soft)}
888
+ .np-bars--skeleton{height:96px;padding-top:14px}
889
+ .np-bar__skeleton{display:block;width:100%;max-width:18px;height:62px;align-self:end;justify-self:center;border-radius:3px;background:linear-gradient(100deg,var(--np-sheet2) 20%,var(--np-line) 45%,var(--np-sheet2) 70%);background-size:200% 100%;animation:np-shimmer 1.5s linear infinite}
855
890
  .np-railcard{position:relative;padding:16px;background:var(--np-sheet);border:1px solid var(--np-line);border-radius:12px;box-shadow:var(--np-shadow)}
891
+ .np-railcard__body{display:flex;flex-direction:column;gap:8px}
892
+ .np-railcard__pkg{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px;color:var(--np-ink-soft);word-break:break-all}
893
+ .np-railcard__hint{font-size:11px;line-height:1.55;color:var(--np-ink-soft)}
894
+ .np-railcard__action{width:100%;justify-content:center}
856
895
  .np-railcard__title{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:0 0 12px;font-size:13px;font-weight:600;line-height:1.3}
857
896
  .np-dl{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px 12px;margin:0;font-size:12px;line-height:1.5}
858
897
  .np-dl dt{color:var(--np-ink-soft)}
@@ -879,6 +918,8 @@ window.__ModuleLoader__.load({
879
918
  .np-dialog .np-field{margin:2px 0 10px}
880
919
  .np-dialog__warn{padding:8px 10px;border:1px solid color-mix(in srgb,var(--np-bad) 30%,var(--np-line));border-radius:8px;background:color-mix(in srgb,var(--np-bad) 6%,transparent);color:var(--np-bad)!important}
881
920
  .np-dialog__actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}
921
+ .np-dialog__actions--split{justify-content:space-between;flex-wrap:wrap}
922
+ .np-dialog__actions-right{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin-left:auto}
882
923
  .np-dialog.np-dialog--info{width:min(560px,100%)}
883
924
  .np-dialog.np-dialog--error{width:min(540px,100%)}
884
925
  .np-dialog--error .np-info__head h2{margin:0;flex:1;min-width:0}
@@ -971,6 +1012,19 @@ window.__ModuleLoader__.load({
971
1012
  return text
972
1013
  }
973
1014
 
1015
+ function formatCount(value) {
1016
+ const n = Number(value) || 0
1017
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`
1018
+ if (n >= 10_000) return `${Math.round(n / 1000)}k`
1019
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
1020
+ return String(n)
1021
+ }
1022
+
1023
+ function shortDay(value) {
1024
+ const text = String(value || '')
1025
+ return text.length >= 10 ? text.slice(5, 10) : text
1026
+ }
1027
+
974
1028
  function formatDateTime(value) {
975
1029
  const date = new Date(value)
976
1030
  if (Number.isNaN(date.getTime())) return '—'
@@ -1254,6 +1308,10 @@ window.__ModuleLoader__.load({
1254
1308
  ['rect', { x: 2.2, y: 3.2, width: 11.6, height: 9.6, rx: 1.6 }],
1255
1309
  ['path', { d: 'm4.8 6.1 2.4 2.2-2.4 2.2M8.6 10.5h2.8' }],
1256
1310
  ],
1311
+ external: [
1312
+ ['path', { d: 'M9.4 2.8H13v3.6M13 2.8 8.2 7.6' }],
1313
+ ['path', { d: 'M12 9.2v2.2a1.6 1.6 0 0 1-1.6 1.6H3.8A1.6 1.6 0 0 1 2.2 11.4V4.8a1.6 1.6 0 0 1 1.6-1.6h2.4' }],
1314
+ ],
1257
1315
  pencil: [
1258
1316
  ['path', { d: 'M3 13l.9-3.1 6.3-6.3 2.2 2.2-6.3 6.3L3 13Z' }],
1259
1317
  ['path', { d: 'M9.6 4.2 11.8 6.4' }],
@@ -1369,6 +1427,69 @@ window.__ModuleLoader__.load({
1369
1427
  )
1370
1428
  }
1371
1429
 
1430
+ function DownloadBars(props) {
1431
+ const [t] = useText()
1432
+ const status = props.status || 'loading'
1433
+ const points = Array.isArray(props.points) ? props.points : []
1434
+ const total = Number(props.total) || 0
1435
+ if (status === 'loading') {
1436
+ return h('div', { className: 'np-downloads' },
1437
+ h('div', { className: 'np-downloads__head' },
1438
+ h('span', null, t('npmInfo.last7')),
1439
+ h('span', { className: 'np-downloads__loading' }, t('npmInfo.loading')),
1440
+ ),
1441
+ h('div', { className: 'np-bars np-bars--skeleton', 'aria-hidden': 'true' },
1442
+ Array.from({ length: 7 }, (_, index) => h('span', { key: index, className: 'np-bar__skeleton' })),
1443
+ ),
1444
+ )
1445
+ }
1446
+ if (status === 'unavailable' || status === 'error') {
1447
+ return h('div', { className: 'np-downloads' },
1448
+ h('div', { className: 'np-downloads__head' }, h('span', null, t('npmInfo.last7'))),
1449
+ h('span', { className: 'np-downloads__hint', title: props.error || undefined }, t('npmInfo.unavailable')),
1450
+ )
1451
+ }
1452
+ if (status === 'empty' || points.length === 0) {
1453
+ return h('div', { className: 'np-downloads' },
1454
+ h('div', { className: 'np-downloads__head' },
1455
+ h('span', null, t('npmInfo.last7')),
1456
+ h('strong', null, '0'),
1457
+ ),
1458
+ h('span', { className: 'np-downloads__hint' }, t('npmInfo.empty')),
1459
+ )
1460
+ }
1461
+ const max = Math.max(1, ...points.map((point) => Number(point.downloads) || 0))
1462
+ return h('div', { className: 'np-downloads' },
1463
+ h('div', { className: 'np-downloads__head' },
1464
+ h('span', null, t('npmInfo.last7')),
1465
+ h('strong', null, formatCount(total)),
1466
+ ),
1467
+ h('div', {
1468
+ className: 'np-bars',
1469
+ role: 'img',
1470
+ 'aria-label': t('npmInfo.chartLabel'),
1471
+ style: { gridTemplateColumns: `repeat(${Math.max(points.length, 1)},minmax(0,1fr))` },
1472
+ },
1473
+ points.map((point, index) => {
1474
+ const value = Number(point.downloads) || 0
1475
+ const height = Math.max(2, Math.round((value / max) * 100))
1476
+ return h('div', {
1477
+ key: point.day || index,
1478
+ className: 'np-bar',
1479
+ title: `${point.day}: ${formatCount(value)}`,
1480
+ },
1481
+ h('span', { className: 'np-bar__value' }, value > 0 ? formatCount(value) : ''),
1482
+ h('span', { className: 'np-bar__track' },
1483
+ h('span', { className: 'np-bar__fill', style: { height: `${height}%` } }),
1484
+ ),
1485
+ h('span', { className: 'np-bar__label' }, shortDay(point.day)),
1486
+ )
1487
+ }),
1488
+ ),
1489
+ h('span', { className: 'np-downloads__hint' }, t('npmInfo.source')),
1490
+ )
1491
+ }
1492
+
1372
1493
  function Toast(props) {
1373
1494
  if (!props.toast) return null
1374
1495
  return h('div', { className: `np-toast np-toast--${props.toast.kind}`, role: 'status', 'aria-live': 'polite' },
@@ -1659,6 +1780,7 @@ window.__ModuleLoader__.load({
1659
1780
  const [showToken, setShowToken] = React.useState(false)
1660
1781
  const [toast, showToast] = useToast()
1661
1782
  const [errorDialog, setErrorDialog] = React.useState(null)
1783
+ const [downloads, setDownloads] = React.useState({ status: 'loading', points: [], total: 0, error: '' })
1662
1784
  const okButtonRef = React.useRef(null)
1663
1785
  const otpInputRef = React.useRef(null)
1664
1786
  const tokenInputRef = React.useRef(null)
@@ -1747,6 +1869,32 @@ window.__ModuleLoader__.load({
1747
1869
  const effectiveAccess = isScopedPackage ? (settings ? settings.access : 'public') : 'public'
1748
1870
  const isPrerelease = typeof targetVersion === 'string' && targetVersion.includes('-')
1749
1871
 
1872
+ React.useEffect(() => {
1873
+ if (!packageName) return undefined
1874
+ const controller = new AbortController()
1875
+ setDownloads({ status: 'loading', points: [], total: 0, error: '' })
1876
+ apiGet(`/downloads?dir=${encodeURIComponent(dir)}`, controller.signal)
1877
+ .then((data) => {
1878
+ const points = Array.isArray(data.points) ? data.points : []
1879
+ setDownloads({
1880
+ status: data.unavailable ? 'unavailable' : points.length > 0 ? 'ready' : 'empty',
1881
+ points,
1882
+ total: Number(data.total) || 0,
1883
+ error: data.error || '',
1884
+ })
1885
+ })
1886
+ .catch((failure) => {
1887
+ if (failure && failure.name === 'AbortError') return
1888
+ setDownloads({
1889
+ status: 'error',
1890
+ points: [],
1891
+ total: 0,
1892
+ error: failure && failure.message ? failure.message : '',
1893
+ })
1894
+ })
1895
+ return () => controller.abort()
1896
+ }, [dir, packageName])
1897
+
1750
1898
  const closeError = React.useCallback(() => setErrorDialog(null), [])
1751
1899
  const showError = React.useCallback((title, message, detail, hint) => {
1752
1900
  setModalOpen(false)
@@ -2019,6 +2167,7 @@ window.__ModuleLoader__.load({
2019
2167
  if (!component || !settings) return null
2020
2168
 
2021
2169
  const recordSummary = component.lastPublish || null
2170
+ const npmUrl = packageName ? `https://www.npmjs.com/package/${packageName}` : ''
2022
2171
 
2023
2172
  return h('div', { className: 'np__inner' },
2024
2173
  h('div', { className: 'np-detail__head' },
@@ -2413,6 +2562,27 @@ window.__ModuleLoader__.load({
2413
2562
  h('dd', null, npmInfo ? (npmInfo.username ? tr('synopsis.npmUser', { name: npmInfo.username }) : t('synopsis.npmUnknown')) : t('synopsis.npmIdle')),
2414
2563
  ),
2415
2564
  ),
2565
+ h('section', { className: 'np-railcard' },
2566
+ h('h2', { className: 'np-railcard__title' }, t('npmInfo.title')),
2567
+ h('div', { className: 'np-railcard__body' },
2568
+ h('code', { className: 'np-railcard__pkg' }, packageName || '—'),
2569
+ h('span', { className: 'np-railcard__hint' }, t('npmInfo.hint')),
2570
+ h(DownloadBars, {
2571
+ status: downloads.status,
2572
+ points: downloads.points,
2573
+ total: downloads.total,
2574
+ error: downloads.error,
2575
+ }),
2576
+ npmUrl
2577
+ ? h('a', {
2578
+ className: 'np-btn np-btn--small np-railcard__action',
2579
+ href: npmUrl,
2580
+ target: '_blank',
2581
+ rel: 'noreferrer noopener',
2582
+ }, h(Glyph, { name: 'external', size: 13 }), t('npmInfo.open'))
2583
+ : h('button', { type: 'button', className: 'np-btn np-btn--small np-railcard__action', disabled: true }, h(Glyph, { name: 'external', size: 13 }), t('npmInfo.open')),
2584
+ ),
2585
+ ),
2416
2586
  h('section', { className: 'np-railcard' },
2417
2587
  h('h2', { className: 'np-railcard__title' },
2418
2588
  t('detail.records'),
@@ -2528,23 +2698,32 @@ window.__ModuleLoader__.load({
2528
2698
  settings.dryRunFirst && !records.some((record) => record.dryRun === true)
2529
2699
  ? h('p', { className: 'np-hint' }, t('modal.dryFirstHint'))
2530
2700
  : null,
2531
- h('div', { className: 'np-dialog__actions' },
2532
- authMode === 'session'
2533
- ? h('button', {
2534
- type: 'button',
2535
- className: 'np-btn',
2536
- disabled: busy !== '' || blocked !== '',
2537
- onClick: publishInTerminal,
2538
- }, h(Glyph, { name: 'terminal', size: 13 }), busy === 'terminal' ? t('action.terminalWaiting') : t('action.publishTerminal'))
2539
- : null,
2540
- h('button', { type: 'button', className: 'np-btn np-btn--ghost', onClick: () => setModalOpen(false) }, t('action.cancel')),
2701
+ h('div', { className: 'np-dialog__actions np-dialog__actions--split' },
2541
2702
  h('button', {
2542
2703
  type: 'button',
2543
- className: 'np-btn np-btn--primary',
2544
- ref: okButtonRef,
2545
- disabled: busy !== '' || blocked !== '',
2546
- onClick: () => publish(false),
2547
- }, t('modal.publishOk')),
2704
+ className: 'np-btn np-btn--ghost',
2705
+ onClick: () => setModalOpen(false),
2706
+ }, t('action.reconsider')),
2707
+ h('div', { className: 'np-dialog__actions-right' },
2708
+ authMode === 'session'
2709
+ ? h('button', {
2710
+ type: 'button',
2711
+ className: 'np-btn np-btn--primary',
2712
+ disabled: busy !== '' || blocked !== '',
2713
+ onClick: publishInTerminal,
2714
+ }, h(Glyph, { name: 'terminal', size: 13 }), busy === 'terminal' ? t('action.terminalWaiting') : t('action.publishTerminal'))
2715
+ : null,
2716
+ h('button', {
2717
+ type: 'button',
2718
+ className: authMode === 'session' ? 'np-btn' : 'np-btn np-btn--primary',
2719
+ ref: okButtonRef,
2720
+ disabled: busy !== '' || blocked !== '',
2721
+ onClick: () => publish(false),
2722
+ },
2723
+ authMode === 'session' ? h(Glyph, { name: 'upload', size: 13 }) : null,
2724
+ authMode === 'session' ? t('modal.otpPublish') : t('modal.publishOk'),
2725
+ ),
2726
+ ),
2548
2727
  ),
2549
2728
  ),
2550
2729
  )
package/index.js CHANGED
@@ -84,6 +84,7 @@ export function apply(ctx, config = {}) {
84
84
  let discoveryCache = { at: 0, value: null }
85
85
  let overviewCache = { at: 0, value: null }
86
86
  let npmCache = { at: 0, value: null }
87
+ const downloadsCache = new Map()
87
88
  let npmrcRegistry = null
88
89
  const publishing = new Set()
89
90
 
@@ -1725,6 +1726,76 @@ export function apply(ctx, config = {}) {
1725
1726
  }
1726
1727
  }
1727
1728
 
1729
+ async function getDownloads(rawDir) {
1730
+ const { pkg } = await requireComponentDir(rawDir)
1731
+ const name = typeof pkg.name === 'string' ? pkg.name : ''
1732
+ if (!name) throw httpError(400, '该组件没有可查询的 npm 包名。')
1733
+ const cached = downloadsCache.get(name)
1734
+ if (cached && Date.now() - cached.at < cached.ttl) return cached.value
1735
+ const remember = (value, ttl) => {
1736
+ downloadsCache.set(name, { at: Date.now(), ttl, value })
1737
+ return value
1738
+ }
1739
+ const unavailable = (message) => remember({
1740
+ package: name,
1741
+ points: [],
1742
+ total: 0,
1743
+ start: null,
1744
+ end: null,
1745
+ unavailable: true,
1746
+ error: message,
1747
+ fetchedAt: new Date().toISOString(),
1748
+ }, 2 * 60_000)
1749
+ const controller = new AbortController()
1750
+ const timer = setTimeout(() => controller.abort(), 10_000)
1751
+ let response
1752
+ try {
1753
+ response = await fetch(`https://api.npmjs.org/downloads/range/last-week/${encodeURIComponent(name)}`, {
1754
+ signal: controller.signal,
1755
+ headers: { accept: 'application/json' },
1756
+ })
1757
+ } catch (error) {
1758
+ return unavailable(error && error.name === 'AbortError' ? '查询下载量超时。' : '无法连接 npm 下载统计接口。')
1759
+ } finally {
1760
+ clearTimeout(timer)
1761
+ }
1762
+ if (response.status === 404) {
1763
+ return remember({
1764
+ package: name,
1765
+ points: [],
1766
+ total: 0,
1767
+ start: null,
1768
+ end: null,
1769
+ unavailable: false,
1770
+ empty: true,
1771
+ fetchedAt: new Date().toISOString(),
1772
+ }, 10 * 60_000)
1773
+ }
1774
+ if (!response.ok) return unavailable(`npm 下载统计接口返回 HTTP ${response.status}。`)
1775
+ let data
1776
+ try {
1777
+ data = await response.json()
1778
+ } catch {
1779
+ return unavailable('npm 下载统计返回了无法解析的数据。')
1780
+ }
1781
+ const points = Array.isArray(data.downloads)
1782
+ ? data.downloads.map((point) => ({
1783
+ day: String(point && point.day ? point.day : ''),
1784
+ downloads: Number(point && point.downloads) || 0,
1785
+ }))
1786
+ : []
1787
+ return remember({
1788
+ package: name,
1789
+ start: data.start || (points.length > 0 ? points[0].day : null),
1790
+ end: data.end || (points.length > 0 ? points[points.length - 1].day : null),
1791
+ points,
1792
+ total: points.reduce((sum, point) => sum + point.downloads, 0),
1793
+ unavailable: false,
1794
+ empty: points.length === 0,
1795
+ fetchedAt: new Date().toISOString(),
1796
+ }, 10 * 60_000)
1797
+ }
1798
+
1728
1799
  async function addTarget(rawDir) {
1729
1800
  await ensureState()
1730
1801
  const { dir } = await requireComponentDir(rawDir)
@@ -1765,6 +1836,9 @@ export function apply(ctx, config = {}) {
1765
1836
  const { dir } = await requireComponentDir(url.searchParams.get('dir'))
1766
1837
  return sendJson(response, 200, { ok: true, ...(await buildDetail(dir)) })
1767
1838
  }
1839
+ if (pathname === `${API}/downloads`) {
1840
+ return sendJson(response, 200, { ok: true, ...(await getDownloads(url.searchParams.get('dir'))) })
1841
+ }
1768
1842
  return sendJson(response, 404, { ok: false, code: 'not-found', message: '未知的发布管理接口。' })
1769
1843
  }
1770
1844
  if (request.method !== 'POST') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wangnanshan/dsh-nanshan-publish-manager",
3
- "version": "1.0.2",
3
+ "version": "1.0.5",
4
4
  "type": "module",
5
5
  "description": "【南山】DSH发布管理:以 npm 包(dsh.bundle + dsh.client)的官方形式,管理本机 DSH 组件的发布设置、发布预检查、npm 发布与发布记录。",
6
6
  "license": "MIT",