AI分析功能:LLM Key 改为数据库管理,支持管理员后台配置

This commit is contained in:
fish
2026-05-10 16:21:15 +08:00
parent ad9edf7ad4
commit 99c2a5bcbf
12 changed files with 814 additions and 23 deletions

View File

@@ -12,6 +12,43 @@ const emit = defineEmits<{ (e: 'close'): void }>()
const score = ref<Score | null>(null)
const loading = ref(false)
// AI 分析
const aiLoading = ref(false)
const aiContent = ref('')
const aiError = ref('')
let es: EventSource | null = null
function closeAI() {
es?.close()
es = null
aiLoading.value = false
}
async function askAI() {
if (!score.value) return
closeAI()
aiLoading.value = true
aiContent.value = ''
aiError.value = ''
const url = `/api/v1/ai/analyze?ts_code=${encodeURIComponent(score.value.ts_code)}&trade_date=${encodeURIComponent(score.value.trade_date)}`
es = new EventSource(url)
es.addEventListener('token', (e) => {
aiContent.value += e.data
})
es.addEventListener('error', (e) => {
aiError.value = (e as any)?.data || '请求失败'
closeAI()
})
es.addEventListener('done', () => {
closeAI()
})
es.onerror = () => {
if (!aiContent.value) aiError.value = '连接中断'
closeAI()
}
}
const visible = computed({
get: () => props.scoreId !== null,
set: (v) => {
@@ -22,6 +59,9 @@ const visible = computed({
watch(
() => props.scoreId,
async (id) => {
closeAI()
aiContent.value = ''
aiError.value = ''
if (id === null) {
score.value = null
return
@@ -59,7 +99,7 @@ const quadrantLabel = (q: string) => {
</script>
<template>
<el-drawer v-model="visible" title="打分明细" :size="isMobile ? '92%' : '680px'" destroy-on-close>
<el-drawer v-model="visible" title="打分明细" :size="isMobile ? '92%' : '720px'" destroy-on-close>
<div v-loading="loading" v-if="score">
<el-descriptions :column="isMobile ? 1 : 2" border>
<el-descriptions-item label="品种">{{ parseTsCode(score.ts_code).symbol }}</el-descriptions-item>
@@ -73,11 +113,21 @@ const quadrantLabel = (q: string) => {
<el-descriptions-item label="信号">
<el-tag>{{ score.signal }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="短期(7d × 0.4)">{{ score.short_term.toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="中期(15d × 0.35)">{{ score.medium_term.toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="长期(30d × 0.25)" :span="isMobile ? 1 : 2">
<el-descriptions-item label="短期(7d)">{{ score.short_term.toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="中期(15d)">{{ score.medium_term.toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="长期(30d)" :span="isMobile ? 1 : 2">
{{ score.long_term.toFixed(2) }}
</el-descriptions-item>
<el-descriptions-item v-if="score.detail?.composite_delta != null" label="Δ1d">
<span :style="{ color: score.detail.composite_delta >= 0 ? '#e4393c' : '#1ca11c' }">
{{ score.detail.composite_delta >= 0 ? '+' : '' }}{{ score.detail.composite_delta.toFixed(1) }}
</span>
</el-descriptions-item>
<el-descriptions-item v-if="score.detail?.composite_delta_5d != null" label="Δ5d">
<span :style="{ color: score.detail.composite_delta_5d >= 0 ? '#e4393c' : '#1ca11c' }">
{{ score.detail.composite_delta_5d >= 0 ? '+' : '' }}{{ score.detail.composite_delta_5d.toFixed(1) }}
</span>
</el-descriptions-item>
</el-descriptions>
<h4 class="section">短期 7 日逐日打分</h4>
@@ -119,7 +169,7 @@ const quadrantLabel = (q: string) => {
</el-table>
</div>
<h4 class="section">中期(15d)细节</h4>
<h4 class="section">中期(15d)资金意愿</h4>
<el-descriptions :column="isMobile ? 1 : 2" border v-if="score.detail?.medium_detail">
<el-descriptions-item label="价格收益率">
{{ score.detail.medium_detail.price_return_pct.toFixed(2) }}%
@@ -127,19 +177,25 @@ const quadrantLabel = (q: string) => {
<el-descriptions-item label="价格信号分">
{{ score.detail.medium_detail.price_signal.toFixed(2) }}
</el-descriptions-item>
<el-descriptions-item label="增仓上涨">
{{ score.detail.medium_detail.long_up_days }}
<el-descriptions-item label="增仓上涨">
{{ score.detail.medium_detail.accumulation_days }}
</el-descriptions-item>
<el-descriptions-item label="增仓下跌">
{{ score.detail.medium_detail.long_down_days }}
<el-descriptions-item label="增仓下跌">
{{ score.detail.medium_detail.distribution_days }}
</el-descriptions-item>
<el-descriptions-item label="减仓上涨">
{{ score.detail.medium_detail.covering_days }}
</el-descriptions-item>
<el-descriptions-item label="减仓下跌">
{{ score.detail.medium_detail.liquidation_days }}
</el-descriptions-item>
<el-descriptions-item label="资金意愿分" :span="isMobile ? 1 : 2">
{{ score.detail.medium_detail.fund_signal.toFixed(1) }}
<span class="formula-hint">(50 + (增仓涨 - 增仓跌)/{{ score.detail.medium_detail.window ?? 15 }} × 50)</span>
<span class="formula-hint">(四象限加权合成)</span>
</el-descriptions-item>
</el-descriptions>
<h4 class="section">长期(30d)细节</h4>
<h4 class="section">长期(30d)结构</h4>
<el-descriptions :column="isMobile ? 1 : 2" border v-if="score.detail?.long_detail">
<el-descriptions-item label="OI 趋势分">
{{ score.detail.long_detail.oi_score?.toFixed(1) ?? '-' }}
@@ -155,14 +211,14 @@ const quadrantLabel = (q: string) => {
<el-descriptions-item label="30 日前收盘">
{{ score.detail.long_detail.price_before_30d?.toFixed(2) ?? '-' }}
</el-descriptions-item>
<el-descriptions-item label="30 日均持仓">
{{ score.detail.long_detail.avg_oi.toFixed(0) }}
<el-descriptions-item label="当前 OI">
{{ score.detail.long_detail.oi_now?.toFixed(0) ?? '-' }}
</el-descriptions-item>
<el-descriptions-item label="30 日前持仓">
{{ score.detail.long_detail.oi_before.toFixed(0) }}
<el-descriptions-item label="30 日前 OI">
{{ score.detail.long_detail.oi_before?.toFixed(0) ?? '-' }}
</el-descriptions-item>
<el-descriptions-item label="OI 变化幅度" :span="isMobile ? 1 : 2">
{{ score.detail.long_detail.change_pct.toFixed(2) }}%
{{ score.detail.long_detail.oi_change_pct?.toFixed(2) ?? '-' }}%
</el-descriptions-item>
</el-descriptions>
@@ -174,10 +230,57 @@ const quadrantLabel = (q: string) => {
<el-descriptions-item label="ATR%">
{{ (score.detail.volatility.atr_pct * 100).toFixed(2) }}%
</el-descriptions-item>
<el-descriptions-item label="波动率惩罚系数" :span="isMobile ? 1 : 2">
<el-descriptions-item label="综合波动风险" v-if="score.detail.volatility.vol_risk !== undefined">
{{ (score.detail.volatility.vol_risk * 100).toFixed(2) }}%
</el-descriptions-item>
<el-descriptions-item label="惩罚系数">
{{ score.detail.volatility.vol_penalty.toFixed(3) }}
</el-descriptions-item>
</el-descriptions>
<template v-if="score.detail?.adaptive_weights">
<h4 class="section">自适应权重</h4>
<el-descriptions :column="isMobile ? 1 : 2" border>
<el-descriptions-item label="趋势强度">
{{ score.detail.adaptive_weights.trend_strength.toFixed(2) }}
</el-descriptions-item>
<el-descriptions-item label="趋势因子">
{{ (score.detail.adaptive_weights.trend_factor * 100).toFixed(0) }}%
</el-descriptions-item>
<el-descriptions-item label="短期权重">
{{ (score.detail.adaptive_weights.w_short * 100).toFixed(0) }}%
</el-descriptions-item>
<el-descriptions-item label="中期权重">
{{ (score.detail.adaptive_weights.w_medium * 100).toFixed(0) }}%
</el-descriptions-item>
<el-descriptions-item label="长期权重" :span="isMobile ? 1 : 2">
{{ (score.detail.adaptive_weights.w_long * 100).toFixed(0) }}%
</el-descriptions-item>
</el-descriptions>
</template>
<el-divider />
<div class="ai-section">
<div v-if="!aiLoading && !aiContent && !aiError">
<el-button type="primary" :loading="aiLoading" @click="askAI">
🤖 AI 分析当前打分
</el-button>
</div>
<div v-if="aiLoading || aiContent" class="ai-card">
<div class="ai-header">
<span>🤖 AI 分析</span>
<el-button text size="small" @click="closeAI" v-if="aiLoading">取消</el-button>
</div>
<div class="ai-body">
<div v-if="aiContent" class="ai-text" v-html="aiContent.replace(/\n/g, '<br>')"></div>
<div v-if="aiError" class="ai-error">{{ aiError }}</div>
<div v-if="aiLoading && !aiContent" class="ai-loading">
<el-icon class="is-loading"><span></span></el-icon> 正在分析...
</div>
</div>
</div>
</div>
</div>
</el-drawer>
</template>
@@ -197,4 +300,34 @@ const quadrantLabel = (q: string) => {
font-size: 12px;
margin-left: 6px;
}
.ai-section {
margin-top: 4px;
}
.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.8;
white-space: pre-wrap;
}
.ai-error {
color: var(--el-color-danger);
}
.ai-loading {
color: var(--el-text-color-secondary);
}
</style>