| <template> |
| <div> |
| |
| <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;"> |
| <div class="cat-tabs"> |
| <button v-for="cat in store.categoryList" :key="cat.key" :class="['cat-tab', { active: selectedCategory === cat.key }]" @click="selectedCategory = cat.key; filterQuotes()">{{ cat.label }}</button> |
| </div> |
| <div style="display: flex; align-items: center; gap: 12px;"> |
| <el-switch v-model="isRealtime" active-text="实时" inactive-text="模拟" @change="toggleMode" /> |
| <div :class="['tag-pill', store.marketMode === 'realtime' ? 'bullish' : 'neutral']">{{ store.marketMode === 'realtime' ? '实时数据' : '模拟数据' }}</div> |
| </div> |
| </div> |
| <div style="display: grid; grid-template-columns: 260px 1fr; gap: 16px;"> |
| |
| <div class="mk-card" style="padding: 0; overflow: hidden;"> |
| <div style="padding: 14px 16px; border-bottom: 1px solid var(--border-light);"><span style="font-weight: 700; font-size: 14px; color: var(--text-dark);">合约列表 ({{ displayedQuotes.length }})</span></div> |
| <div style="padding: 8px 12px;"> |
| <el-input v-model="searchText" placeholder="搜索..." size="small" clearable><template #prefix><el-icon><Search /></el-icon></template></el-input> |
| </div> |
| <div style="max-height: 540px; overflow-y: auto; padding: 0 8px 8px;"> |
| <div v-for="q in displayedQuotes" :key="q.symbol" @click="selectSymbol(q.symbol)" |
| :style="{ padding: '10px 12px', cursor: 'pointer', borderRadius: '8px', marginBottom: '3px', background: selectedSymbol === q.symbol ? 'var(--green-light)' : 'transparent', border: selectedSymbol === q.symbol ? '1px solid rgba(76,175,80,0.3)' : '1px solid transparent', transition: 'all 0.2s' }"> |
| <div style="display: flex; justify-content: space-between; align-items: center;"> |
| <div> |
| <span style="font-weight: 600; font-size: 13px;">{{ q.name || q.symbol }}</span> |
| <span style="font-size: 10px; color: var(--text-muted); margin-left: 4px;">{{ q.exchange }}</span> |
| </div> |
| <span :class="q.change_pct >= 0 ? 'price-up' : 'price-down'" style="font-weight: 700; font-size: 13px;">{{ q.close?.toFixed(2) }}</span> |
| </div> |
| <div style="display: flex; justify-content: space-between; margin-top: 3px;"> |
| <span style="font-size: 10px; color: var(--text-muted);">Vol {{ q.volume?.toLocaleString() }}</span> |
| <span :class="q.change_pct >= 0 ? 'price-up' : 'price-down'" style="font-size: 11px; font-weight: 600;">{{ q.change_pct ? (q.change_pct * 100).toFixed(2) + '%' : '-' }}</span> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| <div style="display: flex; flex-direction: column; gap: 16px;"> |
| <div class="mk-card" style="padding: 0; overflow: hidden;"> |
| <div style="padding: 14px 20px; border-bottom: 1px solid var(--border-light); display: flex; justify-content: space-between; align-items: center;"> |
| <span style="font-weight: 600;">{{ currentQuoteName }}</span> |
| <div class="interval-group"> |
| <button v-for="tf in timeframes" :key="tf.v" :class="['interval-btn', { active: selectedInterval === tf.v }]" @click="selectedInterval = tf.v; loadKlines()">{{ tf.l }}</button> |
| </div> |
| </div> |
| <div style="padding: 8px 12px;"> |
| <v-chart :option="chartOption" autoresize style="height: 420px;" /> |
| </div> |
| </div> |
| <div class="mk-card" style="padding: 0; overflow: hidden;" v-if="currentQuote"> |
| <div style="padding: 12px 20px; border-bottom: 1px solid var(--border-light);"><span style="font-weight: 700; font-size: 14px; color: var(--text-dark);">行情详情</span></div> |
| <div style="padding: 16px 20px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px;"> |
| <div v-for="item in detailItems" :key="item.label"> |
| <div style="font-size: 11px; color: var(--text-muted); margin-bottom: 4px;">{{ item.label }}</div> |
| <div :style="{ fontSize: '14px', fontWeight: 600, color: item.color || 'var(--text-primary)' }">{{ item.value }}</div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </template> |
| |
| <script setup> |
| import { ref, computed, onMounted, watch } from 'vue' |
| import VChart from 'vue-echarts' |
| import { use } from 'echarts/core' |
| import { CandlestickChart, BarChart } from 'echarts/charts' |
| import { GridComponent, TooltipComponent, DataZoomComponent } from 'echarts/components' |
| import { CanvasRenderer } from 'echarts/renderers' |
| import { useTradingStore } from '../stores/trading' |
| import { marketApi } from '../api' |
| |
| use([CandlestickChart, BarChart, GridComponent, TooltipComponent, DataZoomComponent, CanvasRenderer]) |
| |
| const store = useTradingStore() |
| const selectedSymbol = ref('') |
| const selectedCategory = ref('') |
| const selectedInterval = ref('15m') |
| const searchText = ref('') |
| const klines = ref([]) |
| const isRealtime = ref(false) |
| const timeframes = [ |
| { l: '1分', v: '1m' }, { l: '5分', v: '5m' }, { l: '15分', v: '15m' }, { l: '30分', v: '30m' }, |
| { l: '1时', v: '1h' }, { l: '4时', v: '4h' }, { l: '日线', v: '1d' }, { l: '周线', v: '1w' }, |
| ] |
| |
| const displayedQuotes = computed(() => { |
| let qs = [...store.quotes] |
| if (selectedCategory.value) qs = qs.filter(q => q.category === selectedCategory.value) |
| if (searchText.value) { const s = searchText.value.toLowerCase(); qs = qs.filter(q => (q.name || '').toLowerCase().includes(s) || q.symbol.toLowerCase().includes(s)) } |
| return qs.sort((a, b) => (a.name || a.symbol).localeCompare(b.name || b.symbol, 'zh')) |
| }) |
| |
| const currentQuote = computed(() => store.quotes.find(q => q.symbol === selectedSymbol.value)) |
| const currentQuoteName = computed(() => { const q = currentQuote.value; return q ? `${q.name} (${q.symbol})` : selectedSymbol.value }) |
| const detailItems = computed(() => { |
| const q = currentQuote.value; if (!q) return [] |
| const pctColor = q.change_pct >= 0 ? 'var(--green)' : 'var(--red)' |
| return [ |
| { label: '最新价', value: q.close?.toFixed(2), color: pctColor }, |
| { label: '涨跌幅', value: q.change_pct ? (q.change_pct * 100).toFixed(2) + '%' : '-', color: pctColor }, |
| { label: '开盘价', value: q.open?.toFixed(2) }, { label: '昨收', value: q.pre_close?.toFixed(2) || '-' }, |
| { label: '最高价', value: q.high?.toFixed(2) }, { label: '最低价', value: q.low?.toFixed(2) }, |
| { label: '买价', value: q.bid?.toFixed(2) }, { label: '卖价', value: q.ask?.toFixed(2) }, |
| { label: '成交量', value: q.volume?.toLocaleString() }, { label: '持仓量', value: q.open_interest?.toLocaleString() || '-' }, |
| { label: '交易所', value: q.exchange }, { label: '分类', value: q.category }, |
| ] |
| }) |
| |
| async function loadKlines() { |
| if (!selectedSymbol.value) return |
| try { const { data } = await marketApi.getKlines(selectedSymbol.value, 200, selectedInterval.value); klines.value = data } catch (e) { console.error(e) } |
| } |
| function selectSymbol(s) { selectedSymbol.value = s; loadKlines() } |
| function filterQuotes() { store.fetchQuotes(selectedCategory.value ? { category: selectedCategory.value } : {}) } |
| async function toggleMode(v) { await store.setMarketMode(v ? 'realtime' : 'simulated') } |
| |
| const chartOption = computed(() => { |
| const dates = klines.value.map(k => { |
| const t = k.timestamp || '' |
| if (['1d','1w'].includes(selectedInterval.value)) return t.substring(5, 10) |
| if (['4h','1h'].includes(selectedInterval.value)) return t.substring(5, 16) |
| return t.substring(11, 16) |
| }) |
| const ohlc = klines.value.map(k => [k.open, k.close, k.low, k.high]) |
| const volumes = klines.value.map(k => k.volume) |
| return { |
| backgroundColor: 'transparent', |
| tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } }, |
| grid: [{ left: '8%', right: '3%', top: '4%', height: '58%' }, { left: '8%', right: '3%', top: '68%', height: '18%' }], |
| xAxis: [ |
| { type: 'category', data: dates, gridIndex: 0, axisLine: { lineStyle: { color: '#e9ecef' } }, axisLabel: { color: '#8392ab', fontSize: 10 } }, |
| { type: 'category', data: dates, gridIndex: 1, axisLine: { lineStyle: { color: '#e9ecef' } }, axisLabel: { color: '#8392ab', fontSize: 10 } }, |
| ], |
| yAxis: [ |
| { scale: true, gridIndex: 0, splitLine: { lineStyle: { color: '#f0f2f5' } }, axisLabel: { color: '#8392ab', fontSize: 10 } }, |
| { scale: true, gridIndex: 1, splitLine: { lineStyle: { color: '#f0f2f5' } }, axisLabel: { color: '#8392ab', fontSize: 10 } }, |
| ], |
| dataZoom: [ |
| { type: 'inside', xAxisIndex: [0, 1], start: 30, end: 100 }, |
| { type: 'slider', xAxisIndex: [0, 1], start: 30, end: 100, height: 18, bottom: 2, borderColor: '#e9ecef', fillerColor: 'rgba(76,175,80,0.08)', handleStyle: { color: '#4caf50' }, textStyle: { color: '#8392ab' } }, |
| ], |
| series: [ |
| { type: 'candlestick', data: ohlc, xAxisIndex: 0, yAxisIndex: 0, itemStyle: { color: '#4caf50', color0: '#f44336', borderColor: '#4caf50', borderColor0: '#f44336' } }, |
| { type: 'bar', data: volumes, xAxisIndex: 1, yAxisIndex: 1, itemStyle: { color: 'rgba(76,175,80,0.25)' } }, |
| ], |
| } |
| }) |
| |
| watch(selectedSymbol, loadKlines) |
| onMounted(async () => { |
| await store.fetchCategories(); await store.fetchQuotes(); await store.fetchMarketMode() |
| isRealtime.value = store.marketMode === 'realtime' |
| if (store.quotes.length > 0 && !selectedSymbol.value) { selectedSymbol.value = store.quotes[0].symbol; loadKlines() } |
| }) |
| </script> |
| |