dsh-xueqiu 1.22.7 → 1.22.9
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/dynamic/client.js +43 -9
- package/dynamic/host.js +25 -10
- package/package.json +5 -3
- package/src/client/index.js +43 -9
- package/src/index.js +25 -10
package/dynamic/client.js
CHANGED
|
@@ -355,10 +355,11 @@ return {
|
|
|
355
355
|
upBorderColor: pal.up, downBorderColor: pal.down, noChangeBorderColor: pal.text,
|
|
356
356
|
upWickColor: pal.up, downWickColor: pal.down, noChangeWickColor: pal.text
|
|
357
357
|
},
|
|
358
|
-
// OHLC
|
|
359
|
-
//
|
|
358
|
+
// OHLC 图例:K线常显最新一根(v1.22.5 起紧凑中文单行、字号 10,不再压蜡烛);
|
|
359
|
+
// 悬停十字光标时跟随显示对应根;分时在下方 minute 分支整体关闭。
|
|
360
|
+
// 默认英文 time/open/... 标题太长会折成两行——已换中文单行
|
|
360
361
|
tooltip: {
|
|
361
|
-
showRule: '
|
|
362
|
+
showRule: 'always',
|
|
362
363
|
title: { show: false },
|
|
363
364
|
legend: {
|
|
364
365
|
size: 10, color: pal.text,
|
|
@@ -407,6 +408,8 @@ return {
|
|
|
407
408
|
function klcSetup(K, boxRef, kind) {
|
|
408
409
|
const chart = K.init(boxRef.current, { styles: klcStyles(kind, klcPalette()) })
|
|
409
410
|
if (!chart) return null
|
|
411
|
+
// 卸载清理时 boxRef.current 已被 React 置 null,dispose 须用闭包捕获的节点
|
|
412
|
+
const box = boxRef.current
|
|
410
413
|
let ro = null
|
|
411
414
|
try {
|
|
412
415
|
ro = new ResizeObserver(function () { chart.resize() })
|
|
@@ -422,7 +425,8 @@ return {
|
|
|
422
425
|
dispose: function () {
|
|
423
426
|
if (ro) ro.disconnect()
|
|
424
427
|
if (mo) mo.disconnect()
|
|
425
|
-
|
|
428
|
+
// 卸载清理时 boxRef.current 已被 React 置 null(与 dblclick 监听同类坑),须闭包捕获节点
|
|
429
|
+
try { K.dispose(box) } catch (e) { /* 忽略 */ }
|
|
426
430
|
}
|
|
427
431
|
}
|
|
428
432
|
}
|
|
@@ -527,10 +531,15 @@ return {
|
|
|
527
531
|
const [hi, setHi] = React.useState(null)
|
|
528
532
|
const baseRef = React.useRef({ lastClose: lastClose })
|
|
529
533
|
baseRef.current = { lastClose: lastClose }
|
|
534
|
+
// 分时数据是详情页渐进渲染晚到的一块(quote+kline 先上屏、minute 随后 merge):
|
|
535
|
+
// effect 依赖 [] 曾导致空 items 挂载后数据到达永不刷新(空白图)。改为「空数据不挂图表,
|
|
536
|
+
// 数据到达(hasData 变 true)时重建」,彻底消除竞态窗口。
|
|
537
|
+
const hasData = items.length > 0
|
|
530
538
|
|
|
531
539
|
React.useEffect(function () {
|
|
532
540
|
const K = klcLib()
|
|
533
541
|
if (!K || !boxRef.current) { setNoLib(true); return undefined }
|
|
542
|
+
if (!hasData) return undefined // 数据未到:不建图表(等 hasData 变 true 重建)
|
|
534
543
|
// 自定义指标:昨收虚线 + 均价线(series normal → 单独附图,避免干扰价格轴刻度)
|
|
535
544
|
// 注意:registerIndicator 全局只注册一次,calc 闭包捕获的是首次挂载实例的 baseRef——
|
|
536
545
|
// 切换股票后旧 calc 仍读旧实例的昨收(宁德分时曾因此挂上茅台昨收 1304,y 轴被撑爆成直线)。
|
|
@@ -608,9 +617,10 @@ return {
|
|
|
608
617
|
try { chart.unsubscribeAction('onCrosshairChange') } catch (e) { /* 忽略 */ }
|
|
609
618
|
h.dispose()
|
|
610
619
|
}
|
|
611
|
-
}, [])
|
|
620
|
+
}, [hasData, props.symbol])
|
|
612
621
|
|
|
613
622
|
if (noLib) return klcNoLib()
|
|
623
|
+
if (!hasData) return el('div', { className: 'xq-chart-wrap', style: { display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--dsw-alias-label-secondary)', fontSize: 12 } }, '分时数据加载中…')
|
|
614
624
|
// 十字光标 tooltip(沿用 .xq-tip 卡片)
|
|
615
625
|
let tip = null
|
|
616
626
|
if (hi && hi.r) {
|
|
@@ -649,6 +659,12 @@ return {
|
|
|
649
659
|
const [indices, setIndices] = React.useState([])
|
|
650
660
|
const [hot, setHot] = React.useState([])
|
|
651
661
|
const [hotMarket, setHotMarket] = React.useState('cn')
|
|
662
|
+
// 竞态防护:热榜/搜索的响应序号(旧响应到达时序号已过期则丢弃,防慢请求覆盖新结果)
|
|
663
|
+
const hotSeq = React.useRef(0)
|
|
664
|
+
const searchSeq = React.useRef(0)
|
|
665
|
+
// 轮询闭包读当前市场(interval 闭包不随状态更新,直接捕获 state 会拿到 stale 值)
|
|
666
|
+
const hotMarketRef = React.useRef(hotMarket)
|
|
667
|
+
hotMarketRef.current = hotMarket
|
|
652
668
|
const [news, setNews] = React.useState([])
|
|
653
669
|
const [newsMore, setNewsMore] = React.useState(false) // 翻页加载中
|
|
654
670
|
const [newsNoMore, setNewsNoMore] = React.useState(false) // 已到最旧
|
|
@@ -704,10 +720,13 @@ return {
|
|
|
704
720
|
}
|
|
705
721
|
|
|
706
722
|
function refreshContent() {
|
|
723
|
+
const my = ++hotSeq.current
|
|
724
|
+
const market = hotMarketRef.current // 读当前市场(轮询闭包防 stale)
|
|
707
725
|
Promise.all([
|
|
708
|
-
call('hot', { market:
|
|
726
|
+
call('hot', { market: market, size: 10 }),
|
|
709
727
|
call('news', { count: 20 })
|
|
710
728
|
]).then(function (res) {
|
|
729
|
+
if (hotSeq.current !== my) return // 期间用户切了市场/触发过手动刷新:丢弃旧响应
|
|
711
730
|
setHot((res[0] && res[0].list) || [])
|
|
712
731
|
setNews((res[1] && res[1].items) || [])
|
|
713
732
|
setNewsMore(false)
|
|
@@ -844,15 +863,18 @@ return {
|
|
|
844
863
|
const q = searchQ.trim()
|
|
845
864
|
if (!q) return
|
|
846
865
|
setSearching(true)
|
|
866
|
+
const my = ++searchSeq.current
|
|
847
867
|
const p = searchMode === 'stock'
|
|
848
868
|
? call('search', { q: q, count: 8 })
|
|
849
869
|
: call('searchPosts', { q: q, count: 10 })
|
|
850
870
|
p.then(function (data) {
|
|
871
|
+
if (searchSeq.current !== my) return // 快速连搜:只认最后一次
|
|
851
872
|
setSearchRes((data && data.list) || [])
|
|
852
873
|
}).catch(function (e) {
|
|
874
|
+
if (searchSeq.current !== my) return
|
|
853
875
|
setErr(String((e && e.message) || e))
|
|
854
876
|
setSearchRes([])
|
|
855
|
-
}).then(function () { setSearching(false) })
|
|
877
|
+
}).then(function () { if (searchSeq.current === my) setSearching(false) })
|
|
856
878
|
}
|
|
857
879
|
|
|
858
880
|
function addManual() {
|
|
@@ -1135,9 +1157,14 @@ return {
|
|
|
1135
1157
|
key: m[0], className: hotMarket === m[0] ? 'xq-seg-on' : '',
|
|
1136
1158
|
onClick: function () {
|
|
1137
1159
|
setHotMarket(m[0])
|
|
1160
|
+
const my = ++hotSeq.current
|
|
1138
1161
|
call('hot', { market: m[0], size: 10 }).then(function (data) {
|
|
1162
|
+
if (hotSeq.current !== my) return // 快速连点:只认最后一次
|
|
1139
1163
|
setHot((data && data.list) || [])
|
|
1140
|
-
}).catch(function (e) {
|
|
1164
|
+
}).catch(function (e) {
|
|
1165
|
+
if (hotSeq.current !== my) return
|
|
1166
|
+
setErr(String((e && e.message) || e))
|
|
1167
|
+
})
|
|
1141
1168
|
}
|
|
1142
1169
|
}, m[1])
|
|
1143
1170
|
})),
|
|
@@ -1206,6 +1233,13 @@ return {
|
|
|
1206
1233
|
news.forEach(function (it) {
|
|
1207
1234
|
const d = new Date(it.created_at)
|
|
1208
1235
|
const now = new Date()
|
|
1236
|
+
if (isNaN(d.getTime())) { // 脏/缺失时间:跳过分组头(否则渲染 "NaN月NaN日")
|
|
1237
|
+
kids.push(el('div', { key: String(it.id), className: 'xq-news-item' + (it.mark === 1 ? ' xq-important' : '') }, [
|
|
1238
|
+
el('div', { key: 't' }, it.text),
|
|
1239
|
+
el('div', { key: 'm', className: 'xq-news-time' }, fmtTime(it.created_at) + (it.mark === 1 ? ' · 重要' : ''))
|
|
1240
|
+
]))
|
|
1241
|
+
return
|
|
1242
|
+
}
|
|
1209
1243
|
const sameDay = function (a, b) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() }
|
|
1210
1244
|
const yest = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
|
1211
1245
|
let label = null
|
|
@@ -1559,7 +1593,7 @@ return {
|
|
|
1559
1593
|
onDoubleClick: onGripDblClick
|
|
1560
1594
|
}, '⤡')
|
|
1561
1595
|
])
|
|
1562
|
-
return el('div', { style: {
|
|
1596
|
+
return el('div', { style: { display: 'contents' } }, [badgeEl])
|
|
1563
1597
|
}
|
|
1564
1598
|
|
|
1565
1599
|
// ---------- 底部指数条(会话页,输入框下方氛围行) ----------
|
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.9",
|
|
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
|
@@ -386,10 +386,11 @@ exports.default = {
|
|
|
386
386
|
upBorderColor: pal.up, downBorderColor: pal.down, noChangeBorderColor: pal.text,
|
|
387
387
|
upWickColor: pal.up, downWickColor: pal.down, noChangeWickColor: pal.text
|
|
388
388
|
},
|
|
389
|
-
// OHLC
|
|
390
|
-
//
|
|
389
|
+
// OHLC 图例:K线常显最新一根(v1.22.5 起紧凑中文单行、字号 10,不再压蜡烛);
|
|
390
|
+
// 悬停十字光标时跟随显示对应根;分时在下方 minute 分支整体关闭。
|
|
391
|
+
// 默认英文 time/open/... 标题太长会折成两行——已换中文单行
|
|
391
392
|
tooltip: {
|
|
392
|
-
showRule: '
|
|
393
|
+
showRule: 'always',
|
|
393
394
|
title: { show: false },
|
|
394
395
|
legend: {
|
|
395
396
|
size: 10, color: pal.text,
|
|
@@ -438,6 +439,8 @@ exports.default = {
|
|
|
438
439
|
function klcSetup(K, boxRef, kind) {
|
|
439
440
|
const chart = K.init(boxRef.current, { styles: klcStyles(kind, klcPalette()) })
|
|
440
441
|
if (!chart) return null
|
|
442
|
+
// 卸载清理时 boxRef.current 已被 React 置 null,dispose 须用闭包捕获的节点
|
|
443
|
+
const box = boxRef.current
|
|
441
444
|
let ro = null
|
|
442
445
|
try {
|
|
443
446
|
ro = new ResizeObserver(function () { chart.resize() })
|
|
@@ -453,7 +456,8 @@ exports.default = {
|
|
|
453
456
|
dispose: function () {
|
|
454
457
|
if (ro) ro.disconnect()
|
|
455
458
|
if (mo) mo.disconnect()
|
|
456
|
-
|
|
459
|
+
// 卸载清理时 boxRef.current 已被 React 置 null(与 dblclick 监听同类坑),须闭包捕获节点
|
|
460
|
+
try { K.dispose(box) } catch (e) { /* 忽略 */ }
|
|
457
461
|
}
|
|
458
462
|
}
|
|
459
463
|
}
|
|
@@ -558,10 +562,15 @@ exports.default = {
|
|
|
558
562
|
const [hi, setHi] = React.useState(null)
|
|
559
563
|
const baseRef = React.useRef({ lastClose: lastClose })
|
|
560
564
|
baseRef.current = { lastClose: lastClose }
|
|
565
|
+
// 分时数据是详情页渐进渲染晚到的一块(quote+kline 先上屏、minute 随后 merge):
|
|
566
|
+
// effect 依赖 [] 曾导致空 items 挂载后数据到达永不刷新(空白图)。改为「空数据不挂图表,
|
|
567
|
+
// 数据到达(hasData 变 true)时重建」,彻底消除竞态窗口。
|
|
568
|
+
const hasData = items.length > 0
|
|
561
569
|
|
|
562
570
|
React.useEffect(function () {
|
|
563
571
|
const K = klcLib()
|
|
564
572
|
if (!K || !boxRef.current) { setNoLib(true); return undefined }
|
|
573
|
+
if (!hasData) return undefined // 数据未到:不建图表(等 hasData 变 true 重建)
|
|
565
574
|
// 自定义指标:昨收虚线 + 均价线(series normal → 单独附图,避免干扰价格轴刻度)
|
|
566
575
|
// 注意:registerIndicator 全局只注册一次,calc 闭包捕获的是首次挂载实例的 baseRef——
|
|
567
576
|
// 切换股票后旧 calc 仍读旧实例的昨收(宁德分时曾因此挂上茅台昨收 1304,y 轴被撑爆成直线)。
|
|
@@ -639,9 +648,10 @@ exports.default = {
|
|
|
639
648
|
try { chart.unsubscribeAction('onCrosshairChange') } catch (e) { /* 忽略 */ }
|
|
640
649
|
h.dispose()
|
|
641
650
|
}
|
|
642
|
-
}, [])
|
|
651
|
+
}, [hasData, props.symbol])
|
|
643
652
|
|
|
644
653
|
if (noLib) return klcNoLib()
|
|
654
|
+
if (!hasData) return el('div', { className: 'xq-chart-wrap', style: { display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--dsw-alias-label-secondary)', fontSize: 12 } }, '分时数据加载中…')
|
|
645
655
|
// 十字光标 tooltip(沿用 .xq-tip 卡片)
|
|
646
656
|
let tip = null
|
|
647
657
|
if (hi && hi.r) {
|
|
@@ -680,6 +690,12 @@ exports.default = {
|
|
|
680
690
|
const [indices, setIndices] = React.useState([])
|
|
681
691
|
const [hot, setHot] = React.useState([])
|
|
682
692
|
const [hotMarket, setHotMarket] = React.useState('cn')
|
|
693
|
+
// 竞态防护:热榜/搜索的响应序号(旧响应到达时序号已过期则丢弃,防慢请求覆盖新结果)
|
|
694
|
+
const hotSeq = React.useRef(0)
|
|
695
|
+
const searchSeq = React.useRef(0)
|
|
696
|
+
// 轮询闭包读当前市场(interval 闭包不随状态更新,直接捕获 state 会拿到 stale 值)
|
|
697
|
+
const hotMarketRef = React.useRef(hotMarket)
|
|
698
|
+
hotMarketRef.current = hotMarket
|
|
683
699
|
const [news, setNews] = React.useState([])
|
|
684
700
|
const [newsMore, setNewsMore] = React.useState(false) // 翻页加载中
|
|
685
701
|
const [newsNoMore, setNewsNoMore] = React.useState(false) // 已到最旧
|
|
@@ -735,10 +751,13 @@ exports.default = {
|
|
|
735
751
|
}
|
|
736
752
|
|
|
737
753
|
function refreshContent() {
|
|
754
|
+
const my = ++hotSeq.current
|
|
755
|
+
const market = hotMarketRef.current // 读当前市场(轮询闭包防 stale)
|
|
738
756
|
Promise.all([
|
|
739
|
-
call('hot', { market:
|
|
757
|
+
call('hot', { market: market, size: 10 }),
|
|
740
758
|
call('news', { count: 20 })
|
|
741
759
|
]).then(function (res) {
|
|
760
|
+
if (hotSeq.current !== my) return // 期间用户切了市场/触发过手动刷新:丢弃旧响应
|
|
742
761
|
setHot((res[0] && res[0].list) || [])
|
|
743
762
|
setNews((res[1] && res[1].items) || [])
|
|
744
763
|
setNewsMore(false)
|
|
@@ -875,15 +894,18 @@ exports.default = {
|
|
|
875
894
|
const q = searchQ.trim()
|
|
876
895
|
if (!q) return
|
|
877
896
|
setSearching(true)
|
|
897
|
+
const my = ++searchSeq.current
|
|
878
898
|
const p = searchMode === 'stock'
|
|
879
899
|
? call('search', { q: q, count: 8 })
|
|
880
900
|
: call('searchPosts', { q: q, count: 10 })
|
|
881
901
|
p.then(function (data) {
|
|
902
|
+
if (searchSeq.current !== my) return // 快速连搜:只认最后一次
|
|
882
903
|
setSearchRes((data && data.list) || [])
|
|
883
904
|
}).catch(function (e) {
|
|
905
|
+
if (searchSeq.current !== my) return
|
|
884
906
|
setErr(String((e && e.message) || e))
|
|
885
907
|
setSearchRes([])
|
|
886
|
-
}).then(function () { setSearching(false) })
|
|
908
|
+
}).then(function () { if (searchSeq.current === my) setSearching(false) })
|
|
887
909
|
}
|
|
888
910
|
|
|
889
911
|
function addManual() {
|
|
@@ -1166,9 +1188,14 @@ exports.default = {
|
|
|
1166
1188
|
key: m[0], className: hotMarket === m[0] ? 'xq-seg-on' : '',
|
|
1167
1189
|
onClick: function () {
|
|
1168
1190
|
setHotMarket(m[0])
|
|
1191
|
+
const my = ++hotSeq.current
|
|
1169
1192
|
call('hot', { market: m[0], size: 10 }).then(function (data) {
|
|
1193
|
+
if (hotSeq.current !== my) return // 快速连点:只认最后一次
|
|
1170
1194
|
setHot((data && data.list) || [])
|
|
1171
|
-
}).catch(function (e) {
|
|
1195
|
+
}).catch(function (e) {
|
|
1196
|
+
if (hotSeq.current !== my) return
|
|
1197
|
+
setErr(String((e && e.message) || e))
|
|
1198
|
+
})
|
|
1172
1199
|
}
|
|
1173
1200
|
}, m[1])
|
|
1174
1201
|
})),
|
|
@@ -1237,6 +1264,13 @@ exports.default = {
|
|
|
1237
1264
|
news.forEach(function (it) {
|
|
1238
1265
|
const d = new Date(it.created_at)
|
|
1239
1266
|
const now = new Date()
|
|
1267
|
+
if (isNaN(d.getTime())) { // 脏/缺失时间:跳过分组头(否则渲染 "NaN月NaN日")
|
|
1268
|
+
kids.push(el('div', { key: String(it.id), className: 'xq-news-item' + (it.mark === 1 ? ' xq-important' : '') }, [
|
|
1269
|
+
el('div', { key: 't' }, it.text),
|
|
1270
|
+
el('div', { key: 'm', className: 'xq-news-time' }, fmtTime(it.created_at) + (it.mark === 1 ? ' · 重要' : ''))
|
|
1271
|
+
]))
|
|
1272
|
+
return
|
|
1273
|
+
}
|
|
1240
1274
|
const sameDay = function (a, b) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() }
|
|
1241
1275
|
const yest = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
|
1242
1276
|
let label = null
|
|
@@ -1590,7 +1624,7 @@ exports.default = {
|
|
|
1590
1624
|
onDoubleClick: onGripDblClick
|
|
1591
1625
|
}, '⤡')
|
|
1592
1626
|
])
|
|
1593
|
-
return el('div', { style: {
|
|
1627
|
+
return el('div', { style: { display: 'contents' } }, [badgeEl])
|
|
1594
1628
|
}
|
|
1595
1629
|
|
|
1596
1630
|
// ---------- 底部指数条(会话页,输入框下方氛围行) ----------
|
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
|
})
|