2026-07-22 18:02:21 +08:00

188 lines
5.7 KiB
TypeScript
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.

import { onUnmounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { AiAdvice } from '../types'
import { fetchDeepSeekAdvice } from '../api/deepseek/analyze'
import type { AiAnalysisPayload } from '../api/deepseek/types'
import { useQuotaStore, type KlinePeriod } from '../stores/quota'
import { useContractStore } from '../stores/contract'
import { useSettingsStore } from '../stores/settings'
import { isWsTradingSession } from '../utils/tradingDate'
import { useTitleBlink } from './useTitleBlink'
/** 全局额外关键字 + 当前合约关键字(合约段追加在后) */
function mergeKeywords(globalKeywords: string, contractKeywords: string): string {
const global = globalKeywords.trim()
const extra = contractKeywords.trim()
if (!extra) return global
if (!global) return extra
return `${global}\n${extra}`
}
const KLINE_PERIODS: KlinePeriod[] = ['day', 'week', 'month']
/** 调度轮询间隔:检查距上次执行是否已超过设定分钟 */
const SCHEDULE_TICK_MS = 15_000
function emptyAdvice(): AiAdvice {
return {
action: '—',
direction: 'neutral',
confidence: 0,
summary: '点击「一键获取建议」获取 AI 分析',
reasons: [],
updatedAt: '',
}
}
export function useAiAdvice() {
const data = ref<AiAdvice>(emptyAdvice())
const loading = ref(false)
const error = ref<unknown>(null)
/** 上次成功执行分析的时间戳(ms) */
const lastExecutedAt = ref<number | null>(null)
const quota = useQuotaStore()
const contractStore = useContractStore()
const settings = useSettingsStore()
useTitleBlink(data)
let scheduleTimer: ReturnType<typeof setInterval> | null = null
/** 切换合约时递增,丢弃进行中的旧分析结果 */
let analysisEpoch = 0
function clearAdvice() {
data.value = emptyAdvice()
error.value = null
lastExecutedAt.value = null
}
watch(
() => contractStore.selectedId,
() => {
analysisEpoch += 1
loading.value = false
clearAdvice()
},
)
/** 尽量补全日/周/月 K 线,失败不阻断主流程 */
async function ensureKlines() {
await Promise.all(
KLINE_PERIODS.map(async (period) => {
try {
await quota.loadKline(period)
} catch {
/* 已在 store 内打日志 */
}
}),
)
}
/**
* 一键获取建议:收集行情 / 新闻 / 持仓后提交 DeepSeek,返回真实 AI 建议。
* @param opts.silent 定时任务时静默:无 Key / 非开盘 / 进行中则跳过,成功不弹 toast
*/
async function refresh(opts?: { silent?: boolean }) {
const silent = Boolean(opts?.silent)
// 定时分析仅在开盘时段执行(与行情 WS 时段一致)
if (silent && !isWsTradingSession()) return
const apiKey = settings.apiKey.trim()
if (!apiKey) {
if (!silent) ElMessage.warning('请先在设置中配置 DeepSeek API Key')
return
}
if (loading.value) return
const epoch = analysisEpoch
loading.value = true
error.value = null
try {
await quota.fetchForAnalysis()
if (epoch !== analysisEpoch) return
await ensureKlines()
if (epoch !== analysisEpoch) return
const payload: AiAnalysisPayload = {
keywords: mergeKeywords(settings.keywords, contractStore.current.keywords),
snapshot: quota.getAnalysisSnapshot(),
}
console.log('[AI] 提交给 DeepSeek 的关键词', payload.keywords)
console.log('[AI] 提交给 DeepSeek 的数据快照', payload.snapshot)
const advice = await fetchDeepSeekAdvice(apiKey, payload)
if (epoch !== analysisEpoch) return
data.value = advice
lastExecutedAt.value = Date.now()
if (!silent) ElMessage.success('AI 建议已更新')
} catch (e) {
if (epoch !== analysisEpoch) return
error.value = e
const msg = formatError(e)
console.error('[AI] 一键获取建议失败', e)
if (!silent) ElMessage.error(msg)
} finally {
if (epoch === analysisEpoch) loading.value = false
}
}
/** 距上次成功执行是否已满设定间隔(从未执行则视为应执行) */
function shouldRunScheduled(): boolean {
if (!settings.scheduledAnalysisEnabled) return false
if (!isWsTradingSession()) return false
if (loading.value) return false
if (!settings.apiKey.trim()) return false
const minutes = Math.max(1, settings.scheduledAnalysisMinutes || 5)
const last = lastExecutedAt.value
if (last == null) return true
return Date.now() - last >= minutes * 60_000
}
function tickSchedule() {
if (shouldRunScheduled()) {
void refresh({ silent: true })
}
}
function clearSchedule() {
if (scheduleTimer) {
clearInterval(scheduleTimer)
scheduleTimer = null
}
}
function setupSchedule() {
clearSchedule()
if (!settings.scheduledAnalysisEnabled) return
// 开启后立即检查一次,之后按短周期轮询「是否已超过间隔」
tickSchedule()
scheduleTimer = setInterval(tickSchedule, SCHEDULE_TICK_MS)
}
watch(
() =>
[settings.scheduledAnalysisEnabled, settings.scheduledAnalysisMinutes] as const,
() => setupSchedule(),
{ immediate: true },
)
onUnmounted(clearSchedule)
return { data, loading, error, lastExecutedAt, refresh }
}
function formatError(e: unknown): string {
if (typeof e === 'object' && e && 'response' in e) {
const resp = (e as { response?: { status?: number; data?: { error?: { message?: string } } } })
.response
const apiMsg = resp?.data?.error?.message
if (apiMsg) return `DeepSeek:${apiMsg}`
if (resp?.status) return `DeepSeek 请求失败(HTTP ${resp.status})`
}
if (e instanceof Error) return e.message
return '获取 AI 建议失败'
}