行情数据做状态管理

This commit is contained in:
dongzp 2026-07-21 15:43:44 +08:00
parent 671a6355da
commit 6a81305bad
5 changed files with 238 additions and 122 deletions

View File

@ -24,7 +24,7 @@ import {
import type { EChartsOption } from 'echarts'
import VChart from 'vue-echarts'
import type { Candle, QuoteData } from '../../types'
import type { KlinePeriod } from '../../composables/useQuote'
import type { KlinePeriod } from '../../stores/quota'
use([
CanvasRenderer,

View File

@ -1,32 +1,22 @@
import { onMounted, ref } from 'vue'
import type { NewsItem } from '../types'
import { newsMock } from '../mocks/news'
import { contractConfig } from '../config/contract'
import { getFuturesNews } from '../api/baidu/news'
import { mapBaiduNewsToItems } from '../api/baidu/mapNews'
import { storeToRefs } from 'pinia'
import { onMounted } from 'vue'
import { useQuotaStore } from '../stores/quota'
/**
* 新闻 composable:状态统一落在 quota store。
*/
export function useNews() {
const data = ref<NewsItem[]>(structuredClone(newsMock))
const loading = ref(false)
const error = ref<unknown>(null)
async function refresh() {
loading.value = true
error.value = null
try {
const list = await getFuturesNews({ code: contractConfig.code })
data.value = mapBaiduNewsToItems(list)
} catch (e) {
error.value = e
console.error('[useNews] 拉取新闻失败', e)
} finally {
loading.value = false
}
}
const store = useQuotaStore()
const { news: data, newsLoading: loading, newsError: error } = storeToRefs(store)
onMounted(() => {
void refresh()
void store.fetchNews()
})
return { data, loading, error, refresh }
return {
data,
loading,
error,
refresh: () => store.fetchNews(),
}
}

View File

@ -1,28 +1,21 @@
import { ref } from 'vue'
import type { PositionsData } from '../types'
import { positionsMock } from '../mocks/positions'
function delay(ms = 300): Promise<void> {
return new Promise((r) => setTimeout(r, ms))
}
import { storeToRefs } from 'pinia'
import { useQuotaStore } from '../stores/quota'
/**
* 机构持仓 composable:状态统一落在 quota store(暂 mock)。
*/
export function usePositions() {
const data = ref<PositionsData>(structuredClone(positionsMock))
const loading = ref(false)
const error = ref<unknown>(null)
const store = useQuotaStore()
const {
positions: data,
positionsLoading: loading,
positionsError: error,
} = storeToRefs(store)
async function refresh() {
loading.value = true
error.value = null
try {
await delay()
data.value = structuredClone(positionsMock)
} catch (e) {
error.value = e
} finally {
loading.value = false
return {
data,
loading,
error,
refresh: () => store.fetchPositions(),
}
}
return { data, loading, error, refresh }
}

View File

@ -1,81 +1,28 @@
import { onMounted, ref } from 'vue'
import type { Candle, QuoteData } from '../types'
import { quoteMock } from '../mocks/quote'
import { contractConfig } from '../config/contract'
import { getStockKline, getStockQuotation } from '../api/baidu/quotation'
import { mapBaiduQuotationToQuote } from '../api/baidu/mapQuote'
import { mapBaiduKlineToCandles } from '../api/baidu/mapKline'
import type { BaiduKlineType } from '../api/baidu/types'
import { storeToRefs } from 'pinia'
import { onMounted } from 'vue'
import { useQuotaStore, type KlinePeriod } from '../stores/quota'
export type KlinePeriod = 'day' | 'week' | 'month'
const KTYPE_MAP: Record<KlinePeriod, BaiduKlineType> = {
day: 1,
week: 2,
month: 3,
}
export type { KlinePeriod }
/**
* 行情 composable:状态统一落在 quota store,便于 AI 分析读取。
* 页面打开时只拉分时/盘口;K 线点击 Tab 再拉。
*/
export function useQuote() {
const data = ref<QuoteData>(structuredClone(quoteMock))
const loading = ref(false)
const klineLoading = ref(false)
const error = ref<unknown>(null)
const store = useQuotaStore()
const { quote: data, quoteLoading: loading, klineLoading, quoteError: error } =
storeToRefs(store)
/** 已加载过的周期,避免重复请求 */
const loadedKlines = ref<Record<KlinePeriod, boolean>>({
day: false,
week: false,
month: false,
})
async function refresh() {
loading.value = true
error.value = null
try {
const result = await getStockQuotation({ code: contractConfig.code })
const quote = mapBaiduQuotationToQuote(result)
// 分时刷新时清空 K 线缓存,点击 Tab 再拉
quote.candles = { day: [], week: [], month: [] }
loadedKlines.value = { day: false, week: false, month: false }
data.value = quote
} catch (e) {
error.value = e
console.error('[useQuote] 拉取行情失败', e)
} finally {
loading.value = false
}
}
/** 按需加载日/周/月 K 线;已加载则跳过 */
async function loadKline(period: KlinePeriod): Promise<Candle[]> {
if (loadedKlines.value[period]) {
return data.value.candles[period]
}
klineLoading.value = true
error.value = null
try {
const result = await getStockKline({
code: contractConfig.code,
ktype: KTYPE_MAP[period],
})
const candles = mapBaiduKlineToCandles(result)
data.value.candles[period] = candles
loadedKlines.value[period] = true
return candles
} catch (e) {
error.value = e
console.error(`[useQuote] 拉取${period}K线失败`, e)
throw e
} finally {
klineLoading.value = false
}
}
// 页面打开时只请求分时/盘口;K 线点击 Tab 再拉
onMounted(() => {
void refresh()
void store.fetchQuote()
})
return { data, loading, klineLoading, error, refresh, loadKline }
return {
data,
loading,
klineLoading,
error,
refresh: () => store.fetchQuote(),
loadKline: (period: KlinePeriod) => store.loadKline(period),
}
}

186
src/stores/quota.ts Normal file
View File

@ -0,0 +1,186 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type { Candle, NewsItem, PositionsData, QuoteData } from '../types'
import { quoteMock } from '../mocks/quote'
import { newsMock } from '../mocks/news'
import { positionsMock } from '../mocks/positions'
import { contractConfig } from '../config/contract'
import { getStockKline, getStockQuotation } from '../api/baidu/quotation'
import { getFuturesNews } from '../api/baidu/news'
import { mapBaiduQuotationToQuote } from '../api/baidu/mapQuote'
import { mapBaiduKlineToCandles } from '../api/baidu/mapKline'
import { mapBaiduNewsToItems } from '../api/baidu/mapNews'
import type { BaiduKlineType } from '../api/baidu/types'
/** K 线周期(日/周/月) */
export type KlinePeriod = 'day' | 'week' | 'month'
const KTYPE_MAP: Record<KlinePeriod, BaiduKlineType> = {
day: 1,
week: 2,
month: 3,
}
/** 供 AI 分析消费的数据快照(可序列化) */
export interface AnalysisSnapshot {
contract: {
code: string
name: string
exchange: string
}
quote: QuoteData | null
news: NewsItem[]
positions: PositionsData | null
fetchedAt: string
}
function emptyCandles(): QuoteData['candles'] {
return { day: [], week: [], month: [] }
}
export const useQuotaStore = defineStore('quota', () => {
// ─── 行情 ───────────────────────────────────────────────
const quote = ref<QuoteData>(structuredClone(quoteMock))
const quoteLoading = ref(false)
const klineLoading = ref(false)
const quoteError = ref<unknown>(null)
const loadedKlines = ref<Record<KlinePeriod, boolean>>({
day: false,
week: false,
month: false,
})
// ─── 新闻 ───────────────────────────────────────────────
const news = ref<NewsItem[]>(structuredClone(newsMock))
const newsLoading = ref(false)
const newsError = ref<unknown>(null)
// ─── 机构持仓(暂 mock,后续接 API)────────────────────
const positions = ref<PositionsData>(structuredClone(positionsMock))
const positionsLoading = ref(false)
const positionsError = ref<unknown>(null)
/** 是否已有至少一次成功的行情拉取(可用于 AI 门禁) */
const hasQuote = computed(() => quote.value.last > 0 && quote.value.intraday.length > 0)
const hasNews = computed(() => news.value.length > 0)
async function fetchQuote() {
quoteLoading.value = true
quoteError.value = null
try {
const result = await getStockQuotation({ code: contractConfig.code })
const mapped = mapBaiduQuotationToQuote(result)
mapped.candles = emptyCandles()
loadedKlines.value = { day: false, week: false, month: false }
quote.value = mapped
} catch (e) {
quoteError.value = e
console.error('[quota] 拉取行情失败', e)
} finally {
quoteLoading.value = false
}
}
/** 按需加载日/周/月 K 线;已加载则直接返回缓存 */
async function loadKline(period: KlinePeriod): Promise<Candle[]> {
if (loadedKlines.value[period]) {
return quote.value.candles[period]
}
klineLoading.value = true
quoteError.value = null
try {
const result = await getStockKline({
code: contractConfig.code,
ktype: KTYPE_MAP[period],
})
const candles = mapBaiduKlineToCandles(result)
quote.value.candles[period] = candles
loadedKlines.value[period] = true
return candles
} catch (e) {
quoteError.value = e
console.error(`[quota] 拉取${period}K线失败`, e)
throw e
} finally {
klineLoading.value = false
}
}
async function fetchNews() {
newsLoading.value = true
newsError.value = null
try {
const list = await getFuturesNews({ code: contractConfig.code })
news.value = mapBaiduNewsToItems(list)
} catch (e) {
newsError.value = e
console.error('[quota] 拉取新闻失败', e)
} finally {
newsLoading.value = false
}
}
async function fetchPositions() {
positionsLoading.value = true
positionsError.value = null
try {
// TODO: 接入真实持仓 API 后替换
await new Promise((r) => setTimeout(r, 300))
positions.value = structuredClone(positionsMock)
} catch (e) {
positionsError.value = e
console.error('[quota] 拉取持仓失败', e)
} finally {
positionsLoading.value = false
}
}
/** 并行刷新行情 + 新闻 + 持仓(K 线仍按需) */
async function fetchAll() {
await Promise.all([fetchQuote(), fetchNews(), fetchPositions()])
}
/**
* 导出当前全部接口数据快照,供下一步 AI 分析拼接 Prompt。
* 返回深拷贝,避免分析过程中被行情刷新污染。
*/
function getAnalysisSnapshot(): AnalysisSnapshot {
return {
contract: {
code: quote.value.code || contractConfig.code,
name: quote.value.name || contractConfig.name,
exchange: quote.value.exchange || contractConfig.exchange,
},
quote: quote.value ? structuredClone(quote.value) : null,
news: structuredClone(news.value),
positions: positions.value ? structuredClone(positions.value) : null,
fetchedAt: new Date().toISOString(),
}
}
return {
// state
quote,
quoteLoading,
klineLoading,
quoteError,
loadedKlines,
news,
newsLoading,
newsError,
positions,
positionsLoading,
positionsError,
// computed
hasQuote,
hasNews,
// actions
fetchQuote,
loadKline,
fetchNews,
fetchPositions,
fetchAll,
getAnalysisSnapshot,
}
})