2026-04-17 21:20:26 +08:00
|
|
|
|
<script setup lang="ts">
|
2026-04-18 14:46:24 +08:00
|
|
|
|
import { computed, onMounted, onUnmounted, shallowRef, watch } from 'vue'
|
2026-04-17 21:20:26 +08:00
|
|
|
|
|
|
|
|
|
|
import AppHero from './components/AppHero.vue'
|
|
|
|
|
|
import HomeControlScreen from './components/HomeControlScreen.vue'
|
|
|
|
|
|
import StockDetailScreen from './components/StockDetailScreen.vue'
|
|
|
|
|
|
import TraderDetailScreen from './components/TraderDetailScreen.vue'
|
|
|
|
|
|
import WarningCenterScreen from './components/WarningCenterScreen.vue'
|
|
|
|
|
|
import { useDashboardData } from './composables/useDashboardData'
|
2026-05-02 18:27:36 +08:00
|
|
|
|
import type { StockSearchItem, WarningItem } from './types'
|
2026-04-17 21:20:26 +08:00
|
|
|
|
|
|
|
|
|
|
const dashboard = useDashboardData()
|
|
|
|
|
|
|
|
|
|
|
|
type PageKey = 'home' | 'trader' | 'stock' | 'warning'
|
|
|
|
|
|
|
|
|
|
|
|
const currentPage = shallowRef<PageKey>('home')
|
|
|
|
|
|
const selectedTraderId = computed(() => dashboard.selectedTraderId.value)
|
2026-05-02 18:27:36 +08:00
|
|
|
|
const stockSearchQuery = shallowRef('')
|
|
|
|
|
|
const stockSearchResults = shallowRef<StockSearchItem[]>([])
|
|
|
|
|
|
const stockSearchLoading = shallowRef(false)
|
|
|
|
|
|
let stockSearchTimer: ReturnType<typeof window.setTimeout> | null = null
|
|
|
|
|
|
let latestStockSearchToken = 0
|
2026-04-17 21:20:26 +08:00
|
|
|
|
|
|
|
|
|
|
const navItems: Array<{ key: PageKey; label: string }> = [
|
|
|
|
|
|
{ key: 'home', label: '首页总控台' },
|
|
|
|
|
|
{ key: 'trader', label: '游资详情' },
|
|
|
|
|
|
{ key: 'stock', label: '个股详情' },
|
|
|
|
|
|
{ key: 'warning', label: '预警中心' },
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
function pageFromHash(): PageKey {
|
|
|
|
|
|
const hash = window.location.hash.replace(/^#\/?/, '')
|
|
|
|
|
|
if (hash === 'trader' || hash === 'stock' || hash === 'warning') return hash
|
|
|
|
|
|
return 'home'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function syncPageFromHash() {
|
|
|
|
|
|
currentPage.value = pageFromHash()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 14:46:24 +08:00
|
|
|
|
async function ensurePageData(page: PageKey) {
|
|
|
|
|
|
if (dashboard.isBooting.value) return
|
|
|
|
|
|
|
|
|
|
|
|
if (page === 'trader' && dashboard.selectedTraderId.value !== null) {
|
|
|
|
|
|
await dashboard.selectTrader(dashboard.selectedTraderId.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (page === 'stock' && dashboard.selectedStockCode.value) {
|
|
|
|
|
|
await dashboard.selectStock(dashboard.selectedStockCode.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 21:20:26 +08:00
|
|
|
|
function navigate(page: PageKey) {
|
|
|
|
|
|
const nextHash = `#/${page}`
|
|
|
|
|
|
if (window.location.hash !== nextHash) {
|
|
|
|
|
|
window.location.hash = nextHash
|
|
|
|
|
|
}
|
|
|
|
|
|
currentPage.value = page
|
2026-04-18 14:46:24 +08:00
|
|
|
|
void ensurePageData(page)
|
2026-04-17 21:20:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleSelectTrader(traderId: number) {
|
|
|
|
|
|
await dashboard.selectTrader(traderId)
|
|
|
|
|
|
navigate('trader')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleSelectStock(stockCode: string) {
|
|
|
|
|
|
await dashboard.selectStock(stockCode)
|
|
|
|
|
|
navigate('stock')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-02 18:27:36 +08:00
|
|
|
|
async function handleSearchSelectStock(stock: Pick<StockSearchItem, 'stock_code' | 'stock_name'>) {
|
|
|
|
|
|
stockSearchQuery.value = stock.stock_name
|
|
|
|
|
|
stockSearchResults.value = []
|
|
|
|
|
|
await handleSelectStock(stock.stock_code)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 21:20:26 +08:00
|
|
|
|
async function handleSelectWarningInCenter(warning: WarningItem) {
|
|
|
|
|
|
await dashboard.selectWarning(warning)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleSelectTradeDateRange(payload: { dateFrom: string; dateTo: string }) {
|
|
|
|
|
|
await dashboard.selectTradeDateRange(payload.dateFrom, payload.dateTo)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleUpdateTraderFilter(traderName: string) {
|
|
|
|
|
|
dashboard.selectedTraderFilter.value = traderName
|
|
|
|
|
|
void dashboard.loadActions()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleUpdateActionFilter(actionFilter: 'all' | 'buy' | 'sell' | 'net_buy' | 'net_sell') {
|
|
|
|
|
|
dashboard.selectedActionFilter.value = actionFilter
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleFollowStock(payload: {
|
|
|
|
|
|
stock_code: string
|
|
|
|
|
|
stock_name: string
|
|
|
|
|
|
source_trade_date: string | null
|
|
|
|
|
|
source_trader_name: string | null
|
|
|
|
|
|
}) {
|
|
|
|
|
|
await dashboard.addToWatchlist(payload)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleUnfollowStock(stockCode: string) {
|
|
|
|
|
|
await dashboard.removeFromWatchlist(stockCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
syncPageFromHash()
|
|
|
|
|
|
window.addEventListener('hashchange', syncPageFromHash)
|
2026-04-18 14:46:24 +08:00
|
|
|
|
void (async () => {
|
|
|
|
|
|
await dashboard.initialize()
|
|
|
|
|
|
await ensurePageData(currentPage.value)
|
|
|
|
|
|
})()
|
2026-04-17 21:20:26 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
window.removeEventListener('hashchange', syncPageFromHash)
|
2026-05-02 18:27:36 +08:00
|
|
|
|
if (stockSearchTimer !== null) {
|
|
|
|
|
|
window.clearTimeout(stockSearchTimer)
|
|
|
|
|
|
}
|
2026-04-17 21:20:26 +08:00
|
|
|
|
})
|
2026-04-18 14:46:24 +08:00
|
|
|
|
|
|
|
|
|
|
watch(currentPage, (page) => {
|
|
|
|
|
|
void ensurePageData(page)
|
|
|
|
|
|
})
|
2026-05-02 18:27:36 +08:00
|
|
|
|
|
|
|
|
|
|
watch(stockSearchQuery, (nextQuery) => {
|
|
|
|
|
|
const keyword = nextQuery.trim()
|
|
|
|
|
|
if (stockSearchTimer !== null) {
|
|
|
|
|
|
window.clearTimeout(stockSearchTimer)
|
|
|
|
|
|
stockSearchTimer = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!keyword) {
|
|
|
|
|
|
stockSearchResults.value = []
|
|
|
|
|
|
stockSearchLoading.value = false
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
stockSearchTimer = window.setTimeout(() => {
|
|
|
|
|
|
const currentToken = ++latestStockSearchToken
|
|
|
|
|
|
stockSearchLoading.value = true
|
|
|
|
|
|
void dashboard
|
|
|
|
|
|
.searchStocks(keyword, 8)
|
|
|
|
|
|
.then((results) => {
|
|
|
|
|
|
if (currentToken !== latestStockSearchToken) return
|
|
|
|
|
|
stockSearchResults.value = results
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
if (currentToken !== latestStockSearchToken) return
|
|
|
|
|
|
stockSearchResults.value = []
|
|
|
|
|
|
})
|
|
|
|
|
|
.finally(() => {
|
|
|
|
|
|
if (currentToken !== latestStockSearchToken) return
|
|
|
|
|
|
stockSearchLoading.value = false
|
|
|
|
|
|
})
|
|
|
|
|
|
}, 180)
|
|
|
|
|
|
})
|
2026-04-17 21:20:26 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<main class="page-shell">
|
|
|
|
|
|
<div v-if="dashboard.errorMessage.value" class="error-banner">
|
|
|
|
|
|
数据加载失败:{{ dashboard.errorMessage.value }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<AppHero
|
|
|
|
|
|
:summary="dashboard.summary.value"
|
|
|
|
|
|
:status="dashboard.status.value"
|
|
|
|
|
|
:nav-items="navItems"
|
|
|
|
|
|
:current-page="currentPage"
|
2026-05-02 18:27:36 +08:00
|
|
|
|
:search-query="stockSearchQuery"
|
|
|
|
|
|
:search-results="stockSearchResults"
|
|
|
|
|
|
:search-loading="stockSearchLoading"
|
2026-04-17 21:20:26 +08:00
|
|
|
|
@navigate="navigate"
|
2026-05-02 18:27:36 +08:00
|
|
|
|
@update-search-query="stockSearchQuery = $event"
|
|
|
|
|
|
@select-stock="handleSearchSelectStock"
|
2026-04-17 21:20:26 +08:00
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="dashboard.isBooting.value" class="loading-state">
|
|
|
|
|
|
正在加载龙虎榜、游资和预警数据...
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<section v-else class="page-stage">
|
|
|
|
|
|
<HomeControlScreen
|
|
|
|
|
|
v-if="currentPage === 'home'"
|
|
|
|
|
|
:trade-dates="dashboard.availableTradeDates.value"
|
|
|
|
|
|
:selected-date-from="dashboard.selectedDateFrom.value"
|
|
|
|
|
|
:selected-date-to="dashboard.selectedDateTo.value"
|
|
|
|
|
|
:traders="dashboard.traders.value"
|
|
|
|
|
|
:selected-trader-filter="dashboard.selectedTraderFilter.value"
|
|
|
|
|
|
:selected-action-filter="dashboard.selectedActionFilter.value"
|
|
|
|
|
|
:watched-actions="dashboard.watchedActionRows.value"
|
|
|
|
|
|
:candidate-actions="dashboard.candidateActionRows.value"
|
|
|
|
|
|
:watchlist="dashboard.watchlistStocksForDisplay.value"
|
|
|
|
|
|
:metrics="dashboard.watchlistMetrics.value"
|
|
|
|
|
|
@select-trade-date-range="handleSelectTradeDateRange"
|
|
|
|
|
|
@update-trader-filter="handleUpdateTraderFilter"
|
|
|
|
|
|
@update-action-filter="handleUpdateActionFilter"
|
|
|
|
|
|
@select-stock="handleSelectStock"
|
|
|
|
|
|
@follow-stock="handleFollowStock"
|
|
|
|
|
|
@unfollow-stock="handleUnfollowStock"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<TraderDetailScreen
|
|
|
|
|
|
v-else-if="currentPage === 'trader'"
|
|
|
|
|
|
:traders="dashboard.traders.value"
|
|
|
|
|
|
:trader-detail="dashboard.traderDetail.value"
|
|
|
|
|
|
:selected-trader-id="selectedTraderId"
|
|
|
|
|
|
@select-trader="handleSelectTrader"
|
|
|
|
|
|
@select-stock="handleSelectStock"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<StockDetailScreen
|
|
|
|
|
|
v-else-if="currentPage === 'stock'"
|
|
|
|
|
|
:stock-detail="dashboard.stockDetail.value"
|
|
|
|
|
|
:active-warning="dashboard.activeWarning.value"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<WarningCenterScreen
|
|
|
|
|
|
v-else
|
|
|
|
|
|
:warnings="dashboard.warnings.value"
|
|
|
|
|
|
:traders="dashboard.traders.value"
|
|
|
|
|
|
:active-warning="dashboard.activeWarning.value"
|
|
|
|
|
|
@select-warning="handleSelectWarningInCenter"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</main>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.page-shell {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-rows: auto auto minmax(0, 1fr);
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
height: calc(100vh - 12px);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.loading-state,
|
|
|
|
|
|
.error-banner {
|
|
|
|
|
|
padding: 14px 16px;
|
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.04);
|
|
|
|
|
|
color: var(--color-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.error-banner {
|
|
|
|
|
|
border-color: rgba(255, 93, 93, 0.2);
|
|
|
|
|
|
color: #ffb4b4;
|
|
|
|
|
|
background: rgba(255, 93, 93, 0.08);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.page-stage {
|
|
|
|
|
|
min-height: 0;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|