diff --git a/src/components/header/SettingsDialog.vue b/src/components/header/SettingsDialog.vue index 4fd1928..2dba06c 100644 --- a/src/components/header/SettingsDialog.vue +++ b/src/components/header/SettingsDialog.vue @@ -24,6 +24,25 @@ placeholder="用于规定 AI 角色与分析增强,多个词用逗号或换行分隔" /> + + + + + 每 + + 分钟分析一次 + + + + 开启后仅在开盘时段(09:00–11:30 / 13:30–15:30 / 21:00–23:00)按间隔自动分析;出现买入/卖出信号时浏览器标题会闪烁提醒 + + 取消 @@ -35,7 +54,11 @@ \ No newline at end of file + + + diff --git a/src/composables/useAiAdvice.ts b/src/composables/useAiAdvice.ts index aae2d6b..aea60dc 100644 --- a/src/composables/useAiAdvice.ts +++ b/src/composables/useAiAdvice.ts @@ -1,10 +1,12 @@ -import { ref } from 'vue' +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 { useSettingsStore } from '../stores/settings' +import { isWsTradingSession } from '../utils/tradingDate' +import { useTitleBlink } from './useTitleBlink' const KLINE_PERIODS: KlinePeriod[] = ['day', 'week', 'month'] @@ -26,6 +28,10 @@ export function useAiAdvice() { const quota = useQuotaStore() const settings = useSettingsStore() + useTitleBlink(data) + + let scheduleTimer: ReturnType | null = null + /** 尽量补全日/周/月 K 线,失败不阻断主流程 */ async function ensureKlines() { await Promise.all( @@ -41,13 +47,19 @@ export function useAiAdvice() { /** * 一键获取建议:收集行情 / 新闻 / 持仓后提交 DeepSeek,返回真实 AI 建议。 + * @param opts.silent 定时任务时静默:无 Key / 非开盘 / 进行中则跳过,成功不弹 toast */ - async function refresh() { + async function refresh(opts?: { silent?: boolean }) { + const silent = Boolean(opts?.silent) + // 定时分析仅在开盘时段执行(与行情 WS 时段一致) + if (silent && !isWsTradingSession()) return + const apiKey = settings.apiKey.trim() if (!apiKey) { - ElMessage.warning('请先在设置中配置 DeepSeek API Key') + if (!silent) ElMessage.warning('请先在设置中配置 DeepSeek API Key') return } + if (loading.value) return loading.value = true error.value = null @@ -64,17 +76,45 @@ export function useAiAdvice() { console.log('[AI] 提交给 DeepSeek 的数据快照', payload.snapshot) data.value = await fetchDeepSeekAdvice(apiKey, payload) - ElMessage.success('AI 建议已更新') + if (!silent) ElMessage.success('AI 建议已更新') } catch (e) { error.value = e const msg = formatError(e) console.error('[AI] 一键获取建议失败', e) - ElMessage.error(msg) + if (!silent) ElMessage.error(msg) } finally { loading.value = false } } + function clearSchedule() { + if (scheduleTimer) { + clearInterval(scheduleTimer) + scheduleTimer = null + } + } + + function setupSchedule() { + clearSchedule() + if (!settings.scheduledAnalysisEnabled) return + const minutes = Math.max(1, settings.scheduledAnalysisMinutes || 5) + scheduleTimer = setInterval( + () => { + void refresh({ silent: true }) + }, + minutes * 60_000, + ) + } + + watch( + () => + [settings.scheduledAnalysisEnabled, settings.scheduledAnalysisMinutes] as const, + () => setupSchedule(), + { immediate: true }, + ) + + onUnmounted(clearSchedule) + return { data, loading, error, refresh } } diff --git a/src/composables/useTitleBlink.ts b/src/composables/useTitleBlink.ts new file mode 100644 index 0000000..7ce3b0a --- /dev/null +++ b/src/composables/useTitleBlink.ts @@ -0,0 +1,89 @@ +import { onUnmounted, watch, type Ref } from 'vue' +import type { AiAdvice } from '../types' + +const DEFAULT_TITLE = 'AI 实时交易辅助系统' +const BLINK_MS = 800 + +function hasTradeSignal(advice: AiAdvice): boolean { + return advice.direction === 'long' || advice.direction === 'short' +} + +function signalTitle(advice: AiAdvice): string { + const tag = advice.direction === 'long' ? '买入' : '卖出' + return `【${tag}信号】${advice.action} · ${DEFAULT_TITLE}` +} + +/** + * 有多空交易信号时闪烁 document.title;页面获得焦点后停止,直至下一次新信号。 + */ +export function useTitleBlink(advice: Ref) { + const baseTitle = document.title || DEFAULT_TITLE + let timer: ReturnType | null = null + let showAlert = false + /** 用户已看过当前信号,不再闪烁,直到信号内容变化 */ + let dismissedKey = '' + let activeKey = '' + + function stopBlink() { + if (timer) { + clearInterval(timer) + timer = null + } + showAlert = false + activeKey = '' + document.title = baseTitle + } + + function startBlink(alertTitle: string, key: string) { + stopBlink() + activeKey = key + showAlert = true + document.title = alertTitle + timer = setInterval(() => { + showAlert = !showAlert + document.title = showAlert ? alertTitle : baseTitle + }, BLINK_MS) + } + + function signalKey(a: AiAdvice): string { + return `${a.direction}|${a.action}|${a.updatedAt}` + } + + function sync() { + const a = advice.value + if (!hasTradeSignal(a)) { + stopBlink() + dismissedKey = '' + return + } + const key = signalKey(a) + if (key === dismissedKey) { + stopBlink() + return + } + if (timer && activeKey === key) return + startBlink(signalTitle(a), key) + } + + function onFocus() { + const a = advice.value + if (hasTradeSignal(a) && timer) { + dismissedKey = signalKey(a) + } + stopBlink() + } + + function onVisibility() { + if (document.visibilityState === 'visible') onFocus() + } + + watch(advice, sync, { deep: true, immediate: true }) + window.addEventListener('focus', onFocus) + document.addEventListener('visibilitychange', onVisibility) + + onUnmounted(() => { + stopBlink() + window.removeEventListener('focus', onFocus) + document.removeEventListener('visibilitychange', onVisibility) + }) +} diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 6863ff3..ab9fd3d 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -8,6 +8,9 @@ const STORAGE_KEY = 'ai-trade-settings' export const DEFAULT_KEYWORDS = '你是一名专业的国内期货交易员和分析员,精通机构操盘手法,能够根据持仓与交易数据分析当前走势。' +/** 定时分析默认间隔(分钟) */ +export const DEFAULT_SCHEDULE_MINUTES = 5 + function load(): Partial { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') as Partial @@ -16,18 +19,37 @@ function load(): Partial { } } +function clampMinutes(n: unknown): number { + const v = typeof n === 'number' ? n : Number(n) + if (!Number.isFinite(v) || v < 1) return DEFAULT_SCHEDULE_MINUTES + return Math.min(1440, Math.floor(v)) +} + export const useSettingsStore = defineStore('settings', () => { const saved = load() const apiKey = ref(saved.apiKey || '') const keywords = ref(saved.keywords || DEFAULT_KEYWORDS) + const scheduledAnalysisEnabled = ref(Boolean(saved.scheduledAnalysisEnabled)) + const scheduledAnalysisMinutes = ref(clampMinutes(saved.scheduledAnalysisMinutes)) - watch([apiKey, keywords], () => { - const payload: AppSettings = { - apiKey: apiKey.value, - keywords: keywords.value, - } - localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)) - }, { immediate: true }) + watch( + [apiKey, keywords, scheduledAnalysisEnabled, scheduledAnalysisMinutes], + () => { + const payload: AppSettings = { + apiKey: apiKey.value, + keywords: keywords.value, + scheduledAnalysisEnabled: scheduledAnalysisEnabled.value, + scheduledAnalysisMinutes: clampMinutes(scheduledAnalysisMinutes.value), + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)) + }, + { immediate: true }, + ) - return { apiKey, keywords } + return { + apiKey, + keywords, + scheduledAnalysisEnabled, + scheduledAnalysisMinutes, + } }) diff --git a/src/types/index.ts b/src/types/index.ts index bb96f7b..506bf6b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -137,4 +137,8 @@ export interface AiAdvice { export interface AppSettings { apiKey: string keywords: string + /** 是否开启定时分析 */ + scheduledAnalysisEnabled: boolean + /** 定时分析间隔(分钟) */ + scheduledAnalysisMinutes: number }
+ 开启后仅在开盘时段(09:00–11:30 / 13:30–15:30 / 21:00–23:00)按间隔自动分析;出现买入/卖出信号时浏览器标题会闪烁提醒 +