459 lines
14 KiB
Vue
459 lines
14 KiB
Vue
<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>
|