import { computed, ref, shallowRef } from 'vue' import type { ActionItem, ActionsResponse, PipelineStatus, StockDetail, StockSearchItem, Summary, TraderDetail, TraderListItem, WarningItem, WatchlistItem, } from '../types' import { numberFromText } from '../utils/format' async function api(path: string, init: RequestInit = {}): Promise { const headers = new Headers(init.headers) if (init.body && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json') } const response = await fetch(path, { ...init, headers, }) if (!response.ok) { throw new Error(`HTTP ${response.status}`) } return response.json() as Promise } export function useDashboardData() { const status = ref(null) const summary = ref(null) const warnings = ref([]) const traders = ref([]) const traderDetail = ref(null) const stockDetail = ref(null) const actions = ref([]) const watchlist = ref([]) const selectedWarningCode = shallowRef('') const selectedTraderId = shallowRef(null) const selectedStockCode = shallowRef('') const selectedDateFrom = shallowRef('') const selectedDateTo = shallowRef('') const selectedTraderFilter = shallowRef('all') const selectedActionFilter = shallowRef<'all' | 'buy' | 'sell' | 'net_buy' | 'net_sell'>('all') const isBooting = shallowRef(true) const errorMessage = shallowRef('') const activeWarning = computed(() => { return warnings.value.find((item) => item.stock_code === selectedWarningCode.value) ?? null }) const availableTradeDates = computed(() => { return (status.value?.recent_trade_days ?? []) .map((item) => item.trade_date) .filter((item): item is string => Boolean(item)) }) function inferActionSide(buyAmount: number, sellAmount: number, netAmount: number): ActionItem['action_side'] { if (buyAmount > 0 && sellAmount <= 0) return 'buy' if (sellAmount > 0 && buyAmount <= 0) return 'sell' if (netAmount >= 0) return 'net_buy' return 'net_sell' } function matchesActionFilter(item: ActionItem): boolean { const buyAmount = numberFromText(item.buy_amount_wan) ?? 0 const sellAmount = numberFromText(item.sell_amount_wan) ?? 0 const netAmount = numberFromText(item.net_amount_wan) ?? buyAmount - sellAmount if (selectedActionFilter.value === 'buy') return buyAmount > 0 if (selectedActionFilter.value === 'sell') return sellAmount > 0 if (selectedActionFilter.value === 'net_buy') return netAmount > 0 if (selectedActionFilter.value === 'net_sell') return netAmount < 0 return true } function aggregateCandidateActions(rows: ActionItem[]): ActionItem[] { const groups = new Map() for (const item of rows) { const groupKey = `${item.stock_code}::${item.trade_date}` const existing = groups.get(groupKey) if (!existing) { groups.set(groupKey, { ...item, participant_traders: item.trader_name ? [item.trader_name] : [], participant_trader_count: item.trader_name ? 1 : 0, }) continue } const nextBuy = (numberFromText(existing.buy_amount_wan) ?? 0) + (numberFromText(item.buy_amount_wan) ?? 0) const nextSell = (numberFromText(existing.sell_amount_wan) ?? 0) + (numberFromText(item.sell_amount_wan) ?? 0) const nextNet = nextBuy - nextSell const traderNames = new Set([...(existing.participant_traders ?? [existing.trader_name]), item.trader_name].filter(Boolean)) const tableTitles = new Set([existing.table_title, item.table_title].filter(Boolean)) const seatNames = new Set([existing.seat_name, item.seat_name].filter(Boolean)) const participantTraders = [...traderNames] groups.set(groupKey, { ...existing, trader_name: participantTraders.join(' / '), participant_traders: participantTraders, participant_trader_count: participantTraders.length, table_title: [...tableTitles].join(' / '), seat_name: seatNames.size > 1 ? `${seatNames.size}个席位` : existing.seat_name, buy_amount_wan: nextBuy.toFixed(2), sell_amount_wan: nextSell.toFixed(2), net_amount_wan: nextNet.toFixed(2), action_side: inferActionSide(nextBuy, nextSell, nextNet), }) } return [...groups.values()].sort((left, right) => { const traderDiff = (right.participant_trader_count ?? 0) - (left.participant_trader_count ?? 0) if (traderDiff !== 0) return traderDiff const netDiff = (numberFromText(right.net_amount_wan) ?? 0) - (numberFromText(left.net_amount_wan) ?? 0) if (netDiff !== 0) return netDiff return right.trade_date.localeCompare(left.trade_date) }) } const filteredActions = computed(() => { return actions.value.filter(matchesActionFilter) }) const watchlistMap = computed(() => { return new Map(watchlist.value.map((item) => [item.stock_code, item])) }) const watchedActionRows = computed(() => { return filteredActions.value.filter((item) => watchlistMap.value.has(item.stock_code)) }) const candidateActionRows = computed(() => { const rows = actions.value.filter((item) => !watchlistMap.value.has(item.stock_code)) return aggregateCandidateActions(rows).filter(matchesActionFilter) }) const watchlistMetrics = computed(() => { const rows = watchedActionRows.value const uniqueCodes = new Set(rows.map((item) => item.stock_code)) const buyTotal = rows.reduce((sum, item) => sum + (numberFromText(item.buy_amount_wan) ?? 0), 0) const sellTotal = rows.reduce((sum, item) => sum + (numberFromText(item.sell_amount_wan) ?? 0), 0) const netPositiveCount = new Set( rows .filter((item) => (numberFromText(item.net_amount_wan) ?? 0) > 0) .map((item) => item.stock_code), ).size const warningCount = new Set( warnings.value .filter((item) => watchlistMap.value.has(item.stock_code)) .map((item) => item.stock_code), ).size return { watchCount: watchlist.value.length, activeWatchCount: uniqueCodes.size, buyTotalWan: buyTotal, sellTotalWan: sellTotal, netPositiveCount, warningCount, } }) const watchlistStocksForDisplay = computed(() => { return watchlist.value.map((item) => { const warning = warnings.value.find((warningItem) => warningItem.stock_code === item.stock_code) ?? null return { ...item, hasWarning: Boolean(warning), warning, } }) }) function isWatched(stockCode: string): boolean { return watchlistMap.value.has(stockCode) } async function loadWatchlist() { watchlist.value = await api('/api/watchlist') } async function addToWatchlist(item: Pick) { if (watchlistMap.value.has(item.stock_code)) return await api('/api/watchlist', { method: 'POST', body: JSON.stringify(item), }) await loadWatchlist() } async function removeFromWatchlist(stockCode: string) { if (!watchlistMap.value.has(stockCode)) return await api<{ ok: boolean; stock_code: string }>(`/api/watchlist/${encodeURIComponent(stockCode)}`, { method: 'DELETE', }) await loadWatchlist() } async function loadActions() { const params = new URLSearchParams() if (selectedDateFrom.value && selectedDateTo.value) { params.set('date_from', selectedDateFrom.value) params.set('date_to', selectedDateTo.value) } if (selectedTraderFilter.value !== 'all') params.set('trader_name', selectedTraderFilter.value) params.set('limit', '300') const response = await api(`/api/actions?${params.toString()}`) actions.value = response.actions if (!selectedDateFrom.value && response.date_from) { selectedDateFrom.value = response.date_from } if (!selectedDateTo.value && response.date_to) { selectedDateTo.value = response.date_to } } async function selectTrader(traderId: number) { if (traderDetail.value?.trader.id === traderId) { selectedTraderId.value = traderId return } selectedTraderId.value = traderId traderDetail.value = await api(`/api/traders/${traderId}`) } async function selectStock(stockCode: string) { if (stockDetail.value?.stock.stock_code === stockCode) { selectedStockCode.value = stockCode return } selectedStockCode.value = stockCode stockDetail.value = await api(`/api/stocks/${encodeURIComponent(stockCode)}`) } async function searchStocks(query: string, limit = 8) { const keyword = query.trim() if (!keyword) return [] as StockSearchItem[] const params = new URLSearchParams({ q: keyword, limit: String(limit), }) return api(`/api/stocks/search?${params.toString()}`) } async function selectWarning(item: WarningItem) { selectedWarningCode.value = item.stock_code await selectStock(item.stock_code) } async function selectTradeDateRange(dateFrom: string, dateTo: string) { selectedDateFrom.value = dateFrom selectedDateTo.value = dateTo await loadActions() } async function initialize() { isBooting.value = true errorMessage.value = '' try { const [statusResult, summaryResult, warningResult, traderResult, watchlistResult] = await Promise.all([ api('/api/pipeline/status'), api('/api/summary'), api('/api/warnings?limit=40'), api('/api/traders'), api('/api/watchlist'), ]) status.value = statusResult summary.value = summaryResult warnings.value = warningResult traders.value = traderResult watchlist.value = watchlistResult const latestTradeDate = statusResult.recent_trade_days[0]?.trade_date ?? '' selectedDateFrom.value = latestTradeDate selectedDateTo.value = latestTradeDate await loadActions() if (traderResult[0]) { selectedTraderId.value = traderResult[0].id } const watchlistCodeSet = new Set(watchlistResult.map((item) => item.stock_code)) const preferredWarningCode = warningResult.find((item) => watchlistCodeSet.has(item.stock_code))?.stock_code ?? warningResult[0]?.stock_code ?? '' const preferredStockCode = watchlist.value[0]?.stock_code ?? preferredWarningCode if (preferredWarningCode) { selectedWarningCode.value = preferredWarningCode } if (preferredStockCode) { selectedStockCode.value = preferredStockCode } } catch (error) { errorMessage.value = String(error instanceof Error ? error.message : error) } finally { isBooting.value = false } } return { status, summary, warnings, traders, traderDetail, stockDetail, actions, watchlist, selectedWarningCode, selectedTraderId, selectedStockCode, selectedDateFrom, selectedDateTo, selectedTraderFilter, selectedActionFilter, activeWarning, availableTradeDates, filteredActions, watchedActionRows, candidateActionRows, watchlistMetrics, watchlistStocksForDisplay, isBooting, errorMessage, initialize, loadActions, loadWatchlist, selectTrader, selectStock, searchStocks, selectWarning, selectTradeDateRange, isWatched, addToWatchlist, removeFromWatchlist, } }