188 lines
5.7 KiB
TypeScript
188 lines
5.7 KiB
TypeScript
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 建议失败'
|
||
}
|