dsh-xueqiu 1.6.0 → 1.7.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.
package/README.md CHANGED
@@ -20,8 +20,12 @@
20
20
  | 💼 自选股 | 本地持久化,增删随点随改 |
21
21
  | 🧲 嵌入式面板 | 完整面板停靠在**输入框上方**(官方 `conversation.input.dock` 槽位),随页面布局流动,**不遮挡对话记录** |
22
22
  | 🏷️ 迷你徽章 | 常驻悬浮徽章显示**上证/深成指实时涨跌**与盘中状态;点击开合面板,整体可拖动到任意位置(位置记忆) |
23
+ | 📏 面板高度可调 | 拖动面板底边调整高度(160px–85% 视口,双击复位),高度持久化记忆 |
23
24
  | ⌨️ Esc 收起 | Esc 先关个股详情,再收起面板;点徽章或底部指数条重新展开 |
25
+ | 🛡️ 请求防护 | 并发 2 + 100ms 最小间隔对齐网页端行为;**30s 看门狗**强制释放悬挂请求防管线冻结;Cookie 失效/风控自动重播种重试;限频指数退避;**页面隐藏自动暂停全部轮询**(回前台立即恢复) |
26
+ | 🗂️ 渐进详情 | 个股详情报价+K线先上屏,分时/财务/KOL 到达后增量合并,不用等齐 |
24
27
  | ⏱️ 智能刷新 | 盘中 20s 刷新行情,收盘自动放慢,降低被风控概率 |
28
+ | 🕐 交易时段 | 面板头常驻显示 **A 股/港股/美股** 盘中·午休·盘前·休市(本地时区推算,不含节假日),徽章显示精确 A 股时段 |
25
29
  | 🌗 主题自适应 | 跟随 DSH 明暗主题 |
26
30
 
27
31
  所有数据来自雪球公开接口(访问首页获取匿名 cookie + 浏览器 UA/Referer),**无需登录**。
@@ -74,8 +78,20 @@ dsh plugin --profile web add ./dsh-xueqiu
74
78
  - **右下角迷你徽章**常驻显示上证/深成指实时涨跌;**点击**开合面板,**拖动**调整位置(记忆位置)。
75
79
  - 输入框下方的**指数条**点击也可展开面板。
76
80
  - 点击自选股、指数卡或热榜行 → 个股详情:16 项行情数据 + K线/分时切换(悬停图表看十字光标详情)+ 财务指标(ROE/毛利率/净利同比等)+ 热议用户。
81
+ - **面板高度**:拖动面板底边的手柄上下调整(160px ~ 85% 视口高度),**双击手柄复位**;高度会被记忆。
77
82
  - 刷新频率:盘中行情 20s、内容 60s;收盘后自动降为 60s / 3min。
78
83
 
84
+ ## 🔧 稳定性设计
85
+
86
+ 数据层内置多层防护,长时间挂机也不会卡死:
87
+
88
+ - **请求闸门**:并发上限 2、请求间最小间隔 100ms,模拟网页端节奏,降低风控概率。
89
+ - **30s 看门狗**:单个请求悬挂超 30s 即强制释放调度槽并报错,不会冻结后续所有请求。
90
+ - **TTL 缓存 + in-flight 去重**:相同 URL 短时间窗内直接命中缓存,并发重复请求共享同一 Promise。
91
+ - **Cookie 自愈**:匿名 cookie 失效(错误码 400016)或被风控返回空响应时,自动重新访问雪球首页播种新 cookie 再重试。
92
+ - **限频退避**:遇到"请求频繁"按 2s→4s 指数退避后重试。
93
+ - **渐进渲染**:详情页报价 + K线先行上屏,分时/财务/KOL 异步到达后增量合并。
94
+
79
95
  ## 📁 目录结构
80
96
 
81
97
  ```
@@ -96,6 +112,20 @@ dsh-xueqiu/
96
112
  - 数据来自雪球公开 Web 接口,仅供**学习与研究**,**不构成任何投资建议**;请勿高频请求,遵守目标网站条款。
97
113
  - 接口可能随时变更导致功能失效,欢迎提 [Issue](https://github.com/kangjinghang/dsh-xueqiu/issues) / PR。
98
114
 
115
+ ## 📋 更新日志
116
+
117
+ - **1.7.0**(2025-08-19)
118
+ - 新增:页面隐藏时自动暂停面板/徽章/指数条全部轮询,回到前台立即刷新——切走不浪费请求,显著降低雪球风控触发概率。
119
+ - 新增:A 股/港股/美股三市场交易时段提示(盘中·午休·盘前·休市,本地 Intl 时区推算,不含节假日),面板头常驻显示,徽章显示精确 A 股时段,每分钟自动刷新。
120
+ - **1.6.1**(2025-08-18)
121
+ - 修复:请求管线死锁——悬挂请求永久占用调度槽导致面板整体冻结,现由 30s 看门狗强制释放。
122
+ - 修复:详情页内点「行情/热榜/搜索/快讯」标签无响应(view 优先级遮蔽 tab)。
123
+ - 修复:徽章位置持久化后恢复时 `badgePos.y` 被误当函数调用导致位置重置失败。
124
+ - 新增:`debug` RPC(running/waiters/inflight/cache/cookie 实时观测)。
125
+ - **1.6.0** — 面板高度拖拽调节(丝滑零重渲染、双击复位、持久化)。
126
+ - **1.5.x** — 请求调度与缓存优化(并发 2 + 100ms 间隔、TTL 缓存、错误分类重试)。
127
+ - **1.4.x** — 详情提速:渐进渲染、全 K线周期、十字光标。
128
+
99
129
  ## 📄 License
100
130
 
101
131
  [MIT](./LICENSE)
package/dynamic/client.js CHANGED
@@ -171,6 +171,36 @@ return {
171
171
  return null
172
172
  }
173
173
 
174
+ function pageHidden() {
175
+ try { return typeof document !== 'undefined' && document.hidden } catch (e) { return false }
176
+ }
177
+
178
+ // 交易时段:本地 Intl 时区判定(不含节假日,仅供参考)
179
+ // segs: [[开盘分,收盘分],...];lunch: [午休起,午休止](可选)
180
+ function sessionState(tz, segs, lunch) {
181
+ try {
182
+ var now = new Date()
183
+ var f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, weekday: 'short', hour: '2-digit', minute: '2-digit' })
184
+ var p = {}
185
+ f.formatToParts(now).forEach(function (x) { p[x.type] = x.value })
186
+ if (p.weekday === 'Sat' || p.weekday === 'Sun') return '休市'
187
+ var h = parseInt(p.hour, 10) % 24
188
+ var cur = h * 60 + parseInt(p.minute, 10)
189
+ var inSess = segs.some(function (s) { return cur >= s[0] && cur < s[1] })
190
+ if (inSess) return '盘中'
191
+ if (lunch && cur >= lunch[0] && cur < lunch[1]) return '午休'
192
+ return cur < segs[0][0] ? '盘前' : '休市'
193
+ } catch (e) { return '' }
194
+ }
195
+ function aSession() { return sessionState('Asia/Shanghai', [[570, 690], [780, 900]], [690, 780]) }
196
+ function hkSession() { return sessionState('Asia/Hong_Kong', [[570, 720], [780, 960]], [720, 780]) }
197
+ function usSession() { return sessionState('America/New_York', [[570, 960]], null) }
198
+ function sessionsText() {
199
+ var a = aSession(), hk = hkSession(), us = usSession()
200
+ if (!a && !hk && !us) return ''
201
+ return [a && 'A股' + a, hk && '港股' + hk, us && '美股' + us].filter(Boolean).join(' · ')
202
+ }
203
+
174
204
  // ---------- 共享 UI 状态:面板开合 / 当前标签 / 徽章位置 ----------
175
205
  const ui = {
176
206
  open: false, tab: 'market', badgePos: null, dockH: null, hydrated: false,
@@ -492,9 +522,17 @@ return {
492
522
  React.useEffect(function () {
493
523
  const marketMs = marketOpen ? 20000 : 60000
494
524
  const contentMs = marketOpen ? 60000 : 180000
495
- const stopA = ctx.interval(function () { refreshMarket() }, marketMs)
496
- const stopB = ctx.interval(function () { refreshContent() }, contentMs)
497
- return function () { if (stopA) stopA(); if (stopB) stopB() }
525
+ // 页面隐藏时暂停轮询(省请求、降低风控概率),回到前台立即刷新一次
526
+ const stopA = ctx.interval(function () { if (!pageHidden()) refreshMarket() }, marketMs)
527
+ const stopB = ctx.interval(function () { if (!pageHidden()) refreshContent() }, contentMs)
528
+ function onVis() {
529
+ if (!pageHidden()) { refreshMarket(); refreshContent() }
530
+ }
531
+ try { document.addEventListener('visibilitychange', onVis) } catch (e) { /* ignore */ }
532
+ return function () {
533
+ if (stopA) stopA(); if (stopB) stopB()
534
+ try { document.removeEventListener('visibilitychange', onVis) } catch (e) { /* ignore */ }
535
+ }
498
536
  }, [marketOpen])
499
537
 
500
538
  React.useEffect(function () {
@@ -589,7 +627,7 @@ return {
589
627
  ].map(function (t) {
590
628
  return el('button', {
591
629
  key: t[0], className: 'xq-tab' + (tab === t[0] ? ' xq-tab-active' : ''),
592
- onClick: function () { setTab(t[0]) }
630
+ onClick: function () { setTab(t[0]); if (view) setView(null) }
593
631
  }, t[1])
594
632
  }).concat([
595
633
  el('span', { key: 'sp', className: 'xq-spacer' }),
@@ -879,10 +917,16 @@ return {
879
917
 
880
918
  function DockPanel() {
881
919
  const [, force] = React.useState(0)
920
+ const [tick, setTick] = React.useState(0)
882
921
  React.useEffect(function () {
883
922
  const off = ui.subscribe(function () { if (ui.hydrated) saveUi(); force(function (x) { return x + 1 }) })
884
923
  return off
885
924
  }, [])
925
+ // 每分钟重算一次交易时段提示
926
+ React.useEffect(function () {
927
+ const stop = ctx.interval(function () { setTick(function (x) { return x + 1 }) }, 60000)
928
+ return function () { if (stop) stop() }
929
+ }, [])
886
930
  function onDown(e) {
887
931
  if (e.pointerType === 'mouse' && e.button !== 0) return
888
932
  dockResize.active = true
@@ -927,7 +971,7 @@ return {
927
971
  return el('div', { className: 'xq-dock' }, [
928
972
  el('div', { key: 'head', className: 'xq-dock-head' }, [
929
973
  el('span', { key: 'logo', className: 'xq-logo' }, [el('b', { key: 'b' }, '雪球'), ' mini']),
930
- el('span', { key: 'st', className: 'xq-status' }, '行情面板'),
974
+ el('span', { key: 'st', className: 'xq-status', title: 'A股/港股/美股交易时段(本地时区推算,不含节假日)' }, sessionsText() || '行情面板'),
931
975
  el('span', { key: 'hint', className: 'xq-update' }, '已嵌入输入框上方 · Esc 收起'),
932
976
  el('span', { key: 'sp', className: 'xq-spacer' }),
933
977
  el('button', { key: 'min', className: 'xq-btn-mini', title: '收起(点右下角徽章重新打开)', onClick: function () { ui.set({ open: false }) } }, '收起 —')
@@ -951,6 +995,7 @@ return {
951
995
  React.useEffect(function () {
952
996
  let alive = true
953
997
  function refresh() {
998
+ if (pageHidden()) return // 页面隐藏时暂停徽章轮询
954
999
  call('quote', { symbols: ['SH000001', 'SZ399001'] }).then(function (data) {
955
1000
  if (!alive) return
956
1001
  setIdx((data && data.list) || [])
@@ -958,10 +1003,15 @@ return {
958
1003
  setMOpen(st === 5 || st === 6)
959
1004
  }).catch(function () { /* 静默失败 */ })
960
1005
  }
1006
+ function onVis() { if (!pageHidden()) refresh() }
961
1007
  refresh()
962
1008
  const stop = ctx.interval(refresh, 30000)
963
1009
  const off = ui.subscribe(function () { force(function (x) { return x + 1 }) })
964
- return function () { alive = false; if (stop) stop(); off() }
1010
+ try { document.addEventListener('visibilitychange', onVis) } catch (e) { /* ignore */ }
1011
+ return function () {
1012
+ alive = false; if (stop) stop(); off()
1013
+ try { document.removeEventListener('visibilitychange', onVis) } catch (e) { /* ignore */ }
1014
+ }
965
1015
  }, [])
966
1016
 
967
1017
  function onDown(e) {
@@ -1012,7 +1062,7 @@ return {
1012
1062
  onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, onPointerCancel: onUp
1013
1063
  }, [
1014
1064
  el('span', { key: 'logo' }, [el('b', { key: 'b' }, '雪球'), 'mini']),
1015
- el('span', { key: 'st', className: 'xq-status' + (mOpen ? ' xq-live' : '') }, mOpen ? '● 盘中' : '休市'),
1065
+ el('span', { key: 'st', className: 'xq-status' + (aSession() === '盘中' ? ' xq-live' : ''), title: sessionsText() }, (function () { var s = aSession(); return s === '盘中' ? '● 盘中' : (s || (mOpen ? '● 盘中' : '休市')) })()),
1016
1066
  sh ? el('span', { key: 'sh' }, [
1017
1067
  el('span', { key: 'n', className: 'xq-badge-hint' }, sh.name + ' '),
1018
1068
  el('span', { key: 'v', className: 'xq-badge-val ' + colorOf(sh.percent) }, fmt(sh.current)),
@@ -1032,13 +1082,19 @@ return {
1032
1082
  React.useEffect(function () {
1033
1083
  let alive = true
1034
1084
  function refresh() {
1085
+ if (pageHidden()) return // 页面隐藏时暂停指数条轮询
1035
1086
  call('quote', { symbols: ['SH000001', 'SZ399001', 'SZ399006', 'SH000300'] }).then(function (data) {
1036
1087
  if (alive) setIndices((data && data.list) || [])
1037
1088
  }).catch(function () { /* 静默失败 */ })
1038
1089
  }
1090
+ function onVis() { if (!pageHidden()) refresh() }
1039
1091
  refresh()
1040
1092
  const stop = ctx.interval(refresh, 30000)
1041
- return function () { alive = false; if (stop) stop() }
1093
+ try { document.addEventListener('visibilitychange', onVis) } catch (e) { /* ignore */ }
1094
+ return function () {
1095
+ alive = false; if (stop) stop()
1096
+ try { document.removeEventListener('visibilitychange', onVis) } catch (e) { /* ignore */ }
1097
+ }
1042
1098
  }, [])
1043
1099
  if (!indices.length) return null
1044
1100
  const kids = indices.map(function (q) {
package/dynamic/host.js CHANGED
@@ -32,18 +32,38 @@ return {
32
32
  }
33
33
  }
34
34
 
35
+ // 看门狗:单个请求最长 30s,超时强制释放槽位,防止悬挂请求冻结整条管线
36
+ const GATE_TIMEOUT_MS = 30000
37
+
35
38
  function gate(fn) {
36
39
  return new Promise(function (resolve, reject) {
37
40
  waiters.push(async function () {
38
- const wait = lastStart + MIN_GAP_MS - Date.now()
39
- if (wait > 0) await delay(wait)
40
- lastStart = Date.now()
41
+ let released = false
42
+ function rel() {
43
+ if (released) return
44
+ released = true
45
+ running--
46
+ pump()
47
+ }
48
+ let settled = false
49
+ let watchdog = null
50
+ function settle(fnName, arg) {
51
+ if (settled) return
52
+ settled = true
53
+ if (watchdog) { try { watchdog() } catch (e) { /* ignore */ } }
54
+ if (fnName === 'resolve') resolve(arg)
55
+ else reject(arg)
56
+ }
57
+ try { watchdog = ctx.timeout(function () { rel(); settle('reject', new Error('[timeout] 请求超时(' + GATE_TIMEOUT_MS + 'ms),已释放调度槽')) }, GATE_TIMEOUT_MS) } catch (e) { /* ignore */ }
41
58
  try {
42
- resolve(await fn())
59
+ const wait = lastStart + MIN_GAP_MS - Date.now()
60
+ if (wait > 0) await delay(wait)
61
+ lastStart = Date.now()
62
+ settle('resolve', await fn())
43
63
  } catch (e) {
44
- reject(e)
64
+ settle('reject', e)
45
65
  } finally {
46
- release()
66
+ rel()
47
67
  }
48
68
  })
49
69
  pump()
@@ -490,7 +510,7 @@ return {
490
510
  return {
491
511
  tab: s.tab || 'market', open: s.open !== false,
492
512
  dockH: (s.dockH !== undefined && isFinite(hNum) && hNum >= 160 && hNum <= 1200) ? hNum : null,
493
- badgePos: (s.badgePos && isFinite(Number(s.badgePos.x)) && isFinite(Number(s.badgePos.y())))
513
+ badgePos: (s.badgePos && isFinite(Number(s.badgePos.x)) && isFinite(Number(s.badgePos.y)))
494
514
  ? { x: Number(s.badgePos.x), y: Number(s.badgePos.y) } : null
495
515
  }
496
516
  }
@@ -516,7 +536,10 @@ return {
516
536
  hot: actHot, search: actSearch, searchPosts: actSearchPosts, news: actNews,
517
537
  kol: actKOL, user: actUser, finance: actFinance,
518
538
  'watchlist.get': actWatchlistGet, 'watchlist.add': actWatchlistAdd, 'watchlist.remove': actWatchlistRemove,
519
- 'ui.get': actUiGet, 'ui.set': actUiSet
539
+ 'ui.get': actUiGet, 'ui.set': actUiSet,
540
+ debug: async function () {
541
+ return { running: running, waiters: waiters.length, inflight: Array.from(inflight.keys()), cacheKeys: cache.size, cookie: state.cookie ? 'set' : 'none' }
542
+ }
520
543
  }
521
544
 
522
545
  ctx.effect(function () {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-xueqiu",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "雪球 mini 行情面板 — DeepSeek Harness 免登录 A股/港美股实时行情、K线(蜡烛/成交量/均线)、分时图、热榜、搜索、7x24 快讯、热议用户。嵌入式面板 + 可拖拽行情徽标,内置请求节流、TTL 缓存与 Cookie 自动续期。",
5
5
  "keywords": [
6
6
  "dsh",
@@ -47,4 +47,4 @@
47
47
  "README.md",
48
48
  "LICENSE"
49
49
  ]
50
- }
50
+ }
@@ -171,6 +171,36 @@ return {
171
171
  return null
172
172
  }
173
173
 
174
+ function pageHidden() {
175
+ try { return typeof document !== 'undefined' && document.hidden } catch (e) { return false }
176
+ }
177
+
178
+ // 交易时段:本地 Intl 时区判定(不含节假日,仅供参考)
179
+ // segs: [[开盘分,收盘分],...];lunch: [午休起,午休止](可选)
180
+ function sessionState(tz, segs, lunch) {
181
+ try {
182
+ var now = new Date()
183
+ var f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, weekday: 'short', hour: '2-digit', minute: '2-digit' })
184
+ var p = {}
185
+ f.formatToParts(now).forEach(function (x) { p[x.type] = x.value })
186
+ if (p.weekday === 'Sat' || p.weekday === 'Sun') return '休市'
187
+ var h = parseInt(p.hour, 10) % 24
188
+ var cur = h * 60 + parseInt(p.minute, 10)
189
+ var inSess = segs.some(function (s) { return cur >= s[0] && cur < s[1] })
190
+ if (inSess) return '盘中'
191
+ if (lunch && cur >= lunch[0] && cur < lunch[1]) return '午休'
192
+ return cur < segs[0][0] ? '盘前' : '休市'
193
+ } catch (e) { return '' }
194
+ }
195
+ function aSession() { return sessionState('Asia/Shanghai', [[570, 690], [780, 900]], [690, 780]) }
196
+ function hkSession() { return sessionState('Asia/Hong_Kong', [[570, 720], [780, 960]], [720, 780]) }
197
+ function usSession() { return sessionState('America/New_York', [[570, 960]], null) }
198
+ function sessionsText() {
199
+ var a = aSession(), hk = hkSession(), us = usSession()
200
+ if (!a && !hk && !us) return ''
201
+ return [a && 'A股' + a, hk && '港股' + hk, us && '美股' + us].filter(Boolean).join(' · ')
202
+ }
203
+
174
204
  // ---------- 共享 UI 状态:面板开合 / 当前标签 / 徽章位置 ----------
175
205
  const ui = {
176
206
  open: false, tab: 'market', badgePos: null, dockH: null, hydrated: false,
@@ -492,9 +522,17 @@ return {
492
522
  React.useEffect(function () {
493
523
  const marketMs = marketOpen ? 20000 : 60000
494
524
  const contentMs = marketOpen ? 60000 : 180000
495
- const stopA = ctx.interval(function () { refreshMarket() }, marketMs)
496
- const stopB = ctx.interval(function () { refreshContent() }, contentMs)
497
- return function () { if (stopA) stopA(); if (stopB) stopB() }
525
+ // 页面隐藏时暂停轮询(省请求、降低风控概率),回到前台立即刷新一次
526
+ const stopA = ctx.interval(function () { if (!pageHidden()) refreshMarket() }, marketMs)
527
+ const stopB = ctx.interval(function () { if (!pageHidden()) refreshContent() }, contentMs)
528
+ function onVis() {
529
+ if (!pageHidden()) { refreshMarket(); refreshContent() }
530
+ }
531
+ try { document.addEventListener('visibilitychange', onVis) } catch (e) { /* ignore */ }
532
+ return function () {
533
+ if (stopA) stopA(); if (stopB) stopB()
534
+ try { document.removeEventListener('visibilitychange', onVis) } catch (e) { /* ignore */ }
535
+ }
498
536
  }, [marketOpen])
499
537
 
500
538
  React.useEffect(function () {
@@ -589,7 +627,7 @@ return {
589
627
  ].map(function (t) {
590
628
  return el('button', {
591
629
  key: t[0], className: 'xq-tab' + (tab === t[0] ? ' xq-tab-active' : ''),
592
- onClick: function () { setTab(t[0]) }
630
+ onClick: function () { setTab(t[0]); if (view) setView(null) }
593
631
  }, t[1])
594
632
  }).concat([
595
633
  el('span', { key: 'sp', className: 'xq-spacer' }),
@@ -879,10 +917,16 @@ return {
879
917
 
880
918
  function DockPanel() {
881
919
  const [, force] = React.useState(0)
920
+ const [tick, setTick] = React.useState(0)
882
921
  React.useEffect(function () {
883
922
  const off = ui.subscribe(function () { if (ui.hydrated) saveUi(); force(function (x) { return x + 1 }) })
884
923
  return off
885
924
  }, [])
925
+ // 每分钟重算一次交易时段提示
926
+ React.useEffect(function () {
927
+ const stop = ctx.interval(function () { setTick(function (x) { return x + 1 }) }, 60000)
928
+ return function () { if (stop) stop() }
929
+ }, [])
886
930
  function onDown(e) {
887
931
  if (e.pointerType === 'mouse' && e.button !== 0) return
888
932
  dockResize.active = true
@@ -927,7 +971,7 @@ return {
927
971
  return el('div', { className: 'xq-dock' }, [
928
972
  el('div', { key: 'head', className: 'xq-dock-head' }, [
929
973
  el('span', { key: 'logo', className: 'xq-logo' }, [el('b', { key: 'b' }, '雪球'), ' mini']),
930
- el('span', { key: 'st', className: 'xq-status' }, '行情面板'),
974
+ el('span', { key: 'st', className: 'xq-status', title: 'A股/港股/美股交易时段(本地时区推算,不含节假日)' }, sessionsText() || '行情面板'),
931
975
  el('span', { key: 'hint', className: 'xq-update' }, '已嵌入输入框上方 · Esc 收起'),
932
976
  el('span', { key: 'sp', className: 'xq-spacer' }),
933
977
  el('button', { key: 'min', className: 'xq-btn-mini', title: '收起(点右下角徽章重新打开)', onClick: function () { ui.set({ open: false }) } }, '收起 —')
@@ -951,6 +995,7 @@ return {
951
995
  React.useEffect(function () {
952
996
  let alive = true
953
997
  function refresh() {
998
+ if (pageHidden()) return // 页面隐藏时暂停徽章轮询
954
999
  call('quote', { symbols: ['SH000001', 'SZ399001'] }).then(function (data) {
955
1000
  if (!alive) return
956
1001
  setIdx((data && data.list) || [])
@@ -958,10 +1003,15 @@ return {
958
1003
  setMOpen(st === 5 || st === 6)
959
1004
  }).catch(function () { /* 静默失败 */ })
960
1005
  }
1006
+ function onVis() { if (!pageHidden()) refresh() }
961
1007
  refresh()
962
1008
  const stop = ctx.interval(refresh, 30000)
963
1009
  const off = ui.subscribe(function () { force(function (x) { return x + 1 }) })
964
- return function () { alive = false; if (stop) stop(); off() }
1010
+ try { document.addEventListener('visibilitychange', onVis) } catch (e) { /* ignore */ }
1011
+ return function () {
1012
+ alive = false; if (stop) stop(); off()
1013
+ try { document.removeEventListener('visibilitychange', onVis) } catch (e) { /* ignore */ }
1014
+ }
965
1015
  }, [])
966
1016
 
967
1017
  function onDown(e) {
@@ -1012,7 +1062,7 @@ return {
1012
1062
  onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, onPointerCancel: onUp
1013
1063
  }, [
1014
1064
  el('span', { key: 'logo' }, [el('b', { key: 'b' }, '雪球'), 'mini']),
1015
- el('span', { key: 'st', className: 'xq-status' + (mOpen ? ' xq-live' : '') }, mOpen ? '● 盘中' : '休市'),
1065
+ el('span', { key: 'st', className: 'xq-status' + (aSession() === '盘中' ? ' xq-live' : ''), title: sessionsText() }, (function () { var s = aSession(); return s === '盘中' ? '● 盘中' : (s || (mOpen ? '● 盘中' : '休市')) })()),
1016
1066
  sh ? el('span', { key: 'sh' }, [
1017
1067
  el('span', { key: 'n', className: 'xq-badge-hint' }, sh.name + ' '),
1018
1068
  el('span', { key: 'v', className: 'xq-badge-val ' + colorOf(sh.percent) }, fmt(sh.current)),
@@ -1032,13 +1082,19 @@ return {
1032
1082
  React.useEffect(function () {
1033
1083
  let alive = true
1034
1084
  function refresh() {
1085
+ if (pageHidden()) return // 页面隐藏时暂停指数条轮询
1035
1086
  call('quote', { symbols: ['SH000001', 'SZ399001', 'SZ399006', 'SH000300'] }).then(function (data) {
1036
1087
  if (alive) setIndices((data && data.list) || [])
1037
1088
  }).catch(function () { /* 静默失败 */ })
1038
1089
  }
1090
+ function onVis() { if (!pageHidden()) refresh() }
1039
1091
  refresh()
1040
1092
  const stop = ctx.interval(refresh, 30000)
1041
- return function () { alive = false; if (stop) stop() }
1093
+ try { document.addEventListener('visibilitychange', onVis) } catch (e) { /* ignore */ }
1094
+ return function () {
1095
+ alive = false; if (stop) stop()
1096
+ try { document.removeEventListener('visibilitychange', onVis) } catch (e) { /* ignore */ }
1097
+ }
1042
1098
  }, [])
1043
1099
  if (!indices.length) return null
1044
1100
  const kids = indices.map(function (q) {
package/src/index.js CHANGED
@@ -32,18 +32,38 @@ export default {
32
32
  }
33
33
  }
34
34
 
35
+ // 看门狗:单个请求最长 30s,超时强制释放槽位,防止悬挂请求冻结整条管线
36
+ const GATE_TIMEOUT_MS = 30000
37
+
35
38
  function gate(fn) {
36
39
  return new Promise(function (resolve, reject) {
37
40
  waiters.push(async function () {
38
- const wait = lastStart + MIN_GAP_MS - Date.now()
39
- if (wait > 0) await delay(wait)
40
- lastStart = Date.now()
41
+ let released = false
42
+ function rel() {
43
+ if (released) return
44
+ released = true
45
+ running--
46
+ pump()
47
+ }
48
+ let settled = false
49
+ let watchdog = null
50
+ function settle(fnName, arg) {
51
+ if (settled) return
52
+ settled = true
53
+ if (watchdog) { try { watchdog() } catch (e) { /* ignore */ } }
54
+ if (fnName === 'resolve') resolve(arg)
55
+ else reject(arg)
56
+ }
57
+ try { watchdog = ctx.timeout(function () { rel(); settle('reject', new Error('[timeout] 请求超时(' + GATE_TIMEOUT_MS + 'ms),已释放调度槽')) }, GATE_TIMEOUT_MS) } catch (e) { /* ignore */ }
41
58
  try {
42
- resolve(await fn())
59
+ const wait = lastStart + MIN_GAP_MS - Date.now()
60
+ if (wait > 0) await delay(wait)
61
+ lastStart = Date.now()
62
+ settle('resolve', await fn())
43
63
  } catch (e) {
44
- reject(e)
64
+ settle('reject', e)
45
65
  } finally {
46
- release()
66
+ rel()
47
67
  }
48
68
  })
49
69
  pump()
@@ -490,7 +510,7 @@ export default {
490
510
  return {
491
511
  tab: s.tab || 'market', open: s.open !== false,
492
512
  dockH: (s.dockH !== undefined && isFinite(hNum) && hNum >= 160 && hNum <= 1200) ? hNum : null,
493
- badgePos: (s.badgePos && isFinite(Number(s.badgePos.x)) && isFinite(Number(s.badgePos.y())))
513
+ badgePos: (s.badgePos && isFinite(Number(s.badgePos.x)) && isFinite(Number(s.badgePos.y)))
494
514
  ? { x: Number(s.badgePos.x), y: Number(s.badgePos.y) } : null
495
515
  }
496
516
  }
@@ -516,7 +536,10 @@ export default {
516
536
  hot: actHot, search: actSearch, searchPosts: actSearchPosts, news: actNews,
517
537
  kol: actKOL, user: actUser, finance: actFinance,
518
538
  'watchlist.get': actWatchlistGet, 'watchlist.add': actWatchlistAdd, 'watchlist.remove': actWatchlistRemove,
519
- 'ui.get': actUiGet, 'ui.set': actUiSet
539
+ 'ui.get': actUiGet, 'ui.set': actUiSet,
540
+ debug: async function () {
541
+ return { running: running, waiters: waiters.length, inflight: Array.from(inflight.keys()), cacheKeys: cache.size, cookie: state.cookie ? 'set' : 'none' }
542
+ }
520
543
  }
521
544
 
522
545
  ctx.effect(function () {