dsh-xueqiu 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,10 @@
18
18
  | 📰 快讯 | 7×24 实时快讯,重要新闻高亮 |
19
19
  | 👥 热议用户 | 个股热门 KOL(粉丝数/认证标识) |
20
20
  | 💼 自选股 | 本地持久化,增删随点随改 |
21
- | 🖱️ 悬浮面板 | 按住标题栏**拖到任意位置**,位置/展开状态自动记忆 |
21
+ | 🖱️ 悬浮面板 | 按住标题栏自由拖拽,**拖到左/右屏幕边缘自动贴靠为窄栏**(Windows 贴靠风格,380px 可调),**拖到角落自动磁吸**,📌 一键贴右下角/右侧窄栏,**右下角手柄自由缩放宽高** |
22
+ | 🪟 贴靠助手 | 拖动时在屏幕边缘显示**半透明贴靠预览**,松手即吸附;贴靠后拖动标题栏解除 |
23
+ | 📐 窄栏自适应 | 贴靠窄栏自动切换紧凑布局(隐藏次要列、指数卡缩排、K线/表格自适应) |
24
+ | 📌 嵌入模式 | 一键切换到**输入框上方**(页面布局内,**不遮挡对话记录**),宽度可调,随页面滚动 |
22
25
  | ⏱️ 智能刷新 | 盘中 20s 刷新行情,收盘自动放慢,降低被风控概率 |
23
26
  | 🌗 主题自适应 | 跟随 DSH 明暗主题 |
24
27
 
@@ -28,18 +31,21 @@
28
31
 
29
32
  **主面板**:大盘指数 + 自选股行情,可拖拽悬浮窗口:
30
33
 
31
- ![主面板](./assets/panel.png)
34
+ ![主面板](https://raw.githubusercontent.com/kangjinghang/dsh-xueqiu/main/assets/panel.png)
32
35
 
33
36
  **个股详情**:16 项行情数据 + K线蜡烛图(成交量柱 / MA5-10-20 均线)+ 财务指标 + 热议用户:
34
37
 
35
- ![个股详情](./assets/detail.png)
38
+ ![个股详情](https://raw.githubusercontent.com/kangjinghang/dsh-xueqiu/main/assets/detail.png)
36
39
 
37
40
  ## 📦 安装
38
41
 
39
42
  ### 方式一:标准 bundle 插件(推荐)
40
43
 
41
44
  ```bash
42
- # GitHub 源(git 安装会直接从源码构建)
45
+ # npm 包
46
+ dsh plugin --profile web add dsh-xueqiu
47
+
48
+ # 或 GitHub 源(git 安装会直接从源码构建)
43
49
  dsh plugin --profile web add github:kangjinghang/dsh-xueqiu
44
50
 
45
51
  # 或本地目录
package/dynamic/client.js CHANGED
@@ -169,13 +169,7 @@ return {
169
169
  }
170
170
  }
171
171
 
172
- // UI 状态防抖保存
173
- const saveUi = ctx.debounce(function (s) {
174
- call('ui.set', s).catch(function () { /* 忽略 */ })
175
- }, 800)
176
-
177
- // 拖拽用可变引用(面板实例唯一)
178
- let dragRef = null
172
+ // (saveUi / dragRef / ui 共享状态见下方新块)
179
173
 
180
174
  // ---------- K线蜡烛图(含成交量与均线) ----------
181
175
  function KlineChart(props) {
@@ -309,61 +303,54 @@ return {
309
303
  ])
310
304
  }
311
305
 
312
- // ---------- 底部指数条 ----------
313
- function Ticker() {
314
- const [indices, setIndices] = React.useState([])
315
- React.useEffect(function () {
316
- let alive = true
317
- function refresh() {
318
- call('quote', { symbols: ['SH000001', 'SZ399001', 'SZ399006', 'SH000300'] }).then(function (data) {
319
- if (alive) setIndices((data && data.list) || [])
320
- }).catch(function () { /* 静默失败 */ })
306
+ // ---------- 共享 UI 状态(悬浮/嵌入/位置/尺寸/标签页) ----------
307
+ const ui = {
308
+ mode: 'floating', pos: null, size: { w: 720, h: 640 }, dockW: 720,
309
+ snap: null, snapEdge: null, snapW: 380,
310
+ minimized: false, tab: 'market', closed: false, hydrated: false,
311
+ _fns: [],
312
+ subscribe: function (fn) {
313
+ this._fns.push(fn)
314
+ const self = this
315
+ return function () {
316
+ const i = self._fns.indexOf(fn)
317
+ if (i !== -1) self._fns.splice(i, 1)
321
318
  }
322
- refresh()
323
- const stop = ctx.interval(refresh, 30000)
324
- return function () { alive = false; if (stop) stop() }
325
- }, [])
326
- if (!indices.length) return null
327
- const kids = indices.map(function (q) {
328
- return el('span', { key: q.symbol, className: 'xq-tick' }, [
329
- el('span', { key: 'n', className: 'xq-tick-name' }, q.name),
330
- el('span', { key: 'c', className: 'xq-tick-cur ' + colorOf(q.percent) }, fmt(q.current)),
331
- el('span', { key: 'p', className: colorOf(q.percent) }, fmtPct(q.percent))
332
- ])
333
- })
334
- return el('div', { className: 'xq-ticker', onClick: function () { bus.emit('open') } }, kids)
319
+ },
320
+ notify: function () {
321
+ const fns = this._fns.slice()
322
+ for (let i = 0; i < fns.length; i++) { try { fns[i]() } catch (e) { /* ignore */ } }
323
+ },
324
+ set: function (patch) {
325
+ let ch = false
326
+ for (const k in patch) {
327
+ if (this[k] !== patch[k]) { this[k] = patch[k]; ch = true }
328
+ }
329
+ if (ch) this.notify()
330
+ }
335
331
  }
336
332
 
337
- // ---------- 输入框上方入口条 ----------
338
- function Entry() {
339
- const [indices, setIndices] = React.useState([])
340
- React.useEffect(function () {
341
- let alive = true
342
- function refresh() {
343
- call('quote', { symbols: ['SH000001', 'SZ399001', 'SZ399006', 'SH000300'] }).then(function (data) {
344
- if (alive) setIndices((data && data.list) || [])
345
- }).catch(function () { /* 静默失败 */ })
346
- }
347
- refresh()
348
- const stop = ctx.interval(refresh, 60000)
349
- return function () { alive = false; if (stop) stop() }
350
- }, [])
351
- const line = indices.map(function (q) {
352
- return q.name + ' ' + fmt(q.current) + ' ' + fmtPct(q.percent)
353
- }).join(' · ')
354
- return el('div', { className: 'xq-entry', onClick: function () { bus.emit('open') } }, [
355
- el('span', { key: 'logo', className: 'xq-logo' }, [el('b', { key: 'b' }, '雪球'), ' mini']),
356
- el('span', { key: 'sum', className: 'xq-sum' }, line || '点击打开行情面板'),
357
- el('span', { key: 'caret', className: 'xq-caret' }, '打开 ▾')
358
- ])
333
+ function viewport() {
334
+ try { if (typeof window !== 'undefined' && window.innerWidth) return { w: window.innerWidth, h: window.innerHeight } } catch (e) { /* ignore */ }
335
+ try { if (typeof document !== 'undefined' && document.documentElement && document.documentElement.clientWidth) return { w: document.documentElement.clientWidth, h: document.documentElement.clientHeight } } catch (e) { /* ignore */ }
336
+ return null
359
337
  }
360
338
 
361
- // ---------- 悬浮主面板 ----------
362
- function Panel() {
363
- const [visible, setVisible] = React.useState(true)
364
- const [minimized, setMinimized] = React.useState(false)
365
- const [pos, setPos] = React.useState(null)
366
- const [tab, setTab] = React.useState('market')
339
+ // 保存 UI 状态(防抖)
340
+ const saveUi = ctx.debounce(function () {
341
+ call('ui.set', {
342
+ mode: ui.mode, pos: ui.pos, size: ui.size, dockW: ui.dockW,
343
+ snap: ui.snap, snapEdge: ui.snapEdge, snapW: ui.snapW,
344
+ tab: ui.tab, minimized: ui.minimized
345
+ }).catch(function () { /* 忽略 */ })
346
+ }, 800)
347
+
348
+ // 拖拽 / 缩放用可变引用(实例唯一)
349
+ let dragRef = null
350
+ let resizeRef = null
351
+
352
+ // ---------- 面板内容(数据 + tabs + 详情),由悬浮/嵌入外壳共用 ----------
353
+ function XueqiuPanel() {
367
354
  const [watchlist, setWatchlist] = React.useState([])
368
355
  const [quotes, setQuotes] = React.useState([])
369
356
  const [indices, setIndices] = React.useState([])
@@ -383,30 +370,8 @@ return {
383
370
  const [updatedAt, setUpdatedAt] = React.useState(null)
384
371
  const [err, setErr] = React.useState('')
385
372
  const [loading, setLoading] = React.useState(true)
386
- const [hydrated, setHydrated] = React.useState(false)
387
-
388
- // 恢复 UI 状态 + 监听 bus
389
- React.useEffect(function () {
390
- let alive = true
391
- call('ui.get', {}).then(function (d) {
392
- if (!alive) return
393
- if (d && d.pos) setPos({ x: Number(d.pos.x), y: Number(d.pos.y) })
394
- if (d && d.tab) setTab(d.tab)
395
- setMinimized(!!(d && d.minimized))
396
- setHydrated(true)
397
- }).catch(function () { setHydrated(true) })
398
- const off = bus.on(function (ev) {
399
- if (ev === 'open') { setVisible(true); setMinimized(false) }
400
- if (ev === 'close') setVisible(false)
401
- })
402
- return function () { alive = false; off() }
403
- }, [])
404
-
405
- // 持久化 UI 状态(hydrated 后再写,避免覆盖)
406
- React.useEffect(function () {
407
- if (!hydrated) return
408
- saveUi({ tab: tab, minimized: minimized, pos: pos })
409
- }, [tab, minimized, pos, hydrated])
373
+ const tab = ui.tab
374
+ function setTab(t) { ui.set({ tab: t }) }
410
375
 
411
376
  function refreshMarket() {
412
377
  return Promise.all([
@@ -522,58 +487,6 @@ return {
522
487
  setManualCode('')
523
488
  }
524
489
 
525
- // 拖拽
526
- function onTitleDown(e) {
527
- if (e.pointerType === 'mouse' && e.button !== 0) return
528
- const baseX = pos ? pos.x : e.clientX
529
- const baseY = pos ? pos.y : e.clientY
530
- dragRef = { dx: e.clientX - baseX, dy: e.clientY - baseY }
531
- try { e.currentTarget.setPointerCapture(e.pointerId) } catch (err) { /* ignore */ }
532
- }
533
- function onTitleMove(e) {
534
- if (!dragRef) return
535
- setPos({ x: Math.max(4, e.clientX - dragRef.dx), y: Math.max(4, e.clientY - dragRef.dy) })
536
- }
537
- function onTitleUp(e) {
538
- dragRef = null
539
- try {
540
- if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId)
541
- } catch (err) { /* ignore */ }
542
- }
543
-
544
- if (!visible) return null
545
-
546
- const dragProps = {
547
- onPointerDown: onTitleDown,
548
- onPointerMove: onTitleMove,
549
- onPointerUp: onTitleUp,
550
- onPointerCancel: onTitleUp
551
- }
552
-
553
- // 最小化条
554
- if (minimized) {
555
- const idxLine = indices.map(function (q) {
556
- return q.name + ' ' + fmt(q.current) + ' ' + fmtPct(q.percent)
557
- }).join(' · ')
558
- return el('div', { className: 'xq-float xq-min', style: pos ? { left: pos.x, top: pos.y, right: 'auto', bottom: 'auto' } : null, onClick: function () { setMinimized(false) }, ...dragProps }, [
559
- el('span', { key: 'logo', className: 'xq-logo' }, [el('b', { key: 'b' }, '雪球'), ' mini']),
560
- el('span', { key: 'sum', className: 'xq-sum' }, idxLine || '点击展开行情面板'),
561
- el('span', { key: 'sp', className: 'xq-spacer' }),
562
- el('button', { key: 'x', className: 'xq-btn-mini', onClick: function (e) { e.stopPropagation(); setVisible(false) }, onPointerDown: function (e) { e.stopPropagation() } }, '✕'),
563
- el('span', { key: 'caret', className: 'xq-caret' }, '展开 ▾')
564
- ])
565
- }
566
-
567
- // 标题栏
568
- const titleBar = el('div', { className: 'xq-title', ...dragProps }, [
569
- el('span', { key: 'logo', className: 'xq-logo' }, [el('b', { key: 'b' }, '雪球'), ' mini']),
570
- el('span', { key: 'st', className: 'xq-status' + (marketOpen ? ' xq-live' : '') }, marketOpen ? '● 盘中' : '已收盘'),
571
- el('span', { key: 'upd', className: 'xq-update' }, updatedAt ? ('更新 ' + fmtTime(updatedAt)) : (loading ? '加载中…' : '')),
572
- el('span', { key: 'sp', className: 'xq-spacer' }),
573
- el('button', { key: 'min', className: 'xq-btn-mini', title: '收起', onPointerDown: function (e) { e.stopPropagation() }, onClick: function (e) { e.stopPropagation(); setMinimized(true) } }, '—'),
574
- el('button', { key: 'cls', className: 'xq-btn-mini', title: '关闭(可从下方入口重新打开)', onPointerDown: function (e) { e.stopPropagation() }, onClick: function (e) { e.stopPropagation(); setVisible(false) } }, '✕')
575
- ])
576
-
577
490
  const tabs = el('div', { className: 'xq-tabs' }, [
578
491
  ['market', '行情'], ['hot', '热榜'], ['search', '搜索'], ['news', '快讯']
579
492
  ].map(function (t) {
@@ -613,7 +526,7 @@ return {
613
526
  ]),
614
527
  el('div', { key: 'c', className: colorOf(q.percent) }, fmt(q.current)),
615
528
  el('div', { key: 'p', className: colorOf(q.percent) }, fmtPct(q.percent)),
616
- el('div', { key: 'v', className: 'xq-sub' }, fmtVol(q.volume)),
529
+ el('div', { key: 'v', className: 'xq-sub xq-vol-col' }, fmtVol(q.volume)),
617
530
  el('div', { key: 'a2', className: 'xq-actions' }, [
618
531
  el('button', { key: 'd', className: 'xq-btn-mini', onClick: function () { openDetail(q.symbol) } }, '详情'),
619
532
  el('button', { key: 'r', className: 'xq-btn-mini', onClick: function () { removeWatch(q.symbol) } }, '×')
@@ -818,19 +731,351 @@ return {
818
731
  content = NewsTab()
819
732
  }
820
733
 
821
- return el('div', { className: 'xq-float', style: pos ? { left: pos.x, top: pos.y, right: 'auto', bottom: 'auto' } : null }, [
822
- titleBar,
823
- el('div', { key: 'body', className: 'xq-body' }, [tabs, errBox, content])
734
+ return el('div', null, [tabs, errBox, content])
735
+ }
736
+
737
+ // ---------- 悬浮外壳(shell.overlay):自由拖拽 + 左右边缘贴靠 + 四角磁吸 + 缩放 ----------
738
+ function FloatingShell() {
739
+ const [, force] = React.useState(0)
740
+ const [fIdx, setFIdx] = React.useState([])
741
+ const [fOpen, setFOpen] = React.useState(true)
742
+ const [dragTarget, setDragTarget] = React.useState(null)
743
+ React.useEffect(function () {
744
+ let alive = true
745
+ function refresh() {
746
+ call('quote', { symbols: ['SH000001', 'SZ399001', 'SZ399006', 'SH000300'] }).then(function (data) {
747
+ if (!alive) return
748
+ setFIdx((data && data.list) || [])
749
+ const st = data ? data.status : null
750
+ setFOpen(st === 5 || st === 6)
751
+ }).catch(function () { /* 静默失败 */ })
752
+ }
753
+ refresh()
754
+ const stop = ctx.interval(refresh, 60000)
755
+ return function () { alive = false; if (stop) stop() }
756
+ }, [])
757
+ React.useEffect(function () {
758
+ let alive = true
759
+ call('ui.get', {}).then(function (d) {
760
+ if (!alive) return
761
+ if (d && d.mode) ui.set({ mode: d.mode })
762
+ if (d && d.pos) ui.set({ pos: { x: Number(d.pos.x), y: Number(d.pos.y) } })
763
+ if (d && d.size) ui.set({ size: { w: Number(d.size.w), h: Number(d.size.h) } })
764
+ if (d && d.dockW) ui.set({ dockW: Number(d.dockW) })
765
+ if (d && d.snap) ui.set({ snap: d.snap })
766
+ if (d && (d.snapEdge === 'left' || d.snapEdge === 'right')) ui.set({ snapEdge: d.snapEdge })
767
+ if (d && d.snapW) ui.set({ snapW: Number(d.snapW) })
768
+ if (d && d.tab) ui.set({ tab: d.tab })
769
+ ui.set({ minimized: !!(d && d.minimized), hydrated: true })
770
+ }).catch(function () { ui.set({ hydrated: true }) })
771
+ const offUi = ui.subscribe(function () {
772
+ force(function (x) { return x + 1 })
773
+ if (ui.hydrated) saveUi()
774
+ })
775
+ const offBus = bus.on(function (ev) {
776
+ if (ev === 'open') { ui.set({ closed: false, minimized: false, mode: 'floating' }) }
777
+ })
778
+ return function () { alive = false; offUi(); offBus() }
779
+ }, [])
780
+
781
+ if (ui.mode !== 'floating' || ui.closed || !ui.hydrated) return null
782
+ const s = ui.size
783
+
784
+ // 拖拽(贴靠状态下按下标题栏 → 解除贴靠回到自由)
785
+ function onTitleDown(e) {
786
+ if (e.pointerType === 'mouse' && e.button !== 0) return
787
+ if (ui.snapEdge) {
788
+ const vp = viewport()
789
+ if (vp) {
790
+ const x = ui.snapEdge === 'right' ? vp.w - ui.snapW : 0
791
+ ui.set({ snapEdge: null, pos: { x: x, y: 0 }, snap: null })
792
+ } else {
793
+ ui.set({ snapEdge: null, pos: { x: e.clientX - 60, y: e.clientY - 20 }, snap: null })
794
+ }
795
+ }
796
+ const baseX = ui.pos ? ui.pos.x : e.clientX
797
+ const baseY = ui.pos ? ui.pos.y : e.clientY
798
+ dragRef = { dx: e.clientX - baseX, dy: e.clientY - baseY }
799
+ ui.set({ snap: null })
800
+ setDragTarget(null)
801
+ try { e.currentTarget.setPointerCapture(e.pointerId) } catch (err) { /* ignore */ }
802
+ }
803
+ function onTitleMove(e) {
804
+ if (!dragRef) return
805
+ ui.set({ pos: { x: Math.max(4, e.clientX - dragRef.dx), y: Math.max(4, e.clientY - dragRef.dy) } })
806
+ const vp = viewport()
807
+ if (vp) {
808
+ if (e.clientX >= vp.w - 70) setDragTarget('right')
809
+ else if (e.clientX <= 70) setDragTarget('left')
810
+ else setDragTarget(null)
811
+ }
812
+ }
813
+ function onTitleUp(e) {
814
+ dragRef = null
815
+ try { if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId) } catch (err) { /* ignore */ }
816
+ if (dragTarget) {
817
+ ui.set({ snapEdge: dragTarget, pos: null, snap: null, snapW: ui.snapW || 380 })
818
+ setDragTarget(null)
819
+ return
820
+ }
821
+ setDragTarget(null)
822
+ snapCheck()
823
+ }
824
+ // 四角磁吸:拖放终点靠近任一屏幕角 → 吸附
825
+ function snapCheck() {
826
+ const vp = viewport()
827
+ const p = ui.pos
828
+ if (!vp || !p) return
829
+ const corners = [
830
+ { key: 'tl', x: 8, y: 8 },
831
+ { key: 'tr', x: vp.w - s.w - 8, y: 8 },
832
+ { key: 'bl', x: 8, y: vp.h - s.h - 8 },
833
+ { key: 'br', x: vp.w - s.w - 8, y: vp.h - s.h - 8 }
834
+ ]
835
+ let best = null, bestD = 110
836
+ for (let i = 0; i < corners.length; i++) {
837
+ const c = corners[i]
838
+ const d = Math.sqrt(Math.pow(p.x - c.x, 2) + Math.pow(p.y - c.y, 2))
839
+ if (d < bestD) { bestD = d; best = c }
840
+ }
841
+ ui.set({ pos: best ? { x: Math.max(0, best.x), y: Math.max(0, best.y) } : p, snap: best ? best.key : null })
842
+ }
843
+ // 📌 循环:右下角 → 右侧窄栏贴靠 → 解除
844
+ function toggleSnap() {
845
+ const vp = viewport()
846
+ if (ui.snapEdge === 'right') { ui.set({ snapEdge: null }); return }
847
+ if (ui.snap === 'br') {
848
+ ui.set({ snap: null })
849
+ if (vp) ui.set({ snapEdge: 'right', pos: null, snapW: ui.snapW || 380 })
850
+ return
851
+ }
852
+ if (vp) { ui.set({ snap: 'br', pos: { x: Math.max(0, vp.w - s.w - 8), y: Math.max(0, vp.h - s.h - 8) } }) }
853
+ else { ui.set({ snap: 'br', pos: null }) }
854
+ }
855
+ // 缩放(右下角手柄;贴靠时只调宽度)
856
+ function onRsDown(e) {
857
+ if (e.pointerType === 'mouse' && e.button !== 0) return
858
+ resizeRef = ui.snapEdge
859
+ ? { w: ui.snapW, h: 0, x: e.clientX, y: e.clientY }
860
+ : { w: s.w, h: s.h, x: e.clientX, y: e.clientY }
861
+ try { e.currentTarget.setPointerCapture(e.pointerId) } catch (err) { /* ignore */ }
862
+ }
863
+ function onRsMove(e) {
864
+ if (!resizeRef) return
865
+ if (ui.snapEdge) {
866
+ const w = Math.min(Math.max(300, resizeRef.w + (e.clientX - resizeRef.x)), 560)
867
+ ui.set({ snapW: w })
868
+ } else {
869
+ const w = Math.min(Math.max(460, resizeRef.w + (e.clientX - resizeRef.x)), 1100)
870
+ const h = Math.min(Math.max(380, resizeRef.h + (e.clientY - resizeRef.y)), 1000)
871
+ ui.set({ size: { w: w, h: h }, snap: null })
872
+ }
873
+ }
874
+ function onRsUp(e) {
875
+ resizeRef = null
876
+ try { if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId) } catch (err) { /* ignore */ }
877
+ }
878
+
879
+ const dragProps = { onPointerDown: onTitleDown, onPointerMove: onTitleMove, onPointerUp: onTitleUp, onPointerCancel: onTitleUp }
880
+ const rsProps = { onPointerDown: onRsDown, onPointerMove: onRsMove, onPointerUp: onRsUp, onPointerCancel: onRsUp }
881
+
882
+ // 贴靠助手预览(拖动中靠近边缘时显示)
883
+ let snapPreview = null
884
+ if (dragTarget) {
885
+ snapPreview = el('div', {
886
+ key: 'prev', className: 'xq-snap-preview xq-snap-' + dragTarget,
887
+ style: { width: ui.snapW || 380 }
888
+ })
889
+ }
890
+
891
+ // 最小化条
892
+ if (ui.minimized) {
893
+ const idxLine = fIdx.map(function (q) {
894
+ return q.name + ' ' + fmt(q.current) + ' ' + fmtPct(q.percent)
895
+ }).join(' · ')
896
+ let minStyle = null
897
+ if (ui.snapEdge === 'right') minStyle = { right: 0, top: 0, bottom: 0, width: ui.snapW, height: 'auto' }
898
+ else if (ui.snapEdge === 'left') minStyle = { left: 0, top: 0, bottom: 0, width: ui.snapW, height: 'auto' }
899
+ else if (ui.pos) minStyle = { left: ui.pos.x, top: ui.pos.y, right: 'auto', bottom: 'auto' }
900
+ return el('div', { className: 'xq-wrap' }, [
901
+ el('div', { key: 'min', className: 'xq-float xq-min' + (ui.snapEdge ? ' xq-snapped' : ''), style: minStyle, ...dragProps, onClick: function () { ui.set({ minimized: false }) } }, [
902
+ el('span', { key: 'logo', className: 'xq-logo' }, [el('b', { key: 'b' }, '雪球'), ' mini']),
903
+ el('span', { key: 'sum', className: 'xq-sum' }, idxLine || '点击展开行情面板'),
904
+ el('span', { key: 'sp', className: 'xq-spacer' }),
905
+ el('button', { key: 'x', className: 'xq-btn-mini', onPointerDown: function (e) { e.stopPropagation() }, onClick: function (e) { e.stopPropagation(); ui.set({ closed: true }) } }, '✕'),
906
+ el('span', { key: 'caret', className: 'xq-caret' }, '展开 ▾')
907
+ ]),
908
+ snapPreview
909
+ ])
910
+ }
911
+
912
+ const snapped = ui.snapEdge ? ' xq-snapped xq-narrow' : (ui.snap ? ' xq-snapped' : '')
913
+ const titleBar = el('div', { className: 'xq-title', ...dragProps }, [
914
+ el('span', { key: 'logo', className: 'xq-logo' }, [el('b', { key: 'b' }, '雪球'), ' mini']),
915
+ el('span', { key: 'st', className: 'xq-status' + (fOpen ? ' xq-live' : '') }, fOpen ? '● 盘中' : '已收盘'),
916
+ el('span', { key: 'upd', className: 'xq-update' }, ui.snapEdge === 'right' ? '已贴右 · 拖走解除' : ui.snapEdge === 'left' ? '已贴左 · 拖走解除' : '拖到左/右边缘贴靠'),
917
+ el('span', { key: 'sp', className: 'xq-spacer' }),
918
+ el('button', { key: 'snap', className: 'xq-btn-mini', title: '循环:右下角 → 右侧窄栏 → 解除', onPointerDown: function (e) { e.stopPropagation() }, onClick: function (e) { e.stopPropagation(); toggleSnap() } }, ui.snapEdge === 'right' ? '◫' : ui.snap === 'br' ? '📌' : '📍'),
919
+ el('button', { key: 'dock', className: 'xq-btn-mini', title: '嵌入到输入框上方(不遮挡对话)', onPointerDown: function (e) { e.stopPropagation() }, onClick: function (e) { e.stopPropagation(); ui.set({ mode: 'docked', minimized: false }) } }, '嵌入'),
920
+ el('button', { key: 'min', className: 'xq-btn-mini', title: '收起', onPointerDown: function (e) { e.stopPropagation() }, onClick: function (e) { e.stopPropagation(); ui.set({ minimized: true }) } }, '—'),
921
+ el('button', { key: 'cls', className: 'xq-btn-mini', title: '关闭(点底部指数条重新打开)', onPointerDown: function (e) { e.stopPropagation() }, onClick: function (e) { e.stopPropagation(); ui.set({ closed: true }) } }, '✕')
922
+ ])
923
+
924
+ return el('div', { className: 'xq-wrap' }, [
925
+ el('div', { key: 'f', className: 'xq-float' + snapped, style: posStyle() }, [
926
+ titleBar,
927
+ el('div', { key: 'body', className: 'xq-body' }, [
928
+ el(XueqiuPanel, null)
929
+ ]),
930
+ el('div', { key: 'rs', className: 'xq-resize', ...rsProps })
931
+ ]),
932
+ snapPreview
933
+ ])
934
+ }
935
+
936
+ function posStyle() {
937
+ if (ui.snapEdge === 'right') return { right: 0, top: 0, bottom: 0, left: 'auto', width: ui.snapW }
938
+ if (ui.snapEdge === 'left') return { left: 0, top: 0, bottom: 0, right: 'auto', width: ui.snapW }
939
+ if (ui.pos) return { left: ui.pos.x, top: ui.pos.y, right: 'auto', bottom: 'auto', width: ui.size.w, height: ui.size.h }
940
+ return { width: ui.size.w, height: ui.size.h }
941
+ }
942
+
943
+ // ---------- 嵌入外壳(conversation.input.dock):输入框上方,不遮挡对话 ----------
944
+ function DockedShell() {
945
+ const [, force] = React.useState(0)
946
+ React.useEffect(function () {
947
+ const off = ui.subscribe(function () { force(function (x) { return x + 1 }) })
948
+ return off
949
+ }, [])
950
+ if (ui.mode !== 'docked' || !ui.hydrated) return null
951
+
952
+ function onDwDown(e) {
953
+ if (e.pointerType === 'mouse' && e.button !== 0) return
954
+ resizeRef = { w: ui.dockW, h: 0, x: e.clientX, y: e.clientY }
955
+ try { e.currentTarget.setPointerCapture(e.pointerId) } catch (err) { /* ignore */ }
956
+ }
957
+ function onDwMove(e) {
958
+ if (!resizeRef) return
959
+ ui.set({ dockW: Math.min(Math.max(480, resizeRef.w + (e.clientX - resizeRef.x)), 1100) })
960
+ }
961
+ function onDwUp(e) {
962
+ resizeRef = null
963
+ try { if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId) } catch (err) { /* ignore */ }
964
+ }
965
+
966
+ if (ui.minimized) {
967
+ return el('div', { className: 'xq-docked xq-docked-min', style: { width: ui.dockW }, onClick: function () { ui.set({ minimized: false }) } }, [
968
+ el('span', { key: 'logo', className: 'xq-logo' }, [el('b', { key: 'b' }, '雪球'), ' mini']),
969
+ el('span', { key: 'sum', className: 'xq-sum' }, '点击展开行情面板'),
970
+ el('span', { key: 'sp', className: 'xq-spacer' }),
971
+ el('span', { key: 'caret', className: 'xq-caret' }, '展开 ▾')
972
+ ])
973
+ }
974
+
975
+ return el('div', { className: 'xq-docked', style: { width: ui.dockW } }, [
976
+ el('div', { key: 'title', className: 'xq-title' }, [
977
+ el('span', { key: 'logo', className: 'xq-logo' }, [el('b', { key: 'b' }, '雪球'), ' mini']),
978
+ el('span', { key: 'hint', className: 'xq-update' }, '已嵌入 · 不遮挡对话'),
979
+ el('span', { key: 'sp', className: 'xq-spacer' }),
980
+ el('button', { key: 'float', className: 'xq-btn-mini', title: '切换回悬浮模式', onClick: function () { ui.set({ mode: 'floating', minimized: false }) } }, '悬浮'),
981
+ el('button', { key: 'min', className: 'xq-btn-mini', title: '收起', onClick: function () { ui.set({ minimized: true }) } }, '—')
982
+ ]),
983
+ el('div', { key: 'body', className: 'xq-body' }, [
984
+ el(XueqiuPanel, null)
985
+ ]),
986
+ el('div', { key: 'rs', className: 'xq-resize xq-resize-w', onPointerDown: onDwDown, onPointerMove: onDwMove, onPointerUp: onDwUp, onPointerCancel: onDwUp })
987
+ ])
988
+ }
989
+
990
+ function posStyle() {
991
+ if (ui.pos) return { left: ui.pos.x, top: ui.pos.y, right: 'auto', bottom: 'auto', width: ui.size.w, height: ui.size.h }
992
+ return { width: ui.size.w, height: ui.size.h }
993
+ }
994
+
995
+ // ---------- 输入框上方入口条(仅悬浮模式下显示) ----------
996
+ function Entry() {
997
+ const [indices, setIndices] = React.useState([])
998
+ const [, force] = React.useState(0)
999
+ React.useEffect(function () {
1000
+ let alive = true
1001
+ function refresh() {
1002
+ call('quote', { symbols: ['SH000001', 'SZ399001', 'SZ399006', 'SH000300'] }).then(function (data) {
1003
+ if (alive) setIndices((data && data.list) || [])
1004
+ }).catch(function () { /* 静默失败 */ })
1005
+ }
1006
+ refresh()
1007
+ const off = ui.subscribe(function () { force(function (x) { return x + 1 }) })
1008
+ const stop = ctx.interval(refresh, 60000)
1009
+ return function () { alive = false; off(); if (stop) stop() }
1010
+ }, [])
1011
+ if (ui.mode === 'docked') return null
1012
+ const line = indices.map(function (q) {
1013
+ return q.name + ' ' + fmt(q.current) + ' ' + fmtPct(q.percent)
1014
+ }).join(' · ')
1015
+ return el('div', { className: 'xq-entry', onClick: function () { bus.emit('open') } }, [
1016
+ el('span', { key: 'logo', className: 'xq-logo' }, [el('b', { key: 'b' }, '雪球'), ' mini']),
1017
+ el('span', { key: 'sum', className: 'xq-sum' }, line || '点击打开行情面板'),
1018
+ el('span', { key: 'caret', className: 'xq-caret' }, '打开 ▾')
824
1019
  ])
825
1020
  }
826
1021
 
1022
+ // ---------- 底部指数条 ----------
1023
+ function Ticker() {
1024
+ const [indices, setIndices] = React.useState([])
1025
+ React.useEffect(function () {
1026
+ let alive = true
1027
+ function refresh() {
1028
+ call('quote', { symbols: ['SH000001', 'SZ399001', 'SZ399006', 'SH000300'] }).then(function (data) {
1029
+ if (alive) setIndices((data && data.list) || [])
1030
+ }).catch(function () { /* 静默失败 */ })
1031
+ }
1032
+ refresh()
1033
+ const stop = ctx.interval(refresh, 30000)
1034
+ return function () { alive = false; if (stop) stop() }
1035
+ }, [])
1036
+ if (!indices.length) return null
1037
+ const kids = indices.map(function (q) {
1038
+ return el('span', { key: q.symbol, className: 'xq-tick' }, [
1039
+ el('span', { key: 'n', className: 'xq-tick-name' }, q.name),
1040
+ el('span', { key: 'c', className: 'xq-tick-cur ' + colorOf(q.percent) }, fmt(q.current)),
1041
+ el('span', { key: 'p', className: colorOf(q.percent) }, fmtPct(q.percent))
1042
+ ])
1043
+ })
1044
+ return el('div', { className: 'xq-ticker', onClick: function () { bus.emit('open') } }, kids)
1045
+ }
1046
+
1047
+ // 追加样式:嵌入外壳 / 缩放手柄 / 磁吸动画
1048
+ styles.insert(
1049
+ '.xq-docked{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;overflow:hidden;position:relative;font-size:13px;line-height:1.45;color:var(--dsw-alias-label-primary);margin-bottom:2px;}\n' +
1050
+ '.xq-docked *{box-sizing:border-box;}\n' +
1051
+ '.xq-docked-min{display:flex;align-items:center;gap:10px;cursor:pointer;padding:6px 12px;}\n' +
1052
+ '.xq-docked .xq-title{cursor:default;}\n' +
1053
+ '.xq-resize{position:absolute;right:2px;bottom:2px;width:18px;height:18px;cursor:nwse-resize;z-index:6;}\n' +
1054
+ '.xq-resize::after{content:"";position:absolute;right:4px;bottom:4px;width:8px;height:8px;border-right:2px solid var(--dsw-alias-label-secondary);border-bottom:2px solid var(--dsw-alias-label-secondary);opacity:.55;}\n' +
1055
+ '.xq-resize-w{position:absolute;right:0;top:0;bottom:0;width:10px;cursor:ew-resize;z-index:6;}\n' +
1056
+ '.xq-resize-w::after{display:none;}\n' +
1057
+ '.xq-snapped{transition:left .16s ease, top .16s ease;}\n' +
1058
+ '.xq-wrap{pointer-events:none;position:fixed;inset:0;z-index:1200;}\n' +
1059
+ '.xq-wrap .xq-float{pointer-events:auto;}\n' +
1060
+ '.xq-snap-preview{position:fixed;top:8px;bottom:8px;border:2px solid var(--dsw-alias-brand-primary);border-radius:10px;background:var(--dsw-alias-brand-primary);opacity:.13;pointer-events:none;z-index:1199;}\n' +
1061
+ '.xq-snap-right{right:8px;}\n' +
1062
+ '.xq-snap-left{left:8px;}\n' +
1063
+ '.xq-float.xq-narrow .xq-grid-hd,.xq-float.xq-narrow .xq-grid-row{grid-template-columns:1.5fr 1fr 1fr 62px;}\n' +
1064
+ '.xq-float.xq-narrow .xq-vol-col{display:none;}\n' +
1065
+ '.xq-float.xq-narrow .xq-idx-card{min-width:88px;padding:5px 6px;}\n' +
1066
+ '.xq-float.xq-narrow .xq-idx-cur{font-size:12px;}\n' +
1067
+ '.xq-float.xq-narrow .xq-stats{grid-template-columns:repeat(2,1fr);}\n' +
1068
+ '.xq-float.xq-narrow .xq-detail-cur{font-size:16px;}\n' +
1069
+ '.xq-float.xq-narrow .xq-periods button,.xq-float.xq-narrow .xq-modes button{padding:2px 6px;}\n'
1070
+ )
1071
+
827
1072
  // ---------- 注册 ----------
828
1073
  const slots = ctx.get('slots')
829
1074
  if (slots === undefined) return
830
1075
  slots.inject('shell.overlay', function () {
831
1076
  return slots.register(
832
1077
  { name: 'shell.overlay', id: 'xueqiu-panel' },
833
- function () { return el(Panel, null) }
1078
+ function () { return el(FloatingShell, null) }
834
1079
  )
835
1080
  })
836
1081
  slots.inject('conversation.input.dock', function () {
@@ -839,11 +1084,18 @@ return {
839
1084
  function () { return el(Entry, null) }
840
1085
  )
841
1086
  })
1087
+ slots.inject('conversation.input.dock', function () {
1088
+ return slots.register(
1089
+ { name: 'conversation.input.dock', id: 'xueqiu-docked', order: 31 },
1090
+ function () { return el(DockedShell, null) }
1091
+ )
1092
+ })
842
1093
  slots.inject('conversation.composer.dock', function () {
843
1094
  return slots.register(
844
1095
  { name: 'conversation.composer.dock', id: 'xueqiu-ticker', order: 1 },
845
1096
  function () { return el(Ticker, null) }
846
1097
  )
847
1098
  })
1099
+
848
1100
  }
849
- }
1101
+ }
package/dynamic/host.js CHANGED
@@ -385,7 +385,13 @@ return {
385
385
 
386
386
  async function actUiGet() {
387
387
  const s = await loadUiState()
388
- return { pos: s.pos || null, tab: s.tab || 'market', minimized: !!s.minimized }
388
+ return {
389
+ mode: s.mode || 'floating', pos: s.pos || null,
390
+ size: (s.size && isFinite(Number(s.size.w)) && isFinite(Number(s.size.h))) ? { w: Number(s.size.w), h: Number(s.size.h) } : null,
391
+ dockW: s.dockW || null, snap: s.snap || null,
392
+ snapEdge: s.snapEdge || null, snapW: s.snapW || 380,
393
+ tab: s.tab || 'market', minimized: !!s.minimized
394
+ }
389
395
  }
390
396
 
391
397
  async function actUiSet(args) {
@@ -395,6 +401,14 @@ return {
395
401
  }
396
402
  if (args.tab !== undefined) s.tab = String(args.tab)
397
403
  if (args.minimized !== undefined) s.minimized = !!args.minimized
404
+ if (args.mode === 'floating' || args.mode === 'docked') s.mode = args.mode
405
+ if (args.size && typeof args.size === 'object' && isFinite(Number(args.size.w)) && isFinite(Number(args.size.h))) {
406
+ s.size = { w: Number(args.size.w), h: Number(args.size.h) }
407
+ }
408
+ if (args.dockW !== undefined && isFinite(Number(args.dockW))) s.dockW = Number(args.dockW)
409
+ if ('snap' in args && args.snap !== undefined) s.snap = args.snap === null ? null : String(args.snap)
410
+ if (args.snapEdge === 'left' || args.snapEdge === 'right' || args.snapEdge === null) s.snapEdge = args.snapEdge
411
+ if (args.snapW !== undefined && isFinite(Number(args.snapW))) s.snapW = Number(args.snapW)
398
412
  await saveUiState()
399
413
  return { ok: true }
400
414
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-xueqiu",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "雪球 mini 行情面板 — DeepSeek Harness 免登录 A股/港美股实时行情、K线(蜡烛/成交量/均线)、分时图、热榜、搜索、7x24 快讯、热议用户。可拖拽悬浮面板,交易时段智能刷新。",
5
5
  "keywords": [
6
6
  "dsh",
@@ -47,4 +47,4 @@
47
47
  "README.md",
48
48
  "LICENSE"
49
49
  ]
50
- }
50
+ }
@@ -174,13 +174,7 @@ export default {
174
174
  }
175
175
  }
176
176
 
177
- // UI 状态防抖保存
178
- const saveUi = ctx.debounce(function (s) {
179
- call('ui.set', s).catch(function () { /* 忽略 */ })
180
- }, 800)
181
-
182
- // 拖拽用可变引用(面板实例唯一)
183
- let dragRef = null
177
+ // (saveUi / dragRef / ui 共享状态见下方新块)
184
178
 
185
179
  // ---------- K线蜡烛图(含成交量与均线) ----------
186
180
  function KlineChart(props) {
@@ -314,61 +308,54 @@ export default {
314
308
  ])
315
309
  }
316
310
 
317
- // ---------- 底部指数条 ----------
318
- function Ticker() {
319
- const [indices, setIndices] = React.useState([])
320
- React.useEffect(function () {
321
- let alive = true
322
- function refresh() {
323
- call('quote', { symbols: ['SH000001', 'SZ399001', 'SZ399006', 'SH000300'] }).then(function (data) {
324
- if (alive) setIndices((data && data.list) || [])
325
- }).catch(function () { /* 静默失败 */ })
311
+ // ---------- 共享 UI 状态(悬浮/嵌入/位置/尺寸/标签页) ----------
312
+ const ui = {
313
+ mode: 'floating', pos: null, size: { w: 720, h: 640 }, dockW: 720,
314
+ snap: null, snapEdge: null, snapW: 380,
315
+ minimized: false, tab: 'market', closed: false, hydrated: false,
316
+ _fns: [],
317
+ subscribe: function (fn) {
318
+ this._fns.push(fn)
319
+ const self = this
320
+ return function () {
321
+ const i = self._fns.indexOf(fn)
322
+ if (i !== -1) self._fns.splice(i, 1)
326
323
  }
327
- refresh()
328
- const stop = ctx.interval(refresh, 30000)
329
- return function () { alive = false; if (stop) stop() }
330
- }, [])
331
- if (!indices.length) return null
332
- const kids = indices.map(function (q) {
333
- return el('span', { key: q.symbol, className: 'xq-tick' }, [
334
- el('span', { key: 'n', className: 'xq-tick-name' }, q.name),
335
- el('span', { key: 'c', className: 'xq-tick-cur ' + colorOf(q.percent) }, fmt(q.current)),
336
- el('span', { key: 'p', className: colorOf(q.percent) }, fmtPct(q.percent))
337
- ])
338
- })
339
- return el('div', { className: 'xq-ticker', onClick: function () { bus.emit('open') } }, kids)
324
+ },
325
+ notify: function () {
326
+ const fns = this._fns.slice()
327
+ for (let i = 0; i < fns.length; i++) { try { fns[i]() } catch (e) { /* ignore */ } }
328
+ },
329
+ set: function (patch) {
330
+ let ch = false
331
+ for (const k in patch) {
332
+ if (this[k] !== patch[k]) { this[k] = patch[k]; ch = true }
333
+ }
334
+ if (ch) this.notify()
335
+ }
340
336
  }
341
337
 
342
- // ---------- 输入框上方入口条 ----------
343
- function Entry() {
344
- const [indices, setIndices] = React.useState([])
345
- React.useEffect(function () {
346
- let alive = true
347
- function refresh() {
348
- call('quote', { symbols: ['SH000001', 'SZ399001', 'SZ399006', 'SH000300'] }).then(function (data) {
349
- if (alive) setIndices((data && data.list) || [])
350
- }).catch(function () { /* 静默失败 */ })
351
- }
352
- refresh()
353
- const stop = ctx.interval(refresh, 60000)
354
- return function () { alive = false; if (stop) stop() }
355
- }, [])
356
- const line = indices.map(function (q) {
357
- return q.name + ' ' + fmt(q.current) + ' ' + fmtPct(q.percent)
358
- }).join(' · ')
359
- return el('div', { className: 'xq-entry', onClick: function () { bus.emit('open') } }, [
360
- el('span', { key: 'logo', className: 'xq-logo' }, [el('b', { key: 'b' }, '雪球'), ' mini']),
361
- el('span', { key: 'sum', className: 'xq-sum' }, line || '点击打开行情面板'),
362
- el('span', { key: 'caret', className: 'xq-caret' }, '打开 ▾')
363
- ])
338
+ function viewport() {
339
+ try { if (typeof window !== 'undefined' && window.innerWidth) return { w: window.innerWidth, h: window.innerHeight } } catch (e) { /* ignore */ }
340
+ try { if (typeof document !== 'undefined' && document.documentElement && document.documentElement.clientWidth) return { w: document.documentElement.clientWidth, h: document.documentElement.clientHeight } } catch (e) { /* ignore */ }
341
+ return null
364
342
  }
365
343
 
366
- // ---------- 悬浮主面板 ----------
367
- function Panel() {
368
- const [visible, setVisible] = React.useState(true)
369
- const [minimized, setMinimized] = React.useState(false)
370
- const [pos, setPos] = React.useState(null)
371
- const [tab, setTab] = React.useState('market')
344
+ // 保存 UI 状态(防抖)
345
+ const saveUi = ctx.debounce(function () {
346
+ call('ui.set', {
347
+ mode: ui.mode, pos: ui.pos, size: ui.size, dockW: ui.dockW,
348
+ snap: ui.snap, snapEdge: ui.snapEdge, snapW: ui.snapW,
349
+ tab: ui.tab, minimized: ui.minimized
350
+ }).catch(function () { /* 忽略 */ })
351
+ }, 800)
352
+
353
+ // 拖拽 / 缩放用可变引用(实例唯一)
354
+ let dragRef = null
355
+ let resizeRef = null
356
+
357
+ // ---------- 面板内容(数据 + tabs + 详情),由悬浮/嵌入外壳共用 ----------
358
+ function XueqiuPanel() {
372
359
  const [watchlist, setWatchlist] = React.useState([])
373
360
  const [quotes, setQuotes] = React.useState([])
374
361
  const [indices, setIndices] = React.useState([])
@@ -388,30 +375,8 @@ export default {
388
375
  const [updatedAt, setUpdatedAt] = React.useState(null)
389
376
  const [err, setErr] = React.useState('')
390
377
  const [loading, setLoading] = React.useState(true)
391
- const [hydrated, setHydrated] = React.useState(false)
392
-
393
- // 恢复 UI 状态 + 监听 bus
394
- React.useEffect(function () {
395
- let alive = true
396
- call('ui.get', {}).then(function (d) {
397
- if (!alive) return
398
- if (d && d.pos) setPos({ x: Number(d.pos.x), y: Number(d.pos.y) })
399
- if (d && d.tab) setTab(d.tab)
400
- setMinimized(!!(d && d.minimized))
401
- setHydrated(true)
402
- }).catch(function () { setHydrated(true) })
403
- const off = bus.on(function (ev) {
404
- if (ev === 'open') { setVisible(true); setMinimized(false) }
405
- if (ev === 'close') setVisible(false)
406
- })
407
- return function () { alive = false; off() }
408
- }, [])
409
-
410
- // 持久化 UI 状态(hydrated 后再写,避免覆盖)
411
- React.useEffect(function () {
412
- if (!hydrated) return
413
- saveUi({ tab: tab, minimized: minimized, pos: pos })
414
- }, [tab, minimized, pos, hydrated])
378
+ const tab = ui.tab
379
+ function setTab(t) { ui.set({ tab: t }) }
415
380
 
416
381
  function refreshMarket() {
417
382
  return Promise.all([
@@ -527,58 +492,6 @@ export default {
527
492
  setManualCode('')
528
493
  }
529
494
 
530
- // 拖拽
531
- function onTitleDown(e) {
532
- if (e.pointerType === 'mouse' && e.button !== 0) return
533
- const baseX = pos ? pos.x : e.clientX
534
- const baseY = pos ? pos.y : e.clientY
535
- dragRef = { dx: e.clientX - baseX, dy: e.clientY - baseY }
536
- try { e.currentTarget.setPointerCapture(e.pointerId) } catch (err) { /* ignore */ }
537
- }
538
- function onTitleMove(e) {
539
- if (!dragRef) return
540
- setPos({ x: Math.max(4, e.clientX - dragRef.dx), y: Math.max(4, e.clientY - dragRef.dy) })
541
- }
542
- function onTitleUp(e) {
543
- dragRef = null
544
- try {
545
- if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId)
546
- } catch (err) { /* ignore */ }
547
- }
548
-
549
- if (!visible) return null
550
-
551
- const dragProps = {
552
- onPointerDown: onTitleDown,
553
- onPointerMove: onTitleMove,
554
- onPointerUp: onTitleUp,
555
- onPointerCancel: onTitleUp
556
- }
557
-
558
- // 最小化条
559
- if (minimized) {
560
- const idxLine = indices.map(function (q) {
561
- return q.name + ' ' + fmt(q.current) + ' ' + fmtPct(q.percent)
562
- }).join(' · ')
563
- return el('div', { className: 'xq-float xq-min', style: pos ? { left: pos.x, top: pos.y, right: 'auto', bottom: 'auto' } : null, onClick: function () { setMinimized(false) }, ...dragProps }, [
564
- el('span', { key: 'logo', className: 'xq-logo' }, [el('b', { key: 'b' }, '雪球'), ' mini']),
565
- el('span', { key: 'sum', className: 'xq-sum' }, idxLine || '点击展开行情面板'),
566
- el('span', { key: 'sp', className: 'xq-spacer' }),
567
- el('button', { key: 'x', className: 'xq-btn-mini', onClick: function (e) { e.stopPropagation(); setVisible(false) }, onPointerDown: function (e) { e.stopPropagation() } }, '✕'),
568
- el('span', { key: 'caret', className: 'xq-caret' }, '展开 ▾')
569
- ])
570
- }
571
-
572
- // 标题栏
573
- const titleBar = el('div', { className: 'xq-title', ...dragProps }, [
574
- el('span', { key: 'logo', className: 'xq-logo' }, [el('b', { key: 'b' }, '雪球'), ' mini']),
575
- el('span', { key: 'st', className: 'xq-status' + (marketOpen ? ' xq-live' : '') }, marketOpen ? '● 盘中' : '已收盘'),
576
- el('span', { key: 'upd', className: 'xq-update' }, updatedAt ? ('更新 ' + fmtTime(updatedAt)) : (loading ? '加载中…' : '')),
577
- el('span', { key: 'sp', className: 'xq-spacer' }),
578
- el('button', { key: 'min', className: 'xq-btn-mini', title: '收起', onPointerDown: function (e) { e.stopPropagation() }, onClick: function (e) { e.stopPropagation(); setMinimized(true) } }, '—'),
579
- el('button', { key: 'cls', className: 'xq-btn-mini', title: '关闭(可从下方入口重新打开)', onPointerDown: function (e) { e.stopPropagation() }, onClick: function (e) { e.stopPropagation(); setVisible(false) } }, '✕')
580
- ])
581
-
582
495
  const tabs = el('div', { className: 'xq-tabs' }, [
583
496
  ['market', '行情'], ['hot', '热榜'], ['search', '搜索'], ['news', '快讯']
584
497
  ].map(function (t) {
@@ -618,7 +531,7 @@ export default {
618
531
  ]),
619
532
  el('div', { key: 'c', className: colorOf(q.percent) }, fmt(q.current)),
620
533
  el('div', { key: 'p', className: colorOf(q.percent) }, fmtPct(q.percent)),
621
- el('div', { key: 'v', className: 'xq-sub' }, fmtVol(q.volume)),
534
+ el('div', { key: 'v', className: 'xq-sub xq-vol-col' }, fmtVol(q.volume)),
622
535
  el('div', { key: 'a2', className: 'xq-actions' }, [
623
536
  el('button', { key: 'd', className: 'xq-btn-mini', onClick: function () { openDetail(q.symbol) } }, '详情'),
624
537
  el('button', { key: 'r', className: 'xq-btn-mini', onClick: function () { removeWatch(q.symbol) } }, '×')
@@ -823,19 +736,351 @@ export default {
823
736
  content = NewsTab()
824
737
  }
825
738
 
826
- return el('div', { className: 'xq-float', style: pos ? { left: pos.x, top: pos.y, right: 'auto', bottom: 'auto' } : null }, [
827
- titleBar,
828
- el('div', { key: 'body', className: 'xq-body' }, [tabs, errBox, content])
739
+ return el('div', null, [tabs, errBox, content])
740
+ }
741
+
742
+ // ---------- 悬浮外壳(shell.overlay):自由拖拽 + 左右边缘贴靠 + 四角磁吸 + 缩放 ----------
743
+ function FloatingShell() {
744
+ const [, force] = React.useState(0)
745
+ const [fIdx, setFIdx] = React.useState([])
746
+ const [fOpen, setFOpen] = React.useState(true)
747
+ const [dragTarget, setDragTarget] = React.useState(null)
748
+ React.useEffect(function () {
749
+ let alive = true
750
+ function refresh() {
751
+ call('quote', { symbols: ['SH000001', 'SZ399001', 'SZ399006', 'SH000300'] }).then(function (data) {
752
+ if (!alive) return
753
+ setFIdx((data && data.list) || [])
754
+ const st = data ? data.status : null
755
+ setFOpen(st === 5 || st === 6)
756
+ }).catch(function () { /* 静默失败 */ })
757
+ }
758
+ refresh()
759
+ const stop = ctx.interval(refresh, 60000)
760
+ return function () { alive = false; if (stop) stop() }
761
+ }, [])
762
+ React.useEffect(function () {
763
+ let alive = true
764
+ call('ui.get', {}).then(function (d) {
765
+ if (!alive) return
766
+ if (d && d.mode) ui.set({ mode: d.mode })
767
+ if (d && d.pos) ui.set({ pos: { x: Number(d.pos.x), y: Number(d.pos.y) } })
768
+ if (d && d.size) ui.set({ size: { w: Number(d.size.w), h: Number(d.size.h) } })
769
+ if (d && d.dockW) ui.set({ dockW: Number(d.dockW) })
770
+ if (d && d.snap) ui.set({ snap: d.snap })
771
+ if (d && (d.snapEdge === 'left' || d.snapEdge === 'right')) ui.set({ snapEdge: d.snapEdge })
772
+ if (d && d.snapW) ui.set({ snapW: Number(d.snapW) })
773
+ if (d && d.tab) ui.set({ tab: d.tab })
774
+ ui.set({ minimized: !!(d && d.minimized), hydrated: true })
775
+ }).catch(function () { ui.set({ hydrated: true }) })
776
+ const offUi = ui.subscribe(function () {
777
+ force(function (x) { return x + 1 })
778
+ if (ui.hydrated) saveUi()
779
+ })
780
+ const offBus = bus.on(function (ev) {
781
+ if (ev === 'open') { ui.set({ closed: false, minimized: false, mode: 'floating' }) }
782
+ })
783
+ return function () { alive = false; offUi(); offBus() }
784
+ }, [])
785
+
786
+ if (ui.mode !== 'floating' || ui.closed || !ui.hydrated) return null
787
+ const s = ui.size
788
+
789
+ // 拖拽(贴靠状态下按下标题栏 → 解除贴靠回到自由)
790
+ function onTitleDown(e) {
791
+ if (e.pointerType === 'mouse' && e.button !== 0) return
792
+ if (ui.snapEdge) {
793
+ const vp = viewport()
794
+ if (vp) {
795
+ const x = ui.snapEdge === 'right' ? vp.w - ui.snapW : 0
796
+ ui.set({ snapEdge: null, pos: { x: x, y: 0 }, snap: null })
797
+ } else {
798
+ ui.set({ snapEdge: null, pos: { x: e.clientX - 60, y: e.clientY - 20 }, snap: null })
799
+ }
800
+ }
801
+ const baseX = ui.pos ? ui.pos.x : e.clientX
802
+ const baseY = ui.pos ? ui.pos.y : e.clientY
803
+ dragRef = { dx: e.clientX - baseX, dy: e.clientY - baseY }
804
+ ui.set({ snap: null })
805
+ setDragTarget(null)
806
+ try { e.currentTarget.setPointerCapture(e.pointerId) } catch (err) { /* ignore */ }
807
+ }
808
+ function onTitleMove(e) {
809
+ if (!dragRef) return
810
+ ui.set({ pos: { x: Math.max(4, e.clientX - dragRef.dx), y: Math.max(4, e.clientY - dragRef.dy) } })
811
+ const vp = viewport()
812
+ if (vp) {
813
+ if (e.clientX >= vp.w - 70) setDragTarget('right')
814
+ else if (e.clientX <= 70) setDragTarget('left')
815
+ else setDragTarget(null)
816
+ }
817
+ }
818
+ function onTitleUp(e) {
819
+ dragRef = null
820
+ try { if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId) } catch (err) { /* ignore */ }
821
+ if (dragTarget) {
822
+ ui.set({ snapEdge: dragTarget, pos: null, snap: null, snapW: ui.snapW || 380 })
823
+ setDragTarget(null)
824
+ return
825
+ }
826
+ setDragTarget(null)
827
+ snapCheck()
828
+ }
829
+ // 四角磁吸:拖放终点靠近任一屏幕角 → 吸附
830
+ function snapCheck() {
831
+ const vp = viewport()
832
+ const p = ui.pos
833
+ if (!vp || !p) return
834
+ const corners = [
835
+ { key: 'tl', x: 8, y: 8 },
836
+ { key: 'tr', x: vp.w - s.w - 8, y: 8 },
837
+ { key: 'bl', x: 8, y: vp.h - s.h - 8 },
838
+ { key: 'br', x: vp.w - s.w - 8, y: vp.h - s.h - 8 }
839
+ ]
840
+ let best = null, bestD = 110
841
+ for (let i = 0; i < corners.length; i++) {
842
+ const c = corners[i]
843
+ const d = Math.sqrt(Math.pow(p.x - c.x, 2) + Math.pow(p.y - c.y, 2))
844
+ if (d < bestD) { bestD = d; best = c }
845
+ }
846
+ ui.set({ pos: best ? { x: Math.max(0, best.x), y: Math.max(0, best.y) } : p, snap: best ? best.key : null })
847
+ }
848
+ // 📌 循环:右下角 → 右侧窄栏贴靠 → 解除
849
+ function toggleSnap() {
850
+ const vp = viewport()
851
+ if (ui.snapEdge === 'right') { ui.set({ snapEdge: null }); return }
852
+ if (ui.snap === 'br') {
853
+ ui.set({ snap: null })
854
+ if (vp) ui.set({ snapEdge: 'right', pos: null, snapW: ui.snapW || 380 })
855
+ return
856
+ }
857
+ if (vp) { ui.set({ snap: 'br', pos: { x: Math.max(0, vp.w - s.w - 8), y: Math.max(0, vp.h - s.h - 8) } }) }
858
+ else { ui.set({ snap: 'br', pos: null }) }
859
+ }
860
+ // 缩放(右下角手柄;贴靠时只调宽度)
861
+ function onRsDown(e) {
862
+ if (e.pointerType === 'mouse' && e.button !== 0) return
863
+ resizeRef = ui.snapEdge
864
+ ? { w: ui.snapW, h: 0, x: e.clientX, y: e.clientY }
865
+ : { w: s.w, h: s.h, x: e.clientX, y: e.clientY }
866
+ try { e.currentTarget.setPointerCapture(e.pointerId) } catch (err) { /* ignore */ }
867
+ }
868
+ function onRsMove(e) {
869
+ if (!resizeRef) return
870
+ if (ui.snapEdge) {
871
+ const w = Math.min(Math.max(300, resizeRef.w + (e.clientX - resizeRef.x)), 560)
872
+ ui.set({ snapW: w })
873
+ } else {
874
+ const w = Math.min(Math.max(460, resizeRef.w + (e.clientX - resizeRef.x)), 1100)
875
+ const h = Math.min(Math.max(380, resizeRef.h + (e.clientY - resizeRef.y)), 1000)
876
+ ui.set({ size: { w: w, h: h }, snap: null })
877
+ }
878
+ }
879
+ function onRsUp(e) {
880
+ resizeRef = null
881
+ try { if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId) } catch (err) { /* ignore */ }
882
+ }
883
+
884
+ const dragProps = { onPointerDown: onTitleDown, onPointerMove: onTitleMove, onPointerUp: onTitleUp, onPointerCancel: onTitleUp }
885
+ const rsProps = { onPointerDown: onRsDown, onPointerMove: onRsMove, onPointerUp: onRsUp, onPointerCancel: onRsUp }
886
+
887
+ // 贴靠助手预览(拖动中靠近边缘时显示)
888
+ let snapPreview = null
889
+ if (dragTarget) {
890
+ snapPreview = el('div', {
891
+ key: 'prev', className: 'xq-snap-preview xq-snap-' + dragTarget,
892
+ style: { width: ui.snapW || 380 }
893
+ })
894
+ }
895
+
896
+ // 最小化条
897
+ if (ui.minimized) {
898
+ const idxLine = fIdx.map(function (q) {
899
+ return q.name + ' ' + fmt(q.current) + ' ' + fmtPct(q.percent)
900
+ }).join(' · ')
901
+ let minStyle = null
902
+ if (ui.snapEdge === 'right') minStyle = { right: 0, top: 0, bottom: 0, width: ui.snapW, height: 'auto' }
903
+ else if (ui.snapEdge === 'left') minStyle = { left: 0, top: 0, bottom: 0, width: ui.snapW, height: 'auto' }
904
+ else if (ui.pos) minStyle = { left: ui.pos.x, top: ui.pos.y, right: 'auto', bottom: 'auto' }
905
+ return el('div', { className: 'xq-wrap' }, [
906
+ el('div', { key: 'min', className: 'xq-float xq-min' + (ui.snapEdge ? ' xq-snapped' : ''), style: minStyle, ...dragProps, onClick: function () { ui.set({ minimized: false }) } }, [
907
+ el('span', { key: 'logo', className: 'xq-logo' }, [el('b', { key: 'b' }, '雪球'), ' mini']),
908
+ el('span', { key: 'sum', className: 'xq-sum' }, idxLine || '点击展开行情面板'),
909
+ el('span', { key: 'sp', className: 'xq-spacer' }),
910
+ el('button', { key: 'x', className: 'xq-btn-mini', onPointerDown: function (e) { e.stopPropagation() }, onClick: function (e) { e.stopPropagation(); ui.set({ closed: true }) } }, '✕'),
911
+ el('span', { key: 'caret', className: 'xq-caret' }, '展开 ▾')
912
+ ]),
913
+ snapPreview
914
+ ])
915
+ }
916
+
917
+ const snapped = ui.snapEdge ? ' xq-snapped xq-narrow' : (ui.snap ? ' xq-snapped' : '')
918
+ const titleBar = el('div', { className: 'xq-title', ...dragProps }, [
919
+ el('span', { key: 'logo', className: 'xq-logo' }, [el('b', { key: 'b' }, '雪球'), ' mini']),
920
+ el('span', { key: 'st', className: 'xq-status' + (fOpen ? ' xq-live' : '') }, fOpen ? '● 盘中' : '已收盘'),
921
+ el('span', { key: 'upd', className: 'xq-update' }, ui.snapEdge === 'right' ? '已贴右 · 拖走解除' : ui.snapEdge === 'left' ? '已贴左 · 拖走解除' : '拖到左/右边缘贴靠'),
922
+ el('span', { key: 'sp', className: 'xq-spacer' }),
923
+ el('button', { key: 'snap', className: 'xq-btn-mini', title: '循环:右下角 → 右侧窄栏 → 解除', onPointerDown: function (e) { e.stopPropagation() }, onClick: function (e) { e.stopPropagation(); toggleSnap() } }, ui.snapEdge === 'right' ? '◫' : ui.snap === 'br' ? '📌' : '📍'),
924
+ el('button', { key: 'dock', className: 'xq-btn-mini', title: '嵌入到输入框上方(不遮挡对话)', onPointerDown: function (e) { e.stopPropagation() }, onClick: function (e) { e.stopPropagation(); ui.set({ mode: 'docked', minimized: false }) } }, '嵌入'),
925
+ el('button', { key: 'min', className: 'xq-btn-mini', title: '收起', onPointerDown: function (e) { e.stopPropagation() }, onClick: function (e) { e.stopPropagation(); ui.set({ minimized: true }) } }, '—'),
926
+ el('button', { key: 'cls', className: 'xq-btn-mini', title: '关闭(点底部指数条重新打开)', onPointerDown: function (e) { e.stopPropagation() }, onClick: function (e) { e.stopPropagation(); ui.set({ closed: true }) } }, '✕')
927
+ ])
928
+
929
+ return el('div', { className: 'xq-wrap' }, [
930
+ el('div', { key: 'f', className: 'xq-float' + snapped, style: posStyle() }, [
931
+ titleBar,
932
+ el('div', { key: 'body', className: 'xq-body' }, [
933
+ el(XueqiuPanel, null)
934
+ ]),
935
+ el('div', { key: 'rs', className: 'xq-resize', ...rsProps })
936
+ ]),
937
+ snapPreview
938
+ ])
939
+ }
940
+
941
+ function posStyle() {
942
+ if (ui.snapEdge === 'right') return { right: 0, top: 0, bottom: 0, left: 'auto', width: ui.snapW }
943
+ if (ui.snapEdge === 'left') return { left: 0, top: 0, bottom: 0, right: 'auto', width: ui.snapW }
944
+ if (ui.pos) return { left: ui.pos.x, top: ui.pos.y, right: 'auto', bottom: 'auto', width: ui.size.w, height: ui.size.h }
945
+ return { width: ui.size.w, height: ui.size.h }
946
+ }
947
+
948
+ // ---------- 嵌入外壳(conversation.input.dock):输入框上方,不遮挡对话 ----------
949
+ function DockedShell() {
950
+ const [, force] = React.useState(0)
951
+ React.useEffect(function () {
952
+ const off = ui.subscribe(function () { force(function (x) { return x + 1 }) })
953
+ return off
954
+ }, [])
955
+ if (ui.mode !== 'docked' || !ui.hydrated) return null
956
+
957
+ function onDwDown(e) {
958
+ if (e.pointerType === 'mouse' && e.button !== 0) return
959
+ resizeRef = { w: ui.dockW, h: 0, x: e.clientX, y: e.clientY }
960
+ try { e.currentTarget.setPointerCapture(e.pointerId) } catch (err) { /* ignore */ }
961
+ }
962
+ function onDwMove(e) {
963
+ if (!resizeRef) return
964
+ ui.set({ dockW: Math.min(Math.max(480, resizeRef.w + (e.clientX - resizeRef.x)), 1100) })
965
+ }
966
+ function onDwUp(e) {
967
+ resizeRef = null
968
+ try { if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId) } catch (err) { /* ignore */ }
969
+ }
970
+
971
+ if (ui.minimized) {
972
+ return el('div', { className: 'xq-docked xq-docked-min', style: { width: ui.dockW }, onClick: function () { ui.set({ minimized: false }) } }, [
973
+ el('span', { key: 'logo', className: 'xq-logo' }, [el('b', { key: 'b' }, '雪球'), ' mini']),
974
+ el('span', { key: 'sum', className: 'xq-sum' }, '点击展开行情面板'),
975
+ el('span', { key: 'sp', className: 'xq-spacer' }),
976
+ el('span', { key: 'caret', className: 'xq-caret' }, '展开 ▾')
977
+ ])
978
+ }
979
+
980
+ return el('div', { className: 'xq-docked', style: { width: ui.dockW } }, [
981
+ el('div', { key: 'title', className: 'xq-title' }, [
982
+ el('span', { key: 'logo', className: 'xq-logo' }, [el('b', { key: 'b' }, '雪球'), ' mini']),
983
+ el('span', { key: 'hint', className: 'xq-update' }, '已嵌入 · 不遮挡对话'),
984
+ el('span', { key: 'sp', className: 'xq-spacer' }),
985
+ el('button', { key: 'float', className: 'xq-btn-mini', title: '切换回悬浮模式', onClick: function () { ui.set({ mode: 'floating', minimized: false }) } }, '悬浮'),
986
+ el('button', { key: 'min', className: 'xq-btn-mini', title: '收起', onClick: function () { ui.set({ minimized: true }) } }, '—')
987
+ ]),
988
+ el('div', { key: 'body', className: 'xq-body' }, [
989
+ el(XueqiuPanel, null)
990
+ ]),
991
+ el('div', { key: 'rs', className: 'xq-resize xq-resize-w', onPointerDown: onDwDown, onPointerMove: onDwMove, onPointerUp: onDwUp, onPointerCancel: onDwUp })
992
+ ])
993
+ }
994
+
995
+ function posStyle() {
996
+ if (ui.pos) return { left: ui.pos.x, top: ui.pos.y, right: 'auto', bottom: 'auto', width: ui.size.w, height: ui.size.h }
997
+ return { width: ui.size.w, height: ui.size.h }
998
+ }
999
+
1000
+ // ---------- 输入框上方入口条(仅悬浮模式下显示) ----------
1001
+ function Entry() {
1002
+ const [indices, setIndices] = React.useState([])
1003
+ const [, force] = React.useState(0)
1004
+ React.useEffect(function () {
1005
+ let alive = true
1006
+ function refresh() {
1007
+ call('quote', { symbols: ['SH000001', 'SZ399001', 'SZ399006', 'SH000300'] }).then(function (data) {
1008
+ if (alive) setIndices((data && data.list) || [])
1009
+ }).catch(function () { /* 静默失败 */ })
1010
+ }
1011
+ refresh()
1012
+ const off = ui.subscribe(function () { force(function (x) { return x + 1 }) })
1013
+ const stop = ctx.interval(refresh, 60000)
1014
+ return function () { alive = false; off(); if (stop) stop() }
1015
+ }, [])
1016
+ if (ui.mode === 'docked') return null
1017
+ const line = indices.map(function (q) {
1018
+ return q.name + ' ' + fmt(q.current) + ' ' + fmtPct(q.percent)
1019
+ }).join(' · ')
1020
+ return el('div', { className: 'xq-entry', onClick: function () { bus.emit('open') } }, [
1021
+ el('span', { key: 'logo', className: 'xq-logo' }, [el('b', { key: 'b' }, '雪球'), ' mini']),
1022
+ el('span', { key: 'sum', className: 'xq-sum' }, line || '点击打开行情面板'),
1023
+ el('span', { key: 'caret', className: 'xq-caret' }, '打开 ▾')
829
1024
  ])
830
1025
  }
831
1026
 
1027
+ // ---------- 底部指数条 ----------
1028
+ function Ticker() {
1029
+ const [indices, setIndices] = React.useState([])
1030
+ React.useEffect(function () {
1031
+ let alive = true
1032
+ function refresh() {
1033
+ call('quote', { symbols: ['SH000001', 'SZ399001', 'SZ399006', 'SH000300'] }).then(function (data) {
1034
+ if (alive) setIndices((data && data.list) || [])
1035
+ }).catch(function () { /* 静默失败 */ })
1036
+ }
1037
+ refresh()
1038
+ const stop = ctx.interval(refresh, 30000)
1039
+ return function () { alive = false; if (stop) stop() }
1040
+ }, [])
1041
+ if (!indices.length) return null
1042
+ const kids = indices.map(function (q) {
1043
+ return el('span', { key: q.symbol, className: 'xq-tick' }, [
1044
+ el('span', { key: 'n', className: 'xq-tick-name' }, q.name),
1045
+ el('span', { key: 'c', className: 'xq-tick-cur ' + colorOf(q.percent) }, fmt(q.current)),
1046
+ el('span', { key: 'p', className: colorOf(q.percent) }, fmtPct(q.percent))
1047
+ ])
1048
+ })
1049
+ return el('div', { className: 'xq-ticker', onClick: function () { bus.emit('open') } }, kids)
1050
+ }
1051
+
1052
+ // 追加样式:嵌入外壳 / 缩放手柄 / 磁吸动画
1053
+ styles.insert(
1054
+ '.xq-docked{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;overflow:hidden;position:relative;font-size:13px;line-height:1.45;color:var(--dsw-alias-label-primary);margin-bottom:2px;}\n' +
1055
+ '.xq-docked *{box-sizing:border-box;}\n' +
1056
+ '.xq-docked-min{display:flex;align-items:center;gap:10px;cursor:pointer;padding:6px 12px;}\n' +
1057
+ '.xq-docked .xq-title{cursor:default;}\n' +
1058
+ '.xq-resize{position:absolute;right:2px;bottom:2px;width:18px;height:18px;cursor:nwse-resize;z-index:6;}\n' +
1059
+ '.xq-resize::after{content:"";position:absolute;right:4px;bottom:4px;width:8px;height:8px;border-right:2px solid var(--dsw-alias-label-secondary);border-bottom:2px solid var(--dsw-alias-label-secondary);opacity:.55;}\n' +
1060
+ '.xq-resize-w{position:absolute;right:0;top:0;bottom:0;width:10px;cursor:ew-resize;z-index:6;}\n' +
1061
+ '.xq-resize-w::after{display:none;}\n' +
1062
+ '.xq-snapped{transition:left .16s ease, top .16s ease;}\n' +
1063
+ '.xq-wrap{pointer-events:none;position:fixed;inset:0;z-index:1200;}\n' +
1064
+ '.xq-wrap .xq-float{pointer-events:auto;}\n' +
1065
+ '.xq-snap-preview{position:fixed;top:8px;bottom:8px;border:2px solid var(--dsw-alias-brand-primary);border-radius:10px;background:var(--dsw-alias-brand-primary);opacity:.13;pointer-events:none;z-index:1199;}\n' +
1066
+ '.xq-snap-right{right:8px;}\n' +
1067
+ '.xq-snap-left{left:8px;}\n' +
1068
+ '.xq-float.xq-narrow .xq-grid-hd,.xq-float.xq-narrow .xq-grid-row{grid-template-columns:1.5fr 1fr 1fr 62px;}\n' +
1069
+ '.xq-float.xq-narrow .xq-vol-col{display:none;}\n' +
1070
+ '.xq-float.xq-narrow .xq-idx-card{min-width:88px;padding:5px 6px;}\n' +
1071
+ '.xq-float.xq-narrow .xq-idx-cur{font-size:12px;}\n' +
1072
+ '.xq-float.xq-narrow .xq-stats{grid-template-columns:repeat(2,1fr);}\n' +
1073
+ '.xq-float.xq-narrow .xq-detail-cur{font-size:16px;}\n' +
1074
+ '.xq-float.xq-narrow .xq-periods button,.xq-float.xq-narrow .xq-modes button{padding:2px 6px;}\n'
1075
+ )
1076
+
832
1077
  // ---------- 注册 ----------
833
1078
  const slots = ctx.get('slots')
834
1079
  if (slots === undefined) return
835
1080
  slots.inject('shell.overlay', function () {
836
1081
  return slots.register(
837
1082
  { name: 'shell.overlay', id: 'xueqiu-panel' },
838
- function () { return el(Panel, null) }
1083
+ function () { return el(FloatingShell, null) }
839
1084
  )
840
1085
  })
841
1086
  slots.inject('conversation.input.dock', function () {
@@ -844,11 +1089,18 @@ export default {
844
1089
  function () { return el(Entry, null) }
845
1090
  )
846
1091
  })
1092
+ slots.inject('conversation.input.dock', function () {
1093
+ return slots.register(
1094
+ { name: 'conversation.input.dock', id: 'xueqiu-docked', order: 31 },
1095
+ function () { return el(DockedShell, null) }
1096
+ )
1097
+ })
847
1098
  slots.inject('conversation.composer.dock', function () {
848
1099
  return slots.register(
849
1100
  { name: 'conversation.composer.dock', id: 'xueqiu-ticker', order: 1 },
850
1101
  function () { return el(Ticker, null) }
851
1102
  )
852
1103
  })
1104
+
853
1105
  }
854
- }
1106
+ }
package/src/index.js CHANGED
@@ -386,7 +386,13 @@ export default {
386
386
 
387
387
  async function actUiGet() {
388
388
  const s = await loadUiState()
389
- return { pos: s.pos || null, tab: s.tab || 'market', minimized: !!s.minimized }
389
+ return {
390
+ mode: s.mode || 'floating', pos: s.pos || null,
391
+ size: (s.size && isFinite(Number(s.size.w)) && isFinite(Number(s.size.h))) ? { w: Number(s.size.w), h: Number(s.size.h) } : null,
392
+ dockW: s.dockW || null, snap: s.snap || null,
393
+ snapEdge: s.snapEdge || null, snapW: s.snapW || 380,
394
+ tab: s.tab || 'market', minimized: !!s.minimized
395
+ }
390
396
  }
391
397
 
392
398
  async function actUiSet(args) {
@@ -396,6 +402,14 @@ export default {
396
402
  }
397
403
  if (args.tab !== undefined) s.tab = String(args.tab)
398
404
  if (args.minimized !== undefined) s.minimized = !!args.minimized
405
+ if (args.mode === 'floating' || args.mode === 'docked') s.mode = args.mode
406
+ if (args.size && typeof args.size === 'object' && isFinite(Number(args.size.w)) && isFinite(Number(args.size.h))) {
407
+ s.size = { w: Number(args.size.w), h: Number(args.size.h) }
408
+ }
409
+ if (args.dockW !== undefined && isFinite(Number(args.dockW))) s.dockW = Number(args.dockW)
410
+ if ('snap' in args && args.snap !== undefined) s.snap = args.snap === null ? null : String(args.snap)
411
+ if (args.snapEdge === 'left' || args.snapEdge === 'right' || args.snapEdge === null) s.snapEdge = args.snapEdge
412
+ if (args.snapW !== undefined && isFinite(Number(args.snapW))) s.snapW = Number(args.snapW)
399
413
  await saveUiState()
400
414
  return { ok: true }
401
415
  }