| <template> |
| <div> |
| <el-row :gutter="16"> |
| <el-col :span="16"> |
| <el-card shadow="never" style="border: 1px solid var(--border-color); margin-bottom: 16px;"> |
| <template #header><span style="font-weight: 600;">{{ currentQuoteName }} K线图</span></template> |
| <v-chart :option="chartOption" autoresize style="height: 360px;" /> |
| </el-card> |
| </el-col> |
| <el-col :span="8"> |
| <el-card shadow="never" style="border: 1px solid var(--border-color); margin-bottom: 16px;"> |
| <template #header><span style="font-weight: 600;">下单面板</span></template> |
| <el-form label-position="top" size="default"> |
| <el-form-item label="分类"> |
| <el-select v-model="selectedCategory" @change="onCategoryChange" style="width: 100%;" placeholder="全部分类"> |
| <el-option label="全部" value="" /> |
| <el-option v-for="cat in store.categoryList.slice(1)" :key="cat.key" :label="cat.label" :value="cat.key" /> |
| </el-select> |
| </el-form-item> |
| <el-form-item label="合约"> |
| <el-select v-model="selectedSymbol" @change="loadKlines" filterable style="width: 100%;" placeholder="选择合约"> |
| <el-option v-for="c in filteredContracts" :key="c.symbol" :label="`${c.name} (${c.exchange})`" :value="c.symbol" /> |
| </el-select> |
| </el-form-item> |
| <el-form-item label="方向"> |
| <el-radio-group v-model="orderForm.side" style="width: 100%;"> |
| <el-radio-button value="BUY" style="width: 50%;"><span style="color: var(--accent-green); font-weight: 600;">买入/做多</span></el-radio-button> |
| <el-radio-button value="SELL" style="width: 50%;"><span style="color: var(--accent-red); font-weight: 600;">卖出/做空</span></el-radio-button> |
| </el-radio-group> |
| </el-form-item> |
| <el-form-item label="订单类型"> |
| <el-select v-model="orderForm.order_type" style="width: 100%;"> |
| <el-option label="市价单" value="MARKET" /> |
| <el-option label="限价单" value="LIMIT" /> |
| <el-option label="止损单" value="STOP" /> |
| </el-select> |
| </el-form-item> |
| <el-form-item label="价格" v-if="orderForm.order_type !== 'MARKET'"> |
| <el-input-number v-model="orderForm.price" :min="0" :precision="2" style="width: 100%;" /> |
| </el-form-item> |
| <el-form-item label="数量 (手)"> |
| <el-input-number v-model="orderForm.quantity" :min="1" :max="100" style="width: 100%;" /> |
| </el-form-item> |
| <el-form-item> |
| <el-button |
| :type="orderForm.side === 'BUY' ? 'success' : 'danger'" |
| style="width: 100%; height: 44px; font-size: 16px; font-weight: 600;" |
| @click="submitOrder" :loading="submitting"> |
| {{ orderForm.side === 'BUY' ? '买入开多' : '卖出开空' }} |
| </el-button> |
| </el-form-item> |
| </el-form> |
| <div v-if="currentQuote" style="margin-top: 8px; padding: 12px; background: var(--bg-secondary); border-radius: 8px;"> |
| <div style="display: flex; justify-content: space-between; margin-bottom: 6px;"> |
| <span style="color: var(--text-secondary); font-size: 12px;">最新价</span> |
| <span style="font-weight: 600;">{{ currentQuote.close?.toFixed(2) }}</span> |
| </div> |
| <div style="display: flex; justify-content: space-between; margin-bottom: 6px;"> |
| <span style="color: var(--text-secondary); font-size: 12px;">涨跌幅</span> |
| <span :class="currentQuote.change_pct >= 0 ? 'price-up' : 'price-down'"> |
| {{ currentQuote.change_pct ? (currentQuote.change_pct * 100).toFixed(2) + '%' : '-' }} |
| </span> |
| </div> |
| <div style="display: flex; justify-content: space-between; margin-bottom: 6px;"> |
| <span style="color: var(--text-secondary); font-size: 12px;">买一</span> |
| <span class="price-up">{{ currentQuote.bid?.toFixed(2) }}</span> |
| </div> |
| <div style="display: flex; justify-content: space-between;"> |
| <span style="color: var(--text-secondary); font-size: 12px;">卖一</span> |
| <span class="price-down">{{ currentQuote.ask?.toFixed(2) }}</span> |
| </div> |
| </div> |
| </el-card> |
| </el-col> |
| </el-row> |
| |
| <el-card shadow="never" style="border: 1px solid var(--border-color);"> |
| <template #header> |
| <div style="display: flex; justify-content: space-between; align-items: center;"> |
| <span style="font-weight: 600;">委托记录</span> |
| <el-button size="small" text @click="store.fetchOrders()"><el-icon><Refresh /></el-icon> 刷新</el-button> |
| </div> |
| </template> |
| <el-table :data="store.orders" stripe style="width: 100%" size="small" :max-height="260"> |
| <el-table-column prop="order_id" label="订单号" width="140" /> |
| <el-table-column prop="symbol" label="合约" width="100" /> |
| <el-table-column label="方向" width="70"> |
| <template #default="{ row }"> |
| <el-tag :type="row.side === 'BUY' ? 'success' : 'danger'" size="small" effect="plain">{{ row.side === 'BUY' ? '买' : '卖' }}</el-tag> |
| </template> |
| </el-table-column> |
| <el-table-column prop="order_type" label="类型" width="80" /> |
| <el-table-column prop="quantity" label="数量" width="60" /> |
| <el-table-column label="成交价" width="100"> |
| <template #default="{ row }">{{ row.avg_price?.toFixed(2) || '-' }}</template> |
| </el-table-column> |
| <el-table-column label="状态" width="90"> |
| <template #default="{ row }"> |
| <el-tag :type="statusType(row.status)" size="small" effect="dark">{{ statusText(row.status) }}</el-tag> |
| </template> |
| </el-table-column> |
| <el-table-column label="操作" width="80"> |
| <template #default="{ row }"> |
| <el-button v-if="row.status === 'PENDING'" size="small" type="danger" text @click="cancelOrder(row.order_id)">撤单</el-button> |
| </template> |
| </el-table-column> |
| </el-table> |
| </el-card> |
| </div> |
| </template> |
| |
| <script setup> |
| import { ref, computed, onMounted, watch } from 'vue' |
| import { ElMessage } from 'element-plus' |
| 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, orderApi } from '../api' |
| |
| use([CandlestickChart, BarChart, GridComponent, TooltipComponent, DataZoomComponent, CanvasRenderer]) |
| |
| const store = useTradingStore() |
| const selectedSymbol = ref('') |
| const selectedCategory = ref('') |
| const klines = ref([]) |
| const submitting = ref(false) |
| const orderForm = ref({ side: 'BUY', order_type: 'MARKET', quantity: 1, price: null }) |
| |
| const filteredContracts = computed(() => { |
| if (!selectedCategory.value) return store.symbolList |
| return store.symbolList.filter(c => c.category === selectedCategory.value) |
| }) |
| |
| 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 |
| }) |
| |
| function onCategoryChange() { |
| const list = filteredContracts.value |
| if (list.length > 0 && !list.find(c => c.symbol === selectedSymbol.value)) { |
| selectedSymbol.value = list[0].symbol |
| loadKlines() |
| } |
| } |
| |
| async function loadKlines() { |
| if (!selectedSymbol.value) return |
| try { |
| const { data } = await marketApi.getKlines(selectedSymbol.value, 200) |
| klines.value = data |
| } catch (e) { console.error(e) } |
| } |
| |
| async function submitOrder() { |
| submitting.value = true |
| try { |
| const payload = { symbol: selectedSymbol.value, side: orderForm.value.side, order_type: orderForm.value.order_type, quantity: orderForm.value.quantity } |
| if (orderForm.value.order_type !== 'MARKET' && orderForm.value.price) payload.price = orderForm.value.price |
| await orderApi.placeOrder(payload) |
| ElMessage.success('下单成功') |
| store.fetchOrders(); store.fetchPositions(); store.fetchAccount() |
| } catch (e) { ElMessage.error(e.response?.data?.detail || '下单失败') } |
| submitting.value = false |
| } |
| |
| async function cancelOrder(orderId) { |
| try { await orderApi.cancelOrder(orderId); ElMessage.success('撤单成功'); store.fetchOrders() } |
| catch (e) { ElMessage.error('撤单失败') } |
| } |
| |
| function statusType(s) { return { FILLED: 'success', PENDING: 'warning', CANCELLED: 'info', REJECTED: 'danger' }[s] || '' } |
| function statusText(s) { return { FILLED: '已成交', PENDING: '待成交', CANCELLED: '已撤单', REJECTED: '已拒绝', PARTIAL: '部分成交' }[s] || s } |
| |
| const chartOption = computed(() => { |
| const dates = klines.value.map(k => k.timestamp?.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: '4%', top: '5%', height: '60%' }, { left: '8%', right: '4%', top: '72%', height: '16%' }], |
| xAxis: [ |
| { type: 'category', data: dates, gridIndex: 0, axisLine: { lineStyle: { color: '#30363d' } }, axisLabel: { color: '#8b949e' } }, |
| { type: 'category', data: dates, gridIndex: 1, axisLine: { lineStyle: { color: '#30363d' } }, axisLabel: { color: '#8b949e' } }, |
| ], |
| yAxis: [ |
| { scale: true, gridIndex: 0, splitLine: { lineStyle: { color: '#30363d' } }, axisLabel: { color: '#8b949e' } }, |
| { scale: true, gridIndex: 1, splitLine: { lineStyle: { color: '#30363d' } }, axisLabel: { color: '#8b949e' } }, |
| ], |
| dataZoom: [{ type: 'inside', xAxisIndex: [0, 1], start: 60, end: 100 }], |
| series: [ |
| { type: 'candlestick', data: ohlc, xAxisIndex: 0, yAxisIndex: 0, itemStyle: { color: '#3fb950', color0: '#f85149', borderColor: '#3fb950', borderColor0: '#f85149' } }, |
| { type: 'bar', data: volumes, xAxisIndex: 1, yAxisIndex: 1, itemStyle: { color: 'rgba(88,166,255,0.4)' } }, |
| ], |
| } |
| }) |
| |
| watch(selectedSymbol, loadKlines) |
| |
| onMounted(async () => { |
| await store.fetchCategories() |
| await store.fetchContracts() |
| await store.fetchQuotes() |
| store.fetchOrders() |
| if (store.quotes.length > 0 && !selectedSymbol.value) { |
| selectedSymbol.value = store.quotes[0].symbol |
| loadKlines() |
| } |
| }) |
| </script> |
| |