@mzzsfy/dsh-stock-assistant 0.3.1 → 0.3.2
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 +10 -7
- package/package.json +5 -2
- package/src/application.mjs +34 -15
- package/src/catalog.mjs +60 -31
- package/src/client.js +1288 -190
- package/src/collector.mjs +16 -6
- package/src/market-model.mjs +39 -11
- package/src/providers/eastmoney.mjs +51 -16
- package/src/providers/sina.mjs +121 -98
- package/src/providers/tencent.mjs +23 -14
- package/src/providers/tushare.mjs +1 -1
- package/src/providers/xueqiu.mjs +81 -0
- package/src/schemas.mjs +32 -9
- package/src/service.mjs +17 -6
- package/vendor/lightweight-charts.standalone.production.js +7 -0
- package//345/205/274/345/256/271/346/200/247/346/265/213/350/257/225.md +101 -2
package/src/client.js
CHANGED
|
@@ -2,6 +2,77 @@ window.__ModuleLoader__.load({
|
|
|
2
2
|
id: '@mzzsfy/dsh-stock-assistant',
|
|
3
3
|
factory(require) {
|
|
4
4
|
const PANEL_ID = 'stock-assistant', BASE = '/api/stock-assistant', BROWSE_PAGE = 50
|
|
5
|
+
// K线结果的归属键: 拉取、校验、灌图三处必须用同一个拼法, 否则校验形同虚设
|
|
6
|
+
function klineKeyOf(securityId, period) { return `${securityId}|${period}` }
|
|
7
|
+
// 灌图计划: 空壳只清屏且不记 key(空数据上 fitContent 是 no-op, 记了就再也不会铺满);
|
|
8
|
+
// 首次看到某个 key 才重置视口, 同一 key 下刷新保留用户缩放
|
|
9
|
+
function klineShipPlan(shippedKey, key, hasData) {
|
|
10
|
+
if (!hasData) return { fit: false, next: shippedKey }
|
|
11
|
+
return { fit: shippedKey !== key, next: key }
|
|
12
|
+
}
|
|
13
|
+
// 折叠状态的键: 组名来自用户输入且只校验长度, 用不可见前缀保证不会与真实组名撞车
|
|
14
|
+
const PINNED_KEY = '\u0000pinned', REST_KEY = '\u0000rest'
|
|
15
|
+
// 自选的分组切面: 置顶 → 各组(按首次出现顺序) → 未分组。列表与悬浮窗勾选共用这一份,
|
|
16
|
+
// 免得两处对"某只属于哪个组"给出不同答案
|
|
17
|
+
function groupSlices(order, items) {
|
|
18
|
+
// 只认确实存在的标的: 自选与明细表短暂不一致时, 不存在的 id 既不该进组也不该进未分组
|
|
19
|
+
const known = order.filter(id => items[id])
|
|
20
|
+
const pinned = known.filter(id => items[id].pinned)
|
|
21
|
+
const rest = known.filter(id => !items[id].pinned)
|
|
22
|
+
const names = [...new Set(rest.map(id => items[id]?.group).filter(Boolean))]
|
|
23
|
+
return {
|
|
24
|
+
pinned,
|
|
25
|
+
groups: names.map(name => ({ name, ids: rest.filter(id => items[id]?.group === name) })),
|
|
26
|
+
ungrouped: rest.filter(id => !items[id]?.group),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// 来源偶发给 null 字段: LWC 只跳过 undefined, null 会按 0 参与价格轴范围把整图压扁;
|
|
30
|
+
// 而非有限的 OHLC 又不能留成 NaN —— 它会顺着均线的滑动窗口把后面所有指标值一起毒掉。
|
|
31
|
+
// 所以四个价格有任一缺失就丢掉整根, 其余字段(avg/amount 等)原样带过去: 分时均价线靠 avg
|
|
32
|
+
// 两份报价里取较新的一份: 自己拉的和面板的都可能旧, 平局取前者
|
|
33
|
+
const pickNewest = (mine, theirs) => {
|
|
34
|
+
if (!mine) return theirs
|
|
35
|
+
if (!theirs) return mine
|
|
36
|
+
return (Number(mine.receivedAt) || 0) >= (Number(theirs.receivedAt) || 0) ? mine : theirs
|
|
37
|
+
}
|
|
38
|
+
// 分时蜡烛的时间是 HH:MM 标签, 日期解析器认不出来
|
|
39
|
+
const CLOCK = /^\d{1,2}:\d{2}(:\d{2})?$/
|
|
40
|
+
function sanitizeCandles(list) {
|
|
41
|
+
return (list || []).flatMap(candle => {
|
|
42
|
+
const prices = { open: finiteOr(candle.open), high: finiteOr(candle.high), low: finiteOr(candle.low), close: finiteOr(candle.close) }
|
|
43
|
+
if (!Object.values(prices).every(Number.isFinite)) return []
|
|
44
|
+
// 时间戳坏掉整根丢弃: 图表库遇到非法日期字符串会抛, 而异常发生在 effect 里,
|
|
45
|
+
// 会把整块图表连同面板一起卸掉, 代价远大于少一根蜡烛.
|
|
46
|
+
// 注意不能用 Number() 来判断——Number('') / Number(true) / Number([]) 全是有限值
|
|
47
|
+
const at = candle.at, time = candle.time
|
|
48
|
+
if (at != null && !(typeof at === 'number' ? Number.isFinite(at) : typeof at === 'string' && !Number.isNaN(Date.parse(at)))) return []
|
|
49
|
+
if (time != null && !(typeof time === 'number' ? Number.isFinite(time) : typeof time === 'string' && (CLOCK.test(time) || !Number.isNaN(Date.parse(time))))) return []
|
|
50
|
+
const volume = finiteOr(candle.volume), avg = finiteOr(candle.avg)
|
|
51
|
+
// 缺失一律给 undefined 而不是 NaN, 让下游的 ?? / 默认值能正常接住
|
|
52
|
+
return [{ ...candle, ...prices, volume: Number.isFinite(volume) ? volume : undefined, avg: Number.isFinite(avg) ? avg : undefined }]
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
// 缺失值不是 0: 直接 Number() 会把 null 变成真实的 0, 画到图上就是一条压在地板上的线
|
|
56
|
+
const finiteOr = value => value == null || value === '' ? NaN : Number(value)
|
|
57
|
+
// 成本标记取等权平均: 没有数量信息, 等权是唯一不引入臆测的口径
|
|
58
|
+
const avgCostOf = marks => { const prices = (marks || []).filter(mark => mark.kind === 'cost').map(mark => mark.price); return prices.length ? prices.reduce((sum, price) => sum + price, 0) / prices.length : null }
|
|
59
|
+
// 价格标记按该证券的价位精度显示: 港股四位小数, 用 toString 会丢位数
|
|
60
|
+
const priceTextOf = (value, scale) => value == null || Number.isNaN(Number(value)) ? '—' : Number(value).toFixed(scale ?? 2)
|
|
61
|
+
// 每个标记各自的盈亏口径: 成本看「现价相对它」的浮盈, 卖价看「它相对成本」的潜在收益,
|
|
62
|
+
// 还没有成本时卖价退回相对现价——三个口径都是同一个式子, 只是基准不同
|
|
63
|
+
const markPct = (mark, last, avgCost) => {
|
|
64
|
+
const base = mark.kind === 'cost' ? mark.price : avgCost ?? last
|
|
65
|
+
return base == null || last == null ? null : (mark.kind === 'cost' ? last - base : mark.price - base) / base * 100
|
|
66
|
+
}
|
|
67
|
+
// 成交额按万/亿缩写避免长列; zeroIsMissing 关掉时 0 按真值显示(成交量用)
|
|
68
|
+
function formatAmount(value, { zeroIsMissing = true } = {}) {
|
|
69
|
+
if (value == null) return '—'
|
|
70
|
+
const amount = Number(value)
|
|
71
|
+
if (!Number.isFinite(amount) || (zeroIsMissing && amount === 0)) return '—'
|
|
72
|
+
if (Math.abs(amount) >= 1e8) return `${(amount / 1e8).toFixed(1)}亿`
|
|
73
|
+
if (Math.abs(amount) >= 1e4) return `${(amount / 1e4).toFixed(0)}万`
|
|
74
|
+
return amount.toFixed(0)
|
|
75
|
+
}
|
|
5
76
|
// 一键全能力自举: 启用实例的适配器能力声明(capabilities×markets)是唯一事实源, 缺什么补什么
|
|
6
77
|
const CAP_ORDER = ['quotes', 'kline', 'catalog', 'search', 'calendar']
|
|
7
78
|
const bootstrapRoutes = ({ adapters, providers, routes }) => {
|
|
@@ -19,11 +90,21 @@ window.__ModuleLoader__.load({
|
|
|
19
90
|
const minCaps = (adapters.find(x => x.adapterId === supporters[0].adapterId)?.capabilities || []).length
|
|
20
91
|
const preferred = supporters.find(p => p.id === marketRoute?.primaryInstanceId && (adapters.find(x => x.adapterId === p.adapterId)?.capabilities || []).length === minCaps)
|
|
21
92
|
const primary = preferred || supporters[0]
|
|
22
|
-
|
|
23
|
-
|
|
93
|
+
// 只有报价与K线支持备用源(schema 约束), 其余能力恒单源不切换
|
|
94
|
+
const failover = ['quotes', 'kline'].includes(capability)
|
|
95
|
+
const fallbacks = failover ? supporters.filter(p => p.id !== primary.id).map(p => p.id).slice(0, 1) : []
|
|
96
|
+
out.push({ id: `${market.toLowerCase()}-${capability}`, capability, market, instrument: 'equity', primaryInstanceId: primary.id, fallbackInstanceIds: fallbacks, failoverMode: failover && fallbacks.length ? 'network' : 'off', maxDelaySeconds: null, acceptUnknownDelay: true })
|
|
24
97
|
}
|
|
25
98
|
return out
|
|
26
99
|
}
|
|
100
|
+
// 探针失败按目标粒度剔除: 主源不可用提升首个可用备源, 备源不可用仅移除该备源, 全部目标不可用才删路由。
|
|
101
|
+
// 适配器声明的能力矩阵是粗粒度笛卡尔积(如腾讯声明 kline 含美股但实际不支持), 整条剔除会连带丢掉可用备源。
|
|
102
|
+
const pruneDeniedTargets = (routes, deniedKeys) => routes.map(route => {
|
|
103
|
+
const target = id => `${id}:${route.capability}:${route.market}`
|
|
104
|
+
const alive = [route.primaryInstanceId, ...route.fallbackInstanceIds].filter(id => !deniedKeys.has(target(id)))
|
|
105
|
+
if (!alive.length) return null
|
|
106
|
+
return { ...route, primaryInstanceId: alive[0], fallbackInstanceIds: alive.slice(1, 2), failoverMode: alive.length > 1 ? route.failoverMode : 'off' }
|
|
107
|
+
}).filter(Boolean)
|
|
27
108
|
const BROWSE_MARKETS = { XSHG: '沪 A', XSHE: '深 A', XHKG: '港股', XNAS: '纳斯达克', XNYS: '纽交所', XJPX: '日本', XKRX: '韩国', XLON: '英国' }
|
|
28
109
|
function createPageRuntime({ layout, bus, tools, document: doc = document, fetch: request = fetch }) {
|
|
29
110
|
let disposed = false, generation = 0, demandGeneration = 0, pageId = crypto.randomUUID(), timer, heartbeat, undoTimer, queryGeneration = 0, querying, composing = false
|
|
@@ -81,7 +162,17 @@ window.__ModuleLoader__.load({
|
|
|
81
162
|
finally { patch({ busy: { ...state.busy, [key]: false } }) }
|
|
82
163
|
}
|
|
83
164
|
// 离开 detail 视图时统一清详情态, 防止列表视图残留 detail 面板
|
|
84
|
-
|
|
165
|
+
// 换详情即清草稿: 收口在唯一的视图写入口, 浏览/搜索/悬浮窗/邻移都不必各自记得清,
|
|
166
|
+
// 否则上一只输了一半的成本会被"保存成本"写到另一只身上。
|
|
167
|
+
// 别名同理在这里回填: 新增一条打开路径时不必记得带 alias, 漏带的后果是把已存别名保存成空串。
|
|
168
|
+
function updateView(next) {
|
|
169
|
+
const leaveDetail = next.view && next.view !== 'detail' && !next.overlay
|
|
170
|
+
const detailChanged = next.detailId !== undefined && next.detailId !== state.view.detailId
|
|
171
|
+
const saved = next.detailId === undefined ? undefined : (state.data?.control.watchlist.items[next.detailId] || state.view.browsePeek?.[next.detailId])
|
|
172
|
+
const alias = next.alias !== undefined ? { alias: next.alias } : detailChanged ? { alias: saved?.alias || '' } : {}
|
|
173
|
+
patch({ view: { ...state.view, ...next, ...alias, ...(leaveDetail ? { detailId: null, alias: '', browsePeek: {} } : {}), ...(detailChanged ? { markDraft: null, groupDraft: null, markError: null, groupError: null } : {}) } })
|
|
174
|
+
sendDemand()
|
|
175
|
+
}
|
|
85
176
|
function scheduleSearch(query) {
|
|
86
177
|
updateView({ query, selectedId: null }); clearTimeout(timer); querying?.abort(); querying = new AbortController()
|
|
87
178
|
if (composing || !query.trim()) { patch({ candidates: [] }); return }
|
|
@@ -99,10 +190,23 @@ window.__ModuleLoader__.load({
|
|
|
99
190
|
}, 300)
|
|
100
191
|
}
|
|
101
192
|
const operation = () => ({ operationId: crypto.randomUUID(), expectedRevision: state.data?.control.revision ?? 0 })
|
|
193
|
+
// K线请求代际: 同 key 并发时只认最后一次发出的请求
|
|
194
|
+
let klineSeq = 0
|
|
102
195
|
return {
|
|
103
196
|
getSnapshot: () => state,
|
|
104
197
|
subscribe(fn) { subscribers.add(fn); return () => subscribers.delete(fn) },
|
|
105
198
|
updateView, api, action, applySnapshot,
|
|
199
|
+
// 详情只有一个入口: 面板/邻移/悬浮窗都走这里。
|
|
200
|
+
// 面板不在前台时"打开详情"点了没反应, 所以先 selectPanel; 草稿由 updateView 在 detailId 变更处统一清。
|
|
201
|
+
// 不预载 K 线: KlineView 的加载 effect 会在 detailId 变化时自己发起, 预载只会让同一 key 请求两遍。
|
|
202
|
+
openDetail(securityId) {
|
|
203
|
+
const config = state.data?.control, item = config?.watchlist.items[securityId] || state.view.browsePeek?.[securityId]
|
|
204
|
+
if (!item) return
|
|
205
|
+
layout.selectPanel?.(PANEL_ID)
|
|
206
|
+
updateView({ view: 'list', detailId: securityId, selectedId: securityId, overlay: 'detail' })
|
|
207
|
+
// 这里必须经 this 调用同对象的成员, 所以 openDetail 只能以 runtime.openDetail(...) 形式使用
|
|
208
|
+
this.refresh([{ id: item.id, exchange: item.exchange, code: item.code, officialName: item.officialName }]).catch(() => {})
|
|
209
|
+
},
|
|
106
210
|
setPaused(value) { patch({ paused: value }); sendDemand(); if (!value) refresher.refresh() },
|
|
107
211
|
search: scheduleSearch,
|
|
108
212
|
composition(value) { composing = value; patch({ composing: value }); if (!value) scheduleSearch(state.view.query) },
|
|
@@ -127,57 +231,83 @@ window.__ModuleLoader__.load({
|
|
|
127
231
|
},
|
|
128
232
|
refresh(securityIds) { return action('refresh', async () => { const gen = generation; const data = await api('/refresh', 'POST', { expectedRoutingRevision: state.data?.routingRevision, ...(securityIds ? { securities: securityIds } : {}) }); if (gen === generation) applySnapshot(data, true) }) },
|
|
129
233
|
preferences(patchValue) { return action('write', async () => { const result = await api('/preferences', 'POST', { ...operation(), patch: patchValue }); if (state.data && state.data.control.revision <= result.revision) patch({ data: { ...state.data, control: { ...state.data.control, revision: result.revision, preferences: { ...state.data.control.preferences, ...patchValue } } } }); refresher.refresh() }) },
|
|
130
|
-
configuration:
|
|
234
|
+
configuration: configuration => action('configuration', async () => { await api('/configuration', 'POST', { ...operation(), ...configuration, expectedRevision: state.view.settingsDraft?.expectedRevision ?? state.data?.control.revision }); updateView({ settingsDraft: null }); refresher.refresh() }),
|
|
131
235
|
sync: market => action('sync', async () => { const result = await api('/catalog-sync', 'POST', { market, expectedRoutingRevision: state.data?.routingRevision }); refresher.refresh(); return result }),
|
|
132
236
|
check: configuration => action('check', () => api('/provider-check', 'POST', configuration)),
|
|
133
237
|
switchPrimary: routeId => action('switch', async () => { const data = await api('/route-selection', 'POST', { routeId, target: 'primary', expectedRoutingRevision: state.data.routingRevision, expectedRouteEpoch: state.data.routeSelections[routeId].routeEpoch }); applySnapshot(data) }),
|
|
134
238
|
setKlinePeriod(key) { patch({ klinePeriod: key }) },
|
|
135
|
-
|
|
239
|
+
// 错误同样带 key: 结果只在它属于当前标的与周期时才落进 state, 否则卸载/切换后的迟到错误会清掉别的标的
|
|
240
|
+
setKlineError(securityId, period, message, code = '') { if (state.klineKey !== klineKeyOf(securityId, period)) return; patch({ kline: { loading: false, error: message, errorCode: code, candles: null } }) },
|
|
136
241
|
// 一键K线路由: 已启用市场(verified 且 quotes 路由存在即视为启用)逐一生成 (market,'kline') 草稿;
|
|
137
242
|
// 主源=该市场 quotes 主源(支持 kline 的备选优先), 备源=支持 kline 的其他启用实例;已存在 kline 路由的市场跳过。
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
let routes = bootstrapRoutes({ adapters: state.data.adapters, providers: draft.providers, routes: draft.routes })
|
|
142
|
-
if (routes.length === draft.routes.length) { updateView({ view: 'list', notice: '已启用数据源的能力路由均已配置,打开证券详情即可查看行情与K线。' }); return }
|
|
143
|
-
const added = routes.length - draft.routes.length
|
|
243
|
+
// 检查通过即保存: 分批探测(避开单请求预算), denied 目标剔除后整轮重查, 收敛即提交。
|
|
244
|
+
// 一键启用与"应用推荐配置"共用此流程——两者的差别只是初始配置从哪来。
|
|
245
|
+
async commitConfiguration({ providers, routes, notice }) {
|
|
144
246
|
const CAP_LABEL = { quotes: '行情', kline: 'K线', catalog: '目录', search: '搜索', calendar: '日历' }
|
|
145
247
|
await action('configuration', async () => {
|
|
146
|
-
|
|
147
|
-
// 每批完成即 patch 进度文案(aria-live 区与按钮文案共享), 避免长检查期 UI 无反馈
|
|
148
|
-
const checkIds = []
|
|
248
|
+
let current = routes
|
|
149
249
|
let progress = ''
|
|
250
|
+
// 推荐配置与一键补全自带体检: 探针不可用的目标先剔除, 保证装上的是立刻能出数的组合。
|
|
251
|
+
// 这只是推荐配置的质量保证, 手动保存路由不走这条路, 也不看探针结果。
|
|
150
252
|
for (let round = 0; round < 3; round++) {
|
|
151
|
-
|
|
152
|
-
let deniedAll = []
|
|
253
|
+
const denied = []
|
|
153
254
|
for (const caps of [['quotes'], ['kline'], ['catalog', 'search']]) {
|
|
154
|
-
const usedCaps = caps.filter(c =>
|
|
255
|
+
const usedCaps = caps.filter(c => current.some(r => r.capability === c))
|
|
155
256
|
if (!usedCaps.length) continue
|
|
156
257
|
progress = usedCaps.map(c => CAP_LABEL[c]).join('/') + (round ? `(重查第${round + 1}轮)` : '') + ':检查中…'
|
|
157
258
|
patch({ checkProgress: progress })
|
|
158
|
-
const check = await api('/provider-check', 'POST', { providers
|
|
159
|
-
|
|
160
|
-
deniedAll.push(...check.checks.filter(c => c.status !== 'ready').map(c => c.providerInstanceId + ':' + c.capability + ':' + c.market))
|
|
259
|
+
const check = await api('/provider-check', 'POST', { providers, routes: current, capabilities: usedCaps })
|
|
260
|
+
denied.push(...check.checks.filter(c => c.status !== 'ready').map(c => c.providerInstanceId + ':' + c.capability + ':' + c.market))
|
|
161
261
|
}
|
|
162
|
-
if (!
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (!routes.length) { patch({ error: '已启用数据源暂无可用探针,请检查网络后重试', checkProgress: null }); return }
|
|
262
|
+
if (!denied.length) break
|
|
263
|
+
current = pruneDeniedTargets(current, new Set(denied))
|
|
264
|
+
if (!current.length) { patch({ error: '已启用数据源暂无可用探针,请检查网络后重试', checkProgress: null }); return }
|
|
166
265
|
}
|
|
167
266
|
patch({ checkProgress: null })
|
|
168
|
-
await api('/configuration', 'POST', { operationId: crypto.randomUUID(), expectedRevision: state.data.control.revision, providers
|
|
169
|
-
updateView({ view: 'list', settingsDraft: null, notice
|
|
267
|
+
await api('/configuration', 'POST', { operationId: crypto.randomUUID(), expectedRevision: state.data.control.revision, providers, routes: current })
|
|
268
|
+
updateView({ view: 'list', settingsDraft: null, notice })
|
|
170
269
|
}).finally(() => patch({ checkProgress: null }))
|
|
171
270
|
},
|
|
271
|
+
// 一键启用: 按当前草稿里已启用的实例补全缺失的市场能力路由
|
|
272
|
+
async oneClickKline() {
|
|
273
|
+
const saved = state.data.control
|
|
274
|
+
const draft = state.view.settingsDraft || { providers: saved.providers, routes: saved.routes, expectedRevision: saved.revision, checkResults: [], probes: {} }
|
|
275
|
+
const routes = bootstrapRoutes({ adapters: state.data.adapters, providers: draft.providers, routes: draft.routes })
|
|
276
|
+
if (routes.length === draft.routes.length) { updateView({ view: 'list', notice: '已启用数据源的能力路由均已配置,打开证券详情即可查看行情与K线。' }); return }
|
|
277
|
+
await this.commitConfiguration({ providers: draft.providers, routes, notice: `已自动启用 ${routes.length - draft.routes.length} 项能力路由(行情/K线/目录/搜索),全部市场可用。` })
|
|
278
|
+
},
|
|
279
|
+
// 推荐配置: 内置免凭据源 + 八市场能力一次到位, 覆盖数据源与路由, 自选与显示偏好不动
|
|
280
|
+
async applyRecommended() {
|
|
281
|
+
const suggested = state.data?.suggested
|
|
282
|
+
if (!suggested?.providers?.length) { patch({ error: '推荐配置不可用' }); return }
|
|
283
|
+
await this.commitConfiguration({ providers: suggested.providers, routes: suggested.routes, notice: '已应用推荐配置:免凭据数据源与八市场能力路由全部就绪。' })
|
|
284
|
+
},
|
|
172
285
|
// kline 数据流: 详情视图触发;force=true 走 ?force=1 让服务端跳缓存读;结果入 state 供 KlineView 汇合灌入。
|
|
286
|
+
// 复用同一份 state 前要看它有多旧: 只比 securityId|period 会让开着的页面拿昨天的分时当实时。
|
|
173
287
|
loadKline(securityId, period, force = false) {
|
|
174
|
-
const key =
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
288
|
+
const key = klineKeyOf(securityId, period)
|
|
289
|
+
const sameKey = state.klineKey === key, held = sameKey ? state.kline : null
|
|
290
|
+
const maxAge = KLINE_MAX_AGE[period] ?? 0
|
|
291
|
+
const fresh = held?.candles && Date.now() - (held.loadedAt || 0) < maxAge
|
|
292
|
+
if (fresh && !force) return Promise.resolve(held)
|
|
293
|
+
// 同一 key 也可能并发(例如 ↻ 连点): 只让最后一次发出的请求落地, 否则先发后到会盖掉新的
|
|
294
|
+
const seq = ++klineSeq
|
|
295
|
+
patch({ klineKey: key, kline: { loading: !held?.candles, error: '', candles: held?.candles ?? null, loadedAt: held?.loadedAt } })
|
|
296
|
+
const attempt = (delayMs = 0, retried = false) => new Promise(resolve => setTimeout(resolve, delayMs)).then(() => {
|
|
297
|
+
// 退避期间可能已经来了同 key 的新请求, 等到点了就别再打这一枪
|
|
298
|
+
if (seq !== klineSeq) return undefined
|
|
299
|
+
return api(`/kline?${new URLSearchParams({ securityId, period, limit: String(250), ...(force ? { force: '1' } : {}) })}`, 'GET')
|
|
300
|
+
}).then(result => {
|
|
301
|
+
// 等待期间换了标的/周期, 或已有更新的同 key 请求: 丢弃这轮结果, 否则旧数据会盖掉新数据
|
|
302
|
+
if (state.klineKey !== key || seq !== klineSeq) return result
|
|
303
|
+
const candles = sanitizeCandles(result?.candles)
|
|
304
|
+
patch({ kline: { loading: false, error: '', loadedAt: Date.now(), ...result, candles } })
|
|
305
|
+
return { ...result, candles }
|
|
180
306
|
}).catch(e => {
|
|
307
|
+
// 已被更新的同 key 请求取代: 既不该落错误(会盖掉新数据), 也不该继续退避
|
|
308
|
+
if (seq !== klineSeq) return undefined
|
|
309
|
+
// 切走之后就别再为废弃的 key 退避重试了, 白打一次请求还占额度
|
|
310
|
+
if (state.klineKey !== key) throw e
|
|
181
311
|
// 额度窗口瞬时满/网络抖动: 退避自动重试一次; 服务端带 retryAfterMs 时按其等待, 否则 4s
|
|
182
312
|
if (!retried && ['RATE_LIMIT', 'NETWORK', 'TIMEOUT'].includes(e.code)) return attempt(Math.max(4 * 1000, e.retryAfterMs || 0), true)
|
|
183
313
|
patch({ kline: { loading: false, error: e.message || 'K线加载失败', errorCode: e.code, candles: null } })
|
|
@@ -185,7 +315,8 @@ window.__ModuleLoader__.load({
|
|
|
185
315
|
})
|
|
186
316
|
return attempt()
|
|
187
317
|
},
|
|
188
|
-
|
|
318
|
+
// key 不匹配说明是上标的一轮结果, 交给当前轮的 effect 灌
|
|
319
|
+
getKlineResult(securityId, period) { return state.klineKey === klineKeyOf(securityId, period) && state.kline?.candles ? state.kline : null },
|
|
189
320
|
dispose() {
|
|
190
321
|
if (disposed) return
|
|
191
322
|
try { bus.publish({ type: 'watch-demand', pageId, generation: ++demandGeneration, active: false }) } catch {}
|
|
@@ -202,24 +333,83 @@ window.__ModuleLoader__.load({
|
|
|
202
333
|
function PanelIcon({ size = 16, active }) {
|
|
203
334
|
return h('svg', { viewBox: '0 0 16 16', width: size, height: size, fill: 'none', stroke: 'currentColor', strokeWidth: active ? 1.8 : 1.4, 'aria-hidden': true }, h('path', { d: 'M2 2v12h12M4 10l3-4 3 2 4-5', strokeLinecap: 'round', strokeLinejoin: 'round' }))
|
|
204
335
|
}
|
|
205
|
-
|
|
336
|
+
// 包住点击处理: 事件对象不会被当成业务参数透传, 而且要把 promise 交回去,
|
|
337
|
+
// 调用方才能等写入成功再改本地 UI 状态
|
|
338
|
+
const quiet = fn => (...args) => Promise.resolve().then(() => fn(...args)).catch(() => { })
|
|
206
339
|
const signed = value => value == null ? '—' : `${Number(value) > 0 ? '+' : ''}${value}`
|
|
207
340
|
const timeText = value => value ? new Date(value).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }) : '未知'
|
|
341
|
+
// 价格: 空值显示破折号
|
|
342
|
+
const formatPrice = value => value == null || Number.isNaN(Number(value)) ? '—' : String(value)
|
|
343
|
+
// 秒数转 1d2h3m4s: 值为 0 的段整段省略, 不留 0 占位
|
|
344
|
+
const formatDuration = total => {
|
|
345
|
+
const seconds = Math.max(0, Math.round(Number(total) || 0))
|
|
346
|
+
let rest = seconds
|
|
347
|
+
const parts = [['d', 86400], ['h', 3600], ['m', 60], ['s', 1]]
|
|
348
|
+
.map(([unit, size]) => { const value = Math.floor(rest / size); rest -= value * size; return [value, unit] })
|
|
349
|
+
.filter(([value]) => value > 0)
|
|
350
|
+
return parts.length ? parts.map(([value, unit]) => `${value}${unit}`).join('') : '0s'
|
|
351
|
+
}
|
|
352
|
+
// 延迟口径: 交易中报秒差, 非交易时段报价停在收盘那刻, 报时间点而不是把收盘后的时长当延迟
|
|
353
|
+
const TRADING_STATES = ['trading', 'auction']
|
|
354
|
+
const delayText = quote => {
|
|
355
|
+
if (!quote || quote.delayKind === 'unknown') return '未知'
|
|
356
|
+
if (quote.delayKind === 'realtime') return '实时'
|
|
357
|
+
// sessionState 缺失(如市场概览)时不据此推断休市, 只认 deriveDelay 给出的 closed
|
|
358
|
+
const offline = quote.delayKind === 'closed' || (quote.sessionState && !TRADING_STATES.includes(quote.sessionState))
|
|
359
|
+
if (offline) return `最后 ${String(timeText(quote.quoteAt)).split(' ').pop()}`
|
|
360
|
+
return quote.delaySeconds == null ? '未知' : `延迟 ${formatDuration(quote.delaySeconds)}`
|
|
361
|
+
}
|
|
362
|
+
// 数据新鲜度: 取最旧报价的接收时间, 进入面板时用户能判断是否为缓存值
|
|
363
|
+
const freshnessText = quotes => {
|
|
364
|
+
const stamps = Object.values(quotes || {}).map(entry => Number(entry?.quote?.receivedAt)).filter(at => Number.isFinite(at))
|
|
365
|
+
if (!stamps.length) return ''
|
|
366
|
+
const age = Math.max(0, Math.round((Date.now() - Math.min(...stamps)) / 1000))
|
|
367
|
+
return age < 3 ? '刚刚更新' : `${formatDuration(age)}前更新`
|
|
368
|
+
}
|
|
208
369
|
const MARKET_LABEL = { XSHG: '沪', XSHE: '深', XHKG: '港', XNAS: '美', XNYS: '美', XJPX: '日', XKRX: '韩', XLON: '英' }
|
|
370
|
+
// 客户端 K线数据保鲜期, 与服务端 kline-cache 同量级: 分时是实时数据, 日K盘中会动, 周月K一天一换
|
|
371
|
+
const KLINE_MAX_AGE = { trend: 60 * 1000, '1d': 60 * 60 * 1000, '1w': 24 * 60 * 60 * 1000, '1M': 24 * 60 * 60 * 1000 }
|
|
372
|
+
// 副标题市场面: 沪深两所合并一档, 按固定优先级输出, 避免随添加顺序抖动
|
|
373
|
+
const MARKET_SCOPE = { XSHG: '沪深A股', XSHE: '沪深A股', XHKG: '港股', XNAS: '美股', XNYS: '美股', XJPX: '日股', XKRX: '韩股', XLON: '英股' }
|
|
374
|
+
const SCOPE_ORDER = ['沪深A股', '港股', '美股', '日股', '韩股', '英股']
|
|
375
|
+
const scopeText = ids => {
|
|
376
|
+
const present = new Set(ids.map(id => MARKET_SCOPE[id.split(':')[0]]).filter(Boolean))
|
|
377
|
+
const listed = SCOPE_ORDER.filter(scope => present.has(scope))
|
|
378
|
+
return `自选行情 · ${listed.length ? listed.join('、') : SCOPE_ORDER.join('、')}`
|
|
379
|
+
}
|
|
209
380
|
function Button({ children, onClick, primary, danger, className = '', ...props }) { return h('button', { type: 'button', className: `ssb-button${primary ? ' ssb-primary' : ''}${danger ? ' ssb-danger' : ''} ${className}`, onClick, ...props }, children) }
|
|
210
381
|
function Toggle({ label, checked, onChange, disabled = false }) {
|
|
211
382
|
return h('label', { className: 'ssb-switch' }, h('input', { type: 'checkbox', checked, disabled, onChange: e => onChange(e.target.checked) }), h('span', { className: 'ssb-switch__track' }, h('span', { className: 'ssb-switch__thumb' })), h('span', null, label))
|
|
212
383
|
}
|
|
213
384
|
function QuoteStatus({ entry, short = false }) {
|
|
214
385
|
const quote = entry?.quote
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
386
|
+
const detail = (quote ? `${quote.providerInstanceId} · ${quote.delayKind === 'unknown' ? '延迟未知' : delayText(quote)}` : '等待报价') + (entry?.lastError?.message ? ` · ${entry.lastError.message}` : '')
|
|
387
|
+
// short: 列表行只给状态点, 语义与原因收进 title, 文字列让位给数据
|
|
388
|
+
if (short) {
|
|
389
|
+
const health = entry?.health || 'ok'
|
|
390
|
+
const tone = health === 'ok' ? '' : health === 'unconfigured' ? ' ssb-dot-idle' : health === 'error' ? ' ssb-dot-danger' : ' ssb-dot-warn'
|
|
391
|
+
const label = ({ limited: '额度受限', error: '请求失败', 'no-data': '暂无报价', unconfigured: '未配置来源' })[health] || health
|
|
392
|
+
return h('span', { className: `ssb-dot ssb-col-status${tone}`, role: 'img', 'aria-label': health === 'ok' ? `${detail},正常` : `${label},${detail}`, title: health === 'ok' ? detail : `${label} · ${detail}` })
|
|
393
|
+
}
|
|
394
|
+
// 详情页只给来源名: 延迟与获取时间已在事实表里逐行列出, 这里重复会把抽屉头部撑长
|
|
395
|
+
return h('span', { className: 'ssb-muted ssb-source', title: detail }, entry?.health && entry.health !== 'ok' ? `${({ limited: '额度受限', error: '请求失败', 'no-data': '暂无报价', unconfigured: '未配置来源' })[entry.health] || entry.health} · ` : '', quote ? quote.providerInstanceId : '等待报价')
|
|
396
|
+
}
|
|
397
|
+
// 拖拽落位: 返回新的完整顺序; 折叠隐藏项按原槽位回填, 保证提交的是全量 order
|
|
398
|
+
function dragOrder(sequence, order, from, over, after) {
|
|
399
|
+
const visible = sequence.filter(id => id !== from)
|
|
400
|
+
const at = visible.indexOf(over)
|
|
401
|
+
if (at < 0) return null
|
|
402
|
+
visible.splice(at + (after ? 1 : 0), 0, from)
|
|
403
|
+
const merged = []
|
|
404
|
+
let index = 0
|
|
405
|
+
for (const id of order) merged.push(sequence.includes(id) ? visible[index++] : id)
|
|
406
|
+
return merged
|
|
218
407
|
}
|
|
219
408
|
function Watchlist({ runtime, state }) {
|
|
220
409
|
const { data, view, busy } = state, control = data.control, w = control.watchlist
|
|
221
410
|
const sortedOrder = [...w.order].sort((a, b) => {
|
|
222
|
-
|
|
411
|
+
// 显式转布尔: 旧记录可能没有 pinned 字段, Number(undefined) 是 NaN, 比较器会退化成随机序
|
|
412
|
+
const pinned = Number(!!w.items[b].pinned) - Number(!!w.items[a].pinned)
|
|
223
413
|
if (pinned || view.sort === 'manual') return pinned
|
|
224
414
|
const qa = data.quotes[a]?.quote, qb = data.quotes[b]?.quote
|
|
225
415
|
const aa = qa?.changePct == null ? null : Number(qa.changePct), bb = qb?.changePct == null ? null : Number(qb.changePct)
|
|
@@ -227,68 +417,155 @@ window.__ModuleLoader__.load({
|
|
|
227
417
|
return view.sort === 'asc' ? aa - bb : bb - aa
|
|
228
418
|
})
|
|
229
419
|
const order = (view.menuId || view.keyboardNavigating) && view.frozenOrder ? [...view.frozenOrder.filter(id => w.items[id]), ...sortedOrder.filter(id => !view.frozenOrder.includes(id))] : sortedOrder
|
|
230
|
-
|
|
420
|
+
// 草稿清理由 updateView 在 detailId 变更处统一处理, 这里再清一遍会把键盘导航到同一只时输了一半的内容抹掉
|
|
421
|
+
function open(id) { runtime.updateView({ detailId: id, selectedId: id, overlay: 'detail', focusId: id, keyboardNavigating: false, menuId: null }) }
|
|
231
422
|
// 概览横幅: 仅异常态显示(健康态运维摘要收进「更多」菜单), Leek-Fund 模式=主页纯列表
|
|
232
423
|
const unhealthy = (state.data.providerStatus || []).filter(s => { const log = s.recentRequests || []; return log.length && log[0].status !== 'ok' })
|
|
233
424
|
const providers = state.data.control.providers || []
|
|
234
425
|
const sourcesBanner = unhealthy.length || providers.some(p => !p.enabled) ? h('div', { className: 'ssb-banner' }, `${providers.filter(p => p.enabled).length}/${providers.length} 个数据源启用${unhealthy.length ? `,${unhealthy.map(s => s.id).join('、')} 最近请求失败` : ''}`, h(Button, { onClick: () => runtime.updateView({ view: 'list', overlay: 'settings' }) }, '查看')) : null
|
|
235
|
-
// 组合概览并入节头(不占独立横幅带)
|
|
236
|
-
const stats = w.order.length ? w.order.reduce((acc, id) => { const pct = Number(data.quotes[id]?.quote?.changePct); if (Number.isNaN(pct)) return acc; acc.up += pct > 0; acc.down += pct < 0;
|
|
426
|
+
// 组合概览并入节头(不占独立横幅带)。不做涨跌额求和: 各市场币种不同且无仓位数量, 相加没有意义
|
|
427
|
+
const stats = w.order.length ? w.order.reduce((acc, id) => { const pct = Number(data.quotes[id]?.quote?.changePct); if (Number.isNaN(pct)) return acc; acc.up += pct > 0; acc.down += pct < 0; const item = w.items[id], last = Number(data.quotes[id]?.quote?.last), avgCost = avgCostOf(item.marks); if (avgCost != null && last) { acc.held += 1; acc.pl += (last - avgCost) / avgCost * 100 } return acc }, { up: 0, down: 0, held: 0, pl: 0 }) : null
|
|
237
428
|
// 持仓合计为等权均值(无仓位数量数据, 百分比求和会随只数放大; 均值是最不坏近似)
|
|
238
|
-
const heldText = stats?.held ? ` · 持仓盈亏 ${signed(Number((stats.pl / stats.held).toFixed(1)))}
|
|
429
|
+
const heldText = stats?.held ? ` · 持仓盈亏 ${signed(Number((stats.pl / stats.held).toFixed(1)))}%(等权)` : ''
|
|
239
430
|
return h('section', { className: 'ssb-list-pane' },
|
|
240
431
|
sourcesBanner,
|
|
241
|
-
h('div', { className: 'ssb-section-head' }, h('h2', null, `自选 · ${w.order.length}`), stats ? h('span', { className: 'ssb-muted' }, `${w.order.length} 只 · ${stats.up} 涨 ${stats.down}
|
|
432
|
+
h('div', { className: 'ssb-section-head' }, h('h2', null, `自选 · ${w.order.length}`), stats ? h('span', { className: 'ssb-muted' }, `${w.order.length} 只 · ${stats.up} 涨 ${stats.down} 跌${heldText}${freshnessText(data.quotes) ? ` · ${freshnessText(data.quotes)}` : ''}`) : null, h('label', null, h('span', { className: 'ssb-sr' }, '自选排序'), h('select', { value: view.sort, onChange: e => runtime.updateView({ sort: e.target.value }) }, h('option', { value: 'manual' }, '手动顺序'), h('option', { value: 'desc' }, '涨幅优先'), h('option', { value: 'asc' }, '跌幅优先')))),
|
|
242
433
|
!order.length ? h('div', { className: 'ssb-empty' }, h('p', null, '添加第一只自选,集中查看价格和来源。'), h(Button, { primary: true, onClick: () => runtime.updateView({ view: 'search' }) }, '添加自选')) :
|
|
243
434
|
h('div', { className: 'ssb-table-scroll', onScroll: e => runtime.updateView({ scrollTop: e.currentTarget.scrollTop }), ref: node => { if (node && Math.abs(node.scrollTop - (view.scrollTop || 0)) > 1) node.scrollTop = view.scrollTop || 0 } },
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
435
|
+
// 表头: 让每列含义自解释; 窄容器整体隐藏(降级为行内键值对)
|
|
436
|
+
h('div', { className: 'ssb-row-head', 'aria-hidden': true },
|
|
437
|
+
h('span', { className: 'ssb-grip' }, ''),
|
|
438
|
+
h('span', { className: 'ssb-col-name' }, '证券'),
|
|
439
|
+
h('span', { className: 'ssb-cell-price' }, '现价'),
|
|
440
|
+
h('span', { className: 'ssb-cell-pct' }, '涨跌幅'),
|
|
441
|
+
h('span', { className: 'ssb-cell-change' }, '涨跌额'),
|
|
442
|
+
h('span', { className: 'ssb-cell-open' }, '今开'),
|
|
443
|
+
h('span', { className: 'ssb-cell-range' }, '最高/最低'),
|
|
444
|
+
h('span', { className: 'ssb-cell-amount' }, '成交额'),
|
|
445
|
+
h('span', { className: 'ssb-cell-amplitude' }, '振幅'),
|
|
446
|
+
h('span', { className: 'ssb-cell-pl' }, '持仓盈亏'),
|
|
447
|
+
h('span', { className: 'ssb-col-status' }, '')),
|
|
448
|
+
// 容器不再是 list: 每个段是 group, 行装在段内的 list 里.
|
|
449
|
+
// 直接让 list 装组头的话, 组折叠按钮与工具条都会变成列表子节点
|
|
450
|
+
h('div', { className: 'ssb-rows' }, ...(() => {
|
|
451
|
+
// 分组分段: 置顶/各组/未分组都常显段头(一只没归组时也要看得见分组这回事); 置顶区不进组段;
|
|
452
|
+
// 组序=首现序; 折叠组隐藏成员。分组与排序正交: 全局排序只改组内顺序, 不改分段
|
|
453
|
+
// 拖拽排序: 提交完整显示序, 折叠组隐藏项保持原槽位由 order 回填
|
|
454
|
+
const sequence = []
|
|
455
|
+
const laneOf = id => `${w.items[id].pinned ? 'p' : 'n'}|${w.items[id].group || ''}`
|
|
456
|
+
const commitOrder = (from, over, after) => {
|
|
457
|
+
if (!from || !over || from === over || laneOf(from) !== laneOf(over)) return
|
|
458
|
+
const merged = dragOrder(sequence, order, from, over, after)
|
|
459
|
+
if (!merged) return
|
|
460
|
+
// 失败错误由 action 通道落到顶部横幅, 这里只防未处理拒绝
|
|
461
|
+
runtime.mutate('reorder', { order: merged }).catch(() => {})
|
|
462
|
+
}
|
|
247
463
|
const rowOf = id => {
|
|
248
464
|
const item = w.items[id], entry = data.quotes[id], q = entry?.quote
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
465
|
+
sequence.push(id)
|
|
466
|
+
const direction = q?.changePct == null ? '' : Number(q.changePct) > 0 ? ' ssb-up' : Number(q.changePct) < 0 ? ' ssb-down' : ''
|
|
467
|
+
// 列表只显示一个数: 多个成本按等权平均合成一个「持仓成本」
|
|
468
|
+
const avgCost = avgCostOf(item.marks)
|
|
469
|
+
const plPct = avgCost != null && q?.last != null ? Number((Number(q.last) - avgCost) / avgCost * 100).toFixed(1) : null
|
|
470
|
+
const amplitude = q?.high != null && q?.low != null && Number(q.previousClose) > 0 ? Number(((Number(q.high) - Number(q.low)) / Number(q.previousClose)) * 100).toFixed(1) : null
|
|
471
|
+
const dragging = view.dragId === id
|
|
472
|
+
const dropEdge = view.dropAt?.id === id ? view.dropAt.after ? ' ssb-drop-after' : ' ssb-drop-before' : ''
|
|
473
|
+
const canDrag = view.sort === 'manual' && !busy.write
|
|
474
|
+
const changeOrder = (from, over, after) => commitOrder(from, over, after)
|
|
475
|
+
return h(React.Fragment, { key: id },
|
|
476
|
+
h('div', {
|
|
477
|
+
className: `ssb-row${view.detailId === id ? ' ssb-selected' : ''}${entry?.health !== 'ok' ? ' ssb-stale' : ''}${dragging ? ' ssb-dragging' : ''}${dropEdge}${item.pinned ? ' ssb-pinned' : ''}`,
|
|
478
|
+
role: 'listitem', 'data-security-id': id, draggable: canDrag,
|
|
479
|
+
onClick: () => open(id),
|
|
480
|
+
onDragStart: e => { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', id); runtime.updateView({ dragId: id, dropAt: null }) },
|
|
481
|
+
onDragEnd: () => runtime.updateView({ dragId: null, dropAt: null }),
|
|
482
|
+
onDragOver: e => {
|
|
483
|
+
if (!view.dragId || view.dragId === id || laneOf(view.dragId) !== laneOf(id)) return
|
|
484
|
+
e.preventDefault()
|
|
485
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
486
|
+
const after = e.clientY > rect.top + rect.height / 2
|
|
487
|
+
if (view.dropAt?.id !== id || view.dropAt.after !== after) runtime.updateView({ dropAt: { id, after } })
|
|
488
|
+
},
|
|
489
|
+
onDrop: e => { e.preventDefault(); changeOrder(view.dragId, id, view.dropAt?.after === true); runtime.updateView({ dragId: null, dropAt: null }) },
|
|
490
|
+
},
|
|
491
|
+
h('div', { className: 'ssb-row-main' },
|
|
492
|
+
h('span', { className: 'ssb-grip', 'aria-hidden': true, title: canDrag ? '拖动排序' : '切回手动顺序后可拖动' }, '⠿'),
|
|
493
|
+
h('button', { className: 'ssb-stock-name', 'data-security-id': id, onClick: () => open(id), onFocus: () => { if (view.focusId !== id) runtime.updateView({ focusId: id }) }, onBlur: e => { if (!e.relatedTarget?.closest('.ssb-stock-name')) runtime.updateView({ keyboardNavigating: false, frozenOrder: null }) }, onKeyDown: e => {
|
|
252
494
|
if (e.key === 'Escape') { runtime.updateView({ menuId: null, frozenOrder: null, keyboardNavigating: false, view: 'list' }); return }
|
|
253
495
|
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) {
|
|
254
496
|
// 导航序列=实际渲染 DOM 序(分段后与 order 错位), focusId 取目标行 data-security-id 保持状态同步
|
|
255
|
-
e.preventDefault(); const buttons = [...e.currentTarget.closest('
|
|
497
|
+
e.preventDefault(); const buttons = [...e.currentTarget.closest('.ssb-rows').querySelectorAll('.ssb-stock-name')]; const at = buttons.indexOf(e.currentTarget), next = e.key === 'Home' ? 0 : e.key === 'End' ? buttons.length - 1 : Math.max(0, Math.min(buttons.length - 1, at + (e.key === 'ArrowDown' ? 1 : -1)))
|
|
256
498
|
const target = buttons[next]; runtime.updateView({ keyboardNavigating: true, frozenOrder: order, focusId: target?.dataset.securityId }); target?.focus()
|
|
257
499
|
}
|
|
258
|
-
} }, item.
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
h(
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
500
|
+
} }, item.alias || item.officialName, h('span', { className: 'ssb-code' }, `${MARKET_LABEL[item.exchange] || item.exchange} ${item.code}`)),
|
|
501
|
+
h('span', { className: `ssb-cell-price ssb-price${direction}`, title: q?.last == null ? '等待报价' : `${item.currency} ${q.last}` }, q?.last ?? '—'),
|
|
502
|
+
q?.changePct == null ? h('span', { className: 'ssb-cell-pct ssb-muted' }, '—') : h('span', { className: `ssb-cell-pct ssb-pill${direction}` }, `${signed(q.changePct)}%`),
|
|
503
|
+
q?.change == null ? h('span', { className: 'ssb-cell-change ssb-muted' }, '—') : h('span', { className: `ssb-cell-change ssb-change${direction}` }, signed(q.change)),
|
|
504
|
+
h('span', { className: 'ssb-cell-open' }, formatPrice(q?.open)),
|
|
505
|
+
h('span', { className: 'ssb-cell-range' }, `${formatPrice(q?.high)}/${formatPrice(q?.low)}`),
|
|
506
|
+
h('span', { className: 'ssb-cell-amount' }, formatAmount(q?.amount)),
|
|
507
|
+
h('span', { className: 'ssb-cell-amplitude' }, amplitude == null ? '—' : `${amplitude}%`),
|
|
508
|
+
h('span', { className: `ssb-cell-pl${plPct == null ? '' : Number(plPct) > 0 ? ' ssb-up' : Number(plPct) < 0 ? ' ssb-down' : ''}` }, plPct == null ? '—' : `${signed(plPct)}%`),
|
|
509
|
+
h(QuoteStatus, { entry, short: true })),
|
|
510
|
+
h('div', { className: 'ssb-row-sub' },
|
|
511
|
+
h('span', { className: 'ssb-kv' }, '开', formatPrice(q?.open)),
|
|
512
|
+
h('span', { className: 'ssb-kv' }, '高', formatPrice(q?.high)),
|
|
513
|
+
h('span', { className: 'ssb-kv' }, '低', formatPrice(q?.low)),
|
|
514
|
+
h('span', { className: 'ssb-kv' }, '额', formatAmount(q?.amount)),
|
|
515
|
+
amplitude == null ? null : h('span', { className: 'ssb-kv' }, '振', `${amplitude}%`),
|
|
516
|
+
plPct == null ? null : h('span', { className: `ssb-kv ssb-kv-pl${Number(plPct) > 0 ? ' ssb-up' : Number(plPct) < 0 ? ' ssb-down' : ''}` }, '持仓', `${signed(plPct)}%`))))
|
|
517
|
+
}
|
|
518
|
+
// 分组是结构、排序是组内顺序, 两者互不排斥: 全局排序时也保留分区, 只是组内按该排序走。
|
|
519
|
+
// 置顶与未分组也各给一个组头, 否则一只都没归组时整页平铺, 分组功能等于不可发现
|
|
520
|
+
// 段序固定按自选顺序: 用排序后的 order 取「首现序」会让段落随行情跳动换位,
|
|
521
|
+
// 正在点的组头会跑掉。排序只决定组内先后
|
|
522
|
+
const rank = new Map(order.map((id, i) => [id, i]))
|
|
523
|
+
// 缺 id 时排到最后: 排到最前会让一个不该出现的条目抢走组内首位
|
|
524
|
+
const sortedIds = ids => ids.slice().sort((left, right) => (rank.get(left) ?? Infinity) - (rank.get(right) ?? Infinity))
|
|
525
|
+
const slices = groupSlices(w.order, w.items)
|
|
526
|
+
const toggleGroup = (key, open) => runtime.updateView({ groupCollapsed: { ...view.groupCollapsed, [key]: open } })
|
|
527
|
+
// 组名来自用户输入: 直接取 groupCollapsed[name] 会让 constructor/toString 这类名字读到原型链上的成员,
|
|
528
|
+
// 被误判成「已折叠」, 整组不渲染
|
|
529
|
+
const collapsedOf = key => Object.hasOwn(view.groupCollapsed || {}, key) && !!view.groupCollapsed[key]
|
|
530
|
+
const head = (key, label, ids, actions) => {
|
|
531
|
+
const open = !collapsedOf(key)
|
|
532
|
+
const gs = ids.reduce((acc, id) => { const pct = Number(data.quotes[id]?.quote?.changePct); if (!Number.isNaN(pct)) { acc.up += pct > 0; acc.down += pct < 0 } return acc }, { up: 0, down: 0 })
|
|
533
|
+
// role=presentation 只是把组头从无障碍树里摘掉, 组折叠按钮与工具条照样会
|
|
534
|
+
// 变成列表的直接子节点. 所以段自己是一个 group, 行装在段内的 list 里
|
|
535
|
+
return h('div', { key: 'sec-' + key, role: 'group', 'aria-label': label },
|
|
536
|
+
h('div', { className: 'ssb-group-head' },
|
|
537
|
+
h('span', { className: 'ssb-group-toggle', role: 'button', tabIndex: 0, 'aria-expanded': open, 'aria-label': `${label} 分组折叠`, onClick: () => toggleGroup(key, open), onKeyDown: e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleGroup(key, open) } } }, open ? '▾' : '▸', ` ${label} · ${ids.length} 只 · ${gs.up} 涨 ${gs.down} 跌`),
|
|
538
|
+
h('span', { className: 'ssb-toolbar' }, actions)),
|
|
539
|
+
open ? h('div', { role: 'list', 'aria-label': label }, ...ids.map(rowOf)) : null)
|
|
272
540
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
541
|
+
const section = (key, label, ids, actions) => head(key, label, ids, actions)
|
|
542
|
+
const out = []
|
|
543
|
+
if (slices.pinned.length) out.push(section(PINNED_KEY, '置顶', sortedIds(slices.pinned), null))
|
|
544
|
+
for (const { name, ids } of slices.groups) {
|
|
545
|
+
const actions = [
|
|
546
|
+
// 折叠态迁移必须等写入成功: 名字不合规时服务端会 400, 先迁移会让旧组突然展开
|
|
547
|
+
h(Button, { key: 'rename', onClick: () => {
|
|
548
|
+
const to = prompt('重命名分组', name)?.trim()
|
|
549
|
+
if (!to) return
|
|
550
|
+
quiet(() => runtime.mutate('groupRename', { from: name, to }))().then(() => { if (to !== name) runtime.updateView({ groupCollapsed: { ...view.groupCollapsed, [to]: collapsedOf(name), [name]: undefined } }) }).catch(() => { })
|
|
551
|
+
} }, '重命名'),
|
|
552
|
+
h(Button, { key: 'drop', disabled: busy.write, onClick: () => { quiet(() => runtime.mutate('groupRemove', { group: name }))(); runtime.updateView({ groupCollapsed: { ...view.groupCollapsed, [name]: undefined } }) } }, '解散'),
|
|
553
|
+
]
|
|
554
|
+
out.push(section(name, name, sortedIds(ids), actions))
|
|
285
555
|
}
|
|
286
|
-
|
|
556
|
+
if (slices.ungrouped.length) out.push(section(REST_KEY, '未分组', sortedIds(slices.ungrouped), null))
|
|
287
557
|
return out
|
|
288
|
-
})())))
|
|
289
|
-
}
|
|
290
|
-
// ---- KlineView: lightweight-charts 图表视图(设计"client 详情重构"节十步数据流) ----
|
|
558
|
+
})())))
|
|
559
|
+
} // ---- KlineView: lightweight-charts 图表视图(设计"client 详情重构"节十步数据流) ----
|
|
291
560
|
const PERIODS = [{ key: 'trend', label: '分时' }, { key: '1d', label: '日K' }, { key: '1w', label: '周K' }, { key: '1M', label: '月K' }]
|
|
561
|
+
const GUIDE_KEY = 'ssb-guides'
|
|
562
|
+
const GUIDE_DEFAULT = { preClose: true, marks: true, highLow: false, ma: true, ema: false, boll: false, bbi: false }
|
|
563
|
+
// horizontal 是横贯全图的参考位(priceLine), indicator 是跟随每根K线的序列(LineSeries)
|
|
564
|
+
const GUIDE_GROUPS = [
|
|
565
|
+
{ title: '参考位', items: [['preClose', '昨收'], ['marks', '价格标记'], ['highLow', '区间最高/最低']] },
|
|
566
|
+
{ title: '指标线', items: [['ma', 'MA 均线'], ['ema', 'EMA 指数均线'], ['boll', 'BOLL 布林带'], ['bbi', 'BBI 多空']] },
|
|
567
|
+
]
|
|
568
|
+
const EMA_WINDOW = 12, BOLL_WINDOW = 20, BOLL_K = 2
|
|
292
569
|
const VENDOR_URL = '/plugins-vendor/dsh-stock-assistant/lightweight-charts.standalone.production.js'
|
|
293
570
|
const MA_WINDOWS = [5, 10, 20, 60]
|
|
294
571
|
// ensureLwc: 模块级 promise 单例;script 已存在等 onload,否则注入 vendor URL;失败统一文案 reject。
|
|
@@ -314,86 +591,362 @@ window.__ModuleLoader__.load({
|
|
|
314
591
|
}
|
|
315
592
|
function readChartTheme(root, colorMode) {
|
|
316
593
|
const cs = getComputedStyle(root)
|
|
317
|
-
const
|
|
594
|
+
const varOf = name => cs.getPropertyValue(name).trim()
|
|
595
|
+
const red = varOf('--ssb-danger'), green = varOf('--ssb-ok')
|
|
318
596
|
const [up, down] = colorMode === 'green-up' ? [green, red] : [red, green]
|
|
319
|
-
|
|
597
|
+
// 背景取面板底色而不是 transparent: 容器自身没有背景, 交给 canvas 留白的话暗色主题下会露出一块白
|
|
598
|
+
return { background: varOf('--ssb-bg') || 'transparent', textColor: varOf('--ssb-text-sub'), gridLine: varOf('--ssb-border'), crosshair: varOf('--ssb-text-dim'), up, down, volumeUp: up + '73', volumeDown: down + '73', MA: [varOf('--ssb-chart-ma-fast'), varOf('--ssb-chart-ma-mid'), varOf('--ssb-chart-ma-slow'), varOf('--ssb-chart-ma-long')], EMA: varOf('--ssb-chart-ema'), BOLL: [varOf('--ssb-chart-boll-mid'), varOf('--ssb-chart-boll-rail')], BBI: varOf('--ssb-chart-bbi'), cost: varOf('--ssb-chart-cost'), target: varOf('--ssb-chart-target') }
|
|
320
599
|
}
|
|
321
|
-
|
|
600
|
+
// 以下指标都返回等长数组(前导 null), 因为 BOLL/BBI 要按索引把多条均线对齐
|
|
601
|
+
function seriesMA(candles, n) {
|
|
602
|
+
const out = new Array(candles.length).fill(null)
|
|
603
|
+
let sum = 0
|
|
604
|
+
for (let i = 0; i < candles.length; i++) {
|
|
605
|
+
sum += candles[i].close
|
|
606
|
+
if (i >= n) sum -= candles[i - n].close
|
|
607
|
+
if (i >= n - 1) out[i] = sum / n
|
|
608
|
+
}
|
|
609
|
+
return out
|
|
610
|
+
}
|
|
611
|
+
function seriesEMA(candles, n) {
|
|
612
|
+
const out = new Array(candles.length).fill(null), k = 2 / (n + 1)
|
|
613
|
+
let prev = null
|
|
614
|
+
for (let i = 0; i < candles.length; i++) {
|
|
615
|
+
prev = prev == null ? candles[i].close : candles[i].close * k + prev * (1 - k)
|
|
616
|
+
if (i >= n - 1) out[i] = prev
|
|
617
|
+
}
|
|
618
|
+
return out
|
|
619
|
+
}
|
|
620
|
+
function seriesBoll(candles, n = 20, k = 2) {
|
|
621
|
+
const mid = seriesMA(candles, n), up = new Array(candles.length).fill(null), low = new Array(candles.length).fill(null)
|
|
622
|
+
for (let i = n - 1; i < candles.length; i++) {
|
|
623
|
+
const slice = candles.slice(i - n + 1, i + 1).map(c => c.close)
|
|
624
|
+
const variance = slice.reduce((s, x) => s + (x - mid[i]) ** 2, 0) / n
|
|
625
|
+
up[i] = mid[i] + k * Math.sqrt(variance)
|
|
626
|
+
low[i] = mid[i] - k * Math.sqrt(variance)
|
|
627
|
+
}
|
|
628
|
+
return [mid, up, low]
|
|
629
|
+
}
|
|
630
|
+
function seriesBBI(candles) {
|
|
631
|
+
const m3 = seriesMA(candles, 3), m6 = seriesMA(candles, 6), m12 = seriesMA(candles, 12), m24 = seriesMA(candles, 24)
|
|
632
|
+
return candles.map((_, i) => m3[i] == null || m6[i] == null || m12[i] == null || m24[i] == null ? null : (m3[i] + m6[i] + m12[i] + m24[i]) / 4)
|
|
633
|
+
}
|
|
634
|
+
// 指标与蜡烛必须同代: 建图补灌时 candles 来自挂起结果而 values 来自最近渲染,
|
|
635
|
+
// 中间若夹过一次更长的数据, 越界取 time 会直接抛 TypeError
|
|
636
|
+
const toPoints = (candles, values) => values.map((value, i) => value == null || !candles[i] ? null : ({ time: candles[i].time, value })).filter(Boolean)
|
|
322
637
|
function KlineError({ message, onRetry, guide, busyOneClick }) {
|
|
323
|
-
|
|
638
|
+
// guide 由调用方给出动作与文案: 缺路由是一键开启, 来源不支持则是去设置换源
|
|
639
|
+
return h('div', { className: 'ssb-banner ssb-error', role: 'alert' }, message, guide ? h(Button, { onClick: guide.onClick, disabled: busyOneClick }, busyOneClick ? '处理中…' : guide.label) : null, onRetry ? h(Button, { onClick: onRetry }, '重试') : null)
|
|
324
640
|
}
|
|
325
641
|
function KlineView({ runtime, state, item }) {
|
|
326
642
|
const period = state.klinePeriod || '1d'
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const
|
|
643
|
+
// 切标的/周期的当帧 state.kline 还是上一轮的, 只有 key 对上才算这一轮的数据;
|
|
644
|
+
// 否则图例会拼出上一只的涨跌并带上残缺的"最近 ()"
|
|
645
|
+
const kline = state.klineKey === klineKeyOf(item.id, period) ? state.kline || {} : {}
|
|
646
|
+
const holder = React.useRef(null), chartRef = React.useRef(null), mainRef = React.useRef(null), indicatorRef = React.useRef({}), volRef = React.useRef(null), avgRef = React.useRef(null), shippedKeyRef = React.useRef(null)
|
|
647
|
+
// 数据链与建图链谁先到不确定: 图还没建好时先把结果存下来, 建完再补灌, 否则先到的数据会被丢掉
|
|
648
|
+
const pendingRef = React.useRef(null)
|
|
649
|
+
// 建图 effect 是空依赖的, 回调里要用当前主题与当前数据, 只能从这里现取
|
|
650
|
+
const liveRef = React.useRef({})
|
|
651
|
+
// 建图后补灌动作也走它, 保证用的是最新渲染的 maybeFill 而不是首帧那份
|
|
652
|
+
const fillRef = React.useRef(null)
|
|
653
|
+
// 上一次铺色用的主题签名, 用来跳过无关的 body style 抖动
|
|
654
|
+
const paintKeyRef = React.useRef('')
|
|
655
|
+
// 参考线随每次灌数据重画, 不先移除会在同一 series 上越叠越多
|
|
656
|
+
const preCloseLineRef = React.useRef(null), highLineRef = React.useRef(null), lowLineRef = React.useRef(null)
|
|
657
|
+
// 价格标记是可变数量的, 数量变了就得把旧的整批撤掉重建
|
|
658
|
+
const markLinesRef = React.useRef([])
|
|
659
|
+
function drawMarks(series, palette, list) {
|
|
660
|
+
if (!series) return
|
|
661
|
+
for (const line of markLinesRef.current) { try { series.removePriceLine(line) } catch { } }
|
|
662
|
+
markLinesRef.current = (list || []).map(mark => series.createPriceLine({
|
|
663
|
+
price: mark.price,
|
|
664
|
+
color: mark.kind === 'cost' ? palette.cost : palette.target,
|
|
665
|
+
lineStyle: 3, lineWidth: 1, axisLabelVisible: true,
|
|
666
|
+
title: mark.kind === 'cost' ? '成本' : '卖价',
|
|
667
|
+
}))
|
|
668
|
+
}
|
|
669
|
+
function drawLine(ref, options) {
|
|
670
|
+
const series = mainRef.current
|
|
671
|
+
if (!series) return
|
|
672
|
+
if (ref.current) { try { series.removePriceLine(ref.current) } catch { } ref.current = null }
|
|
673
|
+
if (options) ref.current = series.createPriceLine(options)
|
|
674
|
+
}
|
|
675
|
+
// 配色取 liveRef 里的当前值: 建图与补灌都在空依赖回调里, 直接用 state 会读到首帧的旧配色
|
|
676
|
+
const themeOf = () => readChartTheme(holder.current?.closest('.ssb-root') || document.documentElement, liveRef.current.colorMode)
|
|
330
677
|
// 量柱着色与 MA 灌入(日/周/月)
|
|
678
|
+
// 指标线只灌开着的: 关掉时喂空数组, series 留着不重建
|
|
679
|
+
function applyIndicators(candles) {
|
|
680
|
+
// 现取而不是用闭包: 建图与补灌都在空依赖回调里, 闭包里的 indicatorValues 永远停在首帧 null
|
|
681
|
+
const values = liveRef.current.indicators
|
|
682
|
+
// 数据被清空时指标线也要跟着清, 否则时间轴仍是旧数据驱动的, 会留下一截没有蜡烛的孤立折线
|
|
683
|
+
if (!candles?.length || !values) { for (const list of Object.values(indicatorRef.current)) list.forEach(series => series.setData([])); return }
|
|
684
|
+
for (const [key, list] of Object.entries(indicatorRef.current)) {
|
|
685
|
+
list.forEach((series, index) => series.setData(guides[key] ? toPoints(candles, values[key][index]) : []))
|
|
686
|
+
}
|
|
687
|
+
}
|
|
331
688
|
function fillCandleData(result) {
|
|
332
|
-
const theme = themeOf()
|
|
333
|
-
mainRef.current.setData(
|
|
334
|
-
volRef.current.setData(
|
|
335
|
-
|
|
689
|
+
const theme = themeOf(), candles = result.candles
|
|
690
|
+
mainRef.current.setData(candles.map(c => ({ time: c.time, open: c.open, high: c.high, low: c.low, close: c.close })))
|
|
691
|
+
volRef.current.setData(candles.map(c => ({ time: c.time, value: c.volume ?? 0, color: c.close >= c.open ? theme.volumeUp : theme.volumeDown })))
|
|
692
|
+
applyIndicators(candles)
|
|
336
693
|
}
|
|
337
694
|
// 分时: Area 收盘线+均价线+昨收虚线
|
|
338
695
|
function fillTrendData(result) {
|
|
696
|
+
// 横轴优先用来源时刻(at):按索引自造会让时间轴整体平移;缺 at 时退回当天 UTC 零点起算
|
|
339
697
|
const base = Math.floor(new Date(`${new Date().toISOString().slice(0, 10)}T00:00:00Z`).getTime() / 1000)
|
|
340
|
-
|
|
698
|
+
// 行情自带时间戳优先: 没有它才按「区间起点 + 分钟偏移」推。
|
|
699
|
+
// 注意 base 是当天 UTC 零点, 推到非 UTC 市场会整体偏时区(美股约 11 小时),
|
|
700
|
+
// 所以这只是最后一道兜底——东财分时恒给 at, 该路径当前不可达
|
|
701
|
+
const axisTime = (c, i) => c.at ?? base + i * 60
|
|
702
|
+
const points = result.candles.map((c, i) => ({ time: axisTime(c, i), value: c.close }))
|
|
341
703
|
mainRef.current.setData(points)
|
|
342
|
-
avgRef.current.setData(result.candles.map((c, i) => ({ time:
|
|
343
|
-
if (result.preClose != null) mainRef.current.createPriceLine({ price: result.preClose, color: themeOf().crosshair, lineStyle: 2, lineWidth: 1, title: '昨收' })
|
|
704
|
+
avgRef.current.setData(result.candles.map((c, i) => ({ time: axisTime(c, i), value: c.avg ?? c.close })))
|
|
344
705
|
}
|
|
345
|
-
//
|
|
346
|
-
|
|
706
|
+
// 分时用 Area、K线用 Candlestick, 同一 chart 上换形态就是删旧 series 建新 series;
|
|
707
|
+
// 时间轴允许 Unix 秒与业务日期混用, 所以不必重建整个实例。
|
|
708
|
+
function chartOptions(theme) {
|
|
709
|
+
return { layout: { background: { color: theme.background }, textColor: theme.textColor }, crosshair: { mode: window.LightweightCharts.CrosshairMode.Normal }, timeScale: { timeVisible: false, borderColor: theme.gridLine }, rightPriceScale: { borderColor: theme.gridLine }, grid: { vertLines: { color: theme.gridLine }, horzLines: { color: theme.gridLine } }, autoSize: true }
|
|
710
|
+
}
|
|
711
|
+
const wantTrend = period === 'trend'
|
|
712
|
+
const [guides, setGuides] = usePref(GUIDE_KEY, GUIDE_DEFAULT)
|
|
713
|
+
// 图例: 报价要素一行、指标值一行, 数值全部集中在这里, 图面只留曲线本身
|
|
714
|
+
// 指标序列只算一次: 图例取末值、图上灌全序列, 两边共用同一份结果
|
|
715
|
+
const indicatorValues = React.useMemo(() => {
|
|
716
|
+
const candles = kline?.candles
|
|
717
|
+
if (!candles?.length || wantTrend) return null
|
|
718
|
+
return { ma: MA_WINDOWS.map(n => seriesMA(candles, n)), ema: [seriesEMA(candles, EMA_WINDOW)], boll: seriesBoll(candles, BOLL_WINDOW, BOLL_K), bbi: [seriesBBI(candles)] }
|
|
719
|
+
}, [kline?.candles, wantTrend])
|
|
720
|
+
// 建图与重涂都在空依赖的回调里跑, 用这份最新值替代首帧闭包
|
|
721
|
+
liveRef.current = { candles: kline?.candles, colorMode: state.data?.control.preferences?.colorMode, indicators: indicatorValues, period }
|
|
722
|
+
const legend = React.useMemo(() => {
|
|
723
|
+
const candles = kline?.candles || []
|
|
724
|
+
if (!candles.length) return null
|
|
725
|
+
const last = candles[candles.length - 1], scale = item.priceScale ?? 2
|
|
726
|
+
const price = value => value == null || value === '' || !Number.isFinite(Number(value)) ? null : Number(value).toFixed(scale)
|
|
727
|
+
// 日K 的 preClose 有时来源不给, 退到前一根收盘; 分时不能这么退——前一根分钟收盘会算出个假的 0.0x% 涨跌
|
|
728
|
+
const base = finiteOr(kline?.preClose) || (wantTrend ? NaN : finiteOr(candles[candles.length - 2]?.close))
|
|
729
|
+
const pct = Number.isFinite(base) && base && Number.isFinite(finiteOr(last.close)) ? (finiteOr(last.close) - base) / base * 100 : null
|
|
730
|
+
const tone = pct == null ? '' : pct > 0 ? ' ssb-up' : pct < 0 ? ' ssb-down' : ''
|
|
731
|
+
const quote = wantTrend
|
|
732
|
+
? [['最新', price(last.close)], ['均价', price(last.avg ?? last.close)], ['昨收', price(kline?.preClose)]]
|
|
733
|
+
: [['开', price(last.open)], ['高', price(last.high)], ['低', price(last.low)], ['收', price(last.close)]]
|
|
734
|
+
// 分时的 volume 是每分钟增量(实测末条远小于当日总量), 要累加; 日K 最后一根本身就是当日总量。
|
|
735
|
+
// 一根有效量都没有时留 null 而不是 0, 否则图例会写出一个并不存在的"0"
|
|
736
|
+
const volume = wantTrend
|
|
737
|
+
? (candles.some(candle => Number.isFinite(finiteOr(candle.volume))) ? candles.reduce((sum, candle) => sum + (finiteOr(candle.volume) || 0), 0) : null)
|
|
738
|
+
: last.volume
|
|
739
|
+
// 指标只列开着的, 否则图例比图还长
|
|
740
|
+
const series = []
|
|
741
|
+
if (indicatorValues && guides.ma) MA_WINDOWS.forEach((window, index) => series.push([`MA${window}`, indicatorValues.ma[index].at(-1), ` ssb-ma-${index}`]))
|
|
742
|
+
if (indicatorValues && guides.ema) series.push([`EMA${EMA_WINDOW}`, indicatorValues.ema[0].at(-1), ' ssb-ema'])
|
|
743
|
+
if (indicatorValues && guides.boll) ['BOLL', 'BOLL上', 'BOLL下'].forEach((name, index) => series.push([name, indicatorValues.boll[index].at(-1), ` ssb-boll-${index}`]))
|
|
744
|
+
if (indicatorValues && guides.bbi) series.push(['BBI', indicatorValues.bbi[0].at(-1), ' ssb-bbi'])
|
|
745
|
+
return {
|
|
746
|
+
quote: [...quote, ['涨跌', pct == null ? null : `${signed(pct.toFixed(2))}%`, tone], ['量', formatAmount(volume, { zeroIsMissing: false })]],
|
|
747
|
+
series: series.filter(([, value]) => value != null && Number.isFinite(Number(value))),
|
|
748
|
+
}
|
|
749
|
+
}, [kline?.candles, kline?.preClose, wantTrend, item.priceScale, guides])
|
|
750
|
+
const marks = item.marks || []
|
|
751
|
+
const avgCost = avgCostOf(marks)
|
|
752
|
+
// 盈亏一律以最后一根收盘为准, 跟图例里的成分保持一致
|
|
753
|
+
const lastClose = kline?.candles?.length ? finiteOr(kline.candles[kline.candles.length - 1].close) : null
|
|
754
|
+
const costPct = avgCost != null && Number.isFinite(lastClose) ? (lastClose - avgCost) / avgCost * 100 : null
|
|
755
|
+
// 把一套主题色铺满整张图: 背景/网格/两轴边框/主序列/成交量/指标线/参考线。
|
|
756
|
+
// 首灌与换主题共用同一份, 免得两处漏项(分时面积序列的键名与蜡烛不同)
|
|
757
|
+
function repaintChart(t) {
|
|
758
|
+
const chart = chartRef.current
|
|
759
|
+
if (!chart) return
|
|
760
|
+
// body 的 style 会因为字体、滚动条之类无关变动抖动, 签名没变就不必整图重涂
|
|
761
|
+
const signature = themeSignature(t)
|
|
762
|
+
if (paintKeyRef.current === signature) return
|
|
763
|
+
paintKeyRef.current = signature
|
|
764
|
+
chart.applyOptions({
|
|
765
|
+
layout: { background: { color: t.background }, textColor: t.textColor },
|
|
766
|
+
grid: { vertLines: { color: t.gridLine }, horzLines: { color: t.gridLine } },
|
|
767
|
+
timeScale: { borderColor: t.gridLine },
|
|
768
|
+
rightPriceScale: { borderColor: t.gridLine },
|
|
769
|
+
})
|
|
770
|
+
// 面积序列用 lineColor/topColor/bottomColor, 蜡烛用 up/down 那一组, 键名互不相通
|
|
771
|
+
mainRef.current?.applyOptions(seriesPaintPlan(t, !!avgRef.current))
|
|
772
|
+
// 成交量序列不认系列级配色, 只认每根柱子自带的 color, 所以必须按当前蜡烛重灌一次
|
|
773
|
+
const candles = liveRef.current.candles
|
|
774
|
+
if (candles?.length) volRef.current?.setData(candles.map(c => ({ time: c.time, value: c.volume ?? 0, color: c.close >= c.open ? t.volumeUp : t.volumeDown })))
|
|
775
|
+
avgRef.current?.applyOptions({ color: t.MA[0] })
|
|
776
|
+
const palette = { ma: t.MA, ema: t.EMA, boll: t.BOLL, bbi: t.BBI }
|
|
777
|
+
for (const [key, list] of Object.entries(indicatorRef.current)) list.forEach((series, index) => series.applyOptions({ color: Array.isArray(palette[key]) ? palette[key][index] : palette[key] }))
|
|
778
|
+
// 参考线建好后就固定了, 只换颜色; 没有对应的线时 applyOptions 是 no-op
|
|
779
|
+
preCloseLineRef.current?.applyOptions({ color: t.crosshair })
|
|
780
|
+
// 标记线按种类各一个颜色, 逐条换
|
|
781
|
+
for (const [index, line] of markLinesRef.current.entries()) {
|
|
782
|
+
const mark = (liveRef.current.marks || [])[index]
|
|
783
|
+
line.applyOptions({ color: mark?.kind === 'target' ? t.target : t.cost })
|
|
784
|
+
}
|
|
785
|
+
highLineRef.current?.applyOptions({ color: t.textColor })
|
|
786
|
+
lowLineRef.current?.applyOptions({ color: t.textColor })
|
|
787
|
+
}
|
|
788
|
+
function ensureSeries(LWC) {
|
|
347
789
|
const chart = chartRef.current
|
|
348
790
|
if (!chart || !LWC) return
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
for (const s of
|
|
352
|
-
mainRef.current = avgRef.current = volRef.current = null;
|
|
791
|
+
if (wantTrend === !!avgRef.current && mainRef.current) return
|
|
792
|
+
const stale = [mainRef.current, avgRef.current, volRef.current, ...Object.values(indicatorRef.current).flat()]
|
|
793
|
+
for (const s of stale) if (s) chart.removeSeries(s)
|
|
794
|
+
mainRef.current = avgRef.current = volRef.current = null; indicatorRef.current = {}
|
|
795
|
+
// 旧 series 上的参考线随 series 一起销毁, 引用要清掉
|
|
796
|
+
preCloseLineRef.current = highLineRef.current = lowLineRef.current = null
|
|
797
|
+
markLinesRef.current = []
|
|
353
798
|
const theme = themeOf()
|
|
354
799
|
if (wantTrend) {
|
|
355
800
|
mainRef.current = chart.addSeries(LWC.AreaSeries, { topColor: theme.up + '4d', bottomColor: theme.up + '07', lineColor: theme.up, lineWidth: 1 }, 0)
|
|
356
801
|
avgRef.current = chart.addSeries(LWC.LineSeries, { color: theme.MA[0], lineWidth: 1, priceLineVisible: false, lastValueVisible: false }, 0)
|
|
357
802
|
} else {
|
|
358
803
|
mainRef.current = chart.addSeries(LWC.CandlestickSeries, { upColor: theme.up, downColor: theme.down, wickUpColor: theme.up, wickDownColor: theme.down, borderUpColor: theme.up, borderDownColor: theme.down }, 0)
|
|
359
|
-
|
|
804
|
+
const line = color => chart.addSeries(LWC.LineSeries, { color, lineWidth: 1, priceLineVisible: false, lastValueVisible: false }, 0)
|
|
805
|
+
indicatorRef.current = {
|
|
806
|
+
ma: MA_WINDOWS.map((n, i) => line(theme.MA[i])),
|
|
807
|
+
ema: [line(theme.EMA)],
|
|
808
|
+
boll: [line(theme.BOLL[0]), line(theme.BOLL[1]), line(theme.BOLL[1])],
|
|
809
|
+
bbi: [line(theme.BBI)],
|
|
810
|
+
}
|
|
360
811
|
volRef.current = chart.addSeries(LWC.HistogramSeries, { priceFormat: { type: 'volume' }, priceLineVisible: false, lastValueVisible: false }, 1)
|
|
361
812
|
}
|
|
362
813
|
}
|
|
363
|
-
//
|
|
364
|
-
function maybeFill(result) {
|
|
365
|
-
if (!
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
814
|
+
// 数据就绪后灌入; 图表实例由 effect A 创建, series 按形态在此处切换
|
|
815
|
+
function maybeFill(result, fit = true) {
|
|
816
|
+
if (!result) return
|
|
817
|
+
if (!chartRef.current) { pendingRef.current = result; return }
|
|
818
|
+
ensureSeries(window.LightweightCharts)
|
|
819
|
+
if (!mainRef.current) return
|
|
820
|
+
// 分时喂的是 Unix 秒, 图表默认按 UTC 渲染那一刻——港股 14:45 会被画成 06:45, 所以刻度一律按该证券
|
|
821
|
+
// 所属市场的时区呈现; 日K的 time 是业务日期(字符串或 BusinessDay), 不参与时区换算, 否则会整体偏一天。
|
|
822
|
+
const zone = item.timezone || 'Asia/Shanghai'
|
|
823
|
+
const asDate = time => {
|
|
824
|
+
if (typeof time === 'number') return new Date(time * 1000)
|
|
825
|
+
if (typeof time === 'string') return new Date(`${time}T00:00:00Z`)
|
|
826
|
+
if (time && typeof time === 'object' && time.year) return new Date(Date.UTC(time.year, time.month - 1, time.day))
|
|
827
|
+
return null
|
|
828
|
+
}
|
|
829
|
+
const clock = time => { const d = asDate(time); return d ? new Intl.DateTimeFormat('zh-CN', { timeZone: zone, hour12: false, hour: '2-digit', minute: '2-digit' }).format(d) : '' }
|
|
830
|
+
const dayStamp = (time, withYear) => {
|
|
831
|
+
const d = asDate(time)
|
|
832
|
+
if (!d) return ''
|
|
833
|
+
const pad = n => String(n).padStart(2, '0')
|
|
834
|
+
return `${withYear ? `${d.getUTCFullYear()}-` : ''}${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`
|
|
835
|
+
}
|
|
836
|
+
const tickMark = (time, tickMarkType) => {
|
|
837
|
+
if (wantTrend) return clock(time)
|
|
838
|
+
const d = asDate(time)
|
|
839
|
+
if (!d) return ''
|
|
840
|
+
return tickMarkType === 0 ? String(d.getUTCFullYear()) : tickMarkType === 1 ? `${d.getUTCMonth() + 1}月` : dayStamp(time, false).slice(3)
|
|
841
|
+
}
|
|
842
|
+
chartRef.current.applyOptions({
|
|
843
|
+
localization: { timeFormatter: wantTrend ? clock : time => dayStamp(time, true) },
|
|
844
|
+
timeScale: { timeVisible: wantTrend, tickMarkFormatter: tickMark },
|
|
845
|
+
})
|
|
846
|
+
wantTrend ? fillTrendData(result) : fillCandleData(result)
|
|
847
|
+
// 辅助线统一在这里画: 全部走 createPriceLine, 具体数值看上方图例, 图上只留参考位置
|
|
848
|
+
const palette = themeOf()
|
|
849
|
+
drawLine(preCloseLineRef, guides.preClose && result.preClose != null ? { price: result.preClose, color: palette.crosshair, lineStyle: 2, lineWidth: 1, title: '昨收' } : null)
|
|
850
|
+
drawMarks(mainRef.current, palette, guides.marks ? marks : [])
|
|
851
|
+
const highs = result.candles.map(c => finiteOr(c.high)).filter(Number.isFinite), lows = result.candles.map(c => finiteOr(c.low)).filter(Number.isFinite)
|
|
852
|
+
drawLine(highLineRef, guides.highLow && highs.length ? { price: Math.max(...highs), color: palette.textColor, lineStyle: 1, lineWidth: 1, title: '区间高' } : null)
|
|
853
|
+
drawLine(lowLineRef, guides.highLow && lows.length ? { price: Math.min(...lows), color: palette.textColor, lineStyle: 1, lineWidth: 1, title: '区间低' } : null)
|
|
854
|
+
// 换周期后时间范围完全不同, 不重置视口会停在上一周期的区间上; 手动刷新保留缩放
|
|
855
|
+
if (fit) chartRef.current.timeScale().fitContent()
|
|
369
856
|
}
|
|
370
|
-
// effect
|
|
857
|
+
// 建图 effect 是空依赖的, 它拿到的是首帧那份 maybeFill; 转发一次, 让它永远调用最新渲染的版本,
|
|
858
|
+
// 否则建图那几百毫秒里切的周期/开关的辅助线/改的成本都会用旧值
|
|
859
|
+
fillRef.current = maybeFill
|
|
860
|
+
// effect A: 图表创建一次; 主题变更(MutationObserver)重涂; 卸载销毁。
|
|
371
861
|
React.useEffect(() => {
|
|
372
862
|
let dead = false, mo
|
|
373
863
|
const root = holder.current?.closest('.ssb-root') || document.documentElement
|
|
374
864
|
ensureLwc(document).then(LWC => {
|
|
375
865
|
if (dead || !holder.current) return
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
866
|
+
chartRef.current = LWC.createChart(holder.current, chartOptions(themeOf()))
|
|
867
|
+
const early = pendingRef.current
|
|
868
|
+
pendingRef.current = null
|
|
869
|
+
fillRef.current?.(early || runtime.getKlineResult(item.id, period))
|
|
870
|
+
}).catch(e => runtime.setKlineError(item.id, liveRef.current.period, e.message, e.code)) // 宿主换主题/切涨跌配色都是给 root 加类或在 body 上打标记, 观察这里重涂。
|
|
871
|
+
// 这个 effect 空依赖, 所以主题与数据都从 liveRef 现取; 主序列是蜡烛还是面积由已建序列判断,
|
|
872
|
+
// 用 wantTrend 会读到首帧的值。分时面积序列的键名与蜡烛完全不同, 只按蜡烛键重涂等于没涂
|
|
873
|
+
mo = new MutationObserver(() => repaintChart(readChartTheme(holder.current?.closest('.ssb-root') || document.documentElement, liveRef.current.colorMode)))
|
|
381
874
|
mo.observe(root, { attributes: true, attributeFilter: ['class', 'style'] })
|
|
875
|
+
// 明暗主题标记落在 body 上, 不在 root 的观察范围里, 单开一路才收得到切换。
|
|
876
|
+
// 同色系两套主题(或第三方主题覆盖 token)切换时这个标记不变, 必须连 style 一起听
|
|
877
|
+
mo.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme', 'style'] })
|
|
382
878
|
return () => { dead = true; mo.disconnect(); chartRef.current?.remove(); chartRef.current = null }
|
|
383
879
|
}, [])
|
|
384
|
-
//
|
|
880
|
+
// 数据时间面: 首末日期 + 共几根 + 今日有没有交易。判断只用数据本身, 不猜日历——
|
|
881
|
+
// 最后一根的日期就是市场时区的今天, 说明今天开过市, 否则说明今天还没有行情。
|
|
882
|
+
function klineMetaText(result, trend) {
|
|
883
|
+
const candles = result?.candles || []
|
|
884
|
+
if (!candles.length) return ''
|
|
885
|
+
const zone = item.timezone || 'Asia/Shanghai'
|
|
886
|
+
const dayOf = candle => {
|
|
887
|
+
const time = trend ? candle.at : candle.time
|
|
888
|
+
if (typeof time === 'number') return new Intl.DateTimeFormat('en-CA', { timeZone: zone }).format(new Date(time * 1000))
|
|
889
|
+
return typeof time === 'string' ? time.slice(0, 10) : ''
|
|
890
|
+
}
|
|
891
|
+
const first = dayOf(candles[0]), last = dayOf(candles[candles.length - 1])
|
|
892
|
+
const today = new Intl.DateTimeFormat('en-CA', { timeZone: zone }).format(new Date())
|
|
893
|
+
const span = trend || first === last ? `${last} · ${candles.length} 根` : `${first} ~ ${last} · ${candles.length} 根`
|
|
894
|
+
const todayMark = last === today ? '今日有交易' : `今日无交易(最近 ${last.slice(5)})`
|
|
895
|
+
return `${span} · ${todayMark}`
|
|
896
|
+
}
|
|
897
|
+
// effect B: 只在标的/周期变化时发起加载; 灌图由下面的数据 effect 统一负责,
|
|
898
|
+
// 否则手动刷新(↻)只改 state 不进图表, 出现图例已更新而蜡烛还是旧的
|
|
385
899
|
React.useEffect(() => {
|
|
386
900
|
let dead = false
|
|
387
|
-
runtime.loadKline(item.id, period).
|
|
901
|
+
runtime.loadKline(item.id, period).catch(e => { if (!dead) runtime.setKlineError(item.id, period, e.message, e.code) })
|
|
388
902
|
return () => { dead = true }
|
|
389
903
|
}, [item.id, period])
|
|
904
|
+
// 数据唯一入口: state.kline 变就重灌, 换 key 才重置视口。
|
|
905
|
+
// 必须校验 key: 切周期当帧 state.kline 已被清成上一轮的空壳, 直接灌会把分时的 "09:30" 交给
|
|
906
|
+
// Candlestick, vendor 对非法日期字符串是抛错而不是忽略; 空数组同样要灌, 否则换只后旧图残留。
|
|
907
|
+
React.useEffect(() => {
|
|
908
|
+
const key = klineKeyOf(item.id, period)
|
|
909
|
+
if (state.klineKey !== key) return
|
|
910
|
+
const plan = klineShipPlan(shippedKeyRef.current, key, !!kline?.candles?.length)
|
|
911
|
+
shippedKeyRef.current = plan.next
|
|
912
|
+
maybeFill({ candles: kline?.candles || [], preClose: kline?.preClose }, plan.fit)
|
|
913
|
+
}, [state.klineKey, kline?.candles, kline?.preClose, item.id, period])
|
|
914
|
+
// 成本/辅助线变化(设/改/清)不会触发数据重灌, 得自己把参考线更新掉
|
|
915
|
+
React.useEffect(() => {
|
|
916
|
+
if (!mainRef.current) return
|
|
917
|
+
const palette = themeOf()
|
|
918
|
+
drawMarks(mainRef.current, palette, guides.marks ? marks : [])
|
|
919
|
+
const candles = kline?.candles || []
|
|
920
|
+
const highs = candles.map(c => finiteOr(c.high)).filter(Number.isFinite), lows = candles.map(c => finiteOr(c.low)).filter(Number.isFinite)
|
|
921
|
+
drawLine(preCloseLineRef, guides.preClose && kline?.preClose != null ? { price: kline.preClose, color: palette.crosshair, lineStyle: 2, lineWidth: 1, title: '昨收' } : null)
|
|
922
|
+
drawLine(highLineRef, guides.highLow && highs.length ? { price: Math.max(...highs), color: palette.textColor, lineStyle: 1, lineWidth: 1, title: '区间高' } : null)
|
|
923
|
+
drawLine(lowLineRef, guides.highLow && lows.length ? { price: Math.min(...lows), color: palette.textColor, lineStyle: 1, lineWidth: 1, title: '区间低' } : null)
|
|
924
|
+
// 开关变化不触发数据重灌, 指标线要自己补画
|
|
925
|
+
if (!wantTrend) applyIndicators(kline?.candles)
|
|
926
|
+
}, [item.marks, kline.candles, guides])
|
|
390
927
|
return h('div', { className: 'ssb-kline' },
|
|
391
|
-
h('div', { className: 'ssb-kline-toolbar' }, h('div', { role: 'tablist', 'aria-label': 'K线周期', style: { display: 'flex', gap: '8px' } }, ...PERIODS.map(p => h(Button, { key: p.key, role: 'tab', 'aria-selected': period === p.key, primary: period === p.key, onClick: () => runtime.setKlinePeriod(p.key) }, p.label))), h(Button, { onClick: quiet(() => runtime.loadKline(item.id, period, true)) }, '↻')
|
|
928
|
+
h('div', { className: 'ssb-kline-toolbar' }, h('div', { role: 'tablist', 'aria-label': 'K线周期', style: { display: 'flex', gap: '8px' } }, ...PERIODS.map(p => h(Button, { key: p.key, role: 'tab', 'aria-selected': period === p.key, primary: period === p.key, onClick: () => runtime.setKlinePeriod(p.key) }, p.label))), h(Button, { onClick: quiet(() => runtime.loadKline(item.id, period, true)) }, '↻'),
|
|
929
|
+
h('div', { className: 'ssb-more', style: { position: 'relative' } }, h(Button, { 'aria-expanded': !!state.view.guideOpen, onClick: () => runtime.updateView({ guideOpen: !state.view.guideOpen }) }, '辅助线'),
|
|
930
|
+
state.view.guideOpen ? h('div', { className: 'ssb-menu', style: { position: 'absolute', left: 0, top: 'calc(100% + 4px)', background: 'var(--ssb-bg)', border: '1px solid var(--ssb-border)', borderRadius: 'var(--ssb-radius-md)', padding: '8px', display: 'flex', flexDirection: 'column', gap: '4px', zIndex: 10, minWidth: '170px' }, role: 'menu' },
|
|
931
|
+
...GUIDE_GROUPS.flatMap(group => [h('div', { key: group.title, className: 'ssb-menu-title' }, group.title), ...group.items.map(([key, label]) => h(Toggle, { key, label, checked: !!guides[key], onChange: () => setGuides({ [key]: !guides[key] }) }))])) : null),
|
|
932
|
+
h('span', { className: 'ssb-kline-meta' }, klineMetaText(kline, wantTrend))),
|
|
933
|
+
legend ? h('div', { className: 'ssb-kline-legend' },
|
|
934
|
+
// 成本与卖价都是报价要素, 跟开高低收放同一行; 放在指标行会被挤到第三行去
|
|
935
|
+
h('div', { className: 'ssb-legend-row' }, ...legend.quote.map(([label, value, tone]) => h('span', { key: label, className: `ssb-legend-item${tone || ''}` }, h('span', { className: 'ssb-legend-label' }, label), value == null ? '—' : value)),
|
|
936
|
+
avgCost == null ? null : h('span', { className: 'ssb-legend-item ssb-legend-cost' }, h('span', { className: 'ssb-legend-label' }, marks.filter(m => m.kind === 'cost').length > 1 ? `成本(${marks.filter(m => m.kind === 'cost').length}笔均价)` : '成本'), priceTextOf(avgCost, item.priceScale), costPct == null ? null : h('span', { className: `ssb-legend-pct${costPct > 0 ? ' ssb-up' : costPct < 0 ? ' ssb-down' : ''}` }, `${signed(costPct.toFixed(2))}%`)),
|
|
937
|
+
// 目标位只在图例里出现一次: 线在价格轴上, 具体数值看这里
|
|
938
|
+
...marks.filter(mark => mark.kind === 'target').map(mark => {
|
|
939
|
+
const pct = markPct(mark, lastClose, avgCost)
|
|
940
|
+
return h('span', { key: mark.id, className: 'ssb-legend-item ssb-legend-target' }, h('span', { className: 'ssb-legend-label' }, '卖价'), priceTextOf(mark.price, item.priceScale), pct == null ? null : h('span', { className: `ssb-legend-pct${pct > 0 ? ' ssb-up' : pct < 0 ? ' ssb-down' : ''}` }, `${signed(pct.toFixed(2))}%`))
|
|
941
|
+
})),
|
|
942
|
+
legend.series.length ? h('div', { className: 'ssb-legend-row' },
|
|
943
|
+
...legend.series.map(([label, value, tone]) => h('span', { key: label, className: `ssb-legend-item${tone || ''}` }, h('span', { className: 'ssb-legend-label' }, label), Number(value).toFixed(item.priceScale ?? 2)))) : null) : null,
|
|
392
944
|
kline.loading ? h('div', { className: 'ssb-kline-skeleton', role: 'status' }, '加载K线…') : null,
|
|
393
|
-
kline.error ? h(KlineError, { message: kline.error, busyOneClick: !!state.busy.configuration, onRetry: () => quiet(() => runtime.loadKline(item.id, period, true))(), guide: kline.errorCode === 'NO_ROUTE' ? () => quiet(async () => { await runtime.oneClickKline(); await runtime.loadKline(item.id, period, true) })() : null }) : null,
|
|
945
|
+
kline.error ? h(KlineError, { message: kline.error, busyOneClick: !!state.busy.configuration, onRetry: () => quiet(() => runtime.loadKline(item.id, period, true))(), guide: kline.errorCode === 'NO_ROUTE' ? { label: '一键开启', onClick: () => quiet(async () => { await runtime.oneClickKline(); await runtime.loadKline(item.id, period, true) })() } : kline.errorCode === 'UNSUPPORTED_CAPABILITY' ? { label: '去设置换源', onClick: () => runtime.updateView({ view: 'list', overlay: 'settings' }) } : null }) : null,
|
|
394
946
|
!kline.loading && !kline.error && kline.candles && !kline.candles.length ? h('div', { className: 'ssb-empty' }, h('p', null, '暂无K线数据(停牌或已退市)')) : null,
|
|
395
947
|
state.data.vendorAvailable === false ? h('div', { className: 'ssb-banner' }, '图表组件不可用,已降级为数字详情。') : null,
|
|
396
|
-
|
|
948
|
+
// 出错时隐藏画布: 图表实例挂在同一个 DOM 节点上, 卸载会丢实例, 只做可见性切换
|
|
949
|
+
h('div', { ref: holder, className: 'ssb-kline-holder', style: kline.error ? { display: 'none' } : undefined }))
|
|
397
950
|
}
|
|
398
951
|
// 浮层原语: 遮罩+Esc/遮罩关闭, 抽屉(detail)与模态(settings)共用关闭语义
|
|
399
952
|
// 浮层原语: 遮罩+Esc/遮罩关闭+焦点移入/困住/还原+背景滚动锁定, 抽屉(detail)与模态(settings)共用
|
|
@@ -428,7 +981,7 @@ window.__ModuleLoader__.load({
|
|
|
428
981
|
const onMaskClick = e => {
|
|
429
982
|
if (e.target !== e.currentTarget) return
|
|
430
983
|
const hit = kind === 'drawer' ? document.elementsFromPoint(e.clientX, e.clientY).find(n => n.classList?.contains('ssb-stock-name')) : null
|
|
431
|
-
if (hit?.dataset.securityId) runtime.updateView({ detailId: hit.dataset.securityId, selectedId: hit.dataset.securityId
|
|
984
|
+
if (hit?.dataset.securityId) runtime.updateView({ detailId: hit.dataset.securityId, selectedId: hit.dataset.securityId })
|
|
432
985
|
else close()
|
|
433
986
|
}
|
|
434
987
|
return h('div', { className: `ssb-overlay ssb-overlay-${kind}`, onClick: onMaskClick, role: 'presentation' },
|
|
@@ -445,7 +998,7 @@ window.__ModuleLoader__.load({
|
|
|
445
998
|
const order = data.control.watchlist.order
|
|
446
999
|
const at = order.indexOf(item.id)
|
|
447
1000
|
const neighbor = dir => order[(at + dir + order.length) % order.length]
|
|
448
|
-
const openNeighbor = id =>
|
|
1001
|
+
const openNeighbor = id => runtime.openDetail(id)
|
|
449
1002
|
const inWatchlist = !!data.control.watchlist.items[view.detailId]
|
|
450
1003
|
if (!item) return h('aside', { className: 'ssb-detail ssb-empty' }, h('p', null, '选择一只证券查看基础行情。'))
|
|
451
1004
|
const entry = data.quotes[item.id], q = entry?.quote
|
|
@@ -453,65 +1006,357 @@ window.__ModuleLoader__.load({
|
|
|
453
1006
|
const marketLabel = MARKET_LABEL[item.exchange] || item.exchange
|
|
454
1007
|
// 币种唯一来源是自选条目 currency(watchlist 写入时由 MARKET_META 保证), client 不设回退表
|
|
455
1008
|
const currency = item.currency || ''
|
|
456
|
-
const fields = [['今开', q?.open], ['最高', q?.high], ['最低', q?.low], ['昨收', q?.previousClose], ['涨跌额', q?.change == null ? null : signed(q.change)], ['
|
|
1009
|
+
const fields = [['今开', formatPrice(q?.open)], ['最高', formatPrice(q?.high)], ['最低', formatPrice(q?.low)], ['昨收', formatPrice(q?.previousClose)], ['涨跌额', q?.change == null ? null : signed(q.change)], ['成交量', q?.volume == null ? null : `${formatAmount(q.volume, { zeroIsMissing: false })}股`], ['成交额', formatAmount(q?.amount)], ['交易时段', ({ trading: '交易中', closed: '休市', break: '午间休市', auction: '集合竞价', preopen: '开市前', unknown: '未知' })[q?.sessionState] || '未知'], ['报价时间', timeText(q?.quoteAt)], ['获取时间', timeText(q?.receivedAt)], ['来源', q?.providerInstanceId], ['延迟', delayText(q)]]
|
|
457
1010
|
// 非自选详情: 加自选为主操作, 不显示别名/删除等自选管理
|
|
458
1011
|
const peekActions = inWatchlist ? null : h('div', { className: 'ssb-toolbar' }, h(Button, { primary: true, disabled: state.busy.write, onClick: quiet(() => runtime.mutate('add', { securityId: item.id })) }, '加自选'))
|
|
459
|
-
//
|
|
1012
|
+
// 价格标记: 每条带语义(成本/卖价), 各自算盈亏, 图上一条线
|
|
1013
|
+
const marks = item.marks || []
|
|
1014
|
+
const avgCost = avgCostOf(marks)
|
|
460
1015
|
const lastN = Number(q?.last)
|
|
461
|
-
const costPl =
|
|
462
|
-
const
|
|
1016
|
+
const costPl = avgCost != null && lastN ? { amount: Number((lastN - avgCost).toFixed(item.priceScale ?? 2)), pct: Number(((lastN - avgCost) / avgCost * 100).toFixed(1)) } : null
|
|
1017
|
+
const applyMarks = next => quiet(() => runtime.mutate('marks', { securityId: item.id, marks: next }))
|
|
1018
|
+
const toneOfPct = pct => pct == null ? '' : pct > 0 ? ' ssb-up' : pct < 0 ? ' ssb-down' : ''
|
|
1019
|
+
// 增删都是整组替换: 协议面只有一个操作, 也不会出现半更新的中间态
|
|
1020
|
+
const markForm = inWatchlist ? h('form', { className: 'ssb-alias-form', onSubmit: e => {
|
|
1021
|
+
e.preventDefault()
|
|
1022
|
+
const raw = String(view.markDraft?.price ?? '').trim()
|
|
1023
|
+
const price = Number(raw)
|
|
1024
|
+
if (!raw || !Number.isFinite(price) || price <= 0) { runtime.updateView({ markError: '价格需为正数' }); return }
|
|
1025
|
+
if (marks.length >= 12) { runtime.updateView({ markError: '最多 12 个标记' }); return }
|
|
1026
|
+
const kind = view.markDraft?.kind === 'target' ? 'target' : 'cost'
|
|
1027
|
+
if (marks.some(mark => mark.kind === kind && mark.price === price)) { runtime.updateView({ markError: '同类型不能有重复价格' }); return }
|
|
1028
|
+
applyMarks([...marks, { id: crypto.randomUUID().slice(0, 8), price, kind }])()
|
|
1029
|
+
runtime.updateView({ markDraft: null, markError: '' })
|
|
1030
|
+
} },
|
|
1031
|
+
h('label', null, '价格标记', h('input', { value: view.markDraft?.price ?? '', inputMode: 'decimal', onChange: e => runtime.updateView({ markDraft: { kind: view.markDraft?.kind ?? 'cost', price: e.target.value }, markError: '' }), placeholder: '输入价格' })),
|
|
1032
|
+
h('div', { className: 'ssb-toolbar', role: 'group', 'aria-label': '标记类型' },
|
|
1033
|
+
h(Button, { primary: (view.markDraft?.kind ?? 'cost') === 'cost', onClick: () => runtime.updateView({ markDraft: { kind: 'cost', price: view.markDraft?.price ?? '' } }) }, '成本'),
|
|
1034
|
+
h(Button, { primary: view.markDraft?.kind === 'target', onClick: () => runtime.updateView({ markDraft: { kind: 'target', price: view.markDraft?.price ?? '' } }) }, '卖价')),
|
|
1035
|
+
h('button', { type: 'submit', className: 'ssb-button', disabled: state.busy.write }, '添加标记'),
|
|
1036
|
+
view.markError ? h('small', { className: 'ssb-muted', role: 'alert' }, view.markError) : null) : null
|
|
1037
|
+
const markList = marks.length ? h('ul', { className: 'ssb-mark-list', 'aria-label': '价格标记' }, ...marks.map(mark => {
|
|
1038
|
+
const pct = markPct(mark, lastN, avgCost)
|
|
1039
|
+
return h('li', { key: mark.id, className: 'ssb-mark-row' },
|
|
1040
|
+
h('span', { className: 'ssb-mark-kind' }, mark.kind === 'cost' ? '成本' : '卖价'),
|
|
1041
|
+
h('span', { className: 'ssb-number' }, priceTextOf(mark.price, item.priceScale)),
|
|
1042
|
+
h('span', { className: `ssb-small${toneOfPct(pct)}` }, pct == null ? '—' : `${signed(pct.toFixed(2))}%`),
|
|
1043
|
+
h(Button, { danger: true, disabled: state.busy.write, onClick: applyMarks(marks.filter(other => other.id !== mark.id)) }, '删除'))
|
|
1044
|
+
})) : null
|
|
463
1045
|
// 分组表单(B1): 单标签, 空=移出分组; 草稿 view.groupDraft
|
|
464
1046
|
const groupForm = inWatchlist ? h('form', { className: 'ssb-alias-form', onSubmit: e => { e.preventDefault(); const raw = view.groupDraft?.trim() ?? item.group; if ([...raw].length > 12) { runtime.updateView({ groupError: '分组名最多12字' }); return } quiet(() => runtime.mutate('group', { securityIds: [item.id], group: raw }))() } }, h('label', null, '设分组', h('input', { value: view.groupDraft ?? (item.group || ''), maxLength: 12, onChange: e => runtime.updateView({ groupDraft: e.target.value, groupError: '' }), placeholder: '留空移出分组' })), h('button', { type: 'submit', className: 'ssb-button', disabled: state.busy.write }, '保存分组'), view.groupError ? h('small', { className: 'ssb-muted', role: 'alert' }, view.groupError) : null) : null
|
|
465
|
-
const costBlock = costPl ? h('div', { className: `ssb-number${costPl.amount > 0 ? ' ssb-up' : costPl.amount < 0 ? ' ssb-down' : ''}` }, h('span', null, `持仓盈亏 ${signed(costPl.amount)} · ${signed(costPl.pct)}%`), h('span', { className: 'ssb-muted ssb-small' },
|
|
466
|
-
const watchActions = inWatchlist ? h(React.Fragment, null,
|
|
1047
|
+
const costBlock = costPl ? h('div', { className: `ssb-number${costPl.amount > 0 ? ' ssb-up' : costPl.amount < 0 ? ' ssb-down' : ''}` }, h('span', null, `持仓盈亏 ${signed(costPl.amount)} · ${signed(costPl.pct)}%`), h('span', { className: 'ssb-muted ssb-small' }, `成本均价 ${priceTextOf(avgCost, item.priceScale)}`)) : null
|
|
1048
|
+
const watchActions = inWatchlist ? h(React.Fragment, null, markForm, markList, groupForm, h('form', { className: 'ssb-alias-form', onSubmit: e => { e.preventDefault(); quiet(() => runtime.mutate('alias', { securityId: item.id, alias: view.alias }))() } }, h('label', null, '自选别名', h('input', { value: view.alias || '', maxLength: 30, onChange: e => runtime.updateView({ alias: e.target.value }), 'aria-describedby': 'ssb-alias-hint' })), h('small', { id: 'ssb-alias-hint', className: 'ssb-muted' }, '最多30个字符,留空显示正式名称'), h('button', { type: 'submit', className: 'ssb-button', disabled: state.busy.write }, '保存别名')),
|
|
1049
|
+
// 行内不再挂操作菜单, 排序走拖拽, 剩下的置顶与移出收敛到这里: 低频、且删除需要确认, 不适合出现在每一行
|
|
1050
|
+
h('div', { className: 'ssb-alias-form' }, h('span', { className: 'ssb-muted ssb-small' }, '自选管理'), h('div', { className: 'ssb-toolbar' },
|
|
1051
|
+
h(Button, { disabled: state.busy.write, onClick: quiet(() => runtime.mutate('pin', { securityId: item.id, pinned: !item.pinned })) }, item.pinned ? '取消置顶' : '置顶'),
|
|
1052
|
+
h(Button, { danger: true, disabled: state.busy.write, onClick: () => { if (!confirm(`从自选移除「${item.alias || item.officialName}」?`)) return; quiet(() => runtime.mutate('remove', { securityId: item.id }))(); runtime.updateView({ detailId: null, selectedId: null, overlay: null, focusId: null }) } }, '移出自选')))) : null
|
|
467
1053
|
return h('aside', { className: 'ssb-detail' }, h('div', { className: 'ssb-section-head' }, h('h2', null, item.alias || item.officialName), inWatchlist && order.length > 1 ? h('div', { className: 'ssb-toolbar' }, h(Button, { onClick: () => openNeighbor(neighbor(-1)) }, '上一只'), h(Button, { onClick: () => openNeighbor(neighbor(1)) }, '下一只')) : null),
|
|
468
1054
|
h('p', { className: 'ssb-muted' }, `${item.officialName} · ${marketLabel} ${item.code}${currency ? ` · ${currency}` : ''}`),
|
|
469
1055
|
h('div', { className: `ssb-detail-price ssb-number${direction}` }, q?.last ?? '—', h('span', { className: 'ssb-small' }, q?.changePct == null ? '—' : `${signed(q.changePct)}%`)),
|
|
470
1056
|
h('div', { className: 'ssb-detail-grid' },
|
|
471
1057
|
h('div', { className: 'ssb-detail-main' }, h(KlineView, { runtime, state, item })),
|
|
472
|
-
h('div', { className: 'ssb-detail-side' }, peekActions, h(QuoteStatus, { entry }), h('dl', { className: 'ssb-facts' }, ...fields.map(([label, value]) => h(React.Fragment, { key: label }, h('dt', null, label), h('dd', null, value ?? '—')))),
|
|
473
|
-
|
|
1058
|
+
h('div', { className: 'ssb-detail-side' }, peekActions, h(QuoteStatus, { entry }), costBlock, h('dl', { className: 'ssb-facts' }, ...fields.map(([label, value]) => h(React.Fragment, { key: label }, h('dt', null, label), h('dd', null, value ?? '—')))))),
|
|
1059
|
+
watchActions ? h('div', { className: 'ssb-detail-forms' }, watchActions) : null)
|
|
1060
|
+
}
|
|
1061
|
+
// 显示偏好(悬浮窗位置/辅助线开关)属于纯客户端状态: 存本地, 不进宿主 schema,
|
|
1062
|
+
// 否则每加一个开关都要重启宿主。同页用自定义事件广播, storage 事件只在跨标签页触发。
|
|
1063
|
+
const readPref = (key, fallback) => { try { return { ...fallback, ...(JSON.parse(localStorage.getItem(key) || 'null') || {}) } } catch { return { ...fallback } } }
|
|
1064
|
+
const writePref = (key, next) => { try { localStorage.setItem(key, JSON.stringify(next)) } catch { } window.dispatchEvent(new Event(`ssb:${key}`)) }
|
|
1065
|
+
function usePref(key, fallback) {
|
|
1066
|
+
const [value, setValue] = React.useState(() => readPref(key, fallback))
|
|
1067
|
+
React.useEffect(() => {
|
|
1068
|
+
const sync = () => setValue(readPref(key, fallback))
|
|
1069
|
+
window.addEventListener(`ssb:${key}`, sync)
|
|
1070
|
+
return () => window.removeEventListener(`ssb:${key}`, sync)
|
|
1071
|
+
}, [key])
|
|
1072
|
+
return [value, next => writePref(key, { ...readPref(key, fallback), ...next })]
|
|
1073
|
+
}
|
|
1074
|
+
// ---- FloatingTicker: 悬浮盯盘, portal 到 body 常驻, 不随面板切换卸载 ----
|
|
1075
|
+
// 悬浮窗自己的数据流: 列表模式批量拉勾选标的的报价, 图形模式只拉当前这只的分时
|
|
1076
|
+
function useFloatingPolling({ open, mode, securityId, picked, items, live, refreshSeconds, quotes, fetched, revision, setFetched, setTrend, setError }) {
|
|
1077
|
+
// quotes 的每条是 { quote, health }, 交易时段在 quote 里
|
|
1078
|
+
// 时段取最新的一份: 自己拉的和面板的都可能旧, 不能无条件让自己的压过面板的。
|
|
1079
|
+
// 时间戳相同取自己那份——自己那份刚由这一拍的刷新拿到, 而面板可能在本次渲染前就已冻结
|
|
1080
|
+
const stateOf = id => pickNewest(fetched?.[id]?.quote, quotes?.[id]?.quote)?.sessionState
|
|
1081
|
+
const pickedKey = picked.join(','), stateKey = picked.map(stateOf).join(',')
|
|
1082
|
+
React.useEffect(() => {
|
|
1083
|
+
if (!open || !picked.length || live === false) return
|
|
1084
|
+
let dead = false
|
|
1085
|
+
setError('')
|
|
1086
|
+
// 间隔跟随设置页的刷新间隔; 全部收市后降到一分钟一拍
|
|
1087
|
+
// 间隔由 floatPollDelay 统一算, 这里只判断要不要降频
|
|
1088
|
+
// 午休与休市都不产生新数据, 一并归入安静档: 只认 closed 的话, 午休那一个半小时
|
|
1089
|
+
// 图形模式还在按交易频率打分时
|
|
1090
|
+
// 名字不能叫 quiet: 模块里已有一个 quiet() 帮助函数用来包住点击处理
|
|
1091
|
+
const allQuiet = picked.every(id => QUIET_STATES.has(stateOf(id)))
|
|
1092
|
+
// 页面不可见时不拉: 后台标签页继续轮询只会白耗来源额度
|
|
1093
|
+
let tick = 0
|
|
1094
|
+
const pull = () => {
|
|
1095
|
+
if (document.visibilityState !== 'visible') return
|
|
1096
|
+
tick += 1
|
|
1097
|
+
const plan = pollPlan({ mode, quiet: allQuiet, tick })
|
|
1098
|
+
if (plan === 'skip') return
|
|
1099
|
+
const refresh = () => fetch(`${BASE}/refresh`, {
|
|
1100
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1101
|
+
body: JSON.stringify({ operationId: crypto.randomUUID(), expectedRevision: revision ?? 0, securities: picked.map(id => ({ id, exchange: items[id].exchange, code: items[id].code })) }),
|
|
1102
|
+
})
|
|
1103
|
+
.then(response => response.json())
|
|
1104
|
+
.then(payload => { if (dead) return; if (payload.ok) { setFetched(payload.data.quotes || {}); setError('') } else setError(payload.error?.message || '行情不可用') })
|
|
1105
|
+
.catch(() => { if (!dead) setError('行情不可用') })
|
|
1106
|
+
if (plan === 'refresh') refresh()
|
|
1107
|
+
else if (plan === 'snapshot') {
|
|
1108
|
+
// 只读快照: 省额度用, 但它不重算交易时段, 单靠它醒不过来
|
|
1109
|
+
fetch(`${BASE}/snapshot`)
|
|
1110
|
+
.then(response => response.json())
|
|
1111
|
+
.then(payload => { if (dead) return; if (payload.ok) setFetched(payload.data.quotes || {}); setError(payload.ok ? '' : payload.error?.message || '行情不可用') })
|
|
1112
|
+
.catch(() => { if (!dead) setError('行情不可用') })
|
|
1113
|
+
} else if (securityId) {
|
|
1114
|
+
fetch(`${BASE}/kline?${new URLSearchParams({ securityId, period: 'trend', limit: '250' })}`)
|
|
1115
|
+
.then(response => response.json())
|
|
1116
|
+
.then(payload => { if (dead) return; if (payload.ok) { setTrend({ securityId, data: payload.data }); setError('') } else setError(payload.error?.message || '行情不可用') })
|
|
1117
|
+
.catch(() => { if (!dead) setError('行情不可用') })
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
pull()
|
|
1121
|
+
// 休市时不设交易节奏的定时器, 但仍留一拍低频检查, 复盘后自动恢复
|
|
1122
|
+
const timer = setInterval(pull, floatPollDelay(refreshSeconds, allQuiet))
|
|
1123
|
+
return () => { dead = true; clearInterval(timer) }
|
|
1124
|
+
}, [open, mode, securityId, pickedKey, live, refreshSeconds, stateKey])
|
|
1125
|
+
}
|
|
1126
|
+
// 拖动: 按住非控件区域移动, 松手时把落点写回偏好
|
|
1127
|
+
function useFloatingDrag(onDrop) {
|
|
1128
|
+
const dragRef = React.useRef(null)
|
|
1129
|
+
const clamp = (x, y, el) => {
|
|
1130
|
+
const width = el?.offsetWidth || 288, height = el?.offsetHeight || 210, margin = 8
|
|
1131
|
+
return { x: Math.min(Math.max(margin, x), Math.max(margin, window.innerWidth - width - margin)), y: Math.min(Math.max(margin, y), Math.max(margin, window.innerHeight - height - margin)) }
|
|
1132
|
+
}
|
|
1133
|
+
return {
|
|
1134
|
+
onPointerDown: e => {
|
|
1135
|
+
// 只认左键, 且只从标题条拖: 窗体内要留纵向手势给列表滚动
|
|
1136
|
+
if (e.button !== 0 || !e.target.closest('.ssb-float-head') || e.target.closest('button, input, label')) return
|
|
1137
|
+
const box = e.currentTarget.getBoundingClientRect()
|
|
1138
|
+
dragRef.current = { dx: e.clientX - box.left, dy: e.clientY - box.top }
|
|
1139
|
+
e.currentTarget.setPointerCapture?.(e.pointerId)
|
|
1140
|
+
},
|
|
1141
|
+
onPointerMove: e => {
|
|
1142
|
+
if (!dragRef.current) return
|
|
1143
|
+
const { x, y } = clamp(e.clientX - dragRef.current.dx, e.clientY - dragRef.current.dy, e.currentTarget)
|
|
1144
|
+
e.currentTarget.style.left = `${x}px`
|
|
1145
|
+
e.currentTarget.style.top = `${y}px`
|
|
1146
|
+
e.currentTarget.style.right = 'auto'
|
|
1147
|
+
e.currentTarget.style.bottom = 'auto'
|
|
1148
|
+
},
|
|
1149
|
+
onPointerUp: e => {
|
|
1150
|
+
if (!dragRef.current) return
|
|
1151
|
+
dragRef.current = null
|
|
1152
|
+
e.currentTarget.releasePointerCapture?.(e.pointerId)
|
|
1153
|
+
const box = e.currentTarget.getBoundingClientRect()
|
|
1154
|
+
onDrop({ x: Math.round(box.left), y: Math.round(box.top) })
|
|
1155
|
+
},
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
// 轮询节奏: 跟随设置页的刷新间隔, 全部收市后压到一分钟一拍
|
|
1159
|
+
const floatPollDelay = (refreshSeconds, quiet) => {
|
|
1160
|
+
const base = Math.max(15, Number(refreshSeconds) || 15) * 1000
|
|
1161
|
+
return quiet ? Math.max(base, 60 * 1000) : base
|
|
1162
|
+
}
|
|
1163
|
+
// 主序列是蜡烛还是面积, 决定重涂时该下发哪一组键名: 两组键名互不相通, 用错等于没涂
|
|
1164
|
+
function seriesPaintPlan(theme, isArea) {
|
|
1165
|
+
return isArea
|
|
1166
|
+
? { lineColor: theme.up, topColor: theme.up + '4d', bottomColor: theme.up + '07' }
|
|
1167
|
+
: { upColor: theme.up, downColor: theme.down, borderUpColor: theme.up, borderDownColor: theme.down, wickUpColor: theme.up, wickDownColor: theme.down }
|
|
1168
|
+
}
|
|
1169
|
+
// 宿主把主题 token 内联写在 body 的 style 上, 同色系两套主题切换时明暗标记并不变,
|
|
1170
|
+
// 只盯着那个标记会整次漏掉换肤。取实际色值当签名, 才算判断了「主题真的变了」。
|
|
1171
|
+
// background 也在签名里: 画布底色就铺的是它, canvas 不透明, 漏掉它等于背景永远不换
|
|
1172
|
+
const themeSignature = t => [t.background, t.textColor, t.gridLine, t.crosshair, t.up, t.down, t.volumeUp, t.volumeDown, t.cost, t.target, t.EMA, t.BBI, ...t.MA, ...t.BOLL].join('|')
|
|
1173
|
+
// 轮询决策。三件事必须同时成立: 收市后不再打上游、又不能永远停在那儿、还不能每分钟真刷一次。
|
|
1174
|
+
// trend 端点不进缓存, 收市后必须彻底停掉; 而 /snapshot 只读缓存不重算交易时段,
|
|
1175
|
+
// 单靠它永远发现不了开市, 所以每隔几拍要用真刷新把状态翻回来
|
|
1176
|
+
const POLL_EVERY = 10
|
|
1177
|
+
// 午休与收盘都不产生新数据, 归同一个安静档
|
|
1178
|
+
const QUIET_STATES = new Set(['closed', 'break'])
|
|
1179
|
+
const pollPlan = ({ mode, quiet, tick }) => {
|
|
1180
|
+
const beat = tick % POLL_EVERY === 0
|
|
1181
|
+
if (mode === 'list') return quiet && !beat ? 'snapshot' : 'refresh'
|
|
1182
|
+
// 整十拍必须真刷新一次, 且不能只在已知安静时才刷: 时段只能由刷新结果更新,
|
|
1183
|
+
// 而面板不活跃时它的报价会冻结在 trading —— 那样 quiet 永远翻不过来, 图形模式就
|
|
1184
|
+
// 会一直按交易频率打 trend(该端点不进缓存, 每拍都是真上游)
|
|
1185
|
+
if (beat) return 'refresh'
|
|
1186
|
+
return quiet ? 'skip' : 'trend'
|
|
1187
|
+
}
|
|
1188
|
+
// 全部收市后只读快照: 只为发现迟到的复盘, 不必每分钟真打一次上游
|
|
1189
|
+
const FLOAT_KEY = 'ssb-floating'
|
|
1190
|
+
// ids 为空时退回自选前几只, 否则第一次打开会是一张空窗
|
|
1191
|
+
// ids 为 null 表示"从没勾过"(退回默认前五只), 空数组表示"明确一只都不盯", 两者不能混
|
|
1192
|
+
const FLOAT_DEFAULT = { open: false, live: true, mode: 'list', ids: null, securityId: null, x: null, y: null }
|
|
1193
|
+
// 分时曲线自绘: 只要一条折线加填充, 不值得为它再挂一个图表实例
|
|
1194
|
+
function Sparkline({ candles, preClose }) {
|
|
1195
|
+
const values = (candles || []).map(c => finiteOr(c.close)).filter(Number.isFinite)
|
|
1196
|
+
if (!values.length) return null
|
|
1197
|
+
const width = 264, height = 88
|
|
1198
|
+
const base = finiteOr(preClose)
|
|
1199
|
+
const all = Number.isFinite(base) ? [...values, base] : values
|
|
1200
|
+
const low = Math.min(...all), high = Math.max(...all), span = high - low || 1
|
|
1201
|
+
const y = value => height - ((value - low) / span) * height
|
|
1202
|
+
const x = index => (index / Math.max(1, values.length - 1)) * width
|
|
1203
|
+
const line = values.map((value, index) => `${index ? 'L' : 'M'}${x(index).toFixed(1)},${y(value).toFixed(1)}`).join('')
|
|
1204
|
+
return h('svg', { className: 'ssb-float-spark', viewBox: `0 0 ${width} ${height}`, preserveAspectRatio: 'none', role: 'img', 'aria-label': '分时走势' },
|
|
1205
|
+
Number.isFinite(base) ? h('line', { className: 'ssb-float-base', x1: 0, x2: width, y1: y(base), y2: y(base) }) : null,
|
|
1206
|
+
h('path', { className: 'ssb-float-area', d: `${line}L${width},${height}L0,${height}Z` }),
|
|
1207
|
+
h('path', { className: 'ssb-float-line', d: line }))
|
|
1208
|
+
}
|
|
1209
|
+
function FloatingTicker({ runtime }) {
|
|
1210
|
+
const state = React.useSyncExternalStore(runtime.subscribe, runtime.getSnapshot, runtime.getSnapshot)
|
|
1211
|
+
const [floating, setFloating] = usePref(FLOAT_KEY, FLOAT_DEFAULT)
|
|
1212
|
+
const [fetched, setFetched] = React.useState({})
|
|
1213
|
+
const [trend, setTrend] = React.useState(null)
|
|
1214
|
+
const [error, setError] = React.useState('')
|
|
1215
|
+
const [picker, setPicker] = React.useState(false)
|
|
1216
|
+
const items = state.data?.control.watchlist.items || {}
|
|
1217
|
+
const order = state.data?.control.watchlist.order || []
|
|
1218
|
+
// 勾选的观测目标: 空则退回自选前几只, 但绝不隐式等于全部自选
|
|
1219
|
+
const picked = (Array.isArray(floating.ids) ? floating.ids : order.slice(0, 5)).filter(id => items[id])
|
|
1220
|
+
const mode = floating.mode === 'kline' ? 'kline' : 'list'
|
|
1221
|
+
const open = !!floating.open
|
|
1222
|
+
const securityId = (floating.securityId && picked.includes(floating.securityId) ? floating.securityId : picked[0]) || null
|
|
1223
|
+
const update = next => setFloating(next)
|
|
1224
|
+
const drag = useFloatingDrag(update)
|
|
1225
|
+
useFloatingPolling({ open, mode, securityId, picked, items, live: floating.live, refreshSeconds: state.data?.control.preferences?.refreshSeconds, quotes: state.data?.quotes, fetched, revision: state.data?.control.revision, setFetched, setTrend, setError })
|
|
1226
|
+
if (!open) return null
|
|
1227
|
+
const position = floating.x == null || floating.y == null ? {} : { left: floating.x, top: floating.y, right: 'auto', bottom: 'auto' }
|
|
1228
|
+
const step = delta => {
|
|
1229
|
+
const at = picked.indexOf(securityId)
|
|
1230
|
+
const next = picked[(at + delta + picked.length) % picked.length]
|
|
1231
|
+
// 只有一只时 next 就是自己, 清掉 trend 反而让图空一转到下一拍才回来
|
|
1232
|
+
if (next && next !== securityId) { setTrend(null); update({ securityId: next }) }
|
|
1233
|
+
}
|
|
1234
|
+
const toneOf = pct => pct == null ? '' : Number(pct) > 0 ? ' ssb-up' : Number(pct) < 0 ? ' ssb-down' : ''
|
|
1235
|
+
// 面板已经刷过的报价先拿来渲染, 悬浮窗自己的 refresh 结果回来后再按 receivedAt 择优:
|
|
1236
|
+
// 否则首次打开要干等一轮上游请求, 列表是空的
|
|
1237
|
+
const entryOf = id => {
|
|
1238
|
+
const panel = state.data?.quotes?.[id], own = fetched[id]
|
|
1239
|
+
if (!panel) return own
|
|
1240
|
+
if (!own) return panel
|
|
1241
|
+
return pickNewest(own.quote, panel.quote) === own.quote ? own : panel
|
|
1242
|
+
}
|
|
1243
|
+
const current = items[securityId]
|
|
1244
|
+
// 分时数据必须和标的绑在一起: 切标的时新请求还没回来, 直接用旧数据会把上一只的价格当成这一只
|
|
1245
|
+
const trendData = trend?.securityId === securityId ? trend.data : null
|
|
1246
|
+
const candles = trendData?.candles
|
|
1247
|
+
// 这里必须先过 finiteOr: Number(null) 是 0, 会把缺失的昨收当成真实的 0 交给下游,
|
|
1248
|
+
// 结果是迷你图基准画在 0、价格显示 "0", 与图例里的「昨收 —」自相矛盾
|
|
1249
|
+
const last = finiteOr(candles?.at(-1)?.close)
|
|
1250
|
+
const base = finiteOr(trendData?.preClose)
|
|
1251
|
+
const pct = Number.isFinite(last) && Number.isFinite(base) && base ? (last - base) / base * 100 : null
|
|
1252
|
+
const tone = toneOf(pct)
|
|
1253
|
+
const rowOf = id => {
|
|
1254
|
+
const item = items[id], quote = entryOf(id)?.quote, change = quote?.changePct
|
|
1255
|
+
return h('button', { key: id, type: 'button', className: `ssb-float-row${id === securityId ? ' ssb-float-row-on' : ''}`, onClick: () => update({ mode: 'kline', securityId: id }) },
|
|
1256
|
+
h('span', { className: 'ssb-float-row-name' }, item.alias || item.officialName),
|
|
1257
|
+
h('span', { className: `ssb-float-row-price ssb-number${toneOf(change)}` }, quote?.last == null ? '—' : formatPrice(quote.last)),
|
|
1258
|
+
h('span', { className: `ssb-float-row-pct ssb-number${toneOf(change)}` }, change == null ? '—' : `${signed(Number(change).toFixed(2))}%`))
|
|
1259
|
+
}
|
|
1260
|
+
const greenUp = state.data?.control.preferences.colorMode === 'green-up' ? ' ssb-green-up' : ''
|
|
1261
|
+
// 勾选写入一律按自选顺序重排, 否则反复勾选会把 ids 变成点击次序, 列表顺序会跳
|
|
1262
|
+
const setPicked = ids => { const set = new Set(ids); update({ ids: order.filter(id => set.has(id)) }) }
|
|
1263
|
+
const pickerBlocks = () => {
|
|
1264
|
+
const slices = groupSlices(order, items)
|
|
1265
|
+
const toggleMany = (ids, on) => { const set = new Set(picked); ids.forEach(id => on ? set.add(id) : set.delete(id)); setPicked([...set]) }
|
|
1266
|
+
const block = (key, label, ids) => {
|
|
1267
|
+
if (!ids.length) return null
|
|
1268
|
+
const allOn = ids.every(id => picked.includes(id))
|
|
1269
|
+
return h('div', { key, className: 'ssb-picker-group' },
|
|
1270
|
+
h('div', { className: 'ssb-picker-head' },
|
|
1271
|
+
h('span', null, label ? `${label} · ${ids.length}` : `未分组 · ${ids.length}`),
|
|
1272
|
+
h('button', { type: 'button', className: 'ssb-picker-all', onClick: () => toggleMany(ids, !allOn) }, allOn ? '全不选' : '全选')),
|
|
1273
|
+
...ids.map(id => h(Toggle, { key: id, label: items[id].alias || items[id].officialName, checked: picked.includes(id), onChange: () => toggleMany([id], !picked.includes(id)) })))
|
|
1274
|
+
}
|
|
1275
|
+
const hasSections = slices.pinned.length || slices.groups.length
|
|
1276
|
+
return [
|
|
1277
|
+
block('pinned', '置顶', slices.pinned),
|
|
1278
|
+
...slices.groups.map(group => block(`grp-${group.name}`, group.name, group.ids)),
|
|
1279
|
+
block('ungrouped', hasSections ? '未分组' : '', slices.ungrouped),
|
|
1280
|
+
]
|
|
1281
|
+
}
|
|
1282
|
+
return h('div', { className: `ssb-float${greenUp}`, role: 'complementary', 'aria-label': '悬浮盯盘', style: position, onPointerDown: drag.onPointerDown, onPointerMove: drag.onPointerMove, onPointerUp: drag.onPointerUp, onPointerCancel: drag.onPointerUp },
|
|
1283
|
+
React.createElement('style', { 'data-plugin': '@mzzsfy/dsh-stock-assistant', dangerouslySetInnerHTML: { __html: CSS } }),
|
|
1284
|
+
h('div', { className: 'ssb-float-head' },
|
|
1285
|
+
mode === 'kline' && !picker ? h('button', { className: 'ssb-float-icon', 'aria-label': '上一只', onClick: () => step(-1) }, '‹') : null,
|
|
1286
|
+
h('span', { className: 'ssb-float-name', title: current ? `${current.officialName} ${current.code}` : '' }, picker ? '选择观测目标' : mode === 'kline' && current ? (current.alias || current.officialName) : `盯盘 ${picked.length}`),
|
|
1287
|
+
mode === 'kline' && !picker ? h('button', { className: 'ssb-float-icon', 'aria-label': '下一只', onClick: () => step(1) }, '›') : null,
|
|
1288
|
+
h('div', { className: 'ssb-float-modes' },
|
|
1289
|
+
h('button', { type: 'button', className: `ssb-float-mode${mode === 'list' ? ' on' : ''}`, 'aria-pressed': mode === 'list', onClick: () => update({ mode: 'list' }) }, '列表'),
|
|
1290
|
+
h('button', { type: 'button', className: `ssb-float-mode${mode === 'kline' ? ' on' : ''}`, 'aria-pressed': mode === 'kline', onClick: () => update({ mode: 'kline' }) }, '图')),
|
|
1291
|
+
h('button', { type: 'button', className: 'ssb-float-icon', 'aria-label': picker ? '完成选择' : '选择观测目标', 'aria-expanded': picker, onClick: () => setPicker(!picker) }, picker ? '✓' : '☰'),
|
|
1292
|
+
h('button', { type: 'button', className: 'ssb-float-icon', 'aria-label': '关闭悬浮盯盘', onClick: () => update({ open: false }) }, '✕')),
|
|
1293
|
+
picker
|
|
1294
|
+
// 勾选目标按分组切面排列, 组头可以整组勾上或清掉: 盯盘通常就是"盯某个板块"
|
|
1295
|
+
? h('div', { className: 'ssb-float-picker' }, ...(order.length ? pickerBlocks() : [h('p', { className: 'ssb-muted' }, '自选为空')]))
|
|
1296
|
+
: mode === 'list'
|
|
1297
|
+
? h('div', { className: 'ssb-float-list' }, ...(picked.length ? picked.map(rowOf) : [h('p', { className: 'ssb-muted' }, '未选择标的,点 ☰ 勾选')]))
|
|
1298
|
+
: !current ? h('p', { className: 'ssb-muted' }, '未选择标的') : h(React.Fragment, null,
|
|
1299
|
+
h('div', { className: `ssb-float-quote ssb-number${tone}` },
|
|
1300
|
+
h('span', { className: 'ssb-float-price' }, Number.isFinite(last) ? last : '—'),
|
|
1301
|
+
h('span', { className: 'ssb-float-pct' }, pct == null ? '—' : `${signed(pct.toFixed(2))}%`)),
|
|
1302
|
+
error ? h('p', { className: 'ssb-float-error' }, error) : h('div', { className: `ssb-float-chart${tone}` }, h(Sparkline, { candles, preClose: base }))),
|
|
1303
|
+
h('div', { className: 'ssb-float-foot' },
|
|
1304
|
+
h('span', { className: 'ssb-muted' }, mode === 'kline' && securityId ? `${picked.indexOf(securityId) + 1}/${picked.length}` : `${picked.length} 只`),
|
|
1305
|
+
// 失败与没选标的都不该继续挂"监听中": 前者让人以为数据是新的, 后者让人以为在看某只标的
|
|
1306
|
+
h('button', { type: 'button', className: `ssb-float-live${error ? ' ssb-float-live-error' : ''}`, 'aria-pressed': floating.live !== false, title: error || '', onClick: () => update({ live: floating.live === false }) }, floating.live === false ? '开始监听' : !picked.length ? '未选择' : error ? '更新失败' : '监听中'),
|
|
1307
|
+
h('button', { type: 'button', className: 'ssb-float-icon', 'aria-label': '打开详情', disabled: !securityId, onClick: () => { if (securityId) runtime.openDetail(securityId) } }, '⤢')))
|
|
474
1308
|
}
|
|
475
1309
|
function BrowseView({ runtime, state }) {
|
|
476
1310
|
const { view, busy, data } = state
|
|
477
1311
|
const rows = view.browseRows
|
|
478
|
-
return h('section', { className: 'ssb-search-view' },
|
|
479
|
-
|
|
480
|
-
h('div', { className: 'ssb-toolbar' }, ...Object.keys(BROWSE_MARKETS).filter(market => (data?.control.routes || []).some(r => r.capability === 'catalog' && r.market === market)).map(market => h(Button, { key: market, primary: (view.browseMarket || 'XSHG') === market, disabled: busy.browse, onClick: quiet(() => runtime.loadBrowse(market, 0)) }, BROWSE_MARKETS[market]))),
|
|
1312
|
+
return h('section', { className: 'ssb-search-view' },
|
|
1313
|
+
h('div', { className: 'ssb-panel-head' }, h('h2', null, '浏览市场'), h('div', { className: 'ssb-toolbar' }, h(Button, { onClick: () => runtime.updateView({ view: 'list' }) }, '返回自选'))),
|
|
1314
|
+
h('div', { className: 'ssb-toolbar', style: { padding: '12px 16px 0' } }, ...Object.keys(BROWSE_MARKETS).filter(market => (data?.control.routes || []).some(r => r.capability === 'catalog' && r.market === market)).map(market => h(Button, { key: market, primary: (view.browseMarket || 'XSHG') === market, disabled: busy.browse, onClick: quiet(() => runtime.loadBrowse(market, 0)) }, BROWSE_MARKETS[market]))),
|
|
1315
|
+
data?.browseOverview ? (() => {
|
|
1316
|
+
// 缺 previousClose 时 change/changePct 会是 null, Number(null) 得 0, 直接拼会渲染出「+null(+null%)」
|
|
1317
|
+
const ov = data.browseOverview, change = finiteOr(ov.change), changePct = finiteOr(ov.changePct)
|
|
1318
|
+
const tone = Number.isFinite(change) ? (change >= 0 ? ' ssb-up' : ' ssb-down') : ''
|
|
1319
|
+
// 保留来源给的位数, 只补正负号; 缺失时给破折号而不是把 null 当 0
|
|
1320
|
+
const fmt = raw => { if (raw == null || raw === '') return '—'; const n = Number(raw); return Number.isFinite(n) ? `${n >= 0 ? '+' : ''}${raw}` : '—' }
|
|
1321
|
+
return h('div', { className: 'ssb-banner ssb-banner-ok', style: { margin: '12px 16px 0' } }, `${ov.name} ${ov.last ?? '—'}`, h('span', { className: `ssb-number${tone}` }, `${fmt(ov.change)}(${fmt(ov.changePct)}%)`), h('span', { className: 'ssb-muted' }, `${ov.source} · ${ov.delayKind === 'unknown' ? '延迟未知' : delayText(ov)}`))
|
|
1322
|
+
})() : null,
|
|
481
1323
|
!rows ? h('div', { className: 'ssb-empty' }, view.browseSyncing ? h('p', null, '目录首次同步中(约一两分钟),完成后重新点击该市场即可浏览。') : h(React.Fragment, null, h('p', null, '该市场目录未同步。可直接搜索添加自选,或同步目录后浏览。'), h(Button, { primary: true, onClick: () => runtime.updateView({ view: 'list', overlay: 'settings' }) }, '前往设置'), h(Button, { disabled: busy.sync, onClick: quiet(() => runtime.sync(view.browseMarket || 'XSHG')) }, '同步当前市场目录'))) :
|
|
482
1324
|
h('div', { className: 'ssb-results', role: 'list', 'aria-label': '市场证券列表' }, ...rows.map(item => {
|
|
483
1325
|
const added = !!data?.control.watchlist.items[item.id]
|
|
484
|
-
return h('div', { className: 'ssb-candidate', role: 'listitem', key: item.id }, h('button', { type: 'button', className: 'ssb-stock-name', onClick: () => { runtime.updateView({ detailId: item.id, view: view.view, overlay: 'detail', browsePeek: { ...(view.browsePeek || {}), [item.id]: item } }); runtime.
|
|
1326
|
+
return h('div', { className: 'ssb-candidate', role: 'listitem', key: item.id }, h('button', { type: 'button', className: 'ssb-stock-name', onClick: () => { runtime.updateView({ detailId: item.id, view: view.view, overlay: 'detail', browsePeek: { ...(view.browsePeek || {}), [item.id]: item } }); runtime.refresh([{ id: item.id, exchange: item.exchange, code: item.code, officialName: item.officialName }]).catch(() => {}) } }, h('strong', null, item.officialName), h('span', { className: 'ssb-muted' }, `${MARKET_LABEL[item.exchange] || item.exchange} ${item.code}`)), h(Button, { disabled: added || busy.write, onClick: quiet(() => runtime.mutate('add', { securityId: item.id })) }, added ? '已在自选' : '加自选'))
|
|
485
1327
|
})),
|
|
486
|
-
rows?.length ? h('div', { className: 'ssb-toolbar' }, h('span', { className: 'ssb-muted' }, `共 ${view.browseTotal} 只`), h(Button, { disabled: !view.browseOffset || busy.browse, onClick: quiet(() => runtime.loadBrowse(view.browseMarket, Math.max(0, view.browseOffset - BROWSE_PAGE))) }, '上一页'), h(Button, { disabled: view.browseOffset + BROWSE_PAGE >= view.browseTotal || busy.browse, onClick: quiet(() => runtime.loadBrowse(view.browseMarket, view.browseOffset + BROWSE_PAGE)) }, '下一页')) : null
|
|
487
|
-
h('div', { className: 'ssb-toolbar' }, h(Button, { onClick: () => runtime.updateView({ view: 'list' }) }, '返回自选')))
|
|
1328
|
+
rows?.length ? h('div', { className: 'ssb-toolbar', style: { padding: '0 16px 12px' } }, h('span', { className: 'ssb-muted' }, `共 ${view.browseTotal} 只`), h(Button, { disabled: !view.browseOffset || busy.browse, onClick: quiet(() => runtime.loadBrowse(view.browseMarket, Math.max(0, view.browseOffset - BROWSE_PAGE))) }, '上一页'), h(Button, { disabled: view.browseOffset + BROWSE_PAGE >= view.browseTotal || busy.browse, onClick: quiet(() => runtime.loadBrowse(view.browseMarket, view.browseOffset + BROWSE_PAGE)) }, '下一页')) : null)
|
|
488
1329
|
}
|
|
489
1330
|
function SearchView({ runtime, state }) {
|
|
490
1331
|
const { view, candidates, data } = state
|
|
491
1332
|
function choose(item) { runtime.updateView({ selectedId: item.id }) }
|
|
492
1333
|
const visible = view.searchMarket && view.searchMarket !== 'all' ? candidates.filter(c => c.exchange === view.searchMarket) : candidates
|
|
493
|
-
return h('section', { className: 'ssb-search-view' },
|
|
494
|
-
h('div', { className: 'ssb-
|
|
495
|
-
h('
|
|
1334
|
+
return h('section', { className: 'ssb-search-view' },
|
|
1335
|
+
h('div', { className: 'ssb-panel-head' }, h('h2', null, '添加自选'), h('div', { className: 'ssb-toolbar' }, h(Button, { onClick: () => runtime.updateView({ view: 'list' }) }, '返回自选'))),
|
|
1336
|
+
h('div', { className: 'ssb-panel-body' },
|
|
1337
|
+
h('div', { className: 'ssb-form-row' },
|
|
1338
|
+
h('label', { className: 'ssb-field' }, h('span', null, '市场'), h('select', { value: view.searchMarket || 'all', onChange: e => runtime.updateView({ searchMarket: e.target.value }) }, ...[['all', '全部'], ['XSHG', '沪A股'], ['XSHE', '深A股'], ['XHKG', '港股'], ['XNAS', '美股(NASDAQ)'], ['XNYS', '美股(NYSE)'], ['XJPX', '日经'], ['XKRX', '韩证'], ['XLON', '伦敦']].map(([value, label]) => h('option', { value, key: value }, label)))),
|
|
1339
|
+
h('label', { className: 'ssb-search-label' }, '搜索名称、代码或拼音首字母', h('input', { type: 'search', autoFocus: true, value: view.query, placeholder: '例如 600519、茅台、腾讯、AAPL', onChange: e => runtime.search(e.target.value), onCompositionStart: () => runtime.composition(true), onCompositionEnd: () => runtime.composition(false), onKeyDown: e => {
|
|
496
1340
|
if (e.nativeEvent?.isComposing || state.composing) return
|
|
497
1341
|
const at = visible.findIndex(c => c.id === view.selectedId)
|
|
498
1342
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); const index = e.key === 'ArrowDown' ? Math.min(visible.length - 1, at + 1) : Math.max(0, at - 1); if (visible[index]) choose(visible[index]) }
|
|
499
1343
|
if (e.key === 'Escape') runtime.updateView({ view: 'list' })
|
|
500
|
-
} })),
|
|
501
|
-
h('div', { role: 'listbox', 'aria-label': '证券搜索结果', className: 'ssb-results' }, ...visible.map(item => h('div', { role: 'option', 'aria-selected': view.selectedId === item.id, key: item.id, className: `ssb-candidate${view.selectedId === item.id ? ' ssb-selected' : ''}`, onClick: () => choose(item) }, h('span', { className: 'ssb-peek', role: 'button', tabIndex: 0, onClick: e => { e.stopPropagation(); runtime.updateView({ detailId: item.id, view: view.view, overlay: 'detail', browsePeek: { ...(view.browsePeek || {}), [item.id]: item } }); runtime.
|
|
502
|
-
!visible.length ? h('p', { className: 'ssb-muted' }, view.query ? '暂无匹配。目录未同步时,请先到设置配置并同步目录。' : '
|
|
503
|
-
state.errorCode === 'CATALOG_NOT_READY' ? h('div', { className: 'ssb-banner' }, '目录未就绪。可以直接浏览市场添加自选。', h(Button, { onClick: () => runtime.updateView({ view: 'browse' }) }, '前往浏览市场')) : null,
|
|
504
|
-
h('div', { className: 'ssb-toolbar' }, h(Button, { primary: true, disabled: !view.selectedId || !!data?.control.watchlist.items[view.selectedId] || state.busy.write, onClick: quiet(async () => { await runtime.mutate('add', { securityId: view.selectedId }); runtime.updateView({ view: 'list', selectedId: null }) }) }, data?.control.watchlist.items[view.selectedId] ? '已在自选' : '确认添加')
|
|
1344
|
+
} }))),
|
|
1345
|
+
h('div', { role: 'listbox', 'aria-label': '证券搜索结果', className: 'ssb-results' }, ...visible.map(item => h('div', { role: 'option', 'aria-selected': view.selectedId === item.id, key: item.id, className: `ssb-candidate${view.selectedId === item.id ? ' ssb-selected' : ''}`, onClick: () => choose(item) }, h('span', { className: 'ssb-peek', role: 'button', tabIndex: 0, onClick: e => { e.stopPropagation(); runtime.updateView({ detailId: item.id, view: view.view, overlay: 'detail', browsePeek: { ...(view.browsePeek || {}), [item.id]: item } }); runtime.refresh([{ id: item.id, exchange: item.exchange, code: item.code, officialName: item.officialName }]).catch(() => {}) } }, h('strong', null, item.officialName), h('span', { className: 'ssb-muted' }, `${MARKET_LABEL[item.exchange] || item.exchange} ${item.code}${item.currency ? ` · ${item.currency}` : ''}`)), h(Button, { disabled: !!data?.control.watchlist.items[item.id] || state.busy.write, onClick: quiet(() => runtime.mutate('add', { securityId: item.id })) }, data?.control.watchlist.items[item.id] ? '已在自选' : '加自选')))),
|
|
1346
|
+
!visible.length ? h('p', { className: 'ssb-muted', style: { margin: 0 } }, view.query ? '暂无匹配。目录未同步时,请先到设置配置并同步目录。' : '输入关键词开始搜索,例如代码、名称或拼音首字母。') : null,
|
|
1347
|
+
state.errorCode === 'CATALOG_NOT_READY' ? h('div', { className: 'ssb-banner', style: { margin: 0 } }, '目录未就绪。可以直接浏览市场添加自选。', h(Button, { onClick: () => runtime.updateView({ view: 'browse' }) }, '前往浏览市场')) : null,
|
|
1348
|
+
h('div', { className: 'ssb-toolbar', style: { margin: 0 } }, h(Button, { primary: true, disabled: !view.selectedId || !!data?.control.watchlist.items[view.selectedId] || state.busy.write, onClick: quiet(async () => { await runtime.mutate('add', { securityId: view.selectedId }); runtime.updateView({ view: 'list', selectedId: null }) }) }, data?.control.watchlist.items[view.selectedId] ? '已在自选' : '确认添加'))))
|
|
505
1349
|
}
|
|
506
1350
|
const defaultProvider = (id, adapterId = 'tushare') => ({ id, adapterId, enabled: true, credentialEnv: adapterId === 'tushare' ? 'DSH_STOCK_TUSHARE_TOKEN' : null, quotaGroup: adapterId + '-account', budget: { requestsPerMinute: adapterId === 'tushare' ? null : 60, dailyRequestLimit: null, billingUnit: 'request', symbolCreditsPerMinute: null, capabilityLimits: {} } })
|
|
507
1351
|
const routeFor = (market, capability, instance) => ({ id: `${market.toLowerCase()}-${capability}`, capability, market, instrument: 'equity', primaryInstanceId: instance, fallbackInstanceIds: [], failoverMode: 'off', maxDelaySeconds: null, acceptUnknownDelay: true })
|
|
508
1352
|
function SettingsView({ runtime, state }) {
|
|
509
1353
|
const saved = state.data.control
|
|
510
|
-
const draft = state.view.settingsDraft || { providers: saved.providers, routes: saved.routes, expectedRevision: saved.revision,
|
|
1354
|
+
const draft = state.view.settingsDraft || { providers: saved.providers, routes: saved.routes, expectedRevision: saved.revision, checkResults: [], probes: {} }
|
|
511
1355
|
const configuration = { providers: draft.providers, routes: draft.routes }
|
|
512
|
-
function change(next, invalidate = true) { runtime.updateView({ settingsDraft: { ...draft, ...next, ...(invalidate ? {
|
|
1356
|
+
function change(next, invalidate = true) { runtime.updateView({ settingsDraft: { ...draft, ...next, ...(invalidate ? { checkResults: [], probes: {} } : {}) } }) }
|
|
513
1357
|
const providerChange = (id, patch) => change({ providers: draft.providers.map(p => p.id === id ? { ...p, ...patch } : p) })
|
|
514
1358
|
const field = (label, value, onChange, props = {}) => h('label', { className: 'ssb-field' }, h('span', null, label), h('input', { value: value ?? '', onChange: e => onChange(e.target.value), ...props }))
|
|
1359
|
+
const CAP_LABEL = { quotes: '报价', catalog: '证券目录', search: '远程搜索', calendar: '交易日历', kline: 'K线' }
|
|
515
1360
|
const tab = draft.tab || 'sources'
|
|
516
1361
|
const TABS = [['sources', '数据源'], ['routes', '市场与能力'], ['display', '显示偏好']]
|
|
517
1362
|
const providersOnly = { providers: draft.providers, routes: saved.routes }
|
|
@@ -519,68 +1364,307 @@ window.__ModuleLoader__.load({
|
|
|
519
1364
|
const providerCards = draft.providers.map(p => {
|
|
520
1365
|
const adapter = state.data.adapters.find(a => a.adapterId === p.adapterId)
|
|
521
1366
|
const status = state.data.providerStatus?.find(s => s.id === p.id)
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
h('
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
1367
|
+
const openSource = adapter?.credential === 'none'
|
|
1368
|
+
const expanded = (draft.expandedProvider || '') === p.id
|
|
1369
|
+
// 该实例实际承担的能力与市场(摘要用, 让「这个源在干什么」一眼可见)
|
|
1370
|
+
const owned = draft.routes.filter(r => r.primaryInstanceId === p.id || r.fallbackInstanceIds.includes(p.id))
|
|
1371
|
+
const asPrimary = draft.routes.filter(r => r.primaryInstanceId === p.id)
|
|
1372
|
+
const asBackup = draft.routes.filter(r => r.fallbackInstanceIds.includes(p.id))
|
|
1373
|
+
const capsText = [...new Set(owned.map(r => CAP_LABEL[r.capability]))].join('/') || '未分配'
|
|
1374
|
+
const marketsText = new Set(owned.map(r => r.market)).size
|
|
1375
|
+
// 主源与备源分开计数: 只当备源的实例不该显示成「0 条路由」, 那读起来像没在用
|
|
1376
|
+
const scopeText = [asPrimary.length ? `${asPrimary.length} 主源` : '', asBackup.length ? `${asBackup.length} 备源` : ''].filter(Boolean).join(' · ') || '未分配'
|
|
1377
|
+
const toggle = () => change({ expandedProvider: expanded ? '' : p.id }, false)
|
|
1378
|
+
return h('div', { className: 'ssb-provider', key: p.id },
|
|
1379
|
+
h('button', { className: 'ssb-provider-toggle', 'aria-expanded': expanded, disabled: state.busy.configuration, onClick: toggle, title: adapter?.description || '' },
|
|
1380
|
+
h('span', { className: `ssb-dot${p.enabled ? '' : ' ssb-dot-idle'}` }),
|
|
1381
|
+
h('span', { className: 'ssb-provider-id' }, p.id),
|
|
1382
|
+
h('span', { className: 'ssb-provider-adapter-name ssb-muted' }, p.adapterId),
|
|
1383
|
+
h('span', { className: 'ssb-provider-scope' }, `${scopeText} · ${marketsText} 市场 · ${capsText}`),
|
|
1384
|
+
h('span', { className: 'ssb-muted ssb-provider-kind' }, openSource ? '公开来源' : status?.credentialPresent ? '凭据已就绪' : '待配置凭据'),
|
|
1385
|
+
h('span', { className: 'ssb-provider-edit' }, expanded ? '收起' : '配置')),
|
|
1386
|
+
adapter?.description ? h('p', { className: 'ssb-provider-desc ssb-muted' }, adapter.description) : null,
|
|
1387
|
+
expanded ? h('div', { className: 'ssb-provider-body', disabled: state.busy.configuration },
|
|
1388
|
+
h('div', { className: 'ssb-provider-head' },
|
|
1389
|
+
h('label', { className: 'ssb-field ssb-provider-adapter' }, h('span', null, '适配器'), h('select', { value: p.adapterId, onChange: e => { const adapterId = e.target.value, descriptor = state.data.adapters.find(a => a.adapterId === adapterId); providerChange(p.id, { adapterId, credentialEnv: descriptor?.credential === 'none' ? null : p.credentialEnv || 'DSH_STOCK_TUSHARE_TOKEN', quotaGroup: adapterId + '-account', budget: descriptor?.credential === 'none' && p.budget.requestsPerMinute === null ? { ...p.budget, requestsPerMinute: 60 } : p.budget }) } }, ...state.data.adapters.map(a => h('option', { value: a.adapterId, key: a.adapterId }, a.adapterId + (a.credential === 'none' ? '(免凭据)' : ''))))),
|
|
1390
|
+
h('div', { className: 'ssb-provider-switches' },
|
|
1391
|
+
h(Toggle, { label: '启用', checked: p.enabled, onChange: value => providerChange(p.id, { enabled: value }) }),
|
|
1392
|
+
h(Button, { danger: true, onClick: () => change({ providers: draft.providers.filter(other => other.id !== p.id), routes: draft.routes.filter(r => r.primaryInstanceId !== p.id).map(r => ({ ...r, fallbackInstanceIds: r.fallbackInstanceIds.filter(id => id !== p.id), failoverMode: r.fallbackInstanceIds.includes(p.id) ? 'off' : r.failoverMode })) }) }, '移除'))),
|
|
1393
|
+
h('div', { className: 'ssb-provider-advanced-open' },
|
|
1394
|
+
h('div', { className: 'ssb-form-grid' },
|
|
1395
|
+
openSource ? h('span', { className: 'ssb-muted', style: { gridColumn: '1 / -1' } }, '公开来源,无需凭据') : field('Host环境变量名', p.credentialEnv, value => providerChange(p.id, { credentialEnv: value }), { autoComplete: 'off', spellCheck: false }),
|
|
1396
|
+
field('共享额度组(同账号保持一致)', p.quotaGroup, value => providerChange(p.id, { quotaGroup: value })),
|
|
1397
|
+
field('每分钟请求上限(留空仅允许探针)', p.budget.requestsPerMinute, value => providerChange(p.id, { budget: { ...p.budget, requestsPerMinute: value ? Number(value) : null } }), { type: 'number', min: 1 }),
|
|
1398
|
+
field('每日请求上限(留空无限制)', p.budget.dailyRequestLimit, value => providerChange(p.id, { budget: { ...p.budget, dailyRequestLimit: value ? Number(value) : null } }), { type: 'number', min: 1 }),
|
|
1399
|
+
h('label', { className: 'ssb-field' }, h('span', null, '计费单位'), h('select', { value: p.budget.billingUnit, onChange: e => providerChange(p.id, { budget: { ...p.budget, billingUnit: e.target.value } }) }, h('option', { value: 'request' }, '请求'), h('option', { value: 'symbol' }, '证券'))),
|
|
1400
|
+
p.budget.billingUnit === 'symbol' ? field('每分钟证券额度', p.budget.symbolCreditsPerMinute, value => providerChange(p.id, { budget: { ...p.budget, symbolCreditsPerMinute: value ? Number(value) : null } }), { type: 'number', min: 1 }) : null,
|
|
1401
|
+
...['quotes', 'catalog', 'search', 'calendar'].map(cap => field(`${({ quotes: '报价', catalog: '目录', search: '搜索', calendar: '日历' })[cap]}每分钟上限`, p.budget.capabilityLimits[cap] ?? '', value => providerChange(p.id, { budget: { ...p.budget, capabilityLimits: Object.fromEntries(Object.entries({ ...p.budget.capabilityLimits, [cap]: value ? Number(value) : null }).filter(([, limit]) => limit !== null)) } }), { type: 'number', min: 1, key: cap })),
|
|
1402
|
+
adapter?.endpointKeys?.length ? h('details', { key: 'ep-' + p.id, style: { gridColumn: '1 / -1' } },
|
|
1403
|
+
h('summary', { className: 'ssb-muted' }, `接口反代${status?.endpointsCovered ? `(已覆盖 ${status.endpointsCovered} 项)` : ''}`),
|
|
1404
|
+
h('p', { className: 'ssb-muted', style: { gridColumn: '1 / -1' } }, '自建反向代理或镜像站时替换对应地址,留空使用官方默认。路径与参数由本插件拼接,反代只需原样转发。'),
|
|
1405
|
+
h('div', { className: 'ssb-form-grid' }, ...adapter.endpointKeys.map(({ key, label }) => field(label, (p.endpoints || {})[key] || '', value => { const endpoints = { ...p.endpoints }; if (value) endpoints[key] = value; else delete endpoints[key]; providerChange(p.id, { endpoints: Object.keys(endpoints).length ? endpoints : undefined }) }, { placeholder: '官方默认', spellCheck: false })))) : null,
|
|
1406
|
+
h('details', { key: 'obs-' + p.id, style: { gridColumn: '1 / -1' } }, h('summary', { className: 'ssb-muted' }, '运行状态'), status?.recentRequests?.length ? h('ul', { className: 'ssb-reqlog' }, ...status.recentRequests.slice(0, 5).map((r, i) => h('li', { key: i }, `${timeText(r.at)} · ${r.url} · ${r.status} · ${r.ms}ms`))) : h('p', { className: 'ssb-muted' }, '暂无请求记录;启用并保存后,行情/K线请求会在此留最近 20 条轨迹。'))))) : null)
|
|
539
1407
|
})
|
|
540
|
-
const
|
|
541
|
-
|
|
1408
|
+
const routeRows = Object.keys(MARKET_LABEL).map(market => {
|
|
1409
|
+
const caps = ['catalog', 'search', 'quotes', 'kline', ...(['XSHG', 'XSHE'].includes(market) ? ['calendar'] : [])]
|
|
1410
|
+
const marketRoutes = draft.routes.filter(r => r.market === market)
|
|
1411
|
+
const expanded = (draft.expandedMarket || '') === market
|
|
1412
|
+
// 摘要优先: 八市场默认收成八行, 只暴露「用什么源、备源是谁」, 改的时候才展开下拉
|
|
1413
|
+
const primaries = [...new Set(marketRoutes.map(r => r.primaryInstanceId))]
|
|
1414
|
+
const backs = marketRoutes.filter(r => r.fallbackInstanceIds.length)
|
|
1415
|
+
// 能力按固定顺序呈现, 不跟随 routes 数组顺序(来源提高的顺序会变)
|
|
1416
|
+
const capOrder = caps.filter(cap => marketRoutes.some(r => r.capability === cap))
|
|
1417
|
+
const allocation = marketRoutes.map(r => `${CAP_LABEL[r.capability]}: ${r.primaryInstanceId}${r.fallbackInstanceIds.length ? `(备 ${r.fallbackInstanceIds[0]})` : ''}`).join('\n')
|
|
1418
|
+
const cells = caps.map(cap => {
|
|
542
1419
|
const route = draft.routes.find(r => r.market === market && r.capability === cap)
|
|
543
|
-
const updateRoute = patch => change({ routes: draft.routes.map(r => r === route ? { ...r, ...patch } : r) })
|
|
544
1420
|
const options = [h('option', { value: '', key: '' }, '未配置'), ...draft.providers.filter(p => p.enabled && state.data.adapters.find(a => a.adapterId === p.adapterId)?.capabilities.includes(cap)).map(p => h('option', { value: p.id, key: p.id }, p.id))]
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
1421
|
+
// 每个能力可单独探一次: 结果只做诊断展示, 不影响能否保存
|
|
1422
|
+
const probeKey = route ? `${route.primaryInstanceId}:${cap}:${market}` : null
|
|
1423
|
+
const probe = probeKey ? (draft.probes || {})[probeKey] : null
|
|
1424
|
+
const probing = probeKey ? (draft.probing || {})[probeKey] : false
|
|
1425
|
+
return h('div', { className: 'ssb-route-cell ssb-route-cell-probe', key: cap },
|
|
1426
|
+
h('label', { className: 'ssb-route-cell-main' }, h('span', { className: 'ssb-route-cell-label' }, CAP_LABEL[cap]), h('select', { value: route?.primaryInstanceId || '', onChange: e => change({ routes: [...draft.routes.filter(r => r !== route), ...(e.target.value ? [routeFor(market, cap, e.target.value)] : [])] }) }, ...options)),
|
|
1427
|
+
route ? h('button', {
|
|
1428
|
+
className: 'ssb-probe', disabled: probing, title: '单独测这一条能力:拉一次真实数据,报告成败与耗时',
|
|
1429
|
+
onClick: quiet(async () => {
|
|
1430
|
+
runtime.updateView({ settingsDraft: { ...runtime.getSnapshot().view.settingsDraft, probing: { ...(draft.probing || {}), [probeKey]: true } } })
|
|
1431
|
+
try {
|
|
1432
|
+
const result = await runtime.check({ providers: draft.providers, routes: [route], capabilities: [cap] })
|
|
1433
|
+
const hit = result.checks.find(c => c.providerInstanceId === route.primaryInstanceId) || result.checks[0]
|
|
1434
|
+
const current = runtime.getSnapshot().view.settingsDraft || draft
|
|
1435
|
+
runtime.updateView({ settingsDraft: { ...current, probing: { ...(current.probing || {}), [probeKey]: false }, probes: { ...(current.probes || {}), [probeKey]: hit ? { status: hit.status, ms: hit.ms, error: hit.error } : { status: 'denied', error: { message: '未返回结果' } } } } })
|
|
1436
|
+
} catch (e) {
|
|
1437
|
+
const current = runtime.getSnapshot().view.settingsDraft || draft
|
|
1438
|
+
runtime.updateView({ settingsDraft: { ...current, probing: { ...(current.probing || {}), [probeKey]: false }, probes: { ...(current.probes || {}), [probeKey]: { status: 'denied', error: { message: e.message } } } } })
|
|
1439
|
+
}
|
|
1440
|
+
}),
|
|
1441
|
+
},
|
|
1442
|
+
probing ? '测试中' : '测试') : null,
|
|
1443
|
+
probe ? h('span', {
|
|
1444
|
+
className: `ssb-probe-result ${probe.status === 'ready' ? 'ssb-probe-ok' : 'ssb-probe-bad'}`,
|
|
1445
|
+
title: probe.error ? `${probe.error.code || ''} ${probe.error.message || ''}`.trim() : `耗时 ${probe.ms}ms`,
|
|
1446
|
+
}, probe.status === 'ready' ? `✓ ${probe.ms ?? '—'}ms` : '✗') : null)
|
|
1447
|
+
})
|
|
1448
|
+
// 备源与延迟只对报价/ K线有意义,收进行内折叠,避免八市场铺成三屏
|
|
1449
|
+
const tuned = draft.routes.filter(r => r.market === market && (r.capability === 'quotes' || r.capability === 'kline'))
|
|
1450
|
+
const advanced = tuned.map(route => {
|
|
1451
|
+
const cap = route.capability
|
|
1452
|
+
const updateRoute = patch => change({ routes: draft.routes.map(r => r === route ? { ...r, ...patch } : r) })
|
|
1453
|
+
const options = [h('option', { value: '', key: '' }, '无'), ...draft.providers.filter(p => p.enabled && state.data.adapters.find(a => a.adapterId === p.adapterId)?.capabilities.includes(cap) && p.id !== route.primaryInstanceId).map(p => h('option', { value: p.id, key: p.id }, p.id))]
|
|
1454
|
+
return h('div', { className: 'ssb-route-tune', key: cap },
|
|
1455
|
+
h('span', { className: 'ssb-route-tune-label' }, CAP_LABEL[cap]),
|
|
1456
|
+
h('label', { className: 'ssb-field' }, h('span', null, '备用源'), h('select', { value: route.fallbackInstanceIds[0] || '', onChange: e => updateRoute({ fallbackInstanceIds: e.target.value ? [e.target.value] : [], failoverMode: 'off' }) }, ...options)),
|
|
1457
|
+
h(Toggle, { label: '自动切换', disabled: !route.fallbackInstanceIds.length, checked: route.failoverMode === 'network', onChange: on => updateRoute({ failoverMode: on ? 'network' : 'off' }) }),
|
|
1458
|
+
cap === 'quotes' ? h(Toggle, { label: '接受延迟未知', checked: route.acceptUnknownDelay, onChange: on => updateRoute({ acceptUnknownDelay: on }) }) : null,
|
|
1459
|
+
cap === 'quotes' ? h('label', { className: 'ssb-field ssb-route-delay' }, h('span', null, '最大延迟秒'), h('input', { type: 'number', min: 0, value: route.maxDelaySeconds ?? '', placeholder: '不限', onChange: e => updateRoute({ maxDelaySeconds: e.target.value ? Number(e.target.value) : null }) })) : null)
|
|
1460
|
+
})
|
|
1461
|
+
return h('div', { className: 'ssb-market-row', key: market },
|
|
1462
|
+
h('button', {
|
|
1463
|
+
className: 'ssb-market-toggle', 'aria-expanded': expanded, disabled: state.busy.configuration, title: allocation,
|
|
1464
|
+
onClick: () => change({ expandedMarket: expanded ? '' : market }, false),
|
|
1465
|
+
},
|
|
1466
|
+
h('span', { className: 'ssb-market-name' }, MARKET_LABEL[market]),
|
|
1467
|
+
h('span', { className: 'ssb-market-code' }, market),
|
|
1468
|
+
h('span', { className: 'ssb-market-caps' }, primaries.length === 1 ? primaries[0] : primaries.length ? `${primaries.length} 个主源` : '未配置',
|
|
1469
|
+
h('span', { className: 'ssb-muted' }, ` · ${capOrder.length ? capOrder.map(c => CAP_LABEL[c]).join('/') : '无能力'}`)),
|
|
1470
|
+
h('span', { className: 'ssb-market-back' }, backs.length ? `备 ${[...new Set(backs.map(r => r.fallbackInstanceIds[0]))].join('/')}` : '无备源'),
|
|
1471
|
+
h('span', { className: 'ssb-market-edit' }, expanded ? '收起' : '改')),
|
|
1472
|
+
expanded ? h('div', { className: 'ssb-market-body' },
|
|
1473
|
+
h('div', { className: 'ssb-route-grid' }, ...cells),
|
|
1474
|
+
advanced.length ? h('div', { className: 'ssb-route-advanced' }, ...advanced) : null) : null)
|
|
1475
|
+
})
|
|
1476
|
+
const configuredMarkets = new Set(draft.routes.map(r => r.market)).size
|
|
548
1477
|
const pane = tab === 'sources' ? h(React.Fragment, null,
|
|
549
|
-
!draft.providers.length ? h('div', { className: 'ssb-banner' }, '
|
|
1478
|
+
!draft.providers.length ? h('div', { className: 'ssb-banner' }, '尚未配置任何数据源。点上方「应用推荐配置」一次装好内置的免凭据源(延迟未知、无SLA)。') : null,
|
|
550
1479
|
h('div', { className: 'ssb-section-head' }, h('h3', null, '数据源'), h(Button, { disabled: state.busy.configuration || draft.providers.length >= 8, onClick: () => { let n = 1; while (draft.providers.some(p => p.id === `ts-${n}`)) n++; change({ providers: [...draft.providers, defaultProvider(`ts-${n}`)] }) } }, '添加实例')),
|
|
551
1480
|
providerCards,
|
|
552
1481
|
h('div', { className: 'ssb-toolbar' }, h(Button, { primary: true, disabled: state.busy.configuration || !draft.providers.length, onClick: quiet(() => runtime.configuration(providersOnly, [])) }, '保存数据源'), h('span', { className: 'ssb-muted' }, '仅保存实例;能力分配在「市场与能力」页检查并保存'))) :
|
|
553
1482
|
tab === 'routes' ? h(React.Fragment, null,
|
|
554
|
-
h('div', { className: 'ssb-section-head' }, h('h3', null, '
|
|
555
|
-
h('
|
|
556
|
-
|
|
557
|
-
|
|
1483
|
+
h('div', { className: 'ssb-section-head' }, h('h3', null, '市场与能力'), h('span', { className: 'ssb-muted', 'aria-live': 'polite' }, state.checkProgress || `${configuredMarkets} 个市场已配置,点行尾「改」调整,其余保持默认即可`)),
|
|
1484
|
+
h('div', { className: 'ssb-market-list' },
|
|
1485
|
+
h('div', { className: 'ssb-market-head', 'aria-hidden': 'true' },
|
|
1486
|
+
h('span', null, '市场'), h('span', null, '代码'), h('span', null, '行情来源'), h('span', null, '备用源'), h('span', null, '')),
|
|
1487
|
+
...routeRows),
|
|
1488
|
+
h('div', { className: 'ssb-section-foot' },
|
|
1489
|
+
h('p', { className: 'ssb-muted' }, '目录、搜索、报价、日历与K线可独立指定来源;未配置的能力不会隐式借用其他来源。展开任一市场可对单项能力做一次探测,结果只作参考,不阻塞保存。')),
|
|
558
1490
|
h('div', { className: 'ssb-toolbar' },
|
|
559
|
-
h(Button, { disabled: state.busy.configuration ||
|
|
560
|
-
h(Button, {
|
|
561
|
-
|
|
1491
|
+
h(Button, { primary: true, disabled: state.busy.configuration || !draft.routes.length, onClick: quiet(() => runtime.configuration(routesOnly)) }, '保存路由'),
|
|
1492
|
+
h(Button, { disabled: state.busy.configuration || state.busy.check || !draft.routes.length, onClick: quiet(async () => {
|
|
1493
|
+
const capsUsed = [...new Set(draft.routes.map(r => r.capability))]
|
|
1494
|
+
const result = await runtime.check({ providers: draft.providers, routes: draft.routes, capabilities: capsUsed })
|
|
1495
|
+
const current = runtime.getSnapshot().view.settingsDraft
|
|
1496
|
+
if (current && JSON.stringify({ providers: current.providers, routes: current.routes }) !== JSON.stringify({ providers: draft.providers, routes: draft.routes })) return
|
|
1497
|
+
const probes = { ...(current?.probes || draft.probes || {}) }
|
|
1498
|
+
for (const c of result.checks) probes[`${c.providerInstanceId}:${c.capability}:${c.market}`] = { status: c.status, ms: c.ms, error: c.error }
|
|
1499
|
+
change({ checkResults: result.checks, probes }, false)
|
|
1500
|
+
}) }, state.busy.check ? '正在全部测试…' : '全部测一次'),
|
|
562
1501
|
h(Button, { disabled: state.busy.check || state.busy.configuration, onClick: () => runtime.updateView({ settingsDraft: null }) }, '放弃草稿')),
|
|
563
|
-
h('h3', null, '
|
|
1502
|
+
h('h3', null, '目录缓存'), h('div', { className: 'ssb-toolbar' }, ...Object.keys(BROWSE_MARKETS).filter(market => (state.data.control.routes || []).some(r => r.capability === 'catalog' && r.market === market)).map(market => h(Button, { key: market, disabled: state.busy.sync, onClick: quiet(async () => { const results = await runtime.sync(market); const errors = results.filter(r => !r.ok); if (errors.length) tools.toast?.show(errors.map(r => r.error.message).join(';'), { kind: 'error' }) }) }, `同步${BROWSE_MARKETS[market]}`))),
|
|
564
1503
|
...state.data.catalogs.map(c => h('p', { key: c.source, className: 'ssb-muted' }, `${c.source} · ${timeText(c.fetchedAt)}${c.stale ? ' · 已过期' : ''}${c.complete ? '' : ' · 目录可能不完整'}`)),
|
|
565
1504
|
...Object.entries(state.data.routeSelections).filter(([id, selected]) => selected.providerInstanceId !== saved.routes.find(r => r.id === id)?.primaryInstanceId).map(([id, selected]) => h('div', { className: 'ssb-banner', key: id }, `${id}当前使用备用源 ${selected.providerInstanceId}。`, h(Button, { disabled: state.busy.switch, onClick: quiet(() => runtime.switchPrimary(id)) }, '检查并切回主源')))) :
|
|
566
1505
|
h(React.Fragment, null, h('h3', null, '显示偏好(即改即存)'), h('div', { className: 'ssb-toolbar' }, h('label', { className: 'ssb-field' }, h('span', null, '刷新间隔'), h('select', { value: saved.preferences.refreshSeconds, disabled: state.busy.write || state.busy.configuration, onChange: quiet(e => runtime.preferences({ refreshSeconds: Number(e.target.value) })) }, ...[15, 30, 60].map(n => h('option', { key: n, value: n }, `${n}秒`)))), h('label', { className: 'ssb-field' }, h('span', null, '涨跌颜色'), h('select', { value: saved.preferences.colorMode, disabled: state.busy.write || state.busy.configuration, onChange: quiet(e => runtime.preferences({ colorMode: e.target.value })) }, h('option', { value: 'red-up' }, '红涨绿跌'), h('option', { value: 'green-up' }, '绿涨红跌')))))
|
|
567
|
-
|
|
1506
|
+
const controlRoutes = saved.routes || []
|
|
1507
|
+
const enabled = (saved.providers || []).filter(p => p.enabled).length
|
|
1508
|
+
const markets = new Set(controlRoutes.map(r => r.market)).size
|
|
1509
|
+
const busyConfig = state.busy.configuration || state.busy.check
|
|
1510
|
+
return h('section', { className: 'ssb-settings' },
|
|
1511
|
+
h('div', { className: 'ssb-settings-head' },
|
|
1512
|
+
h('div', { className: 'ssb-settings-intro' }, h('h2', null, '设置'), h('p', { className: 'ssb-muted' }, `${enabled} 个数据源 · ${controlRoutes.length} 条能力路由 · ${markets} 个市场;凭据保留在Host环境变量中,这里仅保存变量名与数据路由`)),
|
|
1513
|
+
h('div', { className: 'ssb-toolbar' }, h(Button, { primary: true, disabled: busyConfig, title: '写入内置免凭据数据源与八市场能力路由,探针不可用的目标自动剔除;自选与显示偏好不受影响', onClick: quiet(() => runtime.applyRecommended()) }, state.checkProgress || (busyConfig ? '正在应用…' : '应用推荐配置')))),
|
|
568
1514
|
h('div', { className: 'ssb-tablist', role: 'tablist', 'aria-label': '设置分区' }, ...TABS.map(([key, label]) => h('button', { key, role: 'tab', 'aria-selected': tab === key, className: `ssb-tab${tab === key ? ' ssb-tab-on' : ''}`, onClick: () => change({ tab: key }, false) }, label))),
|
|
569
1515
|
pane)
|
|
570
1516
|
}
|
|
571
1517
|
const CSS = `
|
|
572
|
-
.ssb-root { --ssb-accent:var(--dsw-alias-brand-primary); --ssb-accent-deep:var(--dsw-alias-button-primary-hover); --ssb-on-accent:var(--dsw-alias-label-primary-inverted); --ssb-accent-wash:color-mix(in srgb,var(--dsw-alias-brand-primary) 12%,transparent); --ssb-ok:var(--dsw-alias-state-success-primary); --ssb-warn:var(--dsw-alias-state-warn-primary); --ssb-danger:var(--dsw-alias-state-error-primary); --ssb-bg:var(--dsw-alias-bg-layer-1); --ssb-bg-sub:var(--dsw-alias-bg-layer-2); --ssb-bg-muted:var(--dsw-alias-bg-module-platform); --ssb-border:var(--dsw-alias-border-l2); --ssb-text:var(--dsw-alias-label-primary); --ssb-text-sub:var(--dsw-alias-label-secondary); --ssb-text-dim:var(--dsw-alias-label-tertiary); --ssb-radius-sm:6px; --ssb-radius-md:8px; --ssb-radius-lg:12px;
|
|
573
|
-
|
|
574
|
-
.ssb-
|
|
1518
|
+
.ssb-root,.ssb-float-root { --ssb-accent:var(--dsw-alias-brand-primary); --ssb-accent-deep:var(--dsw-alias-button-primary-hover); --ssb-on-accent:var(--dsw-alias-label-primary-inverted); --ssb-accent-wash:color-mix(in srgb,var(--dsw-alias-brand-primary) 12%,transparent); --ssb-ok:var(--dsw-alias-state-success-primary); --ssb-warn:var(--dsw-alias-state-warn-primary); --ssb-danger:var(--dsw-alias-state-error-primary); --ssb-bg:var(--dsw-alias-bg-layer-1); --ssb-bg-sub:var(--dsw-alias-bg-layer-2); --ssb-bg-muted:var(--dsw-alias-bg-module-platform); --ssb-border:var(--dsw-alias-border-l2); --ssb-text:var(--dsw-alias-label-primary); --ssb-text-sub:var(--dsw-alias-label-secondary); --ssb-text-dim:var(--dsw-alias-label-tertiary); --ssb-radius-sm:6px; --ssb-radius-md:8px; --ssb-radius-lg:12px; }
|
|
1519
|
+
/* 图表区分色: 亮色取中低明度保证白底上不糊, 取值由 test/chart-colors.test.mjs 卡对比度与线间色差 */
|
|
1520
|
+
.ssb-root,.ssb-float-root { --ssb-chart-ma-fast: hsl(45 96% 40%); --ssb-chart-ma-mid: hsl(280 88% 52%); --ssb-chart-ma-slow: hsl(189 94% 38%); --ssb-chart-ma-long: hsl(239 84% 56%); --ssb-chart-ema: hsl(330 81% 50%); --ssb-chart-boll-mid: hsl(217 91% 50%); --ssb-chart-boll-rail: hsl(213 90% 62%); --ssb-chart-bbi: hsl(160 84% 34%); --ssb-chart-cost: hsl(20 92% 45%); --ssb-chart-target: hsl(305 80% 45%); }
|
|
1521
|
+
/* 暗色整体提亮一档, 否则深色线条直接溶进背景; 明暗标记由宿主写在 body 上 */
|
|
1522
|
+
[data-ds-dark-theme] .ssb-root,[data-ds-dark-theme] .ssb-float-root { --ssb-chart-ma-fast: hsl(45 96% 58%); --ssb-chart-ma-mid: hsl(280 90% 72%); --ssb-chart-ma-slow: hsl(189 90% 58%); --ssb-chart-ma-long: hsl(239 88% 76%); --ssb-chart-ema: hsl(330 85% 68%); --ssb-chart-boll-mid: hsl(217 92% 68%); --ssb-chart-boll-rail: hsl(213 94% 80%); --ssb-chart-bbi: hsl(160 80% 56%); --ssb-chart-cost: hsl(20 95% 58%); --ssb-chart-target: hsl(305 85% 70%); }
|
|
1523
|
+
/* 悬浮盯盘挂在 body 上, 与面板同源取色但不需要面板的整屏布局 */
|
|
1524
|
+
.ssb-root { height:100%; min-width:0; overflow:auto; color:var(--ssb-text); background:var(--ssb-bg); font:inherit; font-size:12px; line-height:1.5; }
|
|
1525
|
+
.ssb-float-root { color:var(--ssb-text); font:inherit; font-size:12px; line-height:1.5; }
|
|
1526
|
+
.ssb-root *,.ssb-float-root * { box-sizing:border-box; }
|
|
1527
|
+
.ssb-page { max-width:1200px; margin:0 auto; padding:20px; }
|
|
575
1528
|
.ssb-root h1 { margin:0; font-size:20px; line-height:1.3; }.ssb-root h2 { margin:0; font-size:16px; line-height:1.3; }.ssb-root h3 { margin:24px 0 12px; font-size:14px; }
|
|
576
|
-
.ssb-tablist { display:flex; gap:4px; border-bottom:1px solid var(--ssb-border); margin:12px 0 16px; }.ssb-tab { font:inherit; color:var(--ssb-text-sub); background:transparent; border:0; border-bottom:2px solid transparent; padding:8px 14px; cursor:pointer; }.ssb-tab-on { color:var(--ssb-text); border-bottom-color:var(--ssb-accent); }.ssb-header,.ssb-toolbar,.ssb-section-head,.ssb-row-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }.ssb-header { justify-content:space-between; margin-bottom:24px; }.ssb-header p { margin:6px 0 0; }.ssb-section-head { justify-content:space-between; padding:16px; }.ssb-toolbar { margin:12px 0; }
|
|
1529
|
+
.ssb-tablist { display:flex; gap:4px; border-bottom:1px solid var(--ssb-border); margin:12px 0 16px; }.ssb-tab { font:inherit; color:var(--ssb-text-sub); background:transparent; border:0; border-bottom:2px solid transparent; padding:8px 14px; cursor:pointer; }.ssb-tab-on { color:var(--ssb-text); border-bottom-color:var(--ssb-accent); }.ssb-header,.ssb-toolbar,.ssb-section-head,.ssb-row-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }.ssb-header { justify-content:space-between; margin-bottom:24px; }.ssb-header p { margin:6px 0 0; }.ssb-section-head { justify-content:space-between; padding:12px 16px; }.ssb-toolbar { margin:12px 0; }
|
|
577
1530
|
.ssb-button { color:var(--ssb-text); background:transparent; border:1px solid var(--ssb-border); border-radius:var(--ssb-radius-sm); min-height:28px; padding:4px 12px; font:inherit; cursor:pointer; transition:background .15s; }.ssb-button:hover { background:var(--dsw-alias-interactive-bg-hover); }.ssb-button.ssb-primary { background:var(--ssb-accent); color:var(--ssb-on-accent); border-color:var(--ssb-accent); }.ssb-button.ssb-primary:hover { background:var(--ssb-accent-deep); }.ssb-button.ssb-danger { color:var(--ssb-danger); }.ssb-root button:disabled { opacity:.5; cursor:not-allowed; }.ssb-root :focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:2px; }
|
|
578
1531
|
.ssb-root input:not([type=checkbox]),.ssb-root select { background:var(--ssb-bg); color:var(--ssb-text); border:1px solid var(--ssb-border); border-radius:var(--ssb-radius-sm); min-height:32px; padding:6px 8px; font:inherit; max-width:100%; }.ssb-field { display:flex; flex-direction:column; gap:6px; min-width:0; }.ssb-muted { color:var(--ssb-text-sub); }.ssb-code,.ssb-source { display:block; font-size:11px; }.ssb-code,.ssb-number { font-family:var(--ds-font-family-code); font-variant-numeric:tabular-nums; }.ssb-code { color:var(--ssb-text-sub); margin-top:4px; }.ssb-small { font-size:13px; margin-left:12px; }
|
|
579
|
-
.ssb-content { display:grid; grid-template-columns:minmax(0,1fr)
|
|
580
|
-
.ssb-
|
|
581
|
-
.ssb-
|
|
582
|
-
.ssb-
|
|
583
|
-
|
|
1532
|
+
.ssb-content { display:grid; grid-template-columns:minmax(0,1fr); gap:16px; }.ssb-overlay { position:fixed; inset:0; background:color-mix(in srgb, var(--dsw-alias-label-primary) 45%, transparent); display:flex; z-index:10; animation:ssb-fade .15s ease-out; }.ssb-drawer { animation:ssb-slide .2s ease-out; }.ssb-modal-card { animation:ssb-pop .18s ease-out; }@keyframes ssb-fade { from { opacity:0; } }@keyframes ssb-slide { from { transform:translateX(40px); opacity:0; } }@keyframes ssb-pop { from { transform:scale(.96); opacity:0; } }.ssb-overlay-drawer { justify-content:flex-end; }.ssb-overlay-modal { align-items:center; justify-content:center; padding:24px; }.ssb-drawer { width:min(80vw,94vw); height:100%; overflow:auto; background:var(--ssb-bg); border-left:1px solid var(--ssb-border); box-shadow:var(--dsw-shadow-lv3); padding:16px; }.ssb-modal-card { width:min(880px,94vw); max-height:88vh; overflow:auto; background:var(--ssb-bg); border:1px solid var(--ssb-border); border-radius:var(--ssb-radius-lg); box-shadow:var(--dsw-shadow-lv3); padding:16px; }.ssb-drawer-head { display:flex; justify-content:flex-end; }.ssb-drawer .ssb-detail, .ssb-drawer .ssb-settings { border:0; padding:0; background:transparent; }.ssb-list-pane,.ssb-detail { border:1px solid var(--ssb-border); border-radius:var(--ssb-radius-md); min-width:0; }.ssb-detail { padding:16px; background:var(--ssb-bg-sub); }.ssb-detail .ssb-section-head { padding:0; }.ssb-detail-price { font-size:28px; margin:20px 0 4px; }.ssb-table-scroll { max-height:65vh; overflow:auto; border-radius:0 0 var(--ssb-radius-md) var(--ssb-radius-md); }.ssb-foot { margin:12px 0 4px; }.ssb-table { width:100%; border-collapse:collapse; table-layout:auto; }.ssb-table th { color:var(--ssb-text-sub); font-weight:400; font-size:11px; background:var(--ssb-bg-sub); position:sticky; top:0; z-index:1; }.ssb-table td,.ssb-table th { padding:10px 12px; text-align:left; border-top:1px solid var(--ssb-border); }.ssb-table td.ssb-number { text-align:right; white-space:nowrap; }.ssb-table tr:hover { background:var(--dsw-alias-interactive-bg-hover); }.ssb-selected { background:var(--ssb-accent-wash); }.ssb-stock-name { font:inherit; color:inherit; background:transparent; border:0; padding:0; text-align:left; cursor:pointer; }.ssb-stale td.ssb-number { opacity:.65; }.ssb-up { color:var(--ssb-danger); }.ssb-down { color:var(--ssb-ok); }.ssb-green-up .ssb-up { color:var(--ssb-ok); }.ssb-green-up .ssb-down { color:var(--ssb-danger); }
|
|
1533
|
+
.ssb-table th.ssb-number { text-align:right; }.ssb-table th.ssb-col-status { text-align:right; }
|
|
1534
|
+
.ssb-table td { vertical-align:middle; padding-top:8px; padding-bottom:8px; }.ssb-table td:first-child { max-width:240px; }
|
|
1535
|
+
.ssb-stock-name { display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:100%; }
|
|
1536
|
+
/* 行情列表: 单行紧凑, 列宽定宽右对齐, 行高按 32px 量级标尺(见 design 规范) */
|
|
1537
|
+
.ssb-list-pane { container-type:inline-size; }
|
|
1538
|
+
.ssb-list-pane > .ssb-table-scroll { flex:1; min-height:0; }
|
|
1539
|
+
.ssb-list-pane > .ssb-foot, .ssb-list-pane > .ssb-banner { flex:0 0 auto; }
|
|
1540
|
+
.ssb-rows { display:flex; flex-direction:column; }
|
|
1541
|
+
/* 表头与数据行共用同一套列模板, 保证逐列对齐; 数值列右对齐且等宽数字, 长内容不再挤占相邻列 */
|
|
1542
|
+
.ssb-row-head,.ssb-row-main { display:grid; grid-template-columns:12px minmax(140px,1fr) 64px 58px 56px 62px 104px 82px 52px 60px 16px; gap:8px; align-items:center; font-variant-numeric:tabular-nums; }
|
|
1543
|
+
.ssb-row-head { height:32px; padding:0 12px; font-size:12px; color:var(--ssb-text-sub); border-bottom:1px solid var(--ssb-border); }
|
|
1544
|
+
.ssb-row-head .ssb-cell-price,.ssb-row-head .ssb-cell-pct,.ssb-row-head .ssb-cell-change,.ssb-row-head .ssb-cell-range,.ssb-row-head .ssb-cell-amount,.ssb-row-head .ssb-cell-amplitude,.ssb-row-head .ssb-cell-pl { font-family:inherit; color:inherit; }
|
|
1545
|
+
.ssb-row-main > .ssb-cell-price,.ssb-row-main > .ssb-cell-pct,.ssb-row-main > .ssb-cell-change,.ssb-row-main > .ssb-cell-open,.ssb-row-main > .ssb-cell-range,.ssb-row-main > .ssb-cell-amount,.ssb-row-main > .ssb-cell-amplitude,.ssb-row-main > .ssb-cell-pl,.ssb-row-head > span:nth-child(n+3) { justify-self:end; }
|
|
1546
|
+
.ssb-row-main > .ssb-cell-price,.ssb-row-main > .ssb-cell-range,.ssb-row-main > .ssb-cell-amount { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
1547
|
+
.ssb-row { padding:0 12px; cursor:pointer; }
|
|
1548
|
+
.ssb-row-main { height:40px; font-size:12px; }
|
|
1549
|
+
.ssb-row:hover { background:var(--dsw-alias-interactive-bg-hover); }
|
|
1550
|
+
.ssb-row.ssb-selected { background:var(--ssb-accent-wash); }
|
|
1551
|
+
.ssb-row.ssb-stale { opacity:.72; }
|
|
1552
|
+
.ssb-row.ssb-pinned { box-shadow:inset 2px 0 0 var(--ssb-accent); }
|
|
1553
|
+
.ssb-row.ssb-dragging { opacity:.4; }
|
|
1554
|
+
.ssb-row.ssb-drop-before { box-shadow:inset 0 2px 0 var(--ssb-accent); }
|
|
1555
|
+
.ssb-row.ssb-drop-after { box-shadow:inset 0 -2px 0 var(--ssb-accent); }
|
|
1556
|
+
.ssb-grip { flex:0 0 12px; color:var(--ssb-text-dim); cursor:grab; opacity:0; user-select:none; line-height:1; font-size:12px; }
|
|
1557
|
+
.ssb-row:hover .ssb-grip { opacity:1; }
|
|
1558
|
+
.ssb-row.ssb-dragging .ssb-grip { cursor:grabbing; }
|
|
1559
|
+
.ssb-row .ssb-stock-name { min-width:0; font-size:13px; font-weight:500; display:flex; align-items:baseline; gap:6px; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
|
|
1560
|
+
.ssb-row-head .ssb-col-name { min-width:0; }
|
|
1561
|
+
.ssb-row .ssb-code { margin-top:0; flex:0 0 auto; font-size:11px; color:var(--ssb-text-dim); }
|
|
1562
|
+
.ssb-cell-price,.ssb-cell-pct,.ssb-cell-change,.ssb-cell-open,.ssb-cell-amount,.ssb-cell-amplitude,.ssb-cell-pl { text-align:right; white-space:nowrap; font-variant-numeric:tabular-nums; font-family:var(--ds-font-family-code); }
|
|
1563
|
+
.ssb-cell-range { white-space:nowrap; color:var(--ssb-text-dim); font-variant-numeric:tabular-nums; font-family:var(--ds-font-family-code); }
|
|
1564
|
+
.ssb-cell-price { font-size:15px; font-weight:600; }
|
|
1565
|
+
.ssb-cell-amount,.ssb-cell-amplitude,.ssb-cell-open { color:var(--ssb-text-dim); }
|
|
1566
|
+
.ssb-col-status { justify-self:end; }
|
|
1567
|
+
.ssb-pill { display:inline-block; padding:0 5px; border-radius:var(--ssb-radius-sm); background:color-mix(in srgb, currentColor 12%, transparent); }
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
|
|
1571
|
+
/* 窄容器降级: 单行拆两段, 隐藏列头与宽屏列 */
|
|
1572
|
+
.ssb-row-sub { display:none; }
|
|
1573
|
+
@container (max-width:700px) {
|
|
1574
|
+
.ssb-row-head { display:none; }
|
|
1575
|
+
.ssb-row { padding:6px 10px; }
|
|
1576
|
+
.ssb-row-main { grid-template-columns:12px minmax(80px,1fr) 68px 64px 56px 16px; height:auto; }
|
|
1577
|
+
.ssb-cell-open,.ssb-cell-range,.ssb-cell-amount,.ssb-cell-amplitude,.ssb-cell-pl { display:none; }
|
|
1578
|
+
.ssb-row-sub { display:flex; gap:10px; margin-top:2px; padding-left:20px; font-size:12px; color:var(--ssb-text-dim); font-variant-numeric:tabular-nums; font-family:var(--ds-font-family-code); }
|
|
1579
|
+
}
|
|
1580
|
+
.ssb-kv { display:inline-flex; gap:3px; white-space:nowrap; }
|
|
1581
|
+
/* 悬浮盯盘: 固定右下角, 尺寸以 4px 刻度, 只做轻边框分层不挂阴影 */
|
|
1582
|
+
.ssb-float { position:fixed; right:24px; bottom:24px; z-index:60; width:288px; max-height:calc(100vh - 48px); display:flex; flex-direction:column; gap:8px; padding:12px; background:var(--ssb-bg); border:1px solid var(--ssb-border); border-radius:var(--ssb-radius-lg); box-shadow:var(--dsw-shadow-lv3); font-size:12px; }
|
|
1583
|
+
/* 拖动只从标题条发起: touch-action 取的是路径交集, 父级给 none 会把列表的 pan-y 一起废掉 */
|
|
1584
|
+
.ssb-float { touch-action:pan-y; }
|
|
1585
|
+
.ssb-float-head { touch-action:none; cursor:grab; }
|
|
1586
|
+
.ssb-float-list,.ssb-float-picker { touch-action:pan-y; }
|
|
1587
|
+
.ssb-float-head { display:flex; align-items:center; gap:4px; }
|
|
1588
|
+
.ssb-float-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:13px; }
|
|
1589
|
+
.ssb-float-icon { width:20px; height:20px; display:inline-flex; align-items:center; justify-content:center; font:inherit; font-size:12px; line-height:1; color:var(--ssb-text-sub); background:transparent; border:0; border-radius:var(--ssb-radius-sm); cursor:pointer; }
|
|
1590
|
+
.ssb-float-icon:hover { color:var(--ssb-text); background:var(--ssb-accent-wash); }
|
|
1591
|
+
.ssb-float-quote { display:flex; align-items:baseline; gap:8px; }
|
|
1592
|
+
.ssb-float-price { font-size:20px; line-height:1.3; font-family:var(--ds-font-family-code); font-variant-numeric:tabular-nums; }
|
|
1593
|
+
.ssb-float-pct { font-size:12px; }
|
|
1594
|
+
.ssb-float-spark { width:100%; height:88px; display:block; }
|
|
1595
|
+
/* 曲线用 currentColor 取色: 有涨跌时由 .ssb-up/.ssb-down 决定, 只在两者都没有时退回次要文字色 */
|
|
1596
|
+
.ssb-float-chart:not(.ssb-up):not(.ssb-down) { color:var(--ssb-text-sub); }
|
|
1597
|
+
.ssb-float-line { fill:none; stroke:currentColor; stroke-width:1.5; vector-effect:non-scaling-stroke; }
|
|
1598
|
+
.ssb-float-area { fill:currentColor; opacity:.12; stroke:none; }
|
|
1599
|
+
.ssb-float-base { stroke:var(--ssb-text-dim); stroke-width:1; stroke-dasharray:3 3; vector-effect:non-scaling-stroke; opacity:.6; }
|
|
1600
|
+
.ssb-float-error { margin:0; height:88px; display:flex; align-items:center; color:var(--ssb-text-sub); }
|
|
1601
|
+
.ssb-float-foot { display:flex; align-items:center; gap:8px; }
|
|
1602
|
+
.ssb-float-live { margin-left:auto; padding:4px 8px; font:inherit; font-size:11px; color:var(--ssb-text-sub); background:transparent; border:1px solid var(--ssb-border); border-radius:var(--ssb-radius-sm); cursor:pointer; }
|
|
1603
|
+
.ssb-float-live[aria-pressed="true"] { color:var(--ssb-ok); border-color:color-mix(in srgb,var(--ssb-ok) 40%,transparent); background:color-mix(in srgb,var(--ssb-ok) 10%,transparent); }
|
|
1604
|
+
.ssb-float-live-error { color:var(--ssb-warn) !important; border-color:color-mix(in srgb,var(--ssb-warn) 40%,transparent) !important; background:color-mix(in srgb,var(--ssb-warn) 10%,transparent) !important; }
|
|
1605
|
+
.ssb-float-live:hover { border-color:var(--ssb-accent); }
|
|
1606
|
+
/* 列表模式: 每行一只标的, 点击切到该只的图形 */
|
|
1607
|
+
.ssb-float-list,.ssb-float-picker { flex:0 1 auto; min-height:0; display:flex; flex-direction:column; gap:2px; max-height:min(300px, 45vh); overflow:auto; }
|
|
1608
|
+
.ssb-picker-group { display:flex; flex-direction:column; gap:2px; }
|
|
1609
|
+
.ssb-picker-head { display:flex; align-items:center; gap:6px; padding:4px 2px 2px; font-size:11px; color:var(--ssb-text-dim); border-top:1px solid var(--ssb-border); }
|
|
1610
|
+
.ssb-picker-group:first-child .ssb-picker-head { border-top:none; }
|
|
1611
|
+
.ssb-picker-head > span { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
1612
|
+
.ssb-picker-all { flex:0 0 auto; padding:1px 6px; font:inherit; font-size:11px; color:var(--ssb-accent); background:transparent; border:1px solid var(--ssb-border); border-radius:var(--ssb-radius-sm); cursor:pointer; }
|
|
1613
|
+
.ssb-picker-all:hover { border-color:var(--ssb-accent); }
|
|
1614
|
+
.ssb-float-row { display:flex; align-items:baseline; gap:8px; padding:4px 6px; font:inherit; text-align:left; color:inherit; background:transparent; border:0; border-radius:var(--ssb-radius-sm); cursor:pointer; }
|
|
1615
|
+
.ssb-float-row:hover { background:var(--dsw-alias-interactive-bg-hover); }
|
|
1616
|
+
.ssb-float-row-on { background:var(--ssb-accent-wash); }
|
|
1617
|
+
.ssb-float-row-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
1618
|
+
.ssb-float-row-price { font-size:12px; }
|
|
1619
|
+
.ssb-float-row-pct { width:58px; text-align:right; font-size:11px; }
|
|
1620
|
+
.ssb-float-modes { display:flex; gap:2px; }
|
|
1621
|
+
.ssb-float-mode { padding:2px 6px; font:inherit; font-size:11px; color:var(--ssb-text-sub); background:transparent; border:1px solid transparent; border-radius:var(--ssb-radius-sm); cursor:pointer; }
|
|
1622
|
+
.ssb-float-mode.on { color:var(--ssb-text); border-color:var(--ssb-border); background:var(--ssb-bg-sub); }
|
|
1623
|
+
.ssb-float-picker .ssb-switch { font-size:11px; }
|
|
1624
|
+
.ssb-group-head { display:flex; align-items:center; gap:8px; flex-wrap:wrap; height:32px; padding:0 12px; border-top:1px solid var(--ssb-border); background:var(--ssb-bg-sub); font-size:12px; color:var(--ssb-text-sub); }
|
|
1625
|
+
.ssb-dot { display:inline-block; width:6px; height:6px; border-radius:50%; background:var(--ssb-ok); flex:0 0 auto; }.ssb-dot.ssb-dot-warn { background:var(--ssb-warn); }.ssb-dot.ssb-dot-danger { background:var(--ssb-danger); }.ssb-dot.ssb-dot-idle { background:var(--ssb-text-dim); }
|
|
1626
|
+
.ssb-facts { display:grid; grid-template-columns:1fr 1fr; gap:8px; border-top:1px solid var(--ssb-border); padding-top:16px; margin-top:16px; }.ssb-mark-list { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:6px; }.ssb-mark-row { display:flex; align-items:center; gap:8px; }.ssb-mark-row .ssb-number { flex:1; }.ssb-mark-kind { min-width:2.5em; color:var(--ssb-text-dim); font-size:12px; }.ssb-facts dt { color:var(--ssb-text-sub); }.ssb-facts dd { margin:0; text-align:right; overflow-wrap:anywhere; font-family:var(--ds-font-family-code); }.ssb-alias-form { display:grid; gap:6px; margin-top:0; }.ssb-alias-form label { display:grid; gap:6px; }.ssb-alias-form button { justify-self:start; min-width:96px; }.ssb-detail-forms { display:grid; grid-template-columns:repeat(auto-fit,minmax(176px,1fr)); gap:16px; border-top:1px solid var(--ssb-border); padding-top:16px; margin-top:16px; }
|
|
1627
|
+
.ssb-settings-head { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; flex-wrap:wrap; }.ssb-settings-intro { min-width:0; display:grid; gap:6px; }.ssb-settings-intro p { margin:0; }
|
|
1628
|
+
.ssb-provider-head { display:flex; align-items:flex-end; justify-content:space-between; gap:12px; flex-wrap:wrap; }.ssb-provider-adapter { flex:0 0 240px; }.ssb-provider-switches { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }.ssb-provider-desc { margin:8px 0 0; }.ssb-provider-advanced { margin-top:8px; }.ssb-provider-advanced > summary { cursor:pointer; }
|
|
1629
|
+
/* 摘要行: 数据源与市场默认收成一行, 点开才展开编辑区 */
|
|
1630
|
+
.ssb-provider, .ssb-market-row { border-bottom:1px solid var(--ssb-border); }
|
|
1631
|
+
.ssb-provider-toggle, .ssb-market-toggle { display:grid; align-items:center; gap:12px; width:100%; padding:12px 4px; background:none; border:0; color:inherit; font:inherit; text-align:left; cursor:pointer; }
|
|
1632
|
+
.ssb-provider-toggle { grid-template-columns:10px minmax(96px,auto) minmax(88px,auto) minmax(0,1fr) minmax(88px,auto) 42px; }
|
|
1633
|
+
.ssb-market-toggle { grid-template-columns:minmax(88px,auto) 52px minmax(0,1fr) minmax(72px,auto) 42px; }
|
|
1634
|
+
.ssb-provider-toggle:hover, .ssb-market-toggle:hover { background:var(--ssb-hover); }
|
|
1635
|
+
.ssb-provider-toggle:focus-visible, .ssb-market-toggle:focus-visible { outline:2px solid var(--ssb-accent); outline-offset:-2px; }
|
|
1636
|
+
.ssb-provider-toggle[aria-expanded="true"], .ssb-market-toggle[aria-expanded="true"] { border-bottom:1px solid var(--ssb-border); }
|
|
1637
|
+
.ssb-provider-id { font-weight:600; }.ssb-provider-adapter-name { font-size:12px; }.ssb-provider-scope { font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }.ssb-provider-kind { font-size:12px; text-align:right; }.ssb-provider-edit, .ssb-market-edit { font-size:12px; color:var(--ssb-accent); text-align:right; }
|
|
1638
|
+
.ssb-provider-body, .ssb-market-body { padding:4px 4px 16px; display:grid; gap:12px; }
|
|
1639
|
+
.ssb-market-name { font-weight:600; }.ssb-market-code { font-size:11px; color:var(--ssb-text-sub); }.ssb-market-caps { font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }.ssb-market-back { font-size:12px; color:var(--ssb-text-sub); text-align:right; }
|
|
1640
|
+
.ssb-market-list { display:grid; }
|
|
1641
|
+
/* 单元格式: 能力选择 + 单项探针按钮 + 结果徽标 */
|
|
1642
|
+
.ssb-route-cell-probe { display:grid; grid-template-columns:minmax(0,1fr); gap:4px; align-content:start; }
|
|
1643
|
+
.ssb-route-cell-main { display:grid; gap:2px; }
|
|
1644
|
+
.ssb-probe { font:inherit; font-size:11px; padding:2px 6px; border:1px solid var(--ssb-border); border-radius:var(--ssb-radius-sm); background:var(--ssb-bg); color:var(--ssb-text-sub); cursor:pointer; }
|
|
1645
|
+
.ssb-probe:hover:not(:disabled) { color:var(--ssb-text); border-color:var(--ssb-accent); }
|
|
1646
|
+
.ssb-probe:disabled { opacity:.6; cursor:progress; }
|
|
1647
|
+
.ssb-probe-result { font-size:11px; font-variant-numeric:tabular-nums; }
|
|
1648
|
+
.ssb-probe-ok { color:var(--ssb-ok); }
|
|
1649
|
+
.ssb-probe-bad { color:var(--ssb-danger); }
|
|
1650
|
+
.ssb-market-head { display:grid; grid-template-columns:minmax(88px,auto) 52px minmax(0,1fr) minmax(72px,auto) 42px; gap:12px; padding:0 4px 6px; font-size:11px; color:var(--ssb-text-dim); border-bottom:1px solid var(--ssb-border); }
|
|
1651
|
+
.ssb-section-foot { display:grid; gap:6px; padding:12px 0; }.ssb-section-foot p { margin:0; }
|
|
1652
|
+
.ssb-route-advanced { display:grid; gap:8px; padding-top:8px; border-top:1px solid var(--ssb-border); }
|
|
1653
|
+
.ssb-kline { margin:16px 0; }.ssb-kline-toolbar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:8px; }.ssb-kline-meta { margin-left:auto; color:var(--ssb-text-sub); font-size:12px; white-space:nowrap; }
|
|
1654
|
+
/* 图例: 均线/成本的数值只在这里出现, 图面不铺文字标签 */
|
|
1655
|
+
.ssb-kline-legend { display:flex; flex-direction:column; gap:4px; margin:0 0 8px; font-size:11px; color:var(--ssb-text-sub); font-family:var(--ds-font-family-code); font-variant-numeric:tabular-nums; }
|
|
1656
|
+
.ssb-legend-row { display:flex; flex-wrap:wrap; align-items:baseline; gap:12px; }
|
|
1657
|
+
.ssb-legend-label { color:var(--ssb-text-dim); margin-right:4px; }
|
|
1658
|
+
.ssb-legend-cost { color:var(--ssb-chart-cost); }.ssb-legend-target { color:var(--ssb-chart-target); }
|
|
1659
|
+
.ssb-legend-pct { margin-left:4px; }
|
|
1660
|
+
/* 图例指标色与图表 series 色板一一对应(图表侧只能吃具体色值, 无法引用主题变量) */
|
|
1661
|
+
.ssb-ma-0 { color:var(--ssb-chart-ma-fast); }.ssb-ma-1 { color:var(--ssb-chart-ma-mid); }.ssb-ma-2 { color:var(--ssb-chart-ma-slow); }.ssb-ma-3 { color:var(--ssb-chart-ma-long); }
|
|
1662
|
+
.ssb-ema { color:var(--ssb-chart-ema); }
|
|
1663
|
+
.ssb-boll-0 { color:var(--ssb-chart-boll-mid); }.ssb-boll-1 { color:var(--ssb-chart-boll-rail); }.ssb-boll-2 { color:var(--ssb-chart-boll-rail); }
|
|
1664
|
+
.ssb-bbi { color:var(--ssb-chart-bbi); }
|
|
1665
|
+
.ssb-menu-title { padding:8px 8px 2px; font-size:11px; color:var(--ssb-text-dim); }.ssb-kline-holder { height:340px; border:1px solid var(--ssb-border); border-radius:var(--ssb-radius-sm); }.ssb-kline-skeleton { height:340px; border:1px dashed var(--ssb-border); border-radius:var(--ssb-radius-sm); display:flex; align-items:center; justify-content:center; color:var(--ssb-text-sub); margin-bottom:8px; }
|
|
1666
|
+
.ssb-detail-grid { display:grid; grid-template-columns:minmax(0,1fr) 240px; gap:16px; align-items:start; }.ssb-detail-side { min-width:0; }.ssb-detail-side .ssb-facts { grid-template-columns:auto minmax(0,1fr); gap:6px 12px; }.ssb-detail-side .ssb-facts dt { white-space:nowrap; }
|
|
1667
|
+
.ssb-empty { padding:32px 16px; color:var(--ssb-text-sub); text-align:center; }.ssb-banner { border:1px solid var(--ssb-border); border-left:3px solid var(--ssb-warn); border-radius:var(--ssb-radius-sm); padding:12px; margin:12px 0; display:flex; align-items:center; gap:12px; flex-wrap:wrap; }.ssb-banner-ok { border-left-color:var(--ssb-ok); }.ssb-error { border-left-color:var(--ssb-danger); }.ssb-banner p { margin:0; }.ssb-search-view { max-width:760px; border:1px solid var(--ssb-border); border-radius:var(--ssb-radius-md); background:var(--ssb-bg); padding-bottom:4px; }.ssb-panel-head { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px 16px; border-bottom:1px solid var(--ssb-border); flex-wrap:wrap; }.ssb-panel-head h2 { font-size:16px; margin:0; }.ssb-panel-body { padding:16px; display:flex; flex-direction:column; gap:12px; }.ssb-form-row { display:flex; gap:12px; align-items:flex-end; flex-wrap:wrap; }.ssb-form-row > .ssb-field { flex:0 0 176px; }.ssb-search-label { display:grid; gap:8px; margin-top:20px; }.ssb-form-row > .ssb-search-label { flex:1 1 300px; margin-top:0; }.ssb-results { display:grid; gap:8px; margin:16px; }.ssb-candidate { padding:10px 12px; display:flex; justify-content:space-between; align-items:center; gap:12px; background:transparent; border:1px solid var(--ssb-border); border-radius:var(--ssb-radius-sm); color:var(--ssb-text); font:inherit; cursor:pointer; }.ssb-candidate:hover { border-color:var(--ssb-accent); }.ssb-candidate.ssb-selected { background:var(--ssb-accent-wash); border-color:var(--ssb-accent); }.ssb-peek { display:flex; flex:1; flex-direction:column; gap:2px; text-align:left; cursor:pointer; min-width:0; }.ssb-peek:hover strong { text-decoration:underline; }.ssb-provider { border:1px solid var(--ssb-border); border-radius:var(--ssb-radius-md); padding:16px; margin:12px 0; min-width:0; }.ssb-provider legend { padding:0 6px; color:var(--ssb-text-sub); }.ssb-provider details { grid-column:1 / -1; }.ssb-provider summary { cursor:pointer; }.ssb-reqlog { margin:8px 0 0; padding:0 0 0 18px; color:var(--ssb-text-sub); }.ssb-form-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }.ssb-route-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(132px,1fr)); gap:12px; }.ssb-route-cell { display:grid; gap:6px; min-width:0; }.ssb-route-cell-label { color:var(--ssb-text-sub); font-size:11px; }.ssb-route-cell select { width:100%; min-width:0; }.ssb-route-advanced { margin-top:12px; }.ssb-route-advanced > summary { cursor:pointer; }.ssb-route-tune { display:flex; align-items:flex-end; gap:12px; flex-wrap:wrap; padding:8px 0; border-top:1px solid var(--ssb-border); }.ssb-route-tune-label { min-width:32px; color:var(--ssb-text-sub); }.ssb-route-delay { flex:0 0 120px; }
|
|
584
1668
|
.ssb-switch { display:inline-flex; align-items:center; gap:8px; cursor:pointer; position:relative; }.ssb-switch input[type="checkbox"] { position:absolute; opacity:0; width:1px; height:1px; }.ssb-switch__track { display:inline-flex; flex-shrink:0; align-items:center; width:34px; height:19px; padding:3px; border-radius:999px; background:var(--ssb-bg-muted); border:1px solid var(--ssb-border); transition:background .15s ease; }.ssb-switch__thumb { width:13px; height:13px; border-radius:50%; background:var(--ssb-text-sub); transition:transform .15s ease; }.ssb-switch input[type="checkbox"]:checked + .ssb-switch__track { background:var(--ssb-accent); }.ssb-switch input[type="checkbox"]:checked + .ssb-switch__track .ssb-switch__thumb { transform:translateX(13px); background:var(--ssb-bg); }.ssb-switch input[type="checkbox"]:focus-visible + .ssb-switch__track { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:2px; }.ssb-switch input[type="checkbox"]:disabled + .ssb-switch__track { opacity:.5; cursor:not-allowed; }.ssb-switch:hover .ssb-switch__track { border-color:var(--ssb-accent); }.ssb-sr { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); }
|
|
585
1669
|
@media(max-width:720px) { .ssb-detail-grid { grid-template-columns:minmax(0,1fr); } }
|
|
586
1670
|
@media(max-width:959px) { .ssb-content { grid-template-columns:minmax(0,1fr); }.ssb-page { padding:16px; } }
|
|
@@ -596,11 +1680,12 @@ window.__ModuleLoader__.load({
|
|
|
596
1680
|
}, [view.view])
|
|
597
1681
|
return h('div', { className: `ssb-root${data?.control.preferences.colorMode === 'green-up' ? ' ssb-green-up' : ''}`, ref: root },
|
|
598
1682
|
React.createElement('style', { 'data-plugin': '@mzzsfy/dsh-stock-assistant', dangerouslySetInnerHTML: { __html: CSS } }),
|
|
599
|
-
h('div', { className: 'ssb-page' }, h('header', { className: 'ssb-header' }, h('div', null, h('h1', null, '股票'), h('p', { className: 'ssb-muted' },
|
|
600
|
-
h(Button, { primary: view.view === 'list', onClick: () => runtime.updateView({ view: 'list' }) }, '自选'), h(Button, { primary: view.view === 'browse', onClick: () => { runtime.updateView({ view: 'browse' }); if (!runtime.getSnapshot().view.browseRows) quiet(() => runtime.loadBrowse('XSHG', 0))() } }, '市场'), h(Button, { primary: view.view === 'search', onClick: () => runtime.updateView({ view: 'search' }) }, '搜索'), h(Button, { disabled: !data || state.busy.refresh, onClick: quiet(runtime.refresh) }, state.busy.refresh ? '刷新中…' : '刷新'),
|
|
1683
|
+
h('div', { className: 'ssb-page' }, h('header', { className: 'ssb-header' }, h('div', null, h('h1', null, '股票'), h('p', { className: 'ssb-muted' }, scopeText(data?.control.watchlist.order || []))), h('nav', { className: 'ssb-toolbar', 'aria-label': '股票工具操作' },
|
|
1684
|
+
h(Button, { primary: view.view === 'list', onClick: () => runtime.updateView({ view: 'list' }) }, '自选'), h(Button, { primary: view.view === 'browse', onClick: () => { runtime.updateView({ view: 'browse' }); if (!runtime.getSnapshot().view.browseRows) quiet(() => runtime.loadBrowse('XSHG', 0))() } }, '市场'), h(Button, { primary: view.view === 'search', onClick: () => runtime.updateView({ view: 'search' }) }, '搜索'), h(Button, { disabled: !data || state.busy.refresh, onClick: quiet(() => runtime.refresh()) }, state.busy.refresh ? '刷新中…' : '刷新'),
|
|
601
1685
|
h('div', { className: 'ssb-more', style: { position: 'relative' } }, h(Button, { 'aria-label': '更多操作', 'aria-expanded': !!view.menuOpen, onClick: () => runtime.updateView({ menuOpen: !view.menuOpen }) }, '更多'), view.menuOpen ? h('div', { className: 'ssb-menu', style: { position: 'absolute', right: 0, top: 'calc(100% + 4px)', background: 'var(--ssb-bg)', border: '1px solid var(--ssb-border)', borderRadius: 'var(--ssb-radius-md)', padding: '8px', display: 'flex', flexDirection: 'column', gap: '4px', zIndex: 10, minWidth: '140px' }, role: 'menu' },
|
|
602
1686
|
h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '4px 8px' } }, h(Toggle, { label: '暂停采集', checked: state.paused, onChange: runtime.setPaused })),
|
|
603
1687
|
h('div', { className: 'ssb-muted', style: { padding: '0 8px', fontSize: '11px' } }, `${(data?.control.providers || []).filter(p => p.enabled).length}/${(data?.control.providers || []).length} 源启用${state.data?.klineReady ? ` · K线 ${Object.keys(state.data.klineReady).length} 市场` : ''}`),
|
|
1688
|
+
h(Button, { onClick: () => { writePref(FLOAT_KEY, { ...readPref(FLOAT_KEY, FLOAT_DEFAULT), open: !readPref(FLOAT_KEY, FLOAT_DEFAULT).open }); runtime.updateView({ menuOpen: false }) } }, readPref(FLOAT_KEY, FLOAT_DEFAULT).open ? '关闭悬浮盯盘' : '悬浮盯盘'),
|
|
604
1689
|
h(Button, { onClick: () => { runtime.updateView({ view: 'list', overlay: 'settings', menuOpen: false }) } }, '设置'),
|
|
605
1690
|
h(Button, { onClick: () => { runtime.updateView({ menuOpen: false }); layout.selectPanel(null) } }, '返回对话')) : null))),
|
|
606
1691
|
state.error ? h('div', { className: 'ssb-banner ssb-error', role: 'alert' }, state.error, h(Button, { onClick: () => runtime.updateView({ view: 'list', overlay: 'settings' }) }, '检查设置')) : null,
|
|
@@ -610,7 +1695,7 @@ window.__ModuleLoader__.load({
|
|
|
610
1695
|
state.undo && state.undo.until > Date.now() ? h('div', { className: 'ssb-banner', role: 'status' }, '已移除自选。', h(Button, { disabled: state.busy.write, onClick: quiet(() => runtime.mutate('restore', { removeOperationId: state.undo.removeOperationId })) }, '撤销')) : null,
|
|
611
1696
|
!data ? h('div', { className: 'ssb-empty', role: 'status' }, '正在加载股票工具…') : view.view === 'search' ? h(SearchView, { runtime, state }) : view.view === 'browse' ? h(BrowseView, { runtime, state }) : h('div', { className: 'ssb-content' }, h(Watchlist, { runtime, state })),
|
|
612
1697
|
data && view.overlay ? h(Overlay, { runtime, state, layout }) : null,
|
|
613
|
-
h('p', { className: 'ssb-muted' }, '价格与涨跌来自同一数据源。延迟未知表示来源未明确承诺时效。')))
|
|
1698
|
+
h('p', { className: 'ssb-foot ssb-muted' }, '价格与涨跌来自同一数据源。延迟未知表示来源未明确承诺时效。')))
|
|
614
1699
|
}
|
|
615
1700
|
return {
|
|
616
1701
|
inject: ['slots', 'locale', 'layout'],
|
|
@@ -619,6 +1704,19 @@ window.__ModuleLoader__.load({
|
|
|
619
1704
|
const bus = tools.setupCommonTools(ctx)
|
|
620
1705
|
const runtime = createPageRuntime({ layout: ctx.layout, bus, tools })
|
|
621
1706
|
ctx.effect(() => () => runtime.dispose(), 'stock-assistant page runtime')
|
|
1707
|
+
// 悬浮盯盘单独挂到 body: 它要在切走面板后继续存在, 不能跟着面板插槽一起卸载
|
|
1708
|
+
ctx.effect(() => {
|
|
1709
|
+
// 无 DOM 的宿主环境(测试/SSR)直接跳过, 不能让挂载失败连累面板注册
|
|
1710
|
+
const body = globalThis.document?.body
|
|
1711
|
+
if (!body) return () => {}
|
|
1712
|
+
const host = globalThis.document.createElement('div')
|
|
1713
|
+
host.className = 'ssb-float-root'
|
|
1714
|
+
body.appendChild(host)
|
|
1715
|
+
let root = null
|
|
1716
|
+
try { root = require('react-dom/client').createRoot(host) } catch { host.remove(); return () => {} }
|
|
1717
|
+
root.render(h(FloatingTicker, { runtime }))
|
|
1718
|
+
return () => { try { root.unmount() } catch {} host.remove() }
|
|
1719
|
+
}, 'stock-assistant floating ticker')
|
|
622
1720
|
ctx.effect(() => {
|
|
623
1721
|
const disposers = [
|
|
624
1722
|
ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: PANEL_ID }, () => h(StockPage, { runtime, layout: ctx.layout }))),
|
|
@@ -627,7 +1725,7 @@ window.__ModuleLoader__.load({
|
|
|
627
1725
|
return () => { for (const stop of disposers.reverse()) stop() }
|
|
628
1726
|
}, 'stock-assistant main panel')
|
|
629
1727
|
},
|
|
630
|
-
__testing: { createPageRuntime, StockPage },
|
|
1728
|
+
__testing: { createPageRuntime, StockPage, FloatingTicker, dragOrder, quiet, seriesMA, seriesEMA, seriesBoll, seriesBBI, readPref, writePref, klineKeyOf, klineShipPlan, finiteOr, formatAmount, groupSlices, avgCostOf, markPct, pollPlan, QUIET_STATES, POLL_EVERY, floatPollDelay, seriesPaintPlan, sanitizeCandles, themeSignature },
|
|
631
1729
|
}
|
|
632
1730
|
},
|
|
633
1731
|
})
|