@363045841yyt/klinechart 0.7.12 → 0.8.1-alpha.1

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.
@@ -0,0 +1,478 @@
1
+ <template>
2
+ <div ref="chipWrapRef" class="symbol-chip-wrap">
3
+ <button
4
+ type="button"
5
+ class="symbol-chip"
6
+ :class="{ 'is-open': showPopup }"
7
+ :title="symbol"
8
+ :aria-expanded="showPopup"
9
+ aria-haspopup="dialog"
10
+ @click="togglePopup"
11
+ >
12
+ <span class="symbol-chip__code">{{ symbol }}</span>
13
+ <span v-if="loading" class="symbol-chip__spinner" aria-hidden="true" />
14
+ <IconTablerAlertTriangle v-else-if="error" class="symbol-chip__warn" aria-hidden="true" />
15
+ </button>
16
+ <Transition name="symbol-popover">
17
+ <div v-if="showPopup" class="symbol-popover" role="dialog" aria-label="切换合约">
18
+ <div class="symbol-search">
19
+ <span class="symbol-search__icon" aria-hidden="true">
20
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
21
+ <circle cx="6.5" cy="6.5" r="5" stroke="currentColor" stroke-width="1.6" />
22
+ <line
23
+ x1="10.5"
24
+ y1="10.5"
25
+ x2="14.5"
26
+ y2="14.5"
27
+ stroke="currentColor"
28
+ stroke-width="1.6"
29
+ stroke-linecap="round"
30
+ />
31
+ </svg>
32
+ </span>
33
+ <input
34
+ ref="searchInputRef"
35
+ v-model="searchQuery"
36
+ class="symbol-search__input"
37
+ type="text"
38
+ placeholder="搜索代码或名称…"
39
+ autocomplete="off"
40
+ spellcheck="false"
41
+ aria-label="搜索商品"
42
+ @input="onSearchInput"
43
+ />
44
+ <button
45
+ v-if="searchQuery"
46
+ type="button"
47
+ class="symbol-search__clear"
48
+ aria-label="清空搜索"
49
+ @click="clearSearch"
50
+ >
51
+ <svg class="delete-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
52
+ <path d="M3 6h18" />
53
+ <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
54
+ <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
55
+ </svg>
56
+ </button>
57
+ </div>
58
+
59
+ <div class="symbol-list" role="listbox" aria-label="商品列表">
60
+ <div v-if="filteredSymbols.length === 0" class="symbol-list__empty">
61
+ <svg
62
+ width="32"
63
+ height="32"
64
+ viewBox="0 0 32 32"
65
+ fill="none"
66
+ style="margin-bottom: 8px; opacity: 0.35"
67
+ >
68
+ <circle cx="13" cy="13" r="10" stroke="currentColor" stroke-width="2" />
69
+ <line
70
+ x1="21"
71
+ y1="21"
72
+ x2="29"
73
+ y2="29"
74
+ stroke="currentColor"
75
+ stroke-width="2"
76
+ stroke-linecap="round"
77
+ />
78
+ </svg>
79
+ <span>未找到相关商品</span>
80
+ </div>
81
+ <button
82
+ v-for="item in filteredSymbols"
83
+ :key="item.code"
84
+ type="button"
85
+ class="symbol-list__item"
86
+ :class="{ 'is-active': item.code === symbol }"
87
+ role="option"
88
+ :aria-selected="item.code === symbol"
89
+ @click="selectSymbol(item)"
90
+ >
91
+ <span class="symbol-list__left">
92
+ <span class="symbol-list__code">{{ item.code }}</span>
93
+ <span class="symbol-list__desc">{{ item.description }}</span>
94
+ </span>
95
+ <span class="symbol-list__exchange">{{ item.exchange }}</span>
96
+ </button>
97
+ </div>
98
+ </div>
99
+ </Transition>
100
+ </div>
101
+ </template>
102
+
103
+ <script setup lang="ts">
104
+ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
105
+ import IconTablerAlertTriangle from '~icons/tabler/alert-triangle'
106
+
107
+ export interface SymbolItem {
108
+ code: string
109
+ description: string
110
+ exchange: string
111
+ source: string
112
+ }
113
+
114
+ const props = defineProps<{
115
+ symbol: string
116
+ symbols: SymbolItem[]
117
+ loading?: boolean
118
+ error?: boolean
119
+ }>()
120
+
121
+ const emit = defineEmits<{
122
+ (e: 'change', symbol: SymbolItem): void
123
+ }>()
124
+
125
+ const showPopup = ref(false)
126
+ const searchQuery = ref('')
127
+ const searchInputRef = ref<HTMLInputElement | null>(null)
128
+ const chipWrapRef = ref<HTMLElement | null>(null)
129
+
130
+ const filteredSymbols = computed<SymbolItem[]>(() => {
131
+ const q = searchQuery.value.trim().toLowerCase()
132
+ if (!q) return props.symbols
133
+ return props.symbols.filter(
134
+ (s) =>
135
+ s.code.toLowerCase().includes(q) ||
136
+ s.description.toLowerCase().includes(q) ||
137
+ s.exchange.toLowerCase().includes(q),
138
+ )
139
+ })
140
+
141
+ function togglePopup() {
142
+ showPopup.value = !showPopup.value
143
+ if (showPopup.value) {
144
+ nextTick(() => searchInputRef.value?.focus())
145
+ }
146
+ }
147
+
148
+ function clearSearch() {
149
+ searchQuery.value = ''
150
+ searchInputRef.value?.focus()
151
+ }
152
+
153
+ function onSearchInput() {
154
+ }
155
+
156
+ function selectSymbol(item: SymbolItem) {
157
+ emit('change', item)
158
+ showPopup.value = false
159
+ searchQuery.value = ''
160
+ }
161
+
162
+ function onDocumentClick(e: MouseEvent) {
163
+ if (chipWrapRef.value && !chipWrapRef.value.contains(e.target as Node)) {
164
+ showPopup.value = false
165
+ }
166
+ }
167
+
168
+ onMounted(() => document.addEventListener('mousedown', onDocumentClick))
169
+ onBeforeUnmount(() => document.removeEventListener('mousedown', onDocumentClick))
170
+
171
+ watch(() => props.symbol, () => {
172
+ showPopup.value = false
173
+ searchQuery.value = ''
174
+ })
175
+ </script>
176
+
177
+ <style scoped>
178
+ .symbol-chip-wrap {
179
+ position: relative;
180
+ display: inline-flex;
181
+ flex: 0 0 auto;
182
+ }
183
+
184
+ .symbol-chip {
185
+ height: 28px;
186
+ display: inline-flex;
187
+ align-items: center;
188
+ justify-content: center;
189
+ max-width: 160px;
190
+ padding: 0 10px;
191
+ gap: 5px;
192
+ border: 1px solid transparent;
193
+ border-radius: 4px;
194
+ background: transparent;
195
+ color: var(--klc-color-foreground);
196
+ font: inherit;
197
+ cursor: pointer;
198
+ transition:
199
+ background 0.15s ease,
200
+ border-color 0.15s ease,
201
+ color 0.15s ease;
202
+ }
203
+
204
+ .symbol-chip:hover,
205
+ .symbol-chip.is-open {
206
+ border-color: var(--klc-color-border-button);
207
+ background: var(--klc-color-grid-minor);
208
+ }
209
+
210
+ .symbol-chip.is-open .symbol-chip__arrow {
211
+ transform: rotate(180deg);
212
+ }
213
+
214
+ .symbol-chip__code {
215
+ overflow: hidden;
216
+ text-overflow: ellipsis;
217
+ white-space: nowrap;
218
+ font-size: 14px;
219
+ font-weight: 600;
220
+ line-height: 1;
221
+ letter-spacing: 0.01em;
222
+ }
223
+
224
+ .symbol-chip__arrow {
225
+ color: var(--klc-color-axis-text);
226
+ font-size: 12px;
227
+ line-height: 1;
228
+ transition: transform 0.15s ease;
229
+ }
230
+
231
+ .symbol-popover {
232
+ position: absolute;
233
+ top: calc(100% + 8px);
234
+ left: 0;
235
+ z-index: 20;
236
+ width: min(320px, calc(100vw - 24px));
237
+ padding: 14px;
238
+ border: 1px solid var(--klc-color-border-button);
239
+ border-radius: 3px;
240
+ background: var(--klc-color-tag-bg-white);
241
+ color: var(--klc-color-foreground);
242
+
243
+ box-sizing: border-box;
244
+ display: flex;
245
+ flex-direction: column;
246
+ gap: 10px;
247
+ }
248
+
249
+ .symbol-search {
250
+ position: relative;
251
+ display: flex;
252
+ align-items: center;
253
+ gap: 6px;
254
+ padding: 0 10px;
255
+ height: 32px;
256
+ border: 1px solid var(--klc-color-border-button);
257
+ border-radius: 8px;
258
+ background: var(--klc-color-background);
259
+ transition:
260
+ border-color 0.15s ease,
261
+ box-shadow 0.15s ease;
262
+ }
263
+
264
+ .symbol-search:focus-within {
265
+ border-color: var(--klc-color-axis-text);
266
+ }
267
+
268
+ .symbol-search__icon {
269
+ flex: 0 0 auto;
270
+ display: flex;
271
+ align-items: center;
272
+ color: var(--klc-color-axis-text);
273
+ }
274
+
275
+ .symbol-search__input {
276
+ flex: 1 1 0;
277
+ min-width: 0;
278
+ border: none;
279
+ outline: none;
280
+ background: transparent;
281
+ color: var(--klc-color-foreground);
282
+ font: inherit;
283
+ font-size: 13px;
284
+ line-height: 1;
285
+ }
286
+
287
+ .symbol-search__input::placeholder {
288
+ color: var(--klc-color-axis-text);
289
+ opacity: 0.7;
290
+ }
291
+
292
+ .symbol-search__clear {
293
+ flex: 0 0 auto;
294
+ display: inline-flex;
295
+ align-items: center;
296
+ justify-content: center;
297
+ width: 24px;
298
+ height: 24px;
299
+ padding: 0;
300
+ border: 1px solid transparent;
301
+ border-radius: 4px;
302
+ background: transparent;
303
+ color: var(--klc-color-axis-text);
304
+ cursor: pointer;
305
+ transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
306
+ }
307
+
308
+ .symbol-search__clear:hover {
309
+ border-color: var(--klc-color-axis-line);
310
+ background: var(--klc-color-grid-minor);
311
+ color: var(--klc-color-foreground);
312
+ }
313
+
314
+ .symbol-search__clear .delete-icon {
315
+ width: 14px;
316
+ height: 14px;
317
+ }
318
+
319
+ .symbol-list {
320
+ max-height: 280px;
321
+ overflow-y: auto;
322
+ overflow-x: hidden;
323
+ display: flex;
324
+ flex-direction: column;
325
+ margin: 0 -4px;
326
+ }
327
+
328
+ .symbol-list::-webkit-scrollbar {
329
+ width: 6px;
330
+ }
331
+ .symbol-list::-webkit-scrollbar-thumb {
332
+ background: var(--klc-color-border-button);
333
+ border-radius: 999px;
334
+ }
335
+
336
+ .symbol-list__empty {
337
+ display: flex;
338
+ flex-direction: column;
339
+ align-items: center;
340
+ justify-content: center;
341
+ padding: 28px 0;
342
+ color: var(--klc-color-axis-text);
343
+ font-size: 13px;
344
+ text-align: center;
345
+ gap: 2px;
346
+ }
347
+
348
+ .symbol-list__item {
349
+ display: flex;
350
+ align-items: center;
351
+ justify-content: space-between;
352
+ gap: 8px;
353
+ padding: 9px 10px;
354
+ margin: 0 4px;
355
+ border: none;
356
+ border-radius: 7px;
357
+ background: transparent;
358
+ color: var(--klc-color-foreground);
359
+ font: inherit;
360
+ cursor: pointer;
361
+ text-align: left;
362
+ transition: background 0.12s ease;
363
+ flex-shrink: 0;
364
+ }
365
+
366
+ .symbol-list__item:hover {
367
+ background: var(--klc-color-grid-minor);
368
+ }
369
+
370
+ .symbol-list__item.is-active {
371
+ background: color-mix(in srgb, var(--klc-color-alert-active) 10%, transparent);
372
+ }
373
+
374
+ .symbol-list__left {
375
+ display: flex;
376
+ flex-direction: column;
377
+ gap: 3px;
378
+ min-width: 0;
379
+ flex: 1 1 0;
380
+ }
381
+
382
+ .symbol-list__code {
383
+ font-size: 13px;
384
+ font-weight: 700;
385
+ line-height: 1.2;
386
+ letter-spacing: 0.01em;
387
+ color: var(--klc-color-foreground);
388
+ overflow: hidden;
389
+ text-overflow: ellipsis;
390
+ white-space: nowrap;
391
+ }
392
+
393
+ .symbol-list__desc {
394
+ font-size: 11px;
395
+ font-weight: 400;
396
+ line-height: 1.2;
397
+ color: var(--klc-color-axis-text);
398
+ overflow: hidden;
399
+ text-overflow: ellipsis;
400
+ white-space: nowrap;
401
+ }
402
+
403
+ .symbol-list__exchange {
404
+ flex: 0 0 auto;
405
+ padding: 2px 7px;
406
+ border-radius: 4px;
407
+ background: var(--klc-color-grid-major);
408
+ color: var(--klc-color-axis-text);
409
+ font-size: 10px;
410
+ font-weight: 600;
411
+ line-height: 1.4;
412
+ letter-spacing: 0.03em;
413
+ text-transform: uppercase;
414
+ white-space: nowrap;
415
+ }
416
+
417
+ .symbol-list__item.is-active .symbol-list__exchange {
418
+ background: color-mix(in srgb, var(--klc-color-alert-active) 16%, transparent);
419
+ color: var(--klc-color-alert-active);
420
+ }
421
+
422
+ .symbol-popover-enter-active,
423
+ .symbol-popover-leave-active {
424
+ transition:
425
+ opacity 0.15s ease,
426
+ transform 0.15s ease;
427
+ }
428
+
429
+ .symbol-popover-enter-from,
430
+ .symbol-popover-leave-to {
431
+ opacity: 0;
432
+ transform: translateY(-4px);
433
+ }
434
+
435
+ @media (max-width: 768px), (max-height: 640px) {
436
+ .symbol-chip {
437
+ height: 26px;
438
+ max-width: 120px;
439
+ padding: 0 8px;
440
+ }
441
+
442
+ .symbol-chip__code {
443
+ font-size: 13px;
444
+ }
445
+
446
+ .symbol-popover {
447
+ width: min(292px, calc(100vw - 16px));
448
+ padding: 12px;
449
+ gap: 8px;
450
+ }
451
+
452
+ .symbol-list {
453
+ max-height: 220px;
454
+ }
455
+ }
456
+
457
+ .symbol-chip__spinner {
458
+ width: 12px;
459
+ height: 12px;
460
+ border: 2px solid var(--klc-color-axis-text);
461
+ border-top-color: transparent;
462
+ border-radius: 50%;
463
+ animation: symbol-spin 0.6s linear infinite;
464
+ }
465
+
466
+ @keyframes symbol-spin {
467
+ to {
468
+ transform: rotate(360deg);
469
+ }
470
+ }
471
+
472
+ .symbol-chip__warn {
473
+ width: 14px;
474
+ height: 14px;
475
+ color: var(--klc-color-danger, #e53935);
476
+ flex-shrink: 0;
477
+ }
478
+ </style>
@@ -0,0 +1,210 @@
1
+ <template>
2
+ <div class="top-toolbar">
3
+ <SymbolSelector
4
+ v-if="displaySymbol"
5
+ :symbol="displaySymbol"
6
+ :symbols="symbolPool"
7
+ :loading="symbolLoading"
8
+ :error="symbolError"
9
+ @change="onSymbolSelectorChange"
10
+ />
11
+ <button
12
+ type="button"
13
+ class="overlay-symbol-button"
14
+ title="添加比较商品"
15
+ aria-label="添加比较商品"
16
+ @click="emit('addOverlaySymbol')"
17
+ >
18
+ <span class="overlay-symbol-button__icon" aria-hidden="true">+</span>
19
+ <span class="overlay-symbol-button__text">添加比较商品</span>
20
+ </button>
21
+ <KLineLevelDropdown
22
+ :model-value="kLineLevel"
23
+ @update:model-value="emit('kLineLevelChange', $event)"
24
+ />
25
+ <button
26
+ type="button"
27
+ class="indicator-button"
28
+ title="指标"
29
+ aria-label="指标"
30
+ @click="emit('toggleIndicator')"
31
+ >
32
+ <span class="indicator-button__icon" aria-hidden="true">fx</span>
33
+ <span class="indicator-button__text">指标</span>
34
+ </button>
35
+ </div>
36
+ </template>
37
+
38
+ <script setup lang="ts">
39
+ import { computed } from 'vue'
40
+ import KLineLevelDropdown, { type KLineLevel } from './KLineLevelDropdown.vue'
41
+ import SymbolSelector from './SymbolSelector.vue'
42
+ import type { SymbolItem } from './SymbolSelector.vue'
43
+
44
+ export type { SymbolItem }
45
+
46
+ const props = defineProps<{
47
+ symbol?: string
48
+ kLineLevel?: string
49
+ symbols?: SymbolItem[]
50
+ symbolLoading?: boolean
51
+ symbolError?: boolean
52
+ }>()
53
+
54
+ const emit = defineEmits<{
55
+ (e: 'addOverlaySymbol'): void
56
+ (e: 'kLineLevelChange', level: KLineLevel): void
57
+ (e: 'toggleIndicator'): void
58
+ (e: 'symbolChange', symbol: SymbolItem): void
59
+ }>()
60
+
61
+ const MOCK_SYMBOLS: SymbolItem[] = [
62
+ { code: 'AAPL', description: 'Apple Inc.', exchange: 'NASDAQ', source: 'baostock' },
63
+ { code: 'TSLA', description: 'Tesla, Inc.', exchange: 'NASDAQ', source: 'baostock' },
64
+ { code: 'GOOGL', description: 'Alphabet Inc.', exchange: 'NASDAQ', source: 'baostock' },
65
+ { code: 'MSFT', description: 'Microsoft Corporation', exchange: 'NASDAQ', source: 'baostock' },
66
+ { code: 'AMZN', description: 'Amazon.com, Inc.', exchange: 'NASDAQ', source: 'baostock' },
67
+ { code: 'NVDA', description: 'NVIDIA Corporation', exchange: 'NASDAQ', source: 'baostock' },
68
+ { code: 'META', description: 'Meta Platforms, Inc.', exchange: 'NASDAQ', source: 'baostock' },
69
+ { code: 'BRK.B', description: 'Berkshire Hathaway Inc.', exchange: 'NYSE', source: 'baostock' },
70
+ { code: 'JPM', description: 'JPMorgan Chase & Co.', exchange: 'NYSE', source: 'baostock' },
71
+ { code: 'V', description: 'Visa Inc.', exchange: 'NYSE', source: 'baostock' },
72
+ { code: 'BTCUSDT', description: 'Bitcoin / Tether', exchange: 'BINANCE', source: 'baostock' },
73
+ { code: 'ETHUSDT', description: 'Ethereum / Tether', exchange: 'BINANCE', source: 'baostock' },
74
+ { code: 'sh.601360', description: '三六零', exchange: 'SSE', source: 'baostock' },
75
+ { code: 'sh.600519', description: '贵州茅台', exchange: 'SSE', source: 'baostock' },
76
+ { code: '000858', description: '五 粮 液', exchange: 'SZSE', source: 'baostock' },
77
+ { code: '000001', description: '平安银行', exchange: 'SZSE', source: 'baostock' },
78
+ { code: 'MOCK-100', description: 'Mock 100 条', exchange: 'MOCK', source: 'mock-100' },
79
+ { code: 'MOCK-10000', description: 'Mock 10000 条', exchange: 'MOCK', source: 'mock-10000' },
80
+ ]
81
+
82
+ const displaySymbol = computed(() => props.symbol?.trim() ?? '')
83
+
84
+ const symbolPool = computed<SymbolItem[]>(() =>
85
+ props.symbols && props.symbols.length ? props.symbols : MOCK_SYMBOLS,
86
+ )
87
+
88
+ function onSymbolSelectorChange(item: SymbolItem) {
89
+ emit('symbolChange', item)
90
+ }
91
+ </script>
92
+
93
+ <style scoped>
94
+ .top-toolbar {
95
+ position: relative;
96
+ width: 95%;
97
+ height: 40px;
98
+ display: flex;
99
+ flex-direction: row;
100
+ align-items: center;
101
+ gap: 6px;
102
+ padding: 0 8px;
103
+ border: 1px solid var(--klc-color-border-chart);
104
+ border-radius: 3px;
105
+ background: var(--klc-color-background);
106
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
107
+ box-sizing: border-box;
108
+ user-select: none;
109
+ }
110
+
111
+ .overlay-symbol-button {
112
+ height: 28px;
113
+ display: inline-flex;
114
+ align-items: center;
115
+ justify-content: center;
116
+ flex: 0 0 auto;
117
+ gap: 6px;
118
+ padding: 0 10px;
119
+ border: 1px solid var(--klc-color-border-button);
120
+ border-radius: 4px;
121
+ background: var(--klc-color-background);
122
+ color: var(--klc-color-foreground);
123
+ font: inherit;
124
+ cursor: pointer;
125
+ transition:
126
+ background 0.15s ease,
127
+ border-color 0.15s ease,
128
+ color 0.15s ease;
129
+ }
130
+
131
+ .overlay-symbol-button:hover {
132
+ border-color: var(--klc-color-axis-text);
133
+ background: var(--klc-color-grid-minor);
134
+ }
135
+
136
+ .overlay-symbol-button__icon {
137
+ display: inline-flex;
138
+ align-items: center;
139
+ justify-content: center;
140
+ width: 16px;
141
+ height: 16px;
142
+ border-radius: 50%;
143
+ background: var(--klc-color-foreground);
144
+ color: var(--klc-color-background);
145
+ font-size: 13px;
146
+ font-weight: 700;
147
+ line-height: 1;
148
+ }
149
+
150
+ .overlay-symbol-button__text {
151
+ font-size: 13px;
152
+ font-weight: 500;
153
+ line-height: 1;
154
+ white-space: nowrap;
155
+ }
156
+
157
+ .indicator-button {
158
+ height: 28px;
159
+ display: inline-flex;
160
+ align-items: center;
161
+ justify-content: center;
162
+ flex: 0 0 auto;
163
+ gap: 6px;
164
+ padding: 0 10px;
165
+ border: 1px solid var(--klc-color-border-button);
166
+ border-radius: 4px;
167
+ background: var(--klc-color-background);
168
+ color: var(--klc-color-foreground);
169
+ font: inherit;
170
+ cursor: pointer;
171
+ transition:
172
+ background 0.15s ease,
173
+ border-color 0.15s ease,
174
+ color 0.15s ease;
175
+ }
176
+
177
+ .indicator-button:hover {
178
+ border-color: var(--klc-color-axis-text);
179
+ background: var(--klc-color-grid-minor);
180
+ }
181
+
182
+ .indicator-button__icon {
183
+ display: inline-flex;
184
+ align-items: center;
185
+ justify-content: center;
186
+ width: 16px;
187
+ height: 16px;
188
+ border-radius: 3px;
189
+ background: var(--klc-color-foreground);
190
+ color: var(--klc-color-background);
191
+ font-size: 10px;
192
+ font-weight: 800;
193
+ line-height: 1;
194
+ letter-spacing: -0.5px;
195
+ }
196
+
197
+ .indicator-button__text {
198
+ font-size: 13px;
199
+ font-weight: 500;
200
+ line-height: 1;
201
+ white-space: nowrap;
202
+ }
203
+
204
+ @media (max-width: 768px), (max-height: 640px) {
205
+ .overlay-symbol-button__text,
206
+ .indicator-button__text {
207
+ display: none;
208
+ }
209
+ }
210
+ </style>