dsh-maoying 0.1.9 → 0.1.11

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.
package/index.mjs CHANGED
@@ -54,6 +54,7 @@
54
54
  import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, writeFileSync } from 'node:fs'
55
55
  import { createHash } from 'node:crypto'
56
56
  import { pathToFileURL } from 'node:url'
57
+ import { getVersionInfo } from './lib/version.mjs'
57
58
  import { readFile } from 'node:fs/promises'
58
59
  import { createRequire } from 'node:module'
59
60
  import os from 'node:os'
@@ -831,6 +832,13 @@ export async function registerCreatorRoutes(ctx) {
831
832
  return
832
833
  }
833
834
 
835
+ if (route === 'update-check' && req.method === 'GET') {
836
+ // 新版检测(模仿 dsh-paoding):三级源 + 缓存都在 lib/version.mjs,
837
+ // 全失败也静默降级——这里只做转发,绝不影响面板主流程。
838
+ json(res, 200, { ok: true, ...(await getVersionInfo({ timeoutMs: 3000 })) })
839
+ return
840
+ }
841
+
834
842
  if (route === 'host-debug' && req.method === 'GET') {
835
843
  // 现场诊断:宿主版本五级探针轨迹 + 形状判定。与 client.css 同级信任
836
844
  // 围栏(loopback + 可信 Host 才放行),只暴露版本与路径类信息。
package/lib/base.css CHANGED
@@ -573,7 +573,7 @@ html[data-dsh-maoying-active]:not([data-dsh-mnemon-active]):not([data-dsh-taskbo
573
573
  .myt-fsCard { display: flex; flex-direction: column; gap: 6px; padding: 10px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--dsw-alias-bg-layer-3); }
574
574
  .myt-fsTitle { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--dsw-alias-label-primary); overflow-wrap: anywhere; }
575
575
  .myt-fsMeta { display: flex; flex-wrap: wrap; gap: 6px; font-size: 11px; color: var(--dsw-alias-label-tertiary); font-variant-numeric: tabular-nums; }
576
- .myt-fsNotes { font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-tertiary); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
576
+ .myt-fsNotes { font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-tertiary); overflow-wrap: anywhere; }
577
577
  .myt-fsActions { display: flex; flex-wrap: wrap; gap: 6px; }
578
578
  .myt-fsActions .myt-btn { padding: 2px 8px; font-size: 11px; line-height: 16px; }
579
579
 
@@ -693,3 +693,21 @@ html[data-dsh-maoying-active]:not([data-dsh-mnemon-active]):not([data-dsh-taskbo
693
693
  cursor: pointer; font-size: 14px; line-height: 1;
694
694
  }
695
695
  .myt-toastClose:hover { color: var(--dsw-alias-label-primary); }
696
+
697
+ /* ── 新版检测徽章(模仿 dsh-paoding)──────────────────────────────────── */
698
+ .myt-verBadge {
699
+ display: inline-block;
700
+ margin-left: 10px;
701
+ padding: 2px 10px;
702
+ border: 1px solid color-mix(in srgb, var(--dsw-alias-state-business-primary) 45%, transparent);
703
+ border-radius: 999px;
704
+ background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 12%, transparent);
705
+ color: var(--dsw-alias-state-business-primary);
706
+ font-size: 11px;
707
+ line-height: 16px;
708
+ font-weight: 500;
709
+ text-decoration: none;
710
+ vertical-align: middle;
711
+ white-space: nowrap;
712
+ }
713
+ .myt-verBadge:hover { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 22%, transparent); }
package/lib/client.js CHANGED
@@ -538,6 +538,14 @@ window.__ModuleLoader__.load({
538
538
  return effectiveWs ? loadState(effectiveWs) : Promise.resolve();
539
539
  }, [effectiveWs, loadState]);
540
540
 
541
+ // 新版检测(模仿 dsh-paoding):面板打开时拉一次(服务端有 1h 缓存),
542
+ // updateAvailable 才渲染徽章;失败静默,不打扰任何主流程。
543
+ var _ver = useState(null), ver = _ver[0], setVer = _ver[1];
544
+ useEffect(function () {
545
+ apiGet("update-check").then(function (d) {
546
+ if (d && d.updateAvailable) setVer(d);
547
+ }).catch(function () {});
548
+ }, []);
541
549
  useEffect(function () { loadWorkspaces(); }, [loadWorkspaces]);
542
550
  useEffect(function () { loadState(effectiveWs); }, [effectiveWs, loadState]);
543
551
  // 校正后的选择回写记忆(首次打开 / 记忆失效时自动落位)。
@@ -591,7 +599,11 @@ window.__ModuleLoader__.load({
591
599
  return h("div", { className: "myt-shell" },
592
600
  h("header", { className: "myt-header" },
593
601
  h("div", null,
594
- h("h1", { className: "myt-title" }, "毛颖创作台"),
602
+ h("h1", { className: "myt-title" }, "毛颖创作台",
603
+ ver && ver.updateAvailable
604
+ ? h("a", { className: "myt-verBadge", href: ver.releaseUrl || "#", target: "_blank", rel: "noreferrer", title: "当前 " + ver.current + ",点击打开新版发布页" },
605
+ "有新版 " + ver.latest + " ↗")
606
+ : null),
595
607
  h("p", { className: "myt-subtitle" }, "网文项目可视化与状态流转 · 只读正文,写状态")),
596
608
  h("div", { className: "myt-headActions" },
597
609
  workspaces && novelWorkspaces.length
@@ -712,6 +724,18 @@ window.__ModuleLoader__.load({
712
724
  ic(open ? "IconChevronDownOutlineRegular" : "IconChevronRightOutlineRegular", 14))),
713
725
  open ? h("pre", { className: "myt-outlineBody" }, body) : null);
714
726
  }
727
+ // 抽屉/弹层专用 Portal:面板容器挂在宿主会话中栏里,中栏的 transform/
728
+ // overflow 会把 position:fixed 劫持成相对自身并连同裁剪——抽屉渲染了也
729
+ // 看不见(桌面端「点了没反应」实测病灶)。挂 document.body 后 fixed 恒
730
+ // 等于视口、z-index 直接对齐文档层。老宿主无 createPortal 退回原地渲染。
731
+ function MaskPortal(props) {
732
+ var node = h("div", props);
733
+ if (ReactDOMClient && typeof ReactDOMClient.createPortal === "function" && typeof document !== "undefined" && document.body) {
734
+ return ReactDOMClient.createPortal(node, document.body);
735
+ }
736
+ return node;
737
+ }
738
+
715
739
  function ChaptersTab(props) {
716
740
  var snap = props.snap;
717
741
  var reload = props.reload;
@@ -814,7 +838,7 @@ window.__ModuleLoader__.load({
814
838
  h("td", null, h("div", { className: "myt-summaryCell myt-muted" }, orDash(ch.summary))));
815
839
  }))))
816
840
  : (chapters.length ? h(Empty, { text: "该状态下没有章节" }) : h(Empty, { text: "chapters/ 目录还没有章节文件" })),
817
- openFile ? h("div", {
841
+ openFile ? h(MaskPortal, {
818
842
  className: "myt-drawerMask",
819
843
  onMouseDown: function (e) { if (e.target === e.currentTarget) setOpenFile(null); },
820
844
  },
@@ -878,7 +902,7 @@ window.__ModuleLoader__.load({
878
902
  c.personality ? h("div", { className: "myt-charLine" }, h("span", { className: "myt-charLabel" }, "性格 "), truncate(c.personality, 60)) : null,
879
903
  c.motivation ? h("div", { className: "myt-charLine" }, h("span", { className: "myt-charLabel" }, "动机 "), truncate(c.motivation, 60)) : null);
880
904
  })) : h(Empty, { text: "characters/ 目录还没有角色卡" }),
881
- modal ? h("div", {
905
+ modal ? h(MaskPortal, {
882
906
  className: "myt-modalMask",
883
907
  onMouseDown: function (e) { if (e.target === e.currentTarget) setModalFile(null); },
884
908
  },
@@ -0,0 +1,254 @@
1
+ /**
2
+ * 版本提示 —— 检测线上是否有比本地更新的版本(创作台面板用)。
3
+ *
4
+ * 同构移植自 dsh-paoding tools/lib/version.mjs(用户指定模仿其机制)。检测源
5
+ * 三级:npm registry 的 /latest 端点优先(无认证无限流,发布即可见);失败再
6
+ * 试 npmmirror 镜像(官方源超时 / 被墙时国内几秒内可达,分钟级同步延迟对
7
+ * 「有没有新版」这个判断无伤大雅);最后 GitHub releases/latest 兜底。
8
+ *
9
+ * 零依赖纯 Node(>=18):不引 cordis / npm 依赖,fetch 可注入(缺省用
10
+ * globalThis.fetch),超时用 AbortSignal.timeout()。所有失败路径都不抛错,
11
+ * 一律收敛为 { ok: false, error } 或带 error 字段的结果对象——调用方(面板
12
+ * 路由)按「失败即静默」处理,绝不影响主流程。
13
+ *
14
+ * 相对 paoding 版的差异:本文件在包内 lib/ 下,package.json 向上一级即是
15
+ * 包根(paoding 在 tools/lib/ 需向上两级);包名与仓库名换成 dsh-maoying。
16
+ */
17
+ import { readFile } from 'node:fs/promises'
18
+
19
+ // 仓库(与包名一致)的 GitHub latest release 接口与落地页。
20
+ const RELEASE_API = 'https://api.github.com/repos/lifangjin/dsh-maoying/releases/latest'
21
+ const RELEASE_PAGE = 'https://github.com/lifangjin/dsh-maoying/releases/latest'
22
+ // npm registry 的 latest 元数据端点(等价 dist-tags.latest,无认证无限流)。
23
+ const REGISTRY_LATEST = 'https://registry.npmjs.org/dsh-maoying/latest'
24
+ // npmmirror 镜像的同名端点:manifest 形状与官方源一致(顶层 version 即
25
+ // latest),同步自 npm registry,官方源不可达时的国内快路。
26
+ const MIRROR_LATEST = 'https://registry.npmmirror.com/dsh-maoying/latest'
27
+
28
+ // 读不到 package.json 时的版本兜底。当前版本不可知(source: 'fallback')时
29
+ // updateAvailable 恒 false —— 拿「0.0.0 恒小于任何真实版本」去比较,反而必然
30
+ // 对任何 latest 误报新版,升级提示必须整体抑制(读不到版本就不提示)。
31
+ const FALLBACK_VERSION = '0.0.0'
32
+
33
+ // 内存缓存 TTL:1 小时内复用上次成功结果,过期才真的发外网请求。
34
+ const CACHE_TTL_MS = 60 * 60 * 1000
35
+ // 请求失败时的 stale-if-error 窗口:上次成功结果在 7 天内仍原样复用(lastGood)。
36
+ // 窗口刻意明显大于新鲜 TTL:外网偶发抖动不该立刻撤掉本可用的升级提示。
37
+ const LASTGOOD_TTL_MS = 7 * 24 * 60 * 60 * 1000
38
+
39
+ // 模块级缓存:只在成功后写入;失败结果不进缓存(下次调用仍会重试)。
40
+ let cache = { at: 0, info: null }
41
+
42
+ /**
43
+ * 本地版本:读包根 package.json 的 version。读失败(文件缺失 / 解析失败 /
44
+ * 字段异常)回退 FALLBACK_VERSION 并标记 source: 'fallback'。
45
+ * @returns {Promise<{version: string, source: 'package.json'|'fallback'}>}
46
+ */
47
+ export async function currentVersion() {
48
+ try {
49
+ const raw = await readFile(new URL('../package.json', import.meta.url), 'utf8')
50
+ const pkg = JSON.parse(raw)
51
+ if (typeof pkg?.version === 'string' && pkg.version !== '') {
52
+ return { version: pkg.version, source: 'package.json' }
53
+ }
54
+ } catch {
55
+ /* 静默回退:版本读取失败不该打扰任何主流程 */
56
+ }
57
+ return { version: FALLBACK_VERSION, source: 'fallback' }
58
+ }
59
+
60
+ /** 拆版本号:剥前缀 v/V,按 '.' 切数值段,`-` 后为预发布后缀(原样保留)。 */
61
+ function parseVersion(value) {
62
+ let s = String(value ?? '').trim()
63
+ if (s.startsWith('v') || s.startsWith('V')) s = s.slice(1)
64
+ const dash = s.indexOf('-')
65
+ const coreText = dash === -1 ? s : s.slice(0, dash)
66
+ const pre = dash === -1 ? '' : s.slice(dash + 1)
67
+ const core = coreText.split('.').map((n) => {
68
+ const x = parseInt(n, 10)
69
+ return Number.isFinite(x) ? x : 0
70
+ })
71
+ return { core, pre }
72
+ }
73
+
74
+ /**
75
+ * 版本比较(可测纯函数):a < b 返回 -1,相等返回 0,a > b 返回 1。
76
+ * 简化语义(不追求完整 semver):剥前缀 v/V 后按 '.' 逐段数值比较,缺段补 0;
77
+ * 核心段相同时带预发布后缀(如 0.3.0-rc.1)视为低于无后缀同版本,两个都有
78
+ * 预发布后缀则按字符串比较。
79
+ */
80
+ export function compareVersions(a, b) {
81
+ const pa = parseVersion(a)
82
+ const pb = parseVersion(b)
83
+ const len = Math.max(pa.core.length, pb.core.length)
84
+ for (let i = 0; i < len; i++) {
85
+ const x = pa.core[i] ?? 0
86
+ const y = pb.core[i] ?? 0
87
+ if (x !== y) return x < y ? -1 : 1
88
+ }
89
+ if (pa.pre !== pb.pre) {
90
+ if (pa.pre === '') return 1 // 无后缀 > 有后缀
91
+ if (pb.pre === '') return -1
92
+ return pa.pre < pb.pre ? -1 : pa.pre > pb.pre ? 1 : 0
93
+ }
94
+ return 0
95
+ }
96
+
97
+ /**
98
+ * 请求 GitHub latest release。任何失败(网络 / 超时 / 限流 / 解析)都不抛出,
99
+ * 返回 { ok: false, error };仓库尚无 release(404)不算错误,返回
100
+ * { ok: true, tag: null, url: null, notFound: true }。成功返回
101
+ * { ok: true, tag: 'vX.Y.Z', url: releasePage }。
102
+ */
103
+ export async function fetchLatestRelease({ fetchImpl, timeoutMs = 3000 } = {}) {
104
+ const doFetch = typeof fetchImpl === 'function' ? fetchImpl : globalThis.fetch
105
+ if (typeof doFetch !== 'function') return { ok: false, error: 'fetch 不可用(当前运行时无 fetch 实现)' }
106
+ // GitHub API 必须带 User-Agent,顺手报上包名与本地版本便于排障。
107
+ const { version } = await currentVersion()
108
+ try {
109
+ const res = await doFetch(RELEASE_API, {
110
+ headers: {
111
+ 'user-agent': `dsh-maoying/${version}`,
112
+ accept: 'application/vnd.github+json',
113
+ },
114
+ signal: AbortSignal.timeout(timeoutMs),
115
+ })
116
+ if (res.status === 404) return { ok: true, tag: null, url: null, notFound: true }
117
+ if (!res.ok) {
118
+ // 非 404 失败(403 限流、5xx 等):带上 GitHub 的 message 便于排障
119
+ //(典型如未认证配额用尽的 rate limit 提示),仍按失败静默收敛。
120
+ let detail = ''
121
+ try {
122
+ const body = await res.json()
123
+ if (body && typeof body.message === 'string' && body.message !== '') detail = `: ${body.message}`
124
+ } catch {
125
+ /* 正文非 JSON 时忽略,只报状态码 */
126
+ }
127
+ return { ok: false, error: `GitHub API HTTP ${res.status}${detail}` }
128
+ }
129
+ let data = null
130
+ try {
131
+ data = await res.json()
132
+ } catch (err) {
133
+ return { ok: false, error: `GitHub API 响应解析失败: ${err?.message ?? err}` }
134
+ }
135
+ const tag = typeof data?.tag_name === 'string' && data.tag_name !== '' ? data.tag_name : null
136
+ if (!tag) return { ok: false, error: 'GitHub API 响应缺少 tag_name' }
137
+ const url = typeof data?.html_url === 'string' && data.html_url !== '' ? data.html_url : RELEASE_PAGE
138
+ return { ok: true, tag, url }
139
+ } catch (err) {
140
+ return { ok: false, error: String(err?.message ?? err) }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * 请求一个 npm 系 manifest 端点(官方源与 npmmirror 镜像共用同一形状:完整
146
+ * manifest,顶层 version 即 latest)。任何失败(网络 / 超时 / 解析 / 形状不对)
147
+ * 都不抛出,返回 { ok: false, error };成功返回 { ok: true, latest: 'vX.Y.Z',
148
+ * releaseUrl, source }(端点的 version 不带 v 前缀,这里统一补齐,与 GitHub
149
+ * tag 的显示口径一致)。
150
+ */
151
+ async function fetchLatestManifest(url, label, source, { fetchImpl, timeoutMs = 3000 } = {}) {
152
+ const doFetch = typeof fetchImpl === 'function' ? fetchImpl : globalThis.fetch
153
+ if (typeof doFetch !== 'function') return { ok: false, error: 'fetch 不可用(当前运行时无 fetch 实现)' }
154
+ try {
155
+ const res = await doFetch(url, {
156
+ headers: { accept: 'application/vnd.npm+json' },
157
+ signal: AbortSignal.timeout(timeoutMs),
158
+ })
159
+ if (!res.ok) return { ok: false, error: `${label} HTTP ${res.status}` }
160
+ let data = null
161
+ try {
162
+ data = await res.json()
163
+ } catch (err) {
164
+ return { ok: false, error: `${label} 响应解析失败: ${err?.message ?? err}` }
165
+ }
166
+ // /<name>/latest 返回该版本的完整 manifest,顶层 version 即 latest 版本号;
167
+ // CDN 缓存抖动可能回形状不对的正文,按失败处理走下一级检测源。
168
+ const ver = typeof data?.version === 'string' && data.version !== '' ? data.version : null
169
+ if (!ver) return { ok: false, error: `${label} 响应缺少 version` }
170
+ return { ok: true, latest: `v${ver}`, releaseUrl: RELEASE_PAGE, source }
171
+ } catch (err) {
172
+ return { ok: false, error: String(err?.message ?? err) }
173
+ }
174
+ }
175
+
176
+ /**
177
+ * 请求 npm registry(官方源)的 latest 元数据——版本检测首选源。
178
+ */
179
+ export async function fetchLatestFromRegistry(opts) {
180
+ return await fetchLatestManifest(REGISTRY_LATEST, 'npm registry', 'npm', opts)
181
+ }
182
+
183
+ /**
184
+ * 请求 npmmirror 镜像的 latest 元数据——官方源不可达(超时 / 被墙 / 代理
185
+ * 黑洞)时的国内快路:manifest 形状与官方源一致,同步延迟分钟级,对「有
186
+ * 没有新版」这个判断无伤大雅。
187
+ */
188
+ export async function fetchLatestFromMirror(opts) {
189
+ return await fetchLatestManifest(MIRROR_LATEST, 'npmmirror', 'mirror', opts)
190
+ }
191
+
192
+ /**
193
+ * 组合结果(面板路由专用):带 1 小时内存缓存,过期才重新请求;请求失败时
194
+ * 若上次成功结果在 lastGood 窗口(7 天,见 LASTGOOD_TTL_MS)内则原样复用
195
+ * (stale-if-error),否则返回带 error 的降级结果(latest 置 null、
196
+ * updateAvailable 恒 false——调用方静默)。当前版本不可知(FALLBACK_VERSION
197
+ * 兜底)时 updateAvailable 恒 false:0.0.0 对任何 latest 都「更小」,比较必
198
+ * 误报新版,升级提示整体抑制。检测源三级:npm registry 优先,失败再试
199
+ * npmmirror 镜像,最后 GitHub release;三路皆败才算本次检测失败。成功结果的
200
+ * source 字段记录命中源。
201
+ * @returns {Promise<{current: string, latest: string|null, updateAvailable: boolean,
202
+ * releaseUrl: string|null, checkedAt: string, error: string|null,
203
+ * source: 'npm'|'mirror'|'github'|null}>}
204
+ */
205
+ export async function getVersionInfo({ fetchImpl, timeoutMs = 3000, now } = {}) {
206
+ const nowMs = typeof now === 'function' ? now() : typeof now === 'number' ? now : Date.now()
207
+ if (cache.info && nowMs - cache.at < CACHE_TTL_MS) return cache.info
208
+ const { version: current, source: currentSource } = await currentVersion()
209
+ // 三级链:① npm registry(官方源);② npmmirror 镜像(官方源不可达时的
210
+ // 国内快路);③ GitHub releases/latest(镜像也挂时的独立数据源)。
211
+ const failures = []
212
+ let hit = await fetchLatestFromRegistry({ fetchImpl, timeoutMs })
213
+ if (!hit.ok) {
214
+ failures.push(hit.error)
215
+ hit = await fetchLatestFromMirror({ fetchImpl, timeoutMs })
216
+ }
217
+ if (!hit.ok) {
218
+ failures.push(hit.error)
219
+ const rel = await fetchLatestRelease({ fetchImpl, timeoutMs })
220
+ hit = rel.ok && rel.tag
221
+ ? { ok: true, latest: rel.tag, releaseUrl: rel.url, source: 'github' }
222
+ : { ok: false, error: rel.ok ? 'GitHub release 尚未发布(404)' : rel.error }
223
+ }
224
+ if (hit && hit.ok) {
225
+ const info = {
226
+ current,
227
+ latest: hit.latest ?? null,
228
+ // 当前版本不可知(fallback 0.0.0)时不提示升级,latest 缺失同理。
229
+ updateAvailable:
230
+ currentSource === 'fallback' || !hit.latest ? false : compareVersions(current, hit.latest) < 0,
231
+ releaseUrl: hit.releaseUrl ?? null,
232
+ checkedAt: new Date(nowMs).toISOString(),
233
+ error: null,
234
+ source: hit.source ?? null,
235
+ }
236
+ cache = { at: nowMs, info }
237
+ return info
238
+ }
239
+ // 三路皆败:优先复用 lastGood 窗口(7 天)内的上次成功结果;超窗才给降级
240
+ // 结果(不写缓存)。error 把各路原因都带上,并附代理排查提示——最常见的
241
+ // 「浏览器能上网、DSH 进程检测失败」是启动环境的代理变量指向了不可用的
242
+ // 代理(DSH 遵循 HTTPS_PROXY 等变量,浏览器不走它们)。
243
+ const combined = `三路检测均失败(npm registry: ${failures[0]};npmmirror: ${failures[1]};GitHub: ${hit.error})。若本机经代理访问外网,请确认代理可用后重启 DSH 再试——DSH 进程遵循 HTTPS_PROXY / HTTP_PROXY 环境变量,浏览器不走它们,两者连通性可能不同`
244
+ if (cache.info && nowMs - cache.at < LASTGOOD_TTL_MS) return cache.info
245
+ return {
246
+ current,
247
+ latest: null,
248
+ updateAvailable: false,
249
+ releaseUrl: null,
250
+ checkedAt: new Date(nowMs).toISOString(),
251
+ error: combined,
252
+ source: null,
253
+ }
254
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-maoying",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "毛颖 — DSH 网文创作辅助插件:主编 + 五帮手编排(大纲/写手/审校/设定/考据),novel/ 结构化文件为数据库",
5
5
  "type": "module",
6
6
  "license": "MIT",