dsh-xueqiu 1.22.6 → 1.22.8
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.en.md +1 -0
- package/README.md +2 -0
- package/dynamic/client.js +49 -6
- package/dynamic/host.js +25 -10
- package/package.json +5 -3
- package/src/client/index.js +49 -6
- package/src/index.js +25 -10
package/README.en.md
CHANGED
|
@@ -135,6 +135,7 @@ The data layer is defended in depth — safe to leave open for hours:
|
|
|
135
135
|
|
|
136
136
|
## Changelog
|
|
137
137
|
|
|
138
|
+
- **1.22.7** (2026-08-26) — **Fix: minute chart flattened into a straight line after switching stocks** (e.g. viewing CATL after Maotai). `registerIndicator('xq-minute')` registers globally exactly once, and its calc closure captured the *first* mount instance's `baseRef` — so the second stock's minute chart reused the stale calc, keeping the previous stock's prev-close as baseline (CATL got Maotai's 1304.66; the y-axis stretched to 404~1229 while prices sat in 378~388, flattening the line). Fix: rebind a fresh calc bound to the current instance's baseRef via `overrideIndicator` on every mount. Verified with three back-to-back switches (Maotai 1304.66 → CATL 387.89 → BOE 5.75): prev-close always correct, price spans healthy at 130~180px.
|
|
138
139
|
- **1.22.6** (2026-08-26) — **Fix: duplicate hover info on the minute tab** (found while auditing 1.22.5). For the minute area chart open=high=low=close are identical, so the canvas OHLC legend was pure redundancy, and the xq-minute indicator legend (prev-close/avg) duplicated the custom .xq-tip. Minute mode now disables canvas legends entirely (candle + indicator); hover keeps only .xq-tip. Also: **K-line hover now shows MA values** (catching up on a gap since 1.22.0 where MA numbers were visible nowhere) — indicator legend set to `follow_cross`: on hover a second colored line below the candle row shows MA5/10/30/60 values (measured on Maotai: single line, 70% width, no wrap), matching Xueqiu.
|
|
139
140
|
- **1.22.5** (2026-08-26) — **Fix: hover OHLC legend wrapped to two lines over the candles** (leftover from 1.22.3). After `follow_cross`, the default English time/open/high/low/close/volume titles were too long for the narrow panel and the legend folded onto two lines covering candles on hover. Now a compact single-line Chinese legend (`开 高 低 收 量`, size 10) with the ticker title hidden — measured on hover: one 10px line at 45% width, no wrapping.
|
|
140
141
|
- **1.22.4** (2026-08-26) — **Fix: switching to the minute tab crashed the whole panel** (1.22.3 regression). The double-click-to-latest cleanup used `boxRef.current.removeEventListener`, but React clears refs before running effect cleanup on unmount, so `boxRef.current` was null → TypeError → the `conversation.input.dock` slot crashed entirely, leaving only the mini badge with no way back in. Now the node is captured in a closure inside the effect. Regression-tested: K-line↔minute 4 toggle rounds + collapse/badge-reopen/re-enter detail, 0 errors.
|
package/README.md
CHANGED
|
@@ -190,6 +190,8 @@ dsh-xueqiu/
|
|
|
190
190
|
|
|
191
191
|
## 📋 更新日志
|
|
192
192
|
|
|
193
|
+
- **1.22.7**(2026-08-26)
|
|
194
|
+
- 修复:**切换股票后分时图压成直线**(如看完茅台再看宁德时代)。`registerIndicator('xq-minute')` 全局只注册一次,其 calc 闭包捕获的是**首次挂载实例**的 `baseRef`——第二只股票的分时图复用了旧 calc,昨收基准仍是前一只股票的(宁德挂上茅台昨收 1304.66,y 轴被撑到 404~1229,而分时价格只在 378~388,曲线压成直线)。修复:每次挂载分时图用 `overrideIndicator` 重绑当前实例 baseRef 的新 calc。实测三股连环切换(茅台 1304.66 → 宁德 387.89 → 京东方 5.75),昨收均正确、价格波动 span 130~180px 饱满。
|
|
193
195
|
- **1.22.6**(2026-08-26)
|
|
194
196
|
- 修复:**分时悬停信息双份叠加**(1.22.5 排查发现)。分时面积图"开=高=低=收"同值,canvas OHLC 图例纯冗余,且 xq-minute 指标图例(昨收/均价)与自定义 .xq-tip 重复显示两遍。分时模式现在彻底关闭 canvas 图例(蜡烛+指标),悬停只保留 .xq-tip。
|
|
195
197
|
- 新增:**K线悬停显示 MA 数值**(1.22.0 以来 MA 值无处可看的补课)。指标图例改 `follow_cross`:悬停时蜡烛行(开高低收量)下方第二行彩色显示 MA5/10/30/60 数值(实测茅台 MA 行单行占宽 70%,不折行),与雪球一致。
|
package/dynamic/client.js
CHANGED
|
@@ -407,6 +407,8 @@ return {
|
|
|
407
407
|
function klcSetup(K, boxRef, kind) {
|
|
408
408
|
const chart = K.init(boxRef.current, { styles: klcStyles(kind, klcPalette()) })
|
|
409
409
|
if (!chart) return null
|
|
410
|
+
// 卸载清理时 boxRef.current 已被 React 置 null,dispose 须用闭包捕获的节点
|
|
411
|
+
const box = boxRef.current
|
|
410
412
|
let ro = null
|
|
411
413
|
try {
|
|
412
414
|
ro = new ResizeObserver(function () { chart.resize() })
|
|
@@ -422,7 +424,8 @@ return {
|
|
|
422
424
|
dispose: function () {
|
|
423
425
|
if (ro) ro.disconnect()
|
|
424
426
|
if (mo) mo.disconnect()
|
|
425
|
-
|
|
427
|
+
// 卸载清理时 boxRef.current 已被 React 置 null(与 dblclick 监听同类坑),须闭包捕获节点
|
|
428
|
+
try { K.dispose(box) } catch (e) { /* 忽略 */ }
|
|
426
429
|
}
|
|
427
430
|
}
|
|
428
431
|
}
|
|
@@ -527,11 +530,19 @@ return {
|
|
|
527
530
|
const [hi, setHi] = React.useState(null)
|
|
528
531
|
const baseRef = React.useRef({ lastClose: lastClose })
|
|
529
532
|
baseRef.current = { lastClose: lastClose }
|
|
533
|
+
// 分时数据是详情页渐进渲染晚到的一块(quote+kline 先上屏、minute 随后 merge):
|
|
534
|
+
// effect 依赖 [] 曾导致空 items 挂载后数据到达永不刷新(空白图)。改为「空数据不挂图表,
|
|
535
|
+
// 数据到达(hasData 变 true)时重建」,彻底消除竞态窗口。
|
|
536
|
+
const hasData = items.length > 0
|
|
530
537
|
|
|
531
538
|
React.useEffect(function () {
|
|
532
539
|
const K = klcLib()
|
|
533
540
|
if (!K || !boxRef.current) { setNoLib(true); return undefined }
|
|
541
|
+
if (!hasData) return undefined // 数据未到:不建图表(等 hasData 变 true 重建)
|
|
534
542
|
// 自定义指标:昨收虚线 + 均价线(series normal → 单独附图,避免干扰价格轴刻度)
|
|
543
|
+
// 注意:registerIndicator 全局只注册一次,calc 闭包捕获的是首次挂载实例的 baseRef——
|
|
544
|
+
// 切换股票后旧 calc 仍读旧实例的昨收(宁德分时曾因此挂上茅台昨收 1304,y 轴被撑爆成直线)。
|
|
545
|
+
// 因此每次挂载都 overrideIndicator 换成绑定当前 baseRef 的新 calc。
|
|
535
546
|
if (K.getSupportedIndicators().indexOf('xq-minute') < 0) {
|
|
536
547
|
K.registerIndicator({
|
|
537
548
|
name: 'xq-minute', shortName: '分时', series: 'normal',
|
|
@@ -577,6 +588,13 @@ return {
|
|
|
577
588
|
// 涨跌色改面积线色(palette up 色对A股即"涨")
|
|
578
589
|
chart.setStyles({ candle: { type: 'area', area: { lineColor: up ? klcPalette().up : klcPalette().down, backgroundColor: up ? 'rgba(239,68,68,0.10)' : 'rgba(34,197,94,0.10)' } } })
|
|
579
590
|
chart.createIndicator({ name: 'xq-minute', paneId: 'candle_pane' }) // 均价/昨收叠加到主图分时曲线(与雪球一致)
|
|
591
|
+
// 绑定当前实例 baseRef 的 calc(防跨股票串昨收,见上方注释)
|
|
592
|
+
chart.overrideIndicator({ name: 'xq-minute', calc: function (dataList) {
|
|
593
|
+
const base = baseRef.current.lastClose
|
|
594
|
+
return dataList.map(function (d) {
|
|
595
|
+
return { avg: Number(d.avg_price != null ? d.avg_price : d.close), base: base }
|
|
596
|
+
})
|
|
597
|
+
} })
|
|
580
598
|
// 分时固定视角:禁缩放拖拽,全部笔数铺满宽度
|
|
581
599
|
chart.setZoomEnabled(false)
|
|
582
600
|
chart.setScrollEnabled(false)
|
|
@@ -598,9 +616,10 @@ return {
|
|
|
598
616
|
try { chart.unsubscribeAction('onCrosshairChange') } catch (e) { /* 忽略 */ }
|
|
599
617
|
h.dispose()
|
|
600
618
|
}
|
|
601
|
-
}, [])
|
|
619
|
+
}, [hasData, props.symbol])
|
|
602
620
|
|
|
603
621
|
if (noLib) return klcNoLib()
|
|
622
|
+
if (!hasData) return el('div', { className: 'xq-chart-wrap', style: { display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--dsw-alias-label-secondary)', fontSize: 12 } }, '分时数据加载中…')
|
|
604
623
|
// 十字光标 tooltip(沿用 .xq-tip 卡片)
|
|
605
624
|
let tip = null
|
|
606
625
|
if (hi && hi.r) {
|
|
@@ -639,6 +658,12 @@ return {
|
|
|
639
658
|
const [indices, setIndices] = React.useState([])
|
|
640
659
|
const [hot, setHot] = React.useState([])
|
|
641
660
|
const [hotMarket, setHotMarket] = React.useState('cn')
|
|
661
|
+
// 竞态防护:热榜/搜索的响应序号(旧响应到达时序号已过期则丢弃,防慢请求覆盖新结果)
|
|
662
|
+
const hotSeq = React.useRef(0)
|
|
663
|
+
const searchSeq = React.useRef(0)
|
|
664
|
+
// 轮询闭包读当前市场(interval 闭包不随状态更新,直接捕获 state 会拿到 stale 值)
|
|
665
|
+
const hotMarketRef = React.useRef(hotMarket)
|
|
666
|
+
hotMarketRef.current = hotMarket
|
|
642
667
|
const [news, setNews] = React.useState([])
|
|
643
668
|
const [newsMore, setNewsMore] = React.useState(false) // 翻页加载中
|
|
644
669
|
const [newsNoMore, setNewsNoMore] = React.useState(false) // 已到最旧
|
|
@@ -694,10 +719,13 @@ return {
|
|
|
694
719
|
}
|
|
695
720
|
|
|
696
721
|
function refreshContent() {
|
|
722
|
+
const my = ++hotSeq.current
|
|
723
|
+
const market = hotMarketRef.current // 读当前市场(轮询闭包防 stale)
|
|
697
724
|
Promise.all([
|
|
698
|
-
call('hot', { market:
|
|
725
|
+
call('hot', { market: market, size: 10 }),
|
|
699
726
|
call('news', { count: 20 })
|
|
700
727
|
]).then(function (res) {
|
|
728
|
+
if (hotSeq.current !== my) return // 期间用户切了市场/触发过手动刷新:丢弃旧响应
|
|
701
729
|
setHot((res[0] && res[0].list) || [])
|
|
702
730
|
setNews((res[1] && res[1].items) || [])
|
|
703
731
|
setNewsMore(false)
|
|
@@ -834,15 +862,18 @@ return {
|
|
|
834
862
|
const q = searchQ.trim()
|
|
835
863
|
if (!q) return
|
|
836
864
|
setSearching(true)
|
|
865
|
+
const my = ++searchSeq.current
|
|
837
866
|
const p = searchMode === 'stock'
|
|
838
867
|
? call('search', { q: q, count: 8 })
|
|
839
868
|
: call('searchPosts', { q: q, count: 10 })
|
|
840
869
|
p.then(function (data) {
|
|
870
|
+
if (searchSeq.current !== my) return // 快速连搜:只认最后一次
|
|
841
871
|
setSearchRes((data && data.list) || [])
|
|
842
872
|
}).catch(function (e) {
|
|
873
|
+
if (searchSeq.current !== my) return
|
|
843
874
|
setErr(String((e && e.message) || e))
|
|
844
875
|
setSearchRes([])
|
|
845
|
-
}).then(function () { setSearching(false) })
|
|
876
|
+
}).then(function () { if (searchSeq.current === my) setSearching(false) })
|
|
846
877
|
}
|
|
847
878
|
|
|
848
879
|
function addManual() {
|
|
@@ -1125,9 +1156,14 @@ return {
|
|
|
1125
1156
|
key: m[0], className: hotMarket === m[0] ? 'xq-seg-on' : '',
|
|
1126
1157
|
onClick: function () {
|
|
1127
1158
|
setHotMarket(m[0])
|
|
1159
|
+
const my = ++hotSeq.current
|
|
1128
1160
|
call('hot', { market: m[0], size: 10 }).then(function (data) {
|
|
1161
|
+
if (hotSeq.current !== my) return // 快速连点:只认最后一次
|
|
1129
1162
|
setHot((data && data.list) || [])
|
|
1130
|
-
}).catch(function (e) {
|
|
1163
|
+
}).catch(function (e) {
|
|
1164
|
+
if (hotSeq.current !== my) return
|
|
1165
|
+
setErr(String((e && e.message) || e))
|
|
1166
|
+
})
|
|
1131
1167
|
}
|
|
1132
1168
|
}, m[1])
|
|
1133
1169
|
})),
|
|
@@ -1196,6 +1232,13 @@ return {
|
|
|
1196
1232
|
news.forEach(function (it) {
|
|
1197
1233
|
const d = new Date(it.created_at)
|
|
1198
1234
|
const now = new Date()
|
|
1235
|
+
if (isNaN(d.getTime())) { // 脏/缺失时间:跳过分组头(否则渲染 "NaN月NaN日")
|
|
1236
|
+
kids.push(el('div', { key: String(it.id), className: 'xq-news-item' + (it.mark === 1 ? ' xq-important' : '') }, [
|
|
1237
|
+
el('div', { key: 't' }, it.text),
|
|
1238
|
+
el('div', { key: 'm', className: 'xq-news-time' }, fmtTime(it.created_at) + (it.mark === 1 ? ' · 重要' : ''))
|
|
1239
|
+
]))
|
|
1240
|
+
return
|
|
1241
|
+
}
|
|
1199
1242
|
const sameDay = function (a, b) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() }
|
|
1200
1243
|
const yest = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
|
1201
1244
|
let label = null
|
|
@@ -1549,7 +1592,7 @@ return {
|
|
|
1549
1592
|
onDoubleClick: onGripDblClick
|
|
1550
1593
|
}, '⤡')
|
|
1551
1594
|
])
|
|
1552
|
-
return el('div', { style: {
|
|
1595
|
+
return el('div', { style: { display: 'contents' } }, [badgeEl])
|
|
1553
1596
|
}
|
|
1554
1597
|
|
|
1555
1598
|
// ---------- 底部指数条(会话页,输入框下方氛围行) ----------
|
package/dynamic/host.js
CHANGED
|
@@ -185,8 +185,8 @@ return {
|
|
|
185
185
|
const spec = shell.resolve({ command: cmd, timeoutMs: 15000, stdoutMaxBytes: 4194304 })
|
|
186
186
|
const result = await shell.run(spec)
|
|
187
187
|
if (result.exitCode !== 0) {
|
|
188
|
-
|
|
189
|
-
throw new Error('curl 失败 (' + result.exitCode + ')
|
|
188
|
+
// 不回显 stderr:bash 语法错误会把完整命令行(含内部拼接细节)透给调用方
|
|
189
|
+
throw new Error('curl 失败 (' + result.exitCode + ')')
|
|
190
190
|
}
|
|
191
191
|
return String(result.stdout.text || '')
|
|
192
192
|
}
|
|
@@ -529,7 +529,14 @@ return {
|
|
|
529
529
|
syncInflight = true
|
|
530
530
|
try {
|
|
531
531
|
const cloud = await fetchCloudWatchlist()
|
|
532
|
-
if (!cloud.symbols.length)
|
|
532
|
+
if (!cloud.symbols.length) {
|
|
533
|
+
// 空结果不镜像(接口异常/账号无自选都不该清空本地),但必须推进 lastSyncAt——
|
|
534
|
+
// 否则 cookie 半失效时节流永不生效,每次 watchlist.get 都重复打上游 2 个请求
|
|
535
|
+
wl.lastSyncAt = Date.now()
|
|
536
|
+
watchlist = wl
|
|
537
|
+
await saveWatchlist()
|
|
538
|
+
return false
|
|
539
|
+
}
|
|
533
540
|
const cur = wl.symbols
|
|
534
541
|
const changed = cur.length !== cloud.symbols.length || cloud.symbols.some(function (s, i) { return cur[i] !== s })
|
|
535
542
|
if (changed) {
|
|
@@ -554,7 +561,7 @@ return {
|
|
|
554
561
|
if (wl.symbols.indexOf(symbol) === -1) {
|
|
555
562
|
wl.symbols.push(symbol)
|
|
556
563
|
await saveWatchlist()
|
|
557
|
-
cloudWatchAdd(symbol) //
|
|
564
|
+
cloudWatchAdd(symbol).catch(function () { /* 尽力而为:失败由下次同步纠正 */ }) // 云端添加失败不影响本地
|
|
558
565
|
}
|
|
559
566
|
return { symbols: wl.symbols }
|
|
560
567
|
}
|
|
@@ -566,7 +573,7 @@ return {
|
|
|
566
573
|
if (i !== -1) {
|
|
567
574
|
wl.symbols.splice(i, 1)
|
|
568
575
|
await saveWatchlist()
|
|
569
|
-
cloudWatchDelete(symbol) //
|
|
576
|
+
cloudWatchDelete(symbol).catch(function () { /* 尽力而为:失败由下次同步纠正 */ }) // 云端删除失败不影响本地
|
|
570
577
|
}
|
|
571
578
|
return { symbols: wl.symbols }
|
|
572
579
|
}
|
|
@@ -808,8 +815,9 @@ return {
|
|
|
808
815
|
async function actUiGet() {
|
|
809
816
|
const s = await loadUiState()
|
|
810
817
|
const hNum = Number(s.dockH)
|
|
818
|
+
const validTabs = ['market', 'hot', 'search', 'news']
|
|
811
819
|
return {
|
|
812
|
-
tab: s.tab
|
|
820
|
+
tab: (typeof s.tab === 'string' && validTabs.indexOf(s.tab) >= 0) ? s.tab : 'market', // 损坏状态文件的非法 tab 一律回退(防类型穿透) open: s.open !== false,
|
|
813
821
|
dockH: (s.dockH !== undefined && isFinite(hNum) && hNum >= 160 && hNum <= 1200) ? hNum : null,
|
|
814
822
|
badgeW: (s.badgeW !== undefined && isFinite(Number(s.badgeW)) && Number(s.badgeW) >= 120 && Number(s.badgeW) <= 480) ? Number(s.badgeW) : null,
|
|
815
823
|
badgePos: (s.badgePos && isFinite(Number(s.badgePos.x)) && isFinite(Number(s.badgePos.y)))
|
|
@@ -884,13 +892,14 @@ return {
|
|
|
884
892
|
kind: 'prefix',
|
|
885
893
|
path: '/xq-rpc',
|
|
886
894
|
handler: async function (req, res) {
|
|
887
|
-
// 同源栅栏:Host
|
|
895
|
+
// 同源栅栏:Host 头必须是回环,Origin 必须存在且同源(防 DNS rebinding/CSRF/无 Origin 的跨进程直调)
|
|
888
896
|
const hostHeader = String(req.headers.host || '')
|
|
889
897
|
const origin = req.headers.origin ? String(req.headers.origin) : ''
|
|
890
898
|
const hostAuth = hostHeader.replace(/:\d+$/, '')
|
|
891
899
|
const loopback = hostAuth === '127.0.0.1' || hostAuth === 'localhost' || hostAuth === '[::1]' || hostAuth === '::1'
|
|
892
900
|
if (!loopback) { res.writeHead(403); res.end('forbidden'); return }
|
|
893
|
-
|
|
901
|
+
// 浏览器对(哪怕同源的)POST fetch 必带 Origin;无 Origin 一律拒绝(curl/本机进程裸调)
|
|
902
|
+
if (!origin || (origin !== 'http://' + hostHeader && origin !== 'https://' + hostHeader)) {
|
|
894
903
|
res.writeHead(403); res.end('forbidden'); return
|
|
895
904
|
}
|
|
896
905
|
const chunks = []
|
|
@@ -1089,11 +1098,17 @@ return {
|
|
|
1089
1098
|
// 注册:动态环境走 harness 门面(defineTool 会做标记+校验);
|
|
1090
1099
|
// 静态 bundle 层没有 harness,参数已是 JSON Schema 根,直接注册 plain 对象
|
|
1091
1100
|
// (tools 服务在静态路径无 defineTool 守卫),用 inject 等服务就绪。
|
|
1101
|
+
// 统一包装 execute:args 为 null/undefined 时兜底 {}(工具层直解引用会抛 TypeError)
|
|
1102
|
+
function wrapToolSpec(spec) {
|
|
1103
|
+
const raw = spec.execute
|
|
1104
|
+
if (!raw) return spec
|
|
1105
|
+
return Object.assign({}, spec, { execute: function (args) { return raw.call(this, args || {}) } })
|
|
1106
|
+
}
|
|
1092
1107
|
if (typeof harness !== 'undefined') {
|
|
1093
1108
|
for (let i = 0; i < XQ_AGENT_TOOLS.length; i++) {
|
|
1094
1109
|
const spec = XQ_AGENT_TOOLS[i]
|
|
1095
1110
|
ctx.effect(function () {
|
|
1096
|
-
return harness.registerTool(ctx, harness.defineTool(spec))
|
|
1111
|
+
return harness.registerTool(ctx, harness.defineTool(wrapToolSpec(spec)))
|
|
1097
1112
|
})
|
|
1098
1113
|
}
|
|
1099
1114
|
} else {
|
|
@@ -1101,7 +1116,7 @@ return {
|
|
|
1101
1116
|
for (let i = 0; i < XQ_AGENT_TOOLS.length; i++) {
|
|
1102
1117
|
const spec = XQ_AGENT_TOOLS[i]
|
|
1103
1118
|
toolsCtx.effect(function () {
|
|
1104
|
-
return toolsCtx.tools.register(spec)
|
|
1119
|
+
return toolsCtx.tools.register(wrapToolSpec(spec))
|
|
1105
1120
|
})
|
|
1106
1121
|
}
|
|
1107
1122
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-xueqiu",
|
|
3
|
-
"version": "1.22.
|
|
3
|
+
"version": "1.22.8",
|
|
4
4
|
"description": "雪球 mini 行情面板 — DeepSeek Harness 免登录 A股/港美股实时行情、K线(蜡烛/成交量/均线)、分时图、热榜、搜索、7x24 快讯、热议用户。嵌入式面板 + 可拖拽行情徽标,内置请求节流、TTL 缓存与 Cookie 自动续期。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -52,7 +52,9 @@
|
|
|
52
52
|
],
|
|
53
53
|
"scripts": {
|
|
54
54
|
"generate": "python3 scripts/gen-static.py",
|
|
55
|
-
"check": "python3 scripts/gen-static.py --check && node --check src/index.js && node --check src/client/index.js && node -e \"new Function(require('fs').readFileSync('dynamic/host.js','utf8'))\" && node -e \"new Function(require('fs').readFileSync('dynamic/client.js','utf8'))\"
|
|
56
|
-
"
|
|
55
|
+
"check:fast": "python3 scripts/gen-static.py --check && node --check src/index.js && node --check src/client/index.js && node -e \"new Function(require('fs').readFileSync('dynamic/host.js','utf8'))\" && node -e \"new Function(require('fs').readFileSync('dynamic/client.js','utf8'))\"",
|
|
56
|
+
"check": "npm run check:fast && bash scripts/static-smoke.sh",
|
|
57
|
+
"smoke": "bash scripts/static-smoke.sh",
|
|
58
|
+
"prepublishOnly": "npm run check:fast"
|
|
57
59
|
}
|
|
58
60
|
}
|
package/src/client/index.js
CHANGED
|
@@ -438,6 +438,8 @@ exports.default = {
|
|
|
438
438
|
function klcSetup(K, boxRef, kind) {
|
|
439
439
|
const chart = K.init(boxRef.current, { styles: klcStyles(kind, klcPalette()) })
|
|
440
440
|
if (!chart) return null
|
|
441
|
+
// 卸载清理时 boxRef.current 已被 React 置 null,dispose 须用闭包捕获的节点
|
|
442
|
+
const box = boxRef.current
|
|
441
443
|
let ro = null
|
|
442
444
|
try {
|
|
443
445
|
ro = new ResizeObserver(function () { chart.resize() })
|
|
@@ -453,7 +455,8 @@ exports.default = {
|
|
|
453
455
|
dispose: function () {
|
|
454
456
|
if (ro) ro.disconnect()
|
|
455
457
|
if (mo) mo.disconnect()
|
|
456
|
-
|
|
458
|
+
// 卸载清理时 boxRef.current 已被 React 置 null(与 dblclick 监听同类坑),须闭包捕获节点
|
|
459
|
+
try { K.dispose(box) } catch (e) { /* 忽略 */ }
|
|
457
460
|
}
|
|
458
461
|
}
|
|
459
462
|
}
|
|
@@ -558,11 +561,19 @@ exports.default = {
|
|
|
558
561
|
const [hi, setHi] = React.useState(null)
|
|
559
562
|
const baseRef = React.useRef({ lastClose: lastClose })
|
|
560
563
|
baseRef.current = { lastClose: lastClose }
|
|
564
|
+
// 分时数据是详情页渐进渲染晚到的一块(quote+kline 先上屏、minute 随后 merge):
|
|
565
|
+
// effect 依赖 [] 曾导致空 items 挂载后数据到达永不刷新(空白图)。改为「空数据不挂图表,
|
|
566
|
+
// 数据到达(hasData 变 true)时重建」,彻底消除竞态窗口。
|
|
567
|
+
const hasData = items.length > 0
|
|
561
568
|
|
|
562
569
|
React.useEffect(function () {
|
|
563
570
|
const K = klcLib()
|
|
564
571
|
if (!K || !boxRef.current) { setNoLib(true); return undefined }
|
|
572
|
+
if (!hasData) return undefined // 数据未到:不建图表(等 hasData 变 true 重建)
|
|
565
573
|
// 自定义指标:昨收虚线 + 均价线(series normal → 单独附图,避免干扰价格轴刻度)
|
|
574
|
+
// 注意:registerIndicator 全局只注册一次,calc 闭包捕获的是首次挂载实例的 baseRef——
|
|
575
|
+
// 切换股票后旧 calc 仍读旧实例的昨收(宁德分时曾因此挂上茅台昨收 1304,y 轴被撑爆成直线)。
|
|
576
|
+
// 因此每次挂载都 overrideIndicator 换成绑定当前 baseRef 的新 calc。
|
|
566
577
|
if (K.getSupportedIndicators().indexOf('xq-minute') < 0) {
|
|
567
578
|
K.registerIndicator({
|
|
568
579
|
name: 'xq-minute', shortName: '分时', series: 'normal',
|
|
@@ -608,6 +619,13 @@ exports.default = {
|
|
|
608
619
|
// 涨跌色改面积线色(palette up 色对A股即"涨")
|
|
609
620
|
chart.setStyles({ candle: { type: 'area', area: { lineColor: up ? klcPalette().up : klcPalette().down, backgroundColor: up ? 'rgba(239,68,68,0.10)' : 'rgba(34,197,94,0.10)' } } })
|
|
610
621
|
chart.createIndicator({ name: 'xq-minute', paneId: 'candle_pane' }) // 均价/昨收叠加到主图分时曲线(与雪球一致)
|
|
622
|
+
// 绑定当前实例 baseRef 的 calc(防跨股票串昨收,见上方注释)
|
|
623
|
+
chart.overrideIndicator({ name: 'xq-minute', calc: function (dataList) {
|
|
624
|
+
const base = baseRef.current.lastClose
|
|
625
|
+
return dataList.map(function (d) {
|
|
626
|
+
return { avg: Number(d.avg_price != null ? d.avg_price : d.close), base: base }
|
|
627
|
+
})
|
|
628
|
+
} })
|
|
611
629
|
// 分时固定视角:禁缩放拖拽,全部笔数铺满宽度
|
|
612
630
|
chart.setZoomEnabled(false)
|
|
613
631
|
chart.setScrollEnabled(false)
|
|
@@ -629,9 +647,10 @@ exports.default = {
|
|
|
629
647
|
try { chart.unsubscribeAction('onCrosshairChange') } catch (e) { /* 忽略 */ }
|
|
630
648
|
h.dispose()
|
|
631
649
|
}
|
|
632
|
-
}, [])
|
|
650
|
+
}, [hasData, props.symbol])
|
|
633
651
|
|
|
634
652
|
if (noLib) return klcNoLib()
|
|
653
|
+
if (!hasData) return el('div', { className: 'xq-chart-wrap', style: { display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--dsw-alias-label-secondary)', fontSize: 12 } }, '分时数据加载中…')
|
|
635
654
|
// 十字光标 tooltip(沿用 .xq-tip 卡片)
|
|
636
655
|
let tip = null
|
|
637
656
|
if (hi && hi.r) {
|
|
@@ -670,6 +689,12 @@ exports.default = {
|
|
|
670
689
|
const [indices, setIndices] = React.useState([])
|
|
671
690
|
const [hot, setHot] = React.useState([])
|
|
672
691
|
const [hotMarket, setHotMarket] = React.useState('cn')
|
|
692
|
+
// 竞态防护:热榜/搜索的响应序号(旧响应到达时序号已过期则丢弃,防慢请求覆盖新结果)
|
|
693
|
+
const hotSeq = React.useRef(0)
|
|
694
|
+
const searchSeq = React.useRef(0)
|
|
695
|
+
// 轮询闭包读当前市场(interval 闭包不随状态更新,直接捕获 state 会拿到 stale 值)
|
|
696
|
+
const hotMarketRef = React.useRef(hotMarket)
|
|
697
|
+
hotMarketRef.current = hotMarket
|
|
673
698
|
const [news, setNews] = React.useState([])
|
|
674
699
|
const [newsMore, setNewsMore] = React.useState(false) // 翻页加载中
|
|
675
700
|
const [newsNoMore, setNewsNoMore] = React.useState(false) // 已到最旧
|
|
@@ -725,10 +750,13 @@ exports.default = {
|
|
|
725
750
|
}
|
|
726
751
|
|
|
727
752
|
function refreshContent() {
|
|
753
|
+
const my = ++hotSeq.current
|
|
754
|
+
const market = hotMarketRef.current // 读当前市场(轮询闭包防 stale)
|
|
728
755
|
Promise.all([
|
|
729
|
-
call('hot', { market:
|
|
756
|
+
call('hot', { market: market, size: 10 }),
|
|
730
757
|
call('news', { count: 20 })
|
|
731
758
|
]).then(function (res) {
|
|
759
|
+
if (hotSeq.current !== my) return // 期间用户切了市场/触发过手动刷新:丢弃旧响应
|
|
732
760
|
setHot((res[0] && res[0].list) || [])
|
|
733
761
|
setNews((res[1] && res[1].items) || [])
|
|
734
762
|
setNewsMore(false)
|
|
@@ -865,15 +893,18 @@ exports.default = {
|
|
|
865
893
|
const q = searchQ.trim()
|
|
866
894
|
if (!q) return
|
|
867
895
|
setSearching(true)
|
|
896
|
+
const my = ++searchSeq.current
|
|
868
897
|
const p = searchMode === 'stock'
|
|
869
898
|
? call('search', { q: q, count: 8 })
|
|
870
899
|
: call('searchPosts', { q: q, count: 10 })
|
|
871
900
|
p.then(function (data) {
|
|
901
|
+
if (searchSeq.current !== my) return // 快速连搜:只认最后一次
|
|
872
902
|
setSearchRes((data && data.list) || [])
|
|
873
903
|
}).catch(function (e) {
|
|
904
|
+
if (searchSeq.current !== my) return
|
|
874
905
|
setErr(String((e && e.message) || e))
|
|
875
906
|
setSearchRes([])
|
|
876
|
-
}).then(function () { setSearching(false) })
|
|
907
|
+
}).then(function () { if (searchSeq.current === my) setSearching(false) })
|
|
877
908
|
}
|
|
878
909
|
|
|
879
910
|
function addManual() {
|
|
@@ -1156,9 +1187,14 @@ exports.default = {
|
|
|
1156
1187
|
key: m[0], className: hotMarket === m[0] ? 'xq-seg-on' : '',
|
|
1157
1188
|
onClick: function () {
|
|
1158
1189
|
setHotMarket(m[0])
|
|
1190
|
+
const my = ++hotSeq.current
|
|
1159
1191
|
call('hot', { market: m[0], size: 10 }).then(function (data) {
|
|
1192
|
+
if (hotSeq.current !== my) return // 快速连点:只认最后一次
|
|
1160
1193
|
setHot((data && data.list) || [])
|
|
1161
|
-
}).catch(function (e) {
|
|
1194
|
+
}).catch(function (e) {
|
|
1195
|
+
if (hotSeq.current !== my) return
|
|
1196
|
+
setErr(String((e && e.message) || e))
|
|
1197
|
+
})
|
|
1162
1198
|
}
|
|
1163
1199
|
}, m[1])
|
|
1164
1200
|
})),
|
|
@@ -1227,6 +1263,13 @@ exports.default = {
|
|
|
1227
1263
|
news.forEach(function (it) {
|
|
1228
1264
|
const d = new Date(it.created_at)
|
|
1229
1265
|
const now = new Date()
|
|
1266
|
+
if (isNaN(d.getTime())) { // 脏/缺失时间:跳过分组头(否则渲染 "NaN月NaN日")
|
|
1267
|
+
kids.push(el('div', { key: String(it.id), className: 'xq-news-item' + (it.mark === 1 ? ' xq-important' : '') }, [
|
|
1268
|
+
el('div', { key: 't' }, it.text),
|
|
1269
|
+
el('div', { key: 'm', className: 'xq-news-time' }, fmtTime(it.created_at) + (it.mark === 1 ? ' · 重要' : ''))
|
|
1270
|
+
]))
|
|
1271
|
+
return
|
|
1272
|
+
}
|
|
1230
1273
|
const sameDay = function (a, b) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() }
|
|
1231
1274
|
const yest = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
|
1232
1275
|
let label = null
|
|
@@ -1580,7 +1623,7 @@ exports.default = {
|
|
|
1580
1623
|
onDoubleClick: onGripDblClick
|
|
1581
1624
|
}, '⤡')
|
|
1582
1625
|
])
|
|
1583
|
-
return el('div', { style: {
|
|
1626
|
+
return el('div', { style: { display: 'contents' } }, [badgeEl])
|
|
1584
1627
|
}
|
|
1585
1628
|
|
|
1586
1629
|
// ---------- 底部指数条(会话页,输入框下方氛围行) ----------
|
package/src/index.js
CHANGED
|
@@ -185,8 +185,8 @@ export default {
|
|
|
185
185
|
const spec = shell.resolve({ command: cmd, timeoutMs: 15000, stdoutMaxBytes: 4194304 })
|
|
186
186
|
const result = await shell.run(spec)
|
|
187
187
|
if (result.exitCode !== 0) {
|
|
188
|
-
|
|
189
|
-
throw new Error('curl 失败 (' + result.exitCode + ')
|
|
188
|
+
// 不回显 stderr:bash 语法错误会把完整命令行(含内部拼接细节)透给调用方
|
|
189
|
+
throw new Error('curl 失败 (' + result.exitCode + ')')
|
|
190
190
|
}
|
|
191
191
|
return String(result.stdout.text || '')
|
|
192
192
|
}
|
|
@@ -529,7 +529,14 @@ export default {
|
|
|
529
529
|
syncInflight = true
|
|
530
530
|
try {
|
|
531
531
|
const cloud = await fetchCloudWatchlist()
|
|
532
|
-
if (!cloud.symbols.length)
|
|
532
|
+
if (!cloud.symbols.length) {
|
|
533
|
+
// 空结果不镜像(接口异常/账号无自选都不该清空本地),但必须推进 lastSyncAt——
|
|
534
|
+
// 否则 cookie 半失效时节流永不生效,每次 watchlist.get 都重复打上游 2 个请求
|
|
535
|
+
wl.lastSyncAt = Date.now()
|
|
536
|
+
watchlist = wl
|
|
537
|
+
await saveWatchlist()
|
|
538
|
+
return false
|
|
539
|
+
}
|
|
533
540
|
const cur = wl.symbols
|
|
534
541
|
const changed = cur.length !== cloud.symbols.length || cloud.symbols.some(function (s, i) { return cur[i] !== s })
|
|
535
542
|
if (changed) {
|
|
@@ -554,7 +561,7 @@ export default {
|
|
|
554
561
|
if (wl.symbols.indexOf(symbol) === -1) {
|
|
555
562
|
wl.symbols.push(symbol)
|
|
556
563
|
await saveWatchlist()
|
|
557
|
-
cloudWatchAdd(symbol) //
|
|
564
|
+
cloudWatchAdd(symbol).catch(function () { /* 尽力而为:失败由下次同步纠正 */ }) // 云端添加失败不影响本地
|
|
558
565
|
}
|
|
559
566
|
return { symbols: wl.symbols }
|
|
560
567
|
}
|
|
@@ -566,7 +573,7 @@ export default {
|
|
|
566
573
|
if (i !== -1) {
|
|
567
574
|
wl.symbols.splice(i, 1)
|
|
568
575
|
await saveWatchlist()
|
|
569
|
-
cloudWatchDelete(symbol) //
|
|
576
|
+
cloudWatchDelete(symbol).catch(function () { /* 尽力而为:失败由下次同步纠正 */ }) // 云端删除失败不影响本地
|
|
570
577
|
}
|
|
571
578
|
return { symbols: wl.symbols }
|
|
572
579
|
}
|
|
@@ -808,8 +815,9 @@ export default {
|
|
|
808
815
|
async function actUiGet() {
|
|
809
816
|
const s = await loadUiState()
|
|
810
817
|
const hNum = Number(s.dockH)
|
|
818
|
+
const validTabs = ['market', 'hot', 'search', 'news']
|
|
811
819
|
return {
|
|
812
|
-
tab: s.tab
|
|
820
|
+
tab: (typeof s.tab === 'string' && validTabs.indexOf(s.tab) >= 0) ? s.tab : 'market', // 损坏状态文件的非法 tab 一律回退(防类型穿透) open: s.open !== false,
|
|
813
821
|
dockH: (s.dockH !== undefined && isFinite(hNum) && hNum >= 160 && hNum <= 1200) ? hNum : null,
|
|
814
822
|
badgeW: (s.badgeW !== undefined && isFinite(Number(s.badgeW)) && Number(s.badgeW) >= 120 && Number(s.badgeW) <= 480) ? Number(s.badgeW) : null,
|
|
815
823
|
badgePos: (s.badgePos && isFinite(Number(s.badgePos.x)) && isFinite(Number(s.badgePos.y)))
|
|
@@ -884,13 +892,14 @@ export default {
|
|
|
884
892
|
kind: 'prefix',
|
|
885
893
|
path: '/xq-rpc',
|
|
886
894
|
handler: async function (req, res) {
|
|
887
|
-
// 同源栅栏:Host
|
|
895
|
+
// 同源栅栏:Host 头必须是回环,Origin 必须存在且同源(防 DNS rebinding/CSRF/无 Origin 的跨进程直调)
|
|
888
896
|
const hostHeader = String(req.headers.host || '')
|
|
889
897
|
const origin = req.headers.origin ? String(req.headers.origin) : ''
|
|
890
898
|
const hostAuth = hostHeader.replace(/:\d+$/, '')
|
|
891
899
|
const loopback = hostAuth === '127.0.0.1' || hostAuth === 'localhost' || hostAuth === '[::1]' || hostAuth === '::1'
|
|
892
900
|
if (!loopback) { res.writeHead(403); res.end('forbidden'); return }
|
|
893
|
-
|
|
901
|
+
// 浏览器对(哪怕同源的)POST fetch 必带 Origin;无 Origin 一律拒绝(curl/本机进程裸调)
|
|
902
|
+
if (!origin || (origin !== 'http://' + hostHeader && origin !== 'https://' + hostHeader)) {
|
|
894
903
|
res.writeHead(403); res.end('forbidden'); return
|
|
895
904
|
}
|
|
896
905
|
const chunks = []
|
|
@@ -1089,11 +1098,17 @@ export default {
|
|
|
1089
1098
|
// 注册:动态环境走 harness 门面(defineTool 会做标记+校验);
|
|
1090
1099
|
// 静态 bundle 层没有 harness,参数已是 JSON Schema 根,直接注册 plain 对象
|
|
1091
1100
|
// (tools 服务在静态路径无 defineTool 守卫),用 inject 等服务就绪。
|
|
1101
|
+
// 统一包装 execute:args 为 null/undefined 时兜底 {}(工具层直解引用会抛 TypeError)
|
|
1102
|
+
function wrapToolSpec(spec) {
|
|
1103
|
+
const raw = spec.execute
|
|
1104
|
+
if (!raw) return spec
|
|
1105
|
+
return Object.assign({}, spec, { execute: function (args) { return raw.call(this, args || {}) } })
|
|
1106
|
+
}
|
|
1092
1107
|
if (typeof harness !== 'undefined') {
|
|
1093
1108
|
for (let i = 0; i < XQ_AGENT_TOOLS.length; i++) {
|
|
1094
1109
|
const spec = XQ_AGENT_TOOLS[i]
|
|
1095
1110
|
ctx.effect(function () {
|
|
1096
|
-
return harness.registerTool(ctx, harness.defineTool(spec))
|
|
1111
|
+
return harness.registerTool(ctx, harness.defineTool(wrapToolSpec(spec)))
|
|
1097
1112
|
})
|
|
1098
1113
|
}
|
|
1099
1114
|
} else {
|
|
@@ -1101,7 +1116,7 @@ export default {
|
|
|
1101
1116
|
for (let i = 0; i < XQ_AGENT_TOOLS.length; i++) {
|
|
1102
1117
|
const spec = XQ_AGENT_TOOLS[i]
|
|
1103
1118
|
toolsCtx.effect(function () {
|
|
1104
|
-
return toolsCtx.tools.register(spec)
|
|
1119
|
+
return toolsCtx.tools.register(wrapToolSpec(spec))
|
|
1105
1120
|
})
|
|
1106
1121
|
}
|
|
1107
1122
|
})
|