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

@@ -3,3 +3,18 @@ import client from './client'
export function resetAllData() {
return client.post('/admin/reset-data').then((r) => r.data)
}
export interface LLMConfig {
base_url: string
api_key: string
model: string
has_api_key: boolean
}
export function getLLMConfig() {
return client.get<LLMConfig>('/admin/llm-config').then((r) => r.data)
}
export function saveLLMConfig(cfg: { base_url: string; api_key: string; model: string }) {
return client.put('/admin/llm-config', cfg).then((r) => r.data)
}

View File

@@ -17,34 +17,50 @@ export interface ShortDetail {
export interface MediumDetail {
price_return_pct: number
price_signal: number
long_up_days: number
long_down_days: number
accumulation_days: number
distribution_days: number
covering_days: number
liquidation_days: number
fund_signal: number
window: number
}
export interface LongDetail {
avg_oi: number
oi_now: number
oi_before: number
change_pct: number
oi_change_pct: number
oi_score: number
price_score: number
price_return_30d_pct: number
price_before_30d: number
avg_oi_30d: number
window: number
}
export interface VolatilityDetail {
daily_vol_pct: number
atr_pct: number
vol_risk: number
vol_penalty: number
}
export interface AdaptiveWeights {
trend_strength: number
trend_factor: number
w_short: number
w_medium: number
w_long: number
}
export interface ScoreDetail {
short_details?: ShortDetail[]
medium_detail?: MediumDetail
long_detail?: LongDetail
volatility?: VolatilityDetail
adaptive_weights?: AdaptiveWeights
vol_penalty?: number
composite_delta?: number | null
composite_delta_5d?: number | null
}
export interface Score {

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>

View File

@@ -8,6 +8,7 @@ import {
updateUser,
type AdminUser,
} from '@/api/users'
import { getLLMConfig, saveLLMConfig, type LLMConfig } from '@/api/admin'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
@@ -27,6 +28,52 @@ const resetDialog = reactive({
password: '',
})
// LLM 配置
const llmCfg = reactive({
base_url: 'https://api.deepseek.com/v1',
api_key: '',
model: 'deepseek-chat',
has_api_key: false,
saving: false,
loading: false,
})
async function loadLLMConfig() {
llmCfg.loading = true
try {
const cfg = await getLLMConfig()
llmCfg.base_url = cfg.base_url
llmCfg.api_key = cfg.api_key || ''
llmCfg.model = cfg.model
llmCfg.has_api_key = cfg.has_api_key
} catch {
// ignore
} finally {
llmCfg.loading = false
}
}
async function submitLLMConfig() {
if (!llmCfg.base_url.trim()) {
ElMessage.warning('API 地址不能为空')
return
}
llmCfg.saving = true
try {
await saveLLMConfig({
base_url: llmCfg.base_url.trim(),
api_key: llmCfg.api_key.trim(),
model: llmCfg.model.trim(),
})
ElMessage.success('LLM 配置已保存')
await loadLLMConfig()
} catch (e: any) {
ElMessage.error(e?.response?.data?.error || '保存失败')
} finally {
llmCfg.saving = false
}
}
async function reload() {
loading.value = true
try {
@@ -91,7 +138,10 @@ async function remove(u: AdminUser) {
await reload()
}
onMounted(reload)
onMounted(() => {
reload()
loadLLMConfig()
})
</script>
<template>
@@ -145,6 +195,38 @@ onMounted(reload)
</el-table>
</div>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>🤖 LLM 配置</span>
<el-tag :type="llmCfg.has_api_key ? 'success' : 'warning'" size="small">
{{ llmCfg.has_api_key ? '已配置' : '未配置' }}
</el-tag>
</div>
</template>
<el-form label-width="100px" v-loading="llmCfg.loading" @submit.prevent>
<el-form-item label="API 地址">
<el-input v-model="llmCfg.base_url" placeholder="https://api.deepseek.com/v1" />
</el-form-item>
<el-form-item label="API Key">
<el-input
v-model="llmCfg.api_key"
type="password"
show-password
placeholder="sk-..."
/>
</el-form-item>
<el-form-item label="模型">
<el-input v-model="llmCfg.model" placeholder="deepseek-chat" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="llmCfg.saving" @click="submitLLMConfig">
保存配置
</el-button>
</el-form-item>
</el-form>
</el-card>
<el-dialog v-model="createDialog.visible" title="新建账号" width="420px">
<el-form label-width="80px">
<el-form-item label="用户名">
@@ -197,6 +279,11 @@ onMounted(reload)
align-items: center;
color: var(--el-text-color-regular);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.table-wrapper {
background: var(--el-bg-color);
border-radius: 4px;