Files
trade/web/frontend/src/views/ScoresView.vue

459 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { nextTick, onMounted, reactive, ref, watch, computed } from 'vue'
import { marked } from 'marked'
import { ElMessage } from 'element-plus'
import { listScores, listContracts, type Score } from '@/api/scores'
import { runPipeline, type RunResponse } from '@/api/run'
import ScoreDetailDrawer from '@/components/ScoreDetailDrawer.vue'
import { parseTsCode } from '@/utils/contract'
import { useMobile } from '@/composables/useMobile'
const { isMobile } = useMobile()
const EXCHANGES = [
{ code: 'ZCE', name: '郑商所' },
{ code: 'SHF', name: '上期所' },
{ code: 'DCE', name: '大商所' },
]
const SYMBOLS_BY_EXCHANGE: Record<string, string[]> = {
ZCE: ['FG', 'SA', 'MA', 'CF'],
SHF: ['RB'],
DCE: ['M'],
}
// 品种打分
const selectedExchange = ref('')
const selectedSymbol = ref('')
const scoring = ref(false)
const scoreResult = ref<RunResponse | null>(null)
const resultRef = ref<HTMLElement | null>(null)
const availableSymbols = computed(() => {
if (!selectedExchange.value) return []
return SYMBOLS_BY_EXCHANGE[selectedExchange.value] || []
})
watch(selectedExchange, () => {
selectedSymbol.value = ''
})
async function handleScore() {
if (!selectedSymbol.value) {
ElMessage.warning('请选择品种')
return
}
scoring.value = true
scoreResult.value = null
try {
const resp = await runPipeline({ symbol: selectedSymbol.value })
scoreResult.value = resp
ElMessage.success('打分完成')
await nextTick()
resultRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
} catch (err: any) {
const msg = err?.response?.data?.error || err.message || '请求失败'
ElMessage.error(msg)
} finally {
scoring.value = false
}
}
function signalTagType(s: string) {
if (s.includes('强烈看多')) return 'success'
if (s.includes('偏多')) return ''
if (s.includes('偏空')) return 'warning'
if (s.includes('强烈看空')) return 'danger'
return 'info'
}
function signalIcon(s: string) {
if (s.includes('强烈看多')) return '📈📈'
if (s.includes('偏多')) return '📈'
if (s.includes('偏空')) return '📉'
if (s.includes('强烈看空')) return '📉📉'
return ''
}
// AI 分析
const aiLoading = ref(false)
const aiContent = ref('')
const aiError = ref('')
let aiES: EventSource | null = null
function closeAI() {
aiES?.close()
aiES = null
aiLoading.value = false
}
async function askAI() {
if (!scoreResult.value) return
closeAI()
aiLoading.value = true
aiContent.value = ''
aiError.value = ''
const ts = encodeURIComponent(scoreResult.value.ts_code)
const td = encodeURIComponent(scoreResult.value.trade_date)
aiES = new EventSource(`/api/ai/analyze?ts_code=${ts}&trade_date=${td}`)
aiES.addEventListener('token', (e) => { aiContent.value += e.data })
aiES.addEventListener('error', (e) => {
aiError.value = (e as any)?.data || '请求失败'
closeAI()
})
aiES.addEventListener('done', () => closeAI())
aiES.onerror = () => {
if (!aiContent.value && !aiError.value) aiError.value = '连接中断'
closeAI()
}
}
// 历史查询(折叠)
const showHistory = ref(false)
const historyFilter = reactive<{
ts_code?: string
range: [string, string] | []
signal?: string
limit: number
}>({
ts_code: undefined,
range: [],
signal: undefined,
limit: 50,
})
const contracts = ref<string[]>([])
const historyRows = ref<Score[]>([])
const historyLoading = ref(false)
const drawerScoreId = ref<number | null>(null)
async function reloadHistory(silent = false) {
if (!silent) historyLoading.value = true
try {
const [start, end] = historyFilter.range || []
historyRows.value = await listScores({
ts_code: historyFilter.ts_code,
start: start || undefined,
end: end || undefined,
signal: historyFilter.signal,
limit: historyFilter.limit,
})
} finally {
if (!silent) historyLoading.value = false
}
}
function toggleSignal(s: string) {
historyFilter.signal = historyFilter.signal === s ? undefined : s
reloadHistory(true)
}
onMounted(async () => {
contracts.value = await listContracts().catch(() => [])
})
</script>
<template>
<div class="page">
<!-- 品种打分 -->
<el-card shadow="never">
<template #header>
<span>品种打分</span>
</template>
<el-form :inline="!isMobile">
<el-form-item label="交易所">
<el-select
v-model="selectedExchange"
placeholder="选择交易所"
clearable
:style="{ width: isMobile ? '100%' : '160px' }"
>
<el-option
v-for="ex in EXCHANGES"
:key="ex.code"
:label="ex.name"
:value="ex.code"
/>
</el-select>
</el-form-item>
<el-form-item label="品种">
<el-select
v-model="selectedSymbol"
placeholder="选择品种"
:disabled="!selectedExchange"
:style="{ width: isMobile ? '100%' : '120px' }"
>
<el-option
v-for="s in availableSymbols"
:key="s"
:label="s"
:value="s"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="scoring"
:disabled="!selectedSymbol"
@click="handleScore"
>
打分
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 打分结果 -->
<div v-if="scoreResult" ref="resultRef">
<el-card shadow="never" class="result-card">
<template #header>
<span>打分结果</span>
</template>
<el-descriptions :column="isMobile ? 1 : 2" border>
<el-descriptions-item label="品种">
{{ parseTsCode(scoreResult.ts_code).symbol }}
</el-descriptions-item>
<el-descriptions-item label="合约">
{{ parseTsCode(scoreResult.ts_code).contract }}
</el-descriptions-item>
<el-descriptions-item label="日期">{{ scoreResult.trade_date }}</el-descriptions-item>
<el-descriptions-item label="收盘">{{ scoreResult.close }}</el-descriptions-item>
<el-descriptions-item label="持仓">{{ scoreResult.oi }}</el-descriptions-item>
<el-descriptions-item label="短期(7d)">{{ scoreResult.short_term }}</el-descriptions-item>
<el-descriptions-item label="中期(15d)">{{ scoreResult.medium_term }}</el-descriptions-item>
<el-descriptions-item label="长期(30d)">{{ scoreResult.long_term }}</el-descriptions-item>
<el-descriptions-item label="综合">
<strong>{{ scoreResult.composite }}</strong>
</el-descriptions-item>
<el-descriptions-item label="信号" :span="isMobile ? 1 : 2">
<el-tag :type="signalTagType(scoreResult.signal)">
{{ signalIcon(scoreResult.signal) }} {{ scoreResult.signal }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<div class="ai-section">
<el-button
v-if="!aiLoading && !aiContent && !aiError"
type="primary"
:loading="aiLoading"
@click="askAI"
>
🤖 AI 分析当前打分
</el-button>
<div v-if="aiLoading || aiContent || aiError" class="ai-card">
<div class="ai-header">
<span>🤖 AI 分析</span>
<el-button v-if="aiLoading" text size="small" @click="closeAI">取消</el-button>
</div>
<div class="ai-body">
<div v-if="aiContent" class="ai-text" v-html="marked(aiContent)" />
<div v-if="aiError" class="ai-error">{{ aiError }}</div>
<div v-if="aiLoading && !aiContent" class="ai-loading"> 正在分析...</div>
</div>
</div>
</div>
</el-card>
</div>
<!-- 历史查询折叠 -->
<el-card shadow="never">
<template #header>
<div class="history-header" @click="showHistory = !showHistory">
<span>历史打分查询</span>
<span>{{ showHistory ? '▲' : '▼' }}</span>
</div>
</template>
<div v-if="showHistory">
<el-form :inline="!isMobile">
<el-form-item label="合约">
<el-select
v-model="historyFilter.ts_code"
placeholder="全部合约"
clearable
filterable
:style="{ width: isMobile ? '100%' : '200px' }"
>
<el-option v-for="c in contracts" :key="c" :label="c" :value="c" />
</el-select>
</el-form-item>
<el-form-item label="日期">
<el-date-picker
v-model="historyFilter.range"
type="daterange"
value-format="YYYYMMDD"
range-separator=""
start-placeholder=""
end-placeholder=""
:style="{ width: isMobile ? '100%' : 'auto' }"
/>
</el-form-item>
<el-form-item label="条数">
<el-input-number v-model="historyFilter.limit" :min="10" :max="500" :step="50" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="historyLoading" style="width: 88px" @click="reloadHistory">
查询
</el-button>
</el-form-item>
<el-form-item label="快捷" class="signal-item">
<el-button-group class="signal-group" :size="isMobile ? 'small' : 'default'">
<el-button
:type="historyFilter.signal === '强烈看多' ? 'success' : ''"
@click="toggleSignal('强烈看多')"
>
强烈看多
</el-button>
<el-button
:type="historyFilter.signal === '偏多' ? 'primary' : ''"
@click="toggleSignal('偏多')"
>
偏多
</el-button>
<el-button
:type="historyFilter.signal === '偏空' ? 'warning' : ''"
@click="toggleSignal('偏空')"
>
偏空
</el-button>
<el-button
:type="historyFilter.signal === '强烈看空' ? 'danger' : ''"
@click="toggleSignal('强烈看空')"
>
强烈看空
</el-button>
</el-button-group>
</el-form-item>
</el-form>
<div class="table-wrapper" v-loading="historyLoading">
<el-table :data="historyRows" stripe class="score-table">
<el-table-column prop="trade_date" label="日期" width="100" />
<el-table-column label="品种" width="80">
<template #default="{ row }">
{{ parseTsCode(row.ts_code).symbol }}
</template>
</el-table-column>
<el-table-column label="合约" width="80">
<template #default="{ row }">
{{ parseTsCode(row.ts_code).contract }}
</template>
</el-table-column>
<el-table-column prop="close" label="收盘" width="90" />
<el-table-column prop="oi" label="持仓" width="100" />
<el-table-column prop="oi_chg" label="持仓变化" width="100" />
<el-table-column prop="short_term" label="短期(7d)" width="90" />
<el-table-column prop="medium_term" label="中期(15d)" width="90" />
<el-table-column prop="long_term" label="长期(30d)" width="90" />
<el-table-column prop="composite" label="综合" width="80">
<template #default="{ row }">
<strong>{{ row.composite.toFixed(2) }}</strong>
</template>
</el-table-column>
<el-table-column prop="signal" label="信号" min-width="160">
<template #default="{ row }">
<el-tag :type="signalTagType(row.signal)">
{{ signalIcon(row.signal) }} {{ row.signal }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="drawerScoreId = row.id">明细</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-card>
<ScoreDetailDrawer :score-id="drawerScoreId" @close="drawerScoreId = null" />
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 12px;
}
.result-card {
margin-top: 4px;
}
.ai-section {
margin-top: 12px;
}
.ai-card {
border: 1px solid var(--el-border-color);
border-radius: 6px;
margin-top: 8px;
overflow: hidden;
}
.ai-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: var(--el-fill-color-light);
font-weight: 600;
}
.ai-body {
padding: 12px;
}
.ai-text {
line-height: 1.5;
}
.ai-error {
color: var(--el-color-danger);
}
.ai-loading {
color: var(--el-text-color-secondary);
}
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
}
.filter-card :deep(.el-card__body) {
padding: 12px 16px;
}
.signal-group :deep(.el-button) {
transition: none !important;
}
.signal-group :deep(.el-button:focus),
.signal-group :deep(.el-button:active) {
outline: none;
box-shadow: none;
}
.table-wrapper {
background: var(--el-bg-color);
border-radius: 4px;
overflow-x: auto;
}
.score-table {
min-width: 960px;
}
@media (max-width: 768px) {
.signal-item {
flex-wrap: wrap;
}
.signal-group {
flex-wrap: wrap;
}
}
</style>
<style>
/* AI Markdown 输出段落间距(非 scoped确保 v-html 生效) */
.ai-text p { margin: 3px 0; }
.ai-text h1, .ai-text h2, .ai-text h3, .ai-text h4 { margin: 16px 0 6px; font-size: inherit; }
.ai-text ul, .ai-text ol { margin: 3px 0; padding-left: 18px; }
.ai-text li { margin: 1px 0; }
.ai-text strong { color: var(--el-color-primary, #409eff); }
</style>