This commit is contained in:
sion
2026-04-06 16:34:02 +08:00
parent 71c8689989
commit 2e34072f45
20 changed files with 2278 additions and 0 deletions

View File

@@ -0,0 +1,324 @@
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { toast } from 'vue-sonner'
import {
getKlineConfigs,
saveKlineConfig,
getKlinePreview,
type KlineConfig,
} from '../../services/api/monisuo-kline.api.ts'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CandlestickChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
use([CandlestickChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, CanvasRenderer])
// ==================== State ====================
const configs = ref<KlineConfig[]>([])
const isLoading = ref(false)
const editingConfig = ref<KlineConfig | null>(null)
const showEditDialog = ref(false)
const showPreviewDialog = ref(false)
const previewCoinCode = ref('')
const previewInterval = ref('1h')
const previewData = ref<any[]>([])
const previewVolumes = ref<number[]>([])
const previewDates = ref<string[]>([])
// ==================== Load ====================
async function loadConfigs() {
isLoading.value = true
try {
configs.value = await getKlineConfigs()
}
catch (e: any) {
toast.error(`加载失败: ${e.message}`)
}
finally {
isLoading.value = false
}
}
onMounted(loadConfigs)
// ==================== Edit ====================
function openEdit(config: KlineConfig) {
editingConfig.value = { ...config }
showEditDialog.value = true
}
async function saveEdit() {
if (!editingConfig.value) return
try {
await saveKlineConfig({
coinCode: editingConfig.value.coinCode,
tradeStartTime: editingConfig.value.tradeStartTime,
tradeEndTime: editingConfig.value.tradeEndTime,
priceMin: editingConfig.value.priceMin,
priceMax: editingConfig.value.priceMax,
simulationEnabled: editingConfig.value.simulationEnabled,
})
toast.success('保存成功')
showEditDialog.value = false
loadConfigs()
}
catch (e: any) {
toast.error(`保存失败: ${e.message}`)
}
}
function toggleSimulation(config: KlineConfig) {
const newConfig = { ...config }
newConfig.simulationEnabled = config.simulationEnabled === 1 ? 0 : 1
saveKlineConfig({
coinCode: newConfig.coinCode,
simulationEnabled: newConfig.simulationEnabled,
}).then(() => {
toast.success(newConfig.simulationEnabled ? '已启用模拟' : '已关闭模拟')
loadConfigs()
}).catch((e: any) => {
toast.error(`操作失败: ${e.message}`)
})
}
// ==================== Preview ====================
async function openPreview(config: KlineConfig) {
previewCoinCode.value = config.coinCode
previewInterval.value = '1h'
showPreviewDialog.value = true
await loadPreviewData()
}
async function loadPreviewData() {
try {
const data = await getKlinePreview(previewCoinCode.value, previewInterval.value, 100)
previewData.value = data.map((d) => [d.openPrice, d.closePrice, d.lowPrice, d.highPrice])
previewVolumes.value = data.map(d => d.volume)
previewDates.value = data.map((d) => {
const date = new Date(d.openTime)
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
})
}
catch {
previewData.value = []
}
}
watch(previewInterval, loadPreviewData)
const chartOption = computed(() => ({
animation: false,
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
grid: [
{ left: '10%', right: '5%', top: '5%', height: '55%' },
{ left: '10%', right: '5%', top: '68%', height: '20%' },
],
xAxis: [
{ type: 'category', data: previewDates.value, gridIndex: 0, axisLabel: { show: false } },
{ type: 'category', data: previewDates.value, gridIndex: 1 },
],
yAxis: [
{ type: 'value', gridIndex: 0, scale: true },
{ type: 'value', gridIndex: 1, scale: true },
],
dataZoom: [
{ type: 'inside', xAxisIndex: [0, 1], start: 50, end: 100 },
],
series: [
{
name: 'K线',
type: 'candlestick',
data: previewData.value,
xAxisIndex: 0,
yAxisIndex: 0,
itemStyle: {
color: '#ef5350',
color0: '#26a69a',
borderColor: '#ef5350',
borderColor0: '#26a69a',
},
},
{
name: '成交量',
type: 'bar',
data: previewVolumes.value,
xAxisIndex: 1,
yAxisIndex: 1,
itemStyle: { color: '#7986cb' },
},
],
}))
</script>
<template>
<div class="p-6 space-y-6">
<div>
<h1 class="text-2xl font-bold">K线配置</h1>
<p class="text-muted-foreground mt-1">管理币种K线模拟参数配置交易时段和价格区间</p>
</div>
<!-- 币种配置表格 -->
<div class="border rounded-lg">
<table class="w-full">
<thead class="bg-muted/50">
<tr>
<th class="px-4 py-3 text-left text-sm font-medium text-muted-foreground">币种</th>
<th class="px-4 py-3 text-left text-sm font-medium text-muted-foreground">当前价格</th>
<th class="px-4 py-3 text-center text-sm font-medium text-muted-foreground">模拟状态</th>
<th class="px-4 py-3 text-center text-sm font-medium text-muted-foreground">交易时段</th>
<th class="px-4 py-3 text-center text-sm font-medium text-muted-foreground">价格区间</th>
<th class="px-4 py-3 text-center text-sm font-medium text-muted-foreground">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="config in configs" :key="config.coinCode" class="border-t hover:bg-muted/30">
<td class="px-4 py-3">
<div class="font-medium">{{ config.coinCode }}</div>
<div class="text-xs text-muted-foreground">{{ config.coinName }}</div>
</td>
<td class="px-4 py-3 font-mono">{{ config.currentPrice?.toFixed(4) ?? '-' }}</td>
<td class="px-4 py-3 text-center">
<button
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="config.simulationEnabled ? 'bg-primary' : 'bg-muted'"
@click="toggleSimulation(config)"
>
<span
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform"
:class="config.simulationEnabled ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</td>
<td class="px-4 py-3 text-center text-sm">
{{ config.tradeStartTime ?? '09:00' }} - {{ config.tradeEndTime ?? '23:00' }}
</td>
<td class="px-4 py-3 text-center text-sm font-mono">
<template v-if="config.priceMin != null && config.priceMax != null">
{{ config.priceMin }} ~ {{ config.priceMax }}
</template>
<template v-else>-</template>
</td>
<td class="px-4 py-3 text-center">
<div class="flex items-center justify-center gap-2">
<button
class="text-sm text-primary hover:underline"
@click="openEdit(config)"
>
配置
</button>
<button
class="text-sm text-primary hover:underline"
@click="openPreview(config)"
>
预览
</button>
</div>
</td>
</tr>
<tr v-if="configs.length === 0">
<td colspan="6" class="px-4 py-8 text-center text-muted-foreground">
暂无币种数据
</td>
</tr>
</tbody>
</table>
</div>
<!-- 编辑弹窗 -->
<UiDialog v-model:open="showEditDialog">
<UiDialogContent class="max-w-md">
<UiDialogHeader>
<UiDialogTitle>K线配置 {{ editingConfig?.coinCode }}</UiDialogTitle>
</UiDialogHeader>
<div v-if="editingConfig" class="space-y-4 py-4">
<div class="flex items-center justify-between">
<label class="text-sm font-medium">启用模拟</label>
<button
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="editingConfig.simulationEnabled ? 'bg-primary' : 'bg-muted'"
@click="editingConfig.simulationEnabled = editingConfig.simulationEnabled === 1 ? 0 : 1"
>
<span
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform"
:class="editingConfig.simulationEnabled ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-sm font-medium">交易开始时间</label>
<input
v-model="editingConfig.tradeStartTime"
type="time"
class="mt-1 w-full rounded-md border px-3 py-2 text-sm"
/>
</div>
<div>
<label class="text-sm font-medium">交易结束时间</label>
<input
v-model="editingConfig.tradeEndTime"
type="time"
class="mt-1 w-full rounded-md border px-3 py-2 text-sm"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-sm font-medium">最低价</label>
<input
v-model.number="editingConfig.priceMin"
type="number"
step="0.0001"
min="0"
class="mt-1 w-full rounded-md border px-3 py-2 text-sm"
placeholder="最低价"
/>
</div>
<div>
<label class="text-sm font-medium">最高价</label>
<input
v-model.number="editingConfig.priceMax"
type="number"
step="0.0001"
min="0"
class="mt-1 w-full rounded-md border px-3 py-2 text-sm"
placeholder="最高价"
/>
</div>
</div>
</div>
<UiDialogFooter>
<UiButton variant="outline" @click="showEditDialog = false">取消</UiButton>
<UiButton @click="saveEdit">保存</UiButton>
</UiDialogFooter>
</UiDialogContent>
</UiDialog>
<!-- K线预览弹窗 -->
<UiDialog v-model:open="showPreviewDialog">
<UiDialogContent class="max-w-3xl max-h-[80vh]">
<UiDialogHeader>
<UiDialogTitle>K线预览 {{ previewCoinCode }}</UiDialogTitle>
</UiDialogHeader>
<div class="space-y-3">
<select
v-model="previewInterval"
class="rounded-md border px-3 py-1.5 text-sm"
>
<option value="15m">15分钟</option>
<option value="1h">1小时</option>
<option value="4h">4小时</option>
<option value="1d">日线</option>
<option value="1M">月线</option>
</select>
<VChart v-if="previewData.length" :option="chartOption" autoresize style="height: 400px" />
<div v-else class="py-8 text-center text-muted-foreground">暂无数据</div>
</div>
</UiDialogContent>
</UiDialog>
</div>
</template>

View File

@@ -0,0 +1,71 @@
import { useAxios } from '../../composables/use-axios'
// ==================== Types ====================
export interface KlineConfig {
coinId: number
coinCode: string
coinName: string
simulationEnabled: number // 0 or 1
tradeStartTime: string // HH:mm
tradeEndTime: string // HH:mm
priceMin: number
priceMax: number
currentPrice: number
priceType: number
}
export interface KlineConfigUpdate {
coinCode: string
tradeStartTime?: string
tradeEndTime?: string
priceMin?: number
priceMax?: number
simulationEnabled?: number
}
export interface KlineCandle {
coinCode: string
interval: string
openTime: number
openPrice: number
highPrice: number
lowPrice: number
closePrice: number
volume: number
closeTime: number
}
// ==================== API Functions ====================
/** 获取所有币种K线配置 */
export async function getKlineConfigs(): Promise<KlineConfig[]> {
const { axiosInstance } = useAxios()
const { data } = await axiosInstance.get('/admin/kline/config')
const list: any[] = (data as any)?.data ?? []
return list.map(item => ({
coinId: item.id,
coinCode: item.code,
coinName: item.name,
simulationEnabled: item.simulationEnabled ?? 0,
tradeStartTime: item.tradeStartTime,
tradeEndTime: item.tradeEndTime,
priceMin: item.priceMin,
priceMax: item.priceMax,
currentPrice: item.price,
priceType: item.priceType,
}))
}
/** 保存K线配置 */
export async function saveKlineConfig(config: KlineConfigUpdate): Promise<void> {
const { axiosInstance } = useAxios()
await axiosInstance.post('/admin/kline/config', config)
}
/** 获取K线预览数据用于 echarts */
export async function getKlinePreview(coinCode: string, interval: string = '1h', limit: number = 100): Promise<KlineCandle[]> {
const { axiosInstance } = useAxios()
const { data } = await axiosInstance.get('/admin/kline/preview', { params: { coinCode, interval, limit } })
return (data as any)?.data ?? []
}