dsh-xueqiu 1.7.2 → 1.9.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.en.md CHANGED
@@ -121,6 +121,8 @@ The data layer is defended in depth — safe to leave open for hours:
121
121
 
122
122
  ## Changelog
123
123
 
124
+ - **1.9.0** (2025-08-19) — Micro-interactions (tab fade, refresh spinner), news timeline with day-group anchors, badge hover watchlist preview, and crosshair axis labels (price + date/time) on K-line/minute charts.
125
+ - **1.8.0** (2025-08-19) — Pro polish: tabular-nums everywhere (no column jitter), red/green percent chips, and price-change flash animation on watchlist rows.
124
126
  - **1.7.2** (2025-08-19) — News tab pagination: scroll-to-bottom or "load earlier" fetches older items (max_id cursor, deduped); refresh resets to the latest page.
125
127
  - **1.7.1** (2025-08-19) — Fix static install (`dsh plugin add`) crashing `dsh web` startup: dual-mode RPC (harness.handle for dynamic runs, a loopback+same-origin fenced `/xq-rpc` webServer route for static installs) and a proper `__ModuleLoader__` CJS client bundle.
126
128
  - **1.7.0** (2025-08-19) — Hidden-tab polling pause + CN/HK/US market session hints.
package/README.md CHANGED
@@ -151,6 +151,13 @@ dsh-xueqiu/
151
151
 
152
152
  ## 📋 更新日志
153
153
 
154
+ - **1.9.0**(2025-08-19)
155
+ - 微交互:tab/详情切换淡入动画、刷新按钮旋转 loading 态。
156
+ - 快讯时间轴:左侧竖线 + 「今天/昨天/X月X日」分组锚点,长列表快速定位时间。
157
+ - 徽章 hover 预览:悬停 300ms 弹出自选速览(名称/现价/涨跌幅 chip,30s 缓存),点击展开面板,拖拽不误触。
158
+ - K线/分时十字光标轴标签:光标价位(右侧)+ 日期/时间(底部)底色读数块。
159
+ - **1.8.0**(2025-08-19)
160
+ - 专业感升级三件套:全界面等宽数字(tabular-nums,价格跳动不抖列);涨跌幅色块 chip(红涨/绿跌半透明底,扫一眼定位);自选行价格变动闪烁动画(涨红闪/跌绿闪,0.8s 渐隐)。
154
161
  - **1.7.2**(2025-08-19)
155
162
  - 新增:快讯 tab 翻页——滚动到底或点「加载更早」自动加载历史快讯(max_id 游标,自动去重),「刷新」重置回最新一页。
156
163
  - **1.7.1**(2025-08-19)
package/dynamic/client.js CHANGED
@@ -41,6 +41,36 @@ return {
41
41
  '.xq-up{color:var(--dsw-alias-state-error-primary);}\n' +
42
42
  '.xq-down{color:var(--dsw-alias-state-success-primary);}\n' +
43
43
  '.xq-flat{color:var(--dsw-alias-label-secondary);}\n' +
44
+ // 专业感三件套:①等宽数字 ②涨跌幅色块 ③价格闪烁
45
+ '.xq-dock,.xq-badge,.xq-ticker{font-variant-numeric:tabular-nums;}\n' +
46
+ '.xq-pct-chip{display:inline-block;min-width:52px;text-align:center;padding:1px 6px;border-radius:5px;font-size:11.5px;font-weight:600;}\n' +
47
+ '.xq-pct-chip.xq-up{background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);}\n' +
48
+ '.xq-pct-chip.xq-down{background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent);}\n' +
49
+ '.xq-pct-chip.xq-flat{background:var(--dsw-alias-bg-layer-2);}\n' +
50
+ '.xq-idx-pct.xq-pct-chip{min-width:46px;padding:0 5px;font-size:10.5px;}\n' +
51
+ '@keyframes xq-flash-up{0%{background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 18%, transparent);}100%{background:transparent;}}\n' +
52
+ '@keyframes xq-flash-down{0%{background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 18%, transparent);}100%{background:transparent;}}\n' +
53
+ '.xq-flash-up{animation:xq-flash-up .8s ease-out;}\n' +
54
+ '.xq-flash-down{animation:xq-flash-down .8s ease-out;}\n' +
55
+ // 微交互:内容切换淡入 + 刷新按钮旋转
56
+ '@keyframes xq-fade-in{from{opacity:0;transform:translateY(3px);}to{opacity:1;transform:none;}}\n' +
57
+ '.xq-view{animation:xq-fade-in .18s ease-out;}\n' +
58
+ '@keyframes xq-spin{to{transform:rotate(360deg);}}\n' +
59
+ '.xq-refresh-spin{display:inline-block;animation:xq-spin .8s linear infinite;}\n' +
60
+ '.xq-tab{transition:color .15s,border-color .15s;}\n' +
61
+ // 快讯时间轴:左侧竖线 + 时间锚点分组
62
+ '.xq-news-tl{position:relative;padding-left:14px;}\n' +
63
+ '.xq-news-tl::before{content:"";position:absolute;left:4px;top:6px;bottom:6px;width:1px;background:var(--dsw-alias-border-l2);}\n' +
64
+ '.xq-news-group{font-size:10.5px;color:var(--dsw-alias-label-secondary);margin:8px 0 5px;display:flex;align-items:center;gap:6px;}\n' +
65
+ '.xq-news-group::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-border-l2);margin-left:-13px;flex:none;}\n' +
66
+ '.xq-news-item{position:relative;}\n' +
67
+ // 徽章 hover 预览弹层
68
+ '.xq-badge-pop{position:fixed;z-index:1201;min-width:210px;max-width:280px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.28);padding:8px 10px;font-size:12px;}\n' +
69
+ '.xq-badge-pop-hd{font-size:10.5px;color:var(--dsw-alias-label-secondary);margin-bottom:5px;display:flex;justify-content:space-between;}\n' +
70
+ '.xq-bp-row{display:flex;align-items:center;gap:8px;padding:2.5px 0;cursor:pointer;border-radius:5px;}\n' +
71
+ '.xq-bp-row:hover{background:var(--dsw-alias-bg-layer-2);}\n' +
72
+ '.xq-bp-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}\n' +
73
+ '.xq-bp-val{font-weight:600;}\n' +
44
74
  '.xq-grid{display:grid;gap:0;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;overflow:hidden;margin-bottom:8px;}\n' +
45
75
  '.xq-grid-hd,.xq-grid-row{display:grid;grid-template-columns:1.6fr 1fr 1fr 1fr 84px;align-items:center;}\n' +
46
76
  '.xq-grid-hd{font-size:11px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2);padding:5px 8px;}\n' +
@@ -244,8 +274,8 @@ return {
244
274
  const rows = props.rows || []
245
275
  const [hi, setHi] = React.useState(null)
246
276
  if (!rows.length) return el('div', { className: 'xq-muted' }, '暂无K线数据')
247
- const W = 640, MAIN = 150, VOL = 44, PAD = 6, GAP = 4
248
- const H = MAIN + VOL + GAP + PAD
277
+ const W = 640, MAIN = 150, VOL = 44, PAD = 6, GAP = 4, LBL = 14
278
+ const H = MAIN + VOL + GAP + PAD + LBL
249
279
  let min = Infinity, max = -Infinity
250
280
  for (let i = 0; i < rows.length; i++) {
251
281
  const rlo = Number(rows[i].low), rhi = Number(rows[i].high)
@@ -259,7 +289,7 @@ return {
259
289
  const bw = Math.max(step * 0.66, 1)
260
290
  const yOf = function (v) { return PAD + (1 - (v - min) / range) * (MAIN - PAD * 2) }
261
291
  const vTop = PAD + MAIN + GAP
262
- const vBottom = H - PAD
292
+ const vBottom = H - PAD - LBL
263
293
  const vH = vBottom - vTop
264
294
  let vmax = 0
265
295
  for (let i = 0; i < rows.length; i++) {
@@ -319,9 +349,18 @@ return {
319
349
  const r = rows[hi]
320
350
  const cx = PAD + step * hi + step / 2
321
351
  const cy = yOf(Number(r.close))
352
+ // 轴标签:光标价位(右侧底色块)+ 日期(底部底色块)
353
+ const priceTxt = fmt(Number(r.close))
354
+ const pLblW = priceTxt.length * 5.6 + 8
355
+ const dateTxt = fmtFullDay(r.timestamp).slice(5) // MM-DD
356
+ const dLblW = dateTxt.length * 5.6 + 8
322
357
  cross = [
323
358
  el('line', { key: 'cv', x1: cx, y1: PAD, x2: cx, y2: vBottom, stroke: 'var(--dsw-alias-label-secondary)', strokeWidth: 0.6, strokeDasharray: '3 3', opacity: 0.7 }),
324
- el('line', { key: 'ch', x1: PAD, y1: cy, x2: W - PAD, y2: cy, stroke: 'var(--dsw-alias-label-secondary)', strokeWidth: 0.6, strokeDasharray: '3 3', opacity: 0.7 })
359
+ el('line', { key: 'ch', x1: PAD, y1: cy, x2: W - PAD, y2: cy, stroke: 'var(--dsw-alias-label-secondary)', strokeWidth: 0.6, strokeDasharray: '3 3', opacity: 0.7 }),
360
+ el('rect', { key: 'pbg', x: W - PAD - pLblW, y: cy - 7, width: pLblW, height: 14, rx: 3, fill: 'var(--dsw-alias-bg-layer-2)', stroke: 'var(--dsw-alias-border-l2)', strokeWidth: 0.5 }),
361
+ el('text', { key: 'ptx', x: W - PAD - pLblW / 2, y: cy + 3.5, fontSize: 9, textAnchor: 'middle', fill: 'var(--dsw-alias-label-primary)' }, priceTxt),
362
+ el('rect', { key: 'dbg', x: Math.min(Math.max(PAD, cx - dLblW / 2), W - PAD - dLblW), y: vBottom + 1, width: dLblW, height: 13, rx: 3, fill: 'var(--dsw-alias-bg-layer-2)', stroke: 'var(--dsw-alias-border-l2)', strokeWidth: 0.5 }),
363
+ el('text', { key: 'dtx', x: Math.min(Math.max(PAD, cx - dLblW / 2), W - PAD - dLblW) + dLblW / 2, y: vBottom + 10.5, fontSize: 9, textAnchor: 'middle', fill: 'var(--dsw-alias-label-primary)' }, dateTxt)
325
364
  ]
326
365
  tip = el('div', {
327
366
  key: 'tip', className: 'xq-tip',
@@ -379,7 +418,7 @@ return {
379
418
  const [hi, setHi] = React.useState(null)
380
419
  const lastClose = Number(props.lastClose)
381
420
  if (!items.length) return el('div', { className: 'xq-muted' }, '暂无分时数据')
382
- const W = 640, H = 170, PAD = 6
421
+ const W = 640, CH = 170, LBL = 14, H = CH + LBL, PAD = 6 // CH 图区 + LBL 底部时间标签条
383
422
  let min = Infinity, max = -Infinity
384
423
  for (let i = 0; i < items.length; i++) {
385
424
  const c = Number(items[i].current)
@@ -389,7 +428,7 @@ return {
389
428
  if (lastClose) { if (lastClose < min) min = lastClose; if (lastClose > max) max = lastClose }
390
429
  if (!isFinite(min) || !isFinite(max) || min === max) { min -= 1; max += 1 }
391
430
  const range = max - min
392
- const yOf = function (v) { return PAD + (1 - (v - min) / range) * (H - PAD * 2) }
431
+ const yOf = function (v) { return PAD + (1 - (v - min) / range) * (CH - PAD * 2) }
393
432
  const xOf = function (i) { return PAD + (i / (items.length - 1)) * (W - PAD * 2) }
394
433
  const pricePts = []
395
434
  const avgPts = []
@@ -417,9 +456,18 @@ return {
417
456
  const it = items[hi]
418
457
  const cx = xOf(hi), cy = yOf(Number(it.current))
419
458
  const pct = lastClose ? (Number(it.current) - lastClose) / lastClose * 100 : null
459
+ // 轴标签:光标价位(右侧)+ 时间(底部)
460
+ const priceTxt = fmt(Number(it.current))
461
+ const pLblW = priceTxt.length * 5.6 + 8
462
+ const timeTxt = it.timestamp ? fmtTime(it.timestamp) : ''
463
+ const dLblW = timeTxt.length * 5.6 + 8
420
464
  cross = [
421
- el('line', { key: 'cv', x1: cx, y1: PAD, x2: cx, y2: H - PAD, stroke: 'var(--dsw-alias-label-secondary)', strokeWidth: 0.6, strokeDasharray: '3 3', opacity: 0.7 }),
422
- el('line', { key: 'ch', x1: PAD, y1: cy, x2: W - PAD, y2: cy, stroke: 'var(--dsw-alias-label-secondary)', strokeWidth: 0.6, strokeDasharray: '3 3', opacity: 0.7 })
465
+ el('line', { key: 'cv', x1: cx, y1: PAD, x2: cx, y2: CH - PAD, stroke: 'var(--dsw-alias-label-secondary)', strokeWidth: 0.6, strokeDasharray: '3 3', opacity: 0.7 }),
466
+ el('line', { key: 'ch', x1: PAD, y1: cy, x2: W - PAD, y2: cy, stroke: 'var(--dsw-alias-label-secondary)', strokeWidth: 0.6, strokeDasharray: '3 3', opacity: 0.7 }),
467
+ el('rect', { key: 'pbg', x: W - PAD - pLblW, y: cy - 7, width: pLblW, height: 14, rx: 3, fill: 'var(--dsw-alias-bg-layer-2)', stroke: 'var(--dsw-alias-border-l2)', strokeWidth: 0.5 }),
468
+ el('text', { key: 'ptx', x: W - PAD - pLblW / 2, y: cy + 3.5, fontSize: 9, textAnchor: 'middle', fill: 'var(--dsw-alias-label-primary)' }, priceTxt),
469
+ el('rect', { key: 'dbg', x: Math.min(Math.max(PAD, cx - dLblW / 2), W - PAD - dLblW), y: CH + 1, width: dLblW, height: 13, rx: 3, fill: 'var(--dsw-alias-bg-layer-2)', stroke: 'var(--dsw-alias-border-l2)', strokeWidth: 0.5 }),
470
+ el('text', { key: 'dtx', x: Math.min(Math.max(PAD, cx - dLblW / 2), W - PAD - dLblW) + dLblW / 2, y: CH + 10.5, fontSize: 9, textAnchor: 'middle', fill: 'var(--dsw-alias-label-primary)' }, timeTxt)
423
471
  ]
424
472
  tip = el('div', {
425
473
  key: 'tip', className: 'xq-tip',
@@ -459,6 +507,9 @@ return {
459
507
  function XueqiuPanel() {
460
508
  const [watchlist, setWatchlist] = React.useState([])
461
509
  const [quotes, setQuotes] = React.useState([])
510
+ // 价格闪烁:记录上一价与变化方向(symbol -> 1|-1),行渲染时挂一次性动画类
511
+ const prevPrices = React.useRef({})
512
+ const [flashDir, setFlashDir] = React.useState({})
462
513
  const [indices, setIndices] = React.useState([])
463
514
  const [hot, setHot] = React.useState([])
464
515
  const [hotMarket, setHotMarket] = React.useState('cn')
@@ -495,7 +546,15 @@ return {
495
546
  setErr('')
496
547
  if (!wl.length) return
497
548
  return call('quote', { symbols: wl }).then(function (data) {
498
- setQuotes((data && data.list) || [])
549
+ const list = (data && data.list) || []
550
+ const dirs = {}
551
+ list.forEach(function (q) {
552
+ const prev = prevPrices.current[q.symbol]
553
+ if (prev !== undefined && q.current !== prev) dirs[q.symbol] = q.current > prev ? 1 : -1
554
+ prevPrices.current[q.symbol] = q.current
555
+ })
556
+ if (Object.keys(dirs).length) setFlashDir(dirs)
557
+ setQuotes(list)
499
558
  })
500
559
  }).catch(function (e) {
501
560
  setErr(String((e && e.message) || e))
@@ -656,12 +715,12 @@ return {
656
715
  }).concat([
657
716
  el('span', { key: 'sp', className: 'xq-spacer' }),
658
717
  el('button', {
659
- key: 'btn', className: 'xq-btn', disabled: loading,
718
+ key: 'btn', className: 'xq-btn', disabled: loading, title: '刷新全部数据',
660
719
  onClick: function () {
661
720
  setLoading(true)
662
721
  Promise.all([refreshMarket(), refreshContent()]).then(function () { setLoading(false) }).catch(function () { setLoading(false) })
663
722
  }
664
- }, '刷新')
723
+ }, loading ? [el('span', { key: 's', className: 'xq-refresh-spin' }, '⟳'), ' 刷新中'] : '刷新')
665
724
  ]))
666
725
 
667
726
  const errBox = err ? el('div', { className: 'xq-err' }, err) : null
@@ -689,7 +748,7 @@ return {
689
748
  el('div', { key: 'n', className: 'xq-idx-name' }, q.name),
690
749
  el('div', { key: 'r', className: 'xq-idx-row' }, [
691
750
  el('span', { key: 'c', className: 'xq-idx-cur ' + colorOf(q.percent) }, fmt(q.current)),
692
- el('span', { key: 'p', className: 'xq-idx-pct ' + colorOf(q.percent) }, fmtPct(q.percent))
751
+ el('span', { key: 'p', className: 'xq-idx-pct xq-pct-chip ' + colorOf(q.percent) }, fmtPct(q.percent))
693
752
  ])
694
753
  ])
695
754
  })
@@ -699,8 +758,8 @@ return {
699
758
  el('div', { key: 'a', className: 'xq-name' }, q.name),
700
759
  el('div', { key: 'b', className: 'xq-sub' }, q.symbol)
701
760
  ]),
702
- el('div', { key: 'c', className: colorOf(q.percent) }, fmt(q.current)),
703
- el('div', { key: 'p', className: colorOf(q.percent) }, fmtPct(q.percent)),
761
+ el('div', { key: 'c', className: colorOf(q.percent) + (flashDir[q.symbol] === 1 ? ' xq-flash-up' : flashDir[q.symbol] === -1 ? ' xq-flash-down' : '') }, fmt(q.current)),
762
+ el('div', { key: 'p' }, el('span', { key: 'pp', className: 'xq-pct-chip ' + colorOf(q.percent) }, fmtPct(q.percent))),
704
763
  el('div', { key: 'v', className: 'xq-sub' }, fmtVol(q.volume)),
705
764
  el('div', { key: 'a2', className: 'xq-actions' }, [
706
765
  el('button', { key: 'd', className: 'xq-btn-mini', onClick: function (e) { e.stopPropagation(); openDetail(q.symbol) } }, '详情'),
@@ -886,11 +945,25 @@ return {
886
945
  }
887
946
 
888
947
  function NewsTab() {
889
- const kids = news.map(function (it) {
890
- return el('div', { key: String(it.id), className: 'xq-news-item' + (it.mark === 1 ? ' xq-important' : '') }, [
948
+ // 时间轴:按「今天/昨天/M月D日」分组,每组一个锚点头
949
+ const kids = []
950
+ let lastDay = null
951
+ news.forEach(function (it) {
952
+ const d = new Date(it.created_at)
953
+ const now = new Date()
954
+ const sameDay = function (a, b) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() }
955
+ const yest = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
956
+ let label = null
957
+ const dayKey = d.getFullYear() + '-' + d.getMonth() + '-' + d.getDate()
958
+ if (dayKey !== lastDay) {
959
+ lastDay = dayKey
960
+ label = sameDay(d, now) ? '今天' : (sameDay(d, yest) ? '昨天' : (d.getMonth() + 1) + '月' + d.getDate() + '日')
961
+ }
962
+ if (label) kids.push(el('div', { key: 'g' + dayKey, className: 'xq-news-group' }, label))
963
+ kids.push(el('div', { key: String(it.id), className: 'xq-news-item' + (it.mark === 1 ? ' xq-important' : '') }, [
891
964
  el('div', { key: 't' }, it.text),
892
965
  el('div', { key: 'm', className: 'xq-news-time' }, fmtTime(it.created_at) + (it.mark === 1 ? ' · 重要' : ''))
893
- ])
966
+ ]))
894
967
  })
895
968
  // 滚动到底自动加载;手动入口兜底(滚轮惯性/触控板场景)
896
969
  kids.push(el('div', {
@@ -900,7 +973,7 @@ return {
900
973
  }, newsMore ? '加载中…' : (newsNoMore ? '没有更早的快讯了' : '加载更早 ↑')))
901
974
  return news.length
902
975
  ? el('div', {
903
- className: 'xq-news',
976
+ className: 'xq-news xq-news-tl',
904
977
  onScroll: function (e) {
905
978
  const n = e.target
906
979
  if (n.scrollTop + n.clientHeight >= n.scrollHeight - 40) loadMoreNews()
@@ -922,7 +995,8 @@ return {
922
995
  content = NewsTab()
923
996
  }
924
997
 
925
- return el('div', null, [tabs, errBox, content])
998
+ // key 变化触发 xq-fade-in 重放:tab/详情切换有轻微淡入
999
+ return el('div', null, [tabs, errBox, el('div', { key: 'v-' + (view ? 'd' : tab), className: 'xq-view' }, content)])
926
1000
  }
927
1001
 
928
1002
  // ---------- 嵌入式主面板(conversation.input.dock):输入框上方一整行 ----------
@@ -1028,6 +1102,41 @@ return {
1028
1102
  const [idx, setIdx] = React.useState([])
1029
1103
  const [mOpen, setMOpen] = React.useState(true)
1030
1104
  const [, force] = React.useState(0)
1105
+ // hover 预览:延迟出现,拖拽/点击不误触;数据 30s 内复用
1106
+ const [pop, setPop] = React.useState(null) // { quotes: [], at: ts }
1107
+ const hoverTimer = React.useRef(null)
1108
+ const popCache = React.useRef({ at: 0, list: [] })
1109
+
1110
+ function loadPop() {
1111
+ const now = Date.now()
1112
+ if (now - popCache.current.at < 30000 && popCache.current.list.length) {
1113
+ setPop({ quotes: popCache.current.list, at: popCache.current.at })
1114
+ return
1115
+ }
1116
+ // 两次 RPC(watchlist + quotes),走既有节流管线
1117
+ call('watchlist.get', {}).then(function (wl) {
1118
+ const symbols = (wl && wl.symbols) || []
1119
+ if (!symbols.length) { setPop({ quotes: [], at: now }); return }
1120
+ return call('quote', { symbols: symbols }).then(function (data) {
1121
+ const list = (data && data.list) || []
1122
+ popCache.current = { at: now, list: list }
1123
+ setPop({ quotes: list, at: now })
1124
+ })
1125
+ }).catch(function () { /* 静默 */ })
1126
+ }
1127
+ function onEnter() {
1128
+ if (hoverTimer.current) return
1129
+ hoverTimer.current = setTimeout(function () {
1130
+ hoverTimer.current = null
1131
+ if (!badgeDrag) loadPop()
1132
+ }, 300)
1133
+ }
1134
+ function onLeave() {
1135
+ if (hoverTimer.current) { clearTimeout(hoverTimer.current); hoverTimer.current = null }
1136
+ setPop(null)
1137
+ }
1138
+ React.useEffect(function () { return function () { if (hoverTimer.current) clearTimeout(hoverTimer.current) } }, [])
1139
+
1031
1140
  React.useEffect(function () {
1032
1141
  let alive = true
1033
1142
  function refresh() {
@@ -1091,11 +1200,37 @@ return {
1091
1200
  const style = ui.badgePos
1092
1201
  ? { left: ui.badgePos.x, top: ui.badgePos.y, right: 'auto', bottom: 'auto' }
1093
1202
  : null
1094
- return el('div', {
1203
+ // hover 预览弹层:跟随徽章位置(有拖拽坐标用之,否则用默认右下角),显示在徽章上方
1204
+ let popStyle = null
1205
+ if (ui.badgePos) {
1206
+ popStyle = { left: Math.max(6, ui.badgePos.x - 60), top: Math.max(6, ui.badgePos.y - 210), right: 'auto', bottom: 'auto' }
1207
+ } else {
1208
+ popStyle = { right: 16, bottom: 104, left: 'auto', top: 'auto' }
1209
+ }
1210
+ let popEl = null
1211
+ if (pop) {
1212
+ const rows = pop.quotes.slice(0, 8).map(function (q) {
1213
+ return el('div', {
1214
+ key: q.symbol, className: 'xq-bp-row', title: '点击查看详情',
1215
+ onClick: function (e) { e.stopPropagation(); setPop(null); ui.set({ open: true }) }
1216
+ }, [
1217
+ el('span', { key: 'n', className: 'xq-bp-name' }, q.name || q.symbol),
1218
+ el('span', { key: 'v', className: 'xq-bp-val ' + colorOf(q.percent) }, fmt(q.current)),
1219
+ el('span', { key: 'p', className: 'xq-pct-chip ' + colorOf(q.percent) }, fmtPct(q.percent))
1220
+ ])
1221
+ })
1222
+ popEl = el('div', { key: 'pop', className: 'xq-badge-pop', style: popStyle, onMouseEnter: onEnter, onMouseLeave: onLeave }, [
1223
+ el('div', { key: 'hd', className: 'xq-badge-pop-hd' }, [el('span', { key: 't' }, '自选 · ' + pop.quotes.length + ' 只'), el('span', { key: 'h' }, '悬停查看 · 点击展开')]),
1224
+ rows.length ? rows : el('div', { key: 'e', className: 'xq-muted' }, '自选为空')
1225
+ ])
1226
+ }
1227
+
1228
+ const badgeEl = el('div', {
1095
1229
  className: 'xq-badge',
1096
1230
  style: style,
1097
- title: '点击开合行情面板 · 拖动调整位置',
1098
- onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, onPointerCancel: onUp
1231
+ title: '点击开合行情面板 · 拖动调整位置 · 悬停速览自选',
1232
+ onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, onPointerCancel: onUp,
1233
+ onMouseEnter: onEnter, onMouseLeave: onLeave
1099
1234
  }, [
1100
1235
  el('span', { key: 'logo' }, [el('b', { key: 'b' }, '雪球'), 'mini']),
1101
1236
  el('span', { key: 'st', className: 'xq-status' + (aSession() === '盘中' ? ' xq-live' : ''), title: sessionsText() }, (function () { var s = aSession(); return s === '盘中' ? '● 盘中' : (s || (mOpen ? '● 盘中' : '休市')) })()),
@@ -1110,6 +1245,8 @@ return {
1110
1245
  ]) : null,
1111
1246
  el('span', { key: 'ct', className: 'xq-caret' }, ui.open ? '▾' : '▴')
1112
1247
  ])
1248
+ // 徽章 + 弹层同层渲染(都为 fixed 定位,互不嵌套,拖拽事件不穿透到弹层)
1249
+ return el('div', { style: { contents: 'display' } }, popEl ? [badgeEl, popEl] : [badgeEl])
1113
1250
  }
1114
1251
 
1115
1252
  // ---------- 底部指数条(会话页,输入框下方氛围行) ----------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-xueqiu",
3
- "version": "1.7.2",
3
+ "version": "1.9.0",
4
4
  "description": "雪球 mini 行情面板 — DeepSeek Harness 免登录 A股/港美股实时行情、K线(蜡烛/成交量/均线)、分时图、热榜、搜索、7x24 快讯、热议用户。嵌入式面板 + 可拖拽行情徽标,内置请求节流、TTL 缓存与 Cookie 自动续期。",
5
5
  "keywords": [
6
6
  "dsh",
@@ -60,6 +60,36 @@ exports.default = {
60
60
  '.xq-up{color:var(--dsw-alias-state-error-primary);}\n' +
61
61
  '.xq-down{color:var(--dsw-alias-state-success-primary);}\n' +
62
62
  '.xq-flat{color:var(--dsw-alias-label-secondary);}\n' +
63
+ // 专业感三件套:①等宽数字 ②涨跌幅色块 ③价格闪烁
64
+ '.xq-dock,.xq-badge,.xq-ticker{font-variant-numeric:tabular-nums;}\n' +
65
+ '.xq-pct-chip{display:inline-block;min-width:52px;text-align:center;padding:1px 6px;border-radius:5px;font-size:11.5px;font-weight:600;}\n' +
66
+ '.xq-pct-chip.xq-up{background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);}\n' +
67
+ '.xq-pct-chip.xq-down{background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent);}\n' +
68
+ '.xq-pct-chip.xq-flat{background:var(--dsw-alias-bg-layer-2);}\n' +
69
+ '.xq-idx-pct.xq-pct-chip{min-width:46px;padding:0 5px;font-size:10.5px;}\n' +
70
+ '@keyframes xq-flash-up{0%{background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 18%, transparent);}100%{background:transparent;}}\n' +
71
+ '@keyframes xq-flash-down{0%{background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 18%, transparent);}100%{background:transparent;}}\n' +
72
+ '.xq-flash-up{animation:xq-flash-up .8s ease-out;}\n' +
73
+ '.xq-flash-down{animation:xq-flash-down .8s ease-out;}\n' +
74
+ // 微交互:内容切换淡入 + 刷新按钮旋转
75
+ '@keyframes xq-fade-in{from{opacity:0;transform:translateY(3px);}to{opacity:1;transform:none;}}\n' +
76
+ '.xq-view{animation:xq-fade-in .18s ease-out;}\n' +
77
+ '@keyframes xq-spin{to{transform:rotate(360deg);}}\n' +
78
+ '.xq-refresh-spin{display:inline-block;animation:xq-spin .8s linear infinite;}\n' +
79
+ '.xq-tab{transition:color .15s,border-color .15s;}\n' +
80
+ // 快讯时间轴:左侧竖线 + 时间锚点分组
81
+ '.xq-news-tl{position:relative;padding-left:14px;}\n' +
82
+ '.xq-news-tl::before{content:"";position:absolute;left:4px;top:6px;bottom:6px;width:1px;background:var(--dsw-alias-border-l2);}\n' +
83
+ '.xq-news-group{font-size:10.5px;color:var(--dsw-alias-label-secondary);margin:8px 0 5px;display:flex;align-items:center;gap:6px;}\n' +
84
+ '.xq-news-group::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-border-l2);margin-left:-13px;flex:none;}\n' +
85
+ '.xq-news-item{position:relative;}\n' +
86
+ // 徽章 hover 预览弹层
87
+ '.xq-badge-pop{position:fixed;z-index:1201;min-width:210px;max-width:280px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.28);padding:8px 10px;font-size:12px;}\n' +
88
+ '.xq-badge-pop-hd{font-size:10.5px;color:var(--dsw-alias-label-secondary);margin-bottom:5px;display:flex;justify-content:space-between;}\n' +
89
+ '.xq-bp-row{display:flex;align-items:center;gap:8px;padding:2.5px 0;cursor:pointer;border-radius:5px;}\n' +
90
+ '.xq-bp-row:hover{background:var(--dsw-alias-bg-layer-2);}\n' +
91
+ '.xq-bp-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}\n' +
92
+ '.xq-bp-val{font-weight:600;}\n' +
63
93
  '.xq-grid{display:grid;gap:0;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;overflow:hidden;margin-bottom:8px;}\n' +
64
94
  '.xq-grid-hd,.xq-grid-row{display:grid;grid-template-columns:1.6fr 1fr 1fr 1fr 84px;align-items:center;}\n' +
65
95
  '.xq-grid-hd{font-size:11px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2);padding:5px 8px;}\n' +
@@ -263,8 +293,8 @@ exports.default = {
263
293
  const rows = props.rows || []
264
294
  const [hi, setHi] = React.useState(null)
265
295
  if (!rows.length) return el('div', { className: 'xq-muted' }, '暂无K线数据')
266
- const W = 640, MAIN = 150, VOL = 44, PAD = 6, GAP = 4
267
- const H = MAIN + VOL + GAP + PAD
296
+ const W = 640, MAIN = 150, VOL = 44, PAD = 6, GAP = 4, LBL = 14
297
+ const H = MAIN + VOL + GAP + PAD + LBL
268
298
  let min = Infinity, max = -Infinity
269
299
  for (let i = 0; i < rows.length; i++) {
270
300
  const rlo = Number(rows[i].low), rhi = Number(rows[i].high)
@@ -278,7 +308,7 @@ exports.default = {
278
308
  const bw = Math.max(step * 0.66, 1)
279
309
  const yOf = function (v) { return PAD + (1 - (v - min) / range) * (MAIN - PAD * 2) }
280
310
  const vTop = PAD + MAIN + GAP
281
- const vBottom = H - PAD
311
+ const vBottom = H - PAD - LBL
282
312
  const vH = vBottom - vTop
283
313
  let vmax = 0
284
314
  for (let i = 0; i < rows.length; i++) {
@@ -338,9 +368,18 @@ exports.default = {
338
368
  const r = rows[hi]
339
369
  const cx = PAD + step * hi + step / 2
340
370
  const cy = yOf(Number(r.close))
371
+ // 轴标签:光标价位(右侧底色块)+ 日期(底部底色块)
372
+ const priceTxt = fmt(Number(r.close))
373
+ const pLblW = priceTxt.length * 5.6 + 8
374
+ const dateTxt = fmtFullDay(r.timestamp).slice(5) // MM-DD
375
+ const dLblW = dateTxt.length * 5.6 + 8
341
376
  cross = [
342
377
  el('line', { key: 'cv', x1: cx, y1: PAD, x2: cx, y2: vBottom, stroke: 'var(--dsw-alias-label-secondary)', strokeWidth: 0.6, strokeDasharray: '3 3', opacity: 0.7 }),
343
- el('line', { key: 'ch', x1: PAD, y1: cy, x2: W - PAD, y2: cy, stroke: 'var(--dsw-alias-label-secondary)', strokeWidth: 0.6, strokeDasharray: '3 3', opacity: 0.7 })
378
+ el('line', { key: 'ch', x1: PAD, y1: cy, x2: W - PAD, y2: cy, stroke: 'var(--dsw-alias-label-secondary)', strokeWidth: 0.6, strokeDasharray: '3 3', opacity: 0.7 }),
379
+ el('rect', { key: 'pbg', x: W - PAD - pLblW, y: cy - 7, width: pLblW, height: 14, rx: 3, fill: 'var(--dsw-alias-bg-layer-2)', stroke: 'var(--dsw-alias-border-l2)', strokeWidth: 0.5 }),
380
+ el('text', { key: 'ptx', x: W - PAD - pLblW / 2, y: cy + 3.5, fontSize: 9, textAnchor: 'middle', fill: 'var(--dsw-alias-label-primary)' }, priceTxt),
381
+ el('rect', { key: 'dbg', x: Math.min(Math.max(PAD, cx - dLblW / 2), W - PAD - dLblW), y: vBottom + 1, width: dLblW, height: 13, rx: 3, fill: 'var(--dsw-alias-bg-layer-2)', stroke: 'var(--dsw-alias-border-l2)', strokeWidth: 0.5 }),
382
+ el('text', { key: 'dtx', x: Math.min(Math.max(PAD, cx - dLblW / 2), W - PAD - dLblW) + dLblW / 2, y: vBottom + 10.5, fontSize: 9, textAnchor: 'middle', fill: 'var(--dsw-alias-label-primary)' }, dateTxt)
344
383
  ]
345
384
  tip = el('div', {
346
385
  key: 'tip', className: 'xq-tip',
@@ -398,7 +437,7 @@ exports.default = {
398
437
  const [hi, setHi] = React.useState(null)
399
438
  const lastClose = Number(props.lastClose)
400
439
  if (!items.length) return el('div', { className: 'xq-muted' }, '暂无分时数据')
401
- const W = 640, H = 170, PAD = 6
440
+ const W = 640, CH = 170, LBL = 14, H = CH + LBL, PAD = 6 // CH 图区 + LBL 底部时间标签条
402
441
  let min = Infinity, max = -Infinity
403
442
  for (let i = 0; i < items.length; i++) {
404
443
  const c = Number(items[i].current)
@@ -408,7 +447,7 @@ exports.default = {
408
447
  if (lastClose) { if (lastClose < min) min = lastClose; if (lastClose > max) max = lastClose }
409
448
  if (!isFinite(min) || !isFinite(max) || min === max) { min -= 1; max += 1 }
410
449
  const range = max - min
411
- const yOf = function (v) { return PAD + (1 - (v - min) / range) * (H - PAD * 2) }
450
+ const yOf = function (v) { return PAD + (1 - (v - min) / range) * (CH - PAD * 2) }
412
451
  const xOf = function (i) { return PAD + (i / (items.length - 1)) * (W - PAD * 2) }
413
452
  const pricePts = []
414
453
  const avgPts = []
@@ -436,9 +475,18 @@ exports.default = {
436
475
  const it = items[hi]
437
476
  const cx = xOf(hi), cy = yOf(Number(it.current))
438
477
  const pct = lastClose ? (Number(it.current) - lastClose) / lastClose * 100 : null
478
+ // 轴标签:光标价位(右侧)+ 时间(底部)
479
+ const priceTxt = fmt(Number(it.current))
480
+ const pLblW = priceTxt.length * 5.6 + 8
481
+ const timeTxt = it.timestamp ? fmtTime(it.timestamp) : ''
482
+ const dLblW = timeTxt.length * 5.6 + 8
439
483
  cross = [
440
- el('line', { key: 'cv', x1: cx, y1: PAD, x2: cx, y2: H - PAD, stroke: 'var(--dsw-alias-label-secondary)', strokeWidth: 0.6, strokeDasharray: '3 3', opacity: 0.7 }),
441
- el('line', { key: 'ch', x1: PAD, y1: cy, x2: W - PAD, y2: cy, stroke: 'var(--dsw-alias-label-secondary)', strokeWidth: 0.6, strokeDasharray: '3 3', opacity: 0.7 })
484
+ el('line', { key: 'cv', x1: cx, y1: PAD, x2: cx, y2: CH - PAD, stroke: 'var(--dsw-alias-label-secondary)', strokeWidth: 0.6, strokeDasharray: '3 3', opacity: 0.7 }),
485
+ el('line', { key: 'ch', x1: PAD, y1: cy, x2: W - PAD, y2: cy, stroke: 'var(--dsw-alias-label-secondary)', strokeWidth: 0.6, strokeDasharray: '3 3', opacity: 0.7 }),
486
+ el('rect', { key: 'pbg', x: W - PAD - pLblW, y: cy - 7, width: pLblW, height: 14, rx: 3, fill: 'var(--dsw-alias-bg-layer-2)', stroke: 'var(--dsw-alias-border-l2)', strokeWidth: 0.5 }),
487
+ el('text', { key: 'ptx', x: W - PAD - pLblW / 2, y: cy + 3.5, fontSize: 9, textAnchor: 'middle', fill: 'var(--dsw-alias-label-primary)' }, priceTxt),
488
+ el('rect', { key: 'dbg', x: Math.min(Math.max(PAD, cx - dLblW / 2), W - PAD - dLblW), y: CH + 1, width: dLblW, height: 13, rx: 3, fill: 'var(--dsw-alias-bg-layer-2)', stroke: 'var(--dsw-alias-border-l2)', strokeWidth: 0.5 }),
489
+ el('text', { key: 'dtx', x: Math.min(Math.max(PAD, cx - dLblW / 2), W - PAD - dLblW) + dLblW / 2, y: CH + 10.5, fontSize: 9, textAnchor: 'middle', fill: 'var(--dsw-alias-label-primary)' }, timeTxt)
442
490
  ]
443
491
  tip = el('div', {
444
492
  key: 'tip', className: 'xq-tip',
@@ -478,6 +526,9 @@ exports.default = {
478
526
  function XueqiuPanel() {
479
527
  const [watchlist, setWatchlist] = React.useState([])
480
528
  const [quotes, setQuotes] = React.useState([])
529
+ // 价格闪烁:记录上一价与变化方向(symbol -> 1|-1),行渲染时挂一次性动画类
530
+ const prevPrices = React.useRef({})
531
+ const [flashDir, setFlashDir] = React.useState({})
481
532
  const [indices, setIndices] = React.useState([])
482
533
  const [hot, setHot] = React.useState([])
483
534
  const [hotMarket, setHotMarket] = React.useState('cn')
@@ -514,7 +565,15 @@ exports.default = {
514
565
  setErr('')
515
566
  if (!wl.length) return
516
567
  return call('quote', { symbols: wl }).then(function (data) {
517
- setQuotes((data && data.list) || [])
568
+ const list = (data && data.list) || []
569
+ const dirs = {}
570
+ list.forEach(function (q) {
571
+ const prev = prevPrices.current[q.symbol]
572
+ if (prev !== undefined && q.current !== prev) dirs[q.symbol] = q.current > prev ? 1 : -1
573
+ prevPrices.current[q.symbol] = q.current
574
+ })
575
+ if (Object.keys(dirs).length) setFlashDir(dirs)
576
+ setQuotes(list)
518
577
  })
519
578
  }).catch(function (e) {
520
579
  setErr(String((e && e.message) || e))
@@ -675,12 +734,12 @@ exports.default = {
675
734
  }).concat([
676
735
  el('span', { key: 'sp', className: 'xq-spacer' }),
677
736
  el('button', {
678
- key: 'btn', className: 'xq-btn', disabled: loading,
737
+ key: 'btn', className: 'xq-btn', disabled: loading, title: '刷新全部数据',
679
738
  onClick: function () {
680
739
  setLoading(true)
681
740
  Promise.all([refreshMarket(), refreshContent()]).then(function () { setLoading(false) }).catch(function () { setLoading(false) })
682
741
  }
683
- }, '刷新')
742
+ }, loading ? [el('span', { key: 's', className: 'xq-refresh-spin' }, '⟳'), ' 刷新中'] : '刷新')
684
743
  ]))
685
744
 
686
745
  const errBox = err ? el('div', { className: 'xq-err' }, err) : null
@@ -708,7 +767,7 @@ exports.default = {
708
767
  el('div', { key: 'n', className: 'xq-idx-name' }, q.name),
709
768
  el('div', { key: 'r', className: 'xq-idx-row' }, [
710
769
  el('span', { key: 'c', className: 'xq-idx-cur ' + colorOf(q.percent) }, fmt(q.current)),
711
- el('span', { key: 'p', className: 'xq-idx-pct ' + colorOf(q.percent) }, fmtPct(q.percent))
770
+ el('span', { key: 'p', className: 'xq-idx-pct xq-pct-chip ' + colorOf(q.percent) }, fmtPct(q.percent))
712
771
  ])
713
772
  ])
714
773
  })
@@ -718,8 +777,8 @@ exports.default = {
718
777
  el('div', { key: 'a', className: 'xq-name' }, q.name),
719
778
  el('div', { key: 'b', className: 'xq-sub' }, q.symbol)
720
779
  ]),
721
- el('div', { key: 'c', className: colorOf(q.percent) }, fmt(q.current)),
722
- el('div', { key: 'p', className: colorOf(q.percent) }, fmtPct(q.percent)),
780
+ el('div', { key: 'c', className: colorOf(q.percent) + (flashDir[q.symbol] === 1 ? ' xq-flash-up' : flashDir[q.symbol] === -1 ? ' xq-flash-down' : '') }, fmt(q.current)),
781
+ el('div', { key: 'p' }, el('span', { key: 'pp', className: 'xq-pct-chip ' + colorOf(q.percent) }, fmtPct(q.percent))),
723
782
  el('div', { key: 'v', className: 'xq-sub' }, fmtVol(q.volume)),
724
783
  el('div', { key: 'a2', className: 'xq-actions' }, [
725
784
  el('button', { key: 'd', className: 'xq-btn-mini', onClick: function (e) { e.stopPropagation(); openDetail(q.symbol) } }, '详情'),
@@ -905,11 +964,25 @@ exports.default = {
905
964
  }
906
965
 
907
966
  function NewsTab() {
908
- const kids = news.map(function (it) {
909
- return el('div', { key: String(it.id), className: 'xq-news-item' + (it.mark === 1 ? ' xq-important' : '') }, [
967
+ // 时间轴:按「今天/昨天/M月D日」分组,每组一个锚点头
968
+ const kids = []
969
+ let lastDay = null
970
+ news.forEach(function (it) {
971
+ const d = new Date(it.created_at)
972
+ const now = new Date()
973
+ const sameDay = function (a, b) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() }
974
+ const yest = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
975
+ let label = null
976
+ const dayKey = d.getFullYear() + '-' + d.getMonth() + '-' + d.getDate()
977
+ if (dayKey !== lastDay) {
978
+ lastDay = dayKey
979
+ label = sameDay(d, now) ? '今天' : (sameDay(d, yest) ? '昨天' : (d.getMonth() + 1) + '月' + d.getDate() + '日')
980
+ }
981
+ if (label) kids.push(el('div', { key: 'g' + dayKey, className: 'xq-news-group' }, label))
982
+ kids.push(el('div', { key: String(it.id), className: 'xq-news-item' + (it.mark === 1 ? ' xq-important' : '') }, [
910
983
  el('div', { key: 't' }, it.text),
911
984
  el('div', { key: 'm', className: 'xq-news-time' }, fmtTime(it.created_at) + (it.mark === 1 ? ' · 重要' : ''))
912
- ])
985
+ ]))
913
986
  })
914
987
  // 滚动到底自动加载;手动入口兜底(滚轮惯性/触控板场景)
915
988
  kids.push(el('div', {
@@ -919,7 +992,7 @@ exports.default = {
919
992
  }, newsMore ? '加载中…' : (newsNoMore ? '没有更早的快讯了' : '加载更早 ↑')))
920
993
  return news.length
921
994
  ? el('div', {
922
- className: 'xq-news',
995
+ className: 'xq-news xq-news-tl',
923
996
  onScroll: function (e) {
924
997
  const n = e.target
925
998
  if (n.scrollTop + n.clientHeight >= n.scrollHeight - 40) loadMoreNews()
@@ -941,7 +1014,8 @@ exports.default = {
941
1014
  content = NewsTab()
942
1015
  }
943
1016
 
944
- return el('div', null, [tabs, errBox, content])
1017
+ // key 变化触发 xq-fade-in 重放:tab/详情切换有轻微淡入
1018
+ return el('div', null, [tabs, errBox, el('div', { key: 'v-' + (view ? 'd' : tab), className: 'xq-view' }, content)])
945
1019
  }
946
1020
 
947
1021
  // ---------- 嵌入式主面板(conversation.input.dock):输入框上方一整行 ----------
@@ -1047,6 +1121,41 @@ exports.default = {
1047
1121
  const [idx, setIdx] = React.useState([])
1048
1122
  const [mOpen, setMOpen] = React.useState(true)
1049
1123
  const [, force] = React.useState(0)
1124
+ // hover 预览:延迟出现,拖拽/点击不误触;数据 30s 内复用
1125
+ const [pop, setPop] = React.useState(null) // { quotes: [], at: ts }
1126
+ const hoverTimer = React.useRef(null)
1127
+ const popCache = React.useRef({ at: 0, list: [] })
1128
+
1129
+ function loadPop() {
1130
+ const now = Date.now()
1131
+ if (now - popCache.current.at < 30000 && popCache.current.list.length) {
1132
+ setPop({ quotes: popCache.current.list, at: popCache.current.at })
1133
+ return
1134
+ }
1135
+ // 两次 RPC(watchlist + quotes),走既有节流管线
1136
+ call('watchlist.get', {}).then(function (wl) {
1137
+ const symbols = (wl && wl.symbols) || []
1138
+ if (!symbols.length) { setPop({ quotes: [], at: now }); return }
1139
+ return call('quote', { symbols: symbols }).then(function (data) {
1140
+ const list = (data && data.list) || []
1141
+ popCache.current = { at: now, list: list }
1142
+ setPop({ quotes: list, at: now })
1143
+ })
1144
+ }).catch(function () { /* 静默 */ })
1145
+ }
1146
+ function onEnter() {
1147
+ if (hoverTimer.current) return
1148
+ hoverTimer.current = setTimeout(function () {
1149
+ hoverTimer.current = null
1150
+ if (!badgeDrag) loadPop()
1151
+ }, 300)
1152
+ }
1153
+ function onLeave() {
1154
+ if (hoverTimer.current) { clearTimeout(hoverTimer.current); hoverTimer.current = null }
1155
+ setPop(null)
1156
+ }
1157
+ React.useEffect(function () { return function () { if (hoverTimer.current) clearTimeout(hoverTimer.current) } }, [])
1158
+
1050
1159
  React.useEffect(function () {
1051
1160
  let alive = true
1052
1161
  function refresh() {
@@ -1110,11 +1219,37 @@ exports.default = {
1110
1219
  const style = ui.badgePos
1111
1220
  ? { left: ui.badgePos.x, top: ui.badgePos.y, right: 'auto', bottom: 'auto' }
1112
1221
  : null
1113
- return el('div', {
1222
+ // hover 预览弹层:跟随徽章位置(有拖拽坐标用之,否则用默认右下角),显示在徽章上方
1223
+ let popStyle = null
1224
+ if (ui.badgePos) {
1225
+ popStyle = { left: Math.max(6, ui.badgePos.x - 60), top: Math.max(6, ui.badgePos.y - 210), right: 'auto', bottom: 'auto' }
1226
+ } else {
1227
+ popStyle = { right: 16, bottom: 104, left: 'auto', top: 'auto' }
1228
+ }
1229
+ let popEl = null
1230
+ if (pop) {
1231
+ const rows = pop.quotes.slice(0, 8).map(function (q) {
1232
+ return el('div', {
1233
+ key: q.symbol, className: 'xq-bp-row', title: '点击查看详情',
1234
+ onClick: function (e) { e.stopPropagation(); setPop(null); ui.set({ open: true }) }
1235
+ }, [
1236
+ el('span', { key: 'n', className: 'xq-bp-name' }, q.name || q.symbol),
1237
+ el('span', { key: 'v', className: 'xq-bp-val ' + colorOf(q.percent) }, fmt(q.current)),
1238
+ el('span', { key: 'p', className: 'xq-pct-chip ' + colorOf(q.percent) }, fmtPct(q.percent))
1239
+ ])
1240
+ })
1241
+ popEl = el('div', { key: 'pop', className: 'xq-badge-pop', style: popStyle, onMouseEnter: onEnter, onMouseLeave: onLeave }, [
1242
+ el('div', { key: 'hd', className: 'xq-badge-pop-hd' }, [el('span', { key: 't' }, '自选 · ' + pop.quotes.length + ' 只'), el('span', { key: 'h' }, '悬停查看 · 点击展开')]),
1243
+ rows.length ? rows : el('div', { key: 'e', className: 'xq-muted' }, '自选为空')
1244
+ ])
1245
+ }
1246
+
1247
+ const badgeEl = el('div', {
1114
1248
  className: 'xq-badge',
1115
1249
  style: style,
1116
- title: '点击开合行情面板 · 拖动调整位置',
1117
- onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, onPointerCancel: onUp
1250
+ title: '点击开合行情面板 · 拖动调整位置 · 悬停速览自选',
1251
+ onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, onPointerCancel: onUp,
1252
+ onMouseEnter: onEnter, onMouseLeave: onLeave
1118
1253
  }, [
1119
1254
  el('span', { key: 'logo' }, [el('b', { key: 'b' }, '雪球'), 'mini']),
1120
1255
  el('span', { key: 'st', className: 'xq-status' + (aSession() === '盘中' ? ' xq-live' : ''), title: sessionsText() }, (function () { var s = aSession(); return s === '盘中' ? '● 盘中' : (s || (mOpen ? '● 盘中' : '休市')) })()),
@@ -1129,6 +1264,8 @@ exports.default = {
1129
1264
  ]) : null,
1130
1265
  el('span', { key: 'ct', className: 'xq-caret' }, ui.open ? '▾' : '▴')
1131
1266
  ])
1267
+ // 徽章 + 弹层同层渲染(都为 fixed 定位,互不嵌套,拖拽事件不穿透到弹层)
1268
+ return el('div', { style: { contents: 'display' } }, popEl ? [badgeEl, popEl] : [badgeEl])
1132
1269
  }
1133
1270
 
1134
1271
  // ---------- 底部指数条(会话页,输入框下方氛围行) ----------