feat: improve stock detail view and update docs

This commit is contained in:
wanghep
2026-05-02 18:27:36 +08:00
parent a492c73cc6
commit 597aef1271
21 changed files with 1739 additions and 1481 deletions

View File

@ -7,7 +7,7 @@ import StockDetailScreen from './components/StockDetailScreen.vue'
import TraderDetailScreen from './components/TraderDetailScreen.vue'
import WarningCenterScreen from './components/WarningCenterScreen.vue'
import { useDashboardData } from './composables/useDashboardData'
import type { WarningItem } from './types'
import type { StockSearchItem, WarningItem } from './types'
const dashboard = useDashboardData()
@ -15,6 +15,11 @@ type PageKey = 'home' | 'trader' | 'stock' | 'warning'
const currentPage = shallowRef<PageKey>('home')
const selectedTraderId = computed(() => dashboard.selectedTraderId.value)
const stockSearchQuery = shallowRef('')
const stockSearchResults = shallowRef<StockSearchItem[]>([])
const stockSearchLoading = shallowRef(false)
let stockSearchTimer: ReturnType<typeof window.setTimeout> | null = null
let latestStockSearchToken = 0
const navItems: Array<{ key: PageKey; label: string }> = [
{ key: 'home', label: '首页总控台' },
@ -64,6 +69,12 @@ async function handleSelectStock(stockCode: string) {
navigate('stock')
}
async function handleSearchSelectStock(stock: Pick<StockSearchItem, 'stock_code' | 'stock_name'>) {
stockSearchQuery.value = stock.stock_name
stockSearchResults.value = []
await handleSelectStock(stock.stock_code)
}
async function handleSelectWarningInCenter(warning: WarningItem) {
await dashboard.selectWarning(warning)
}
@ -105,11 +116,47 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('hashchange', syncPageFromHash)
if (stockSearchTimer !== null) {
window.clearTimeout(stockSearchTimer)
}
})
watch(currentPage, (page) => {
void ensurePageData(page)
})
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)
})
</script>
<template>
@ -123,7 +170,12 @@ watch(currentPage, (page) => {
:status="dashboard.status.value"
:nav-items="navItems"
:current-page="currentPage"
:search-query="stockSearchQuery"
:search-results="stockSearchResults"
:search-loading="stockSearchLoading"
@navigate="navigate"
@update-search-query="stockSearchQuery = $event"
@select-stock="handleSearchSelectStock"
/>
<div v-if="dashboard.isBooting.value" class="loading-state">

View File

@ -1,24 +1,48 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, shallowRef } from 'vue'
import type { PipelineStatus, Summary } from '../types'
import type { PipelineStatus, StockSearchItem, Summary } from '../types'
const props = defineProps<{
summary: Summary | null
status: PipelineStatus | null
navItems: Array<{ key: 'home' | 'trader' | 'stock' | 'warning'; label: string }>
currentPage: 'home' | 'trader' | 'stock' | 'warning'
searchQuery: string
searchResults: StockSearchItem[]
searchLoading: boolean
}>()
const emit = defineEmits<{
navigate: [page: 'home' | 'trader' | 'stock' | 'warning']
updateSearchQuery: [value: string]
selectStock: [stock: StockSearchItem]
}>()
const searchFocused = shallowRef(false)
const statusBadges = computed(() => [
`最新交易日 ${props.status?.latest_trade_date ?? '-'}`,
`导入交易日 ${props.summary?.imported_days ?? 0}`,
`目标游资 ${props.summary?.trader_total ?? 0}`,
])
const showSearchPanel = computed(() => {
if (!searchFocused.value) return false
if (props.searchLoading) return true
return props.searchQuery.trim().length > 0
})
function handleBlur() {
window.setTimeout(() => {
searchFocused.value = false
}, 120)
}
function handleSelectStock(stock: StockSearchItem) {
searchFocused.value = false
emit('selectStock', stock)
}
</script>
<template>
@ -43,6 +67,41 @@ const statusBadges = computed(() => [
>
{{ item.label }}
</button>
<div class="hero-search" :class="{ open: showSearchPanel }">
<span class="hero-search-icon" aria-hidden="true"></span>
<input
:value="searchQuery"
class="hero-search-input"
type="text"
placeholder="搜索个股"
@focus="searchFocused = true"
@blur="handleBlur"
@input="emit('updateSearchQuery', ($event.target as HTMLInputElement).value)"
/>
<div v-if="showSearchPanel" class="hero-search-panel">
<div v-if="searchLoading" class="hero-search-empty">搜索中...</div>
<template v-else-if="searchResults.length">
<button
v-for="stock in searchResults"
:key="stock.stock_code"
class="hero-search-item"
type="button"
@mousedown.prevent="handleSelectStock(stock)"
>
<div class="hero-search-main">
<strong>{{ stock.stock_name }}</strong>
<span>{{ stock.stock_code }}</span>
</div>
<span class="hero-search-meta">{{ stock.industry || stock.market || 'A股' }}</span>
</button>
</template>
<div v-else class="hero-search-empty">没有匹配到股票</div>
</div>
</div>
</nav>
</div>
@ -109,39 +168,165 @@ const statusBadges = computed(() => [
.hero-nav {
display: flex;
gap: 6px;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.hero-nav-item,
.hero-search-input {
height: 34px;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
background: rgba(255, 255, 255, 0.028);
color: var(--color-muted);
font-size: 11px;
}
.hero-nav-item {
padding: 6px 9px;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 999px;
background: rgba(255, 255, 255, 0.03);
color: var(--color-muted);
font-size: 11px;
padding: 0 12px;
cursor: pointer;
transition: border-color 120ms ease, background 120ms ease, color 120ms ease, transform 120ms ease;
}
.hero-nav-item:hover {
border-color: rgba(240, 192, 113, 0.14);
background: rgba(255, 255, 255, 0.045);
color: var(--color-text);
}
.hero-nav-item.active {
color: #090d14;
background: linear-gradient(180deg, var(--color-gold-soft), var(--color-gold));
border-color: transparent;
box-shadow: 0 8px 18px rgba(212, 163, 92, 0.22);
}
.hero-search {
position: relative;
width: 272px;
}
.hero-search-icon {
position: absolute;
left: 11px;
top: 50%;
z-index: 1;
color: rgba(240, 192, 113, 0.9);
font-size: 12px;
transform: translateY(-50%);
pointer-events: none;
}
.hero-search-input {
width: 100%;
padding: 0 12px 0 28px;
outline: none;
transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease, color 120ms ease;
}
.hero-search-input::placeholder {
color: rgba(147, 162, 181, 0.82);
}
.hero-search-input:focus {
border-color: rgba(240, 192, 113, 0.2);
background: rgba(255, 255, 255, 0.05);
color: var(--color-text);
box-shadow:
0 0 0 3px rgba(240, 192, 113, 0.08),
0 10px 24px rgba(0, 0, 0, 0.18);
}
.hero-search-panel {
position: absolute;
top: calc(100% + 10px);
left: 0;
width: 272px;
z-index: 30;
display: grid;
gap: 4px;
padding: 8px;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
background:
linear-gradient(180deg, rgba(14, 20, 30, 0.98), rgba(8, 12, 18, 0.98));
box-shadow:
0 22px 40px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.hero-search-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 10px;
background: rgba(255, 255, 255, 0.025);
color: var(--color-text);
cursor: pointer;
text-align: left;
transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
}
.hero-search-item:hover {
border-color: rgba(240, 192, 113, 0.16);
background: rgba(240, 192, 113, 0.06);
transform: translateY(-1px);
}
.hero-search-main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.hero-search-main strong {
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.hero-search-main span,
.hero-search-meta,
.hero-search-empty {
color: var(--color-muted);
font-size: 10px;
}
.hero-search-main span {
white-space: nowrap;
}
.hero-search-meta {
flex: 0 0 auto;
max-width: 88px;
overflow: hidden;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.hero-search-empty {
padding: 6px 4px 4px;
}
.hero-badges {
display: flex;
gap: 8px;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.hero-badge {
padding: 6px 10px;
padding: 5px 9px;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 999px;
color: var(--color-muted);
font-size: 11px;
font-size: 10px;
white-space: nowrap;
background: rgba(255, 255, 255, 0.04);
}

View File

@ -516,6 +516,7 @@ function updateDateRange(field: 'from' | 'to', value: string) {
.watch-panel-list {
min-height: 0;
height: 100%;
grid-auto-rows: max-content;
align-content: start;
overflow: auto;
padding-right: 4px;
@ -525,7 +526,9 @@ function updateDateRange(field: 'from' | 'to', value: string) {
display: grid;
gap: 12px;
padding: 12px;
min-height: 176px;
min-height: 0;
align-content: start;
overflow: hidden;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));
}
@ -542,6 +545,8 @@ function updateDateRange(field: 'from' | 'to', value: string) {
.watch-stock-main {
display: grid;
gap: 6px;
min-width: 0;
flex: 1 1 auto;
}
.watch-stock-title {
@ -568,6 +573,8 @@ function updateDateRange(field: 'from' | 'to', value: string) {
align-items: center;
gap: 10px;
flex-wrap: wrap;
flex: none;
align-self: flex-start;
}
.watch-flag {
@ -583,7 +590,9 @@ function updateDateRange(field: 'from' | 'to', value: string) {
}
.watch-action-list {
display: grid;
gap: 10px;
align-content: start;
}
.action-row {
@ -594,6 +603,8 @@ function updateDateRange(field: 'from' | 'to', value: string) {
border-radius: 16px;
background: rgba(4, 8, 12, 0.38);
align-content: start;
min-height: 0;
overflow: hidden;
}
.amounts {
@ -619,7 +630,7 @@ function updateDateRange(field: 'from' | 'to', value: string) {
align-content: start;
overflow: auto;
padding-right: 4px;
grid-auto-rows: minmax(124px, auto);
grid-auto-rows: minmax(136px, auto);
}
.buy {
@ -651,13 +662,6 @@ function updateDateRange(field: 'from' | 'to', value: string) {
cursor: pointer;
}
.inline-empty,
.empty-state {
padding: 12px;
border: 1px dashed rgba(255, 255, 255, 0.08);
border-radius: 16px;
}
.watch-panel-list::-webkit-scrollbar,
.candidate-list::-webkit-scrollbar {
width: 8px;
@ -669,6 +673,13 @@ function updateDateRange(field: 'from' | 'to', value: string) {
background: rgba(255, 255, 255, 0.12);
}
.inline-empty,
.empty-state {
padding: 12px;
border: 1px dashed rgba(255, 255, 255, 0.08);
border-radius: 16px;
}
@media (max-width: 1320px) {
.filter-bar,
.metric-grid,
@ -696,5 +707,10 @@ function updateDateRange(field: 'from' | 'to', value: string) {
flex-direction: column;
align-items: flex-start;
}
.watch-stock-tools {
width: 100%;
justify-content: space-between;
}
}
</style>

View File

@ -73,10 +73,10 @@ const aggregatedRows = computed<AggregatedActionRow[]>(() => {
const chartModel = computed(() => {
const rows = aggregatedRows.value
const measuredWidth = containerWidth.value || 0
const width = Math.max(measuredWidth, 320, rows.length * 54)
const height = 260
const width = Math.max(measuredWidth, 320)
const height = 320
const left = 56
const right = 64
const right = 24
const top = 18
const bottom = 34
const innerWidth = width - left - right
@ -90,9 +90,6 @@ const chartModel = computed(() => {
top,
bottom,
zeroY: top + innerHeight / 2,
stepX: 0,
buyBars: [] as Array<{ x: number; y: number; height: number; title: string }>,
sellBars: [] as Array<{ x: number; y: number; height: number; title: string }>,
netPoints: [] as Array<{ x: number; y: number }>,
cumulativePoints: [] as Array<{ x: number; y: number }>,
labels: [] as Array<{ x: number; label: string; visible: boolean }>,
@ -100,25 +97,15 @@ const chartModel = computed(() => {
hoverColumns: [] as Array<{ x: number; width: number }>,
}
if (!rows.length) {
return emptyModel
}
if (!rows.length) return emptyModel
const valueMax = Math.max(
...rows.flatMap((row) => [
Math.abs(row.buyTotalWan),
Math.abs(row.sellTotalWan),
Math.abs(row.netTotalWan),
Math.abs(row.cumulativeNetWan),
]),
...rows.flatMap((row) => [Math.abs(row.netTotalWan), Math.abs(row.cumulativeNetWan)]),
1,
)
const zeroY = top + innerHeight / 2
const yOfValue = (value: number) => zeroY - (value / valueMax) * (innerHeight / 2)
const stepX = rows.length === 1 ? innerWidth / 2 : innerWidth / (rows.length - 1)
const groupWidth = Math.max(20, Math.min(28, stepX * 0.58))
const barWidth = Math.max(7, groupWidth / 2 - 2)
const stepX = rows.length === 1 ? innerWidth / 2 : innerWidth / Math.max(rows.length - 1, 1)
const labelStep = Math.max(1, Math.ceil(rows.length / 6))
return {
@ -129,28 +116,6 @@ const chartModel = computed(() => {
top,
bottom,
zeroY,
stepX,
buyBars: rows.map((row, index) => {
const x = left + (rows.length === 1 ? innerWidth / 2 : index * stepX)
const y = yOfValue(row.buyTotalWan)
return {
x: x - barWidth - 2,
y,
height: Math.max(2, zeroY - y),
title: `${row.trade_date}\n买入 ${formatWanAmount(row.buyTotalWan)}\n卖出 ${formatWanAmount(row.sellTotalWan)}\n净额 ${formatSignedWanAmount(row.netTotalWan)}`,
}
}),
sellBars: rows.map((row, index) => {
const x = left + (rows.length === 1 ? innerWidth / 2 : index * stepX)
const y = zeroY
const sellY = yOfValue(-row.sellTotalWan)
return {
x: x + 2,
y,
height: Math.max(2, sellY - zeroY),
title: `${row.trade_date}\n买入 ${formatWanAmount(row.buyTotalWan)}\n卖出 ${formatWanAmount(row.sellTotalWan)}\n净额 ${formatSignedWanAmount(row.netTotalWan)}`,
}
}),
netPoints: rows.map((row, index) => ({
x: left + (rows.length === 1 ? innerWidth / 2 : index * stepX),
y: yOfValue(row.netTotalWan),
@ -181,8 +146,7 @@ const cumulativeLinePoints = computed(() =>
)
const activeRow = computed(() => {
if (!aggregatedRows.value.length) return null
if (hoveredIndex.value === null) return null
if (!aggregatedRows.value.length || hoveredIndex.value === null) return null
return aggregatedRows.value[hoveredIndex.value] ?? null
})
@ -191,8 +155,8 @@ const activeTooltip = computed(() => {
const point = chartModel.value.netPoints[hoveredIndex.value]
if (!point) return null
const tooltipWidth = 168
const tooltipHeight = 90
const tooltipWidth = 196
const tooltipHeight = 106
const x = Math.min(
Math.max(point.x - tooltipWidth / 2, chartModel.value.left + 6),
chartModel.value.width - chartModel.value.right - tooltipWidth - 6,
@ -245,17 +209,9 @@ onUnmounted(() => {
<div class="action-chart-head">
<div>
<h4 class="action-chart-title">买卖力度趋势</h4>
<p class="action-chart-note">柱形图显示买卖折线显示单日净额和累计净额</p>
<p class="action-chart-note">仅保留当日净额和累计净额减少干扰便于判断承接与延续</p>
</div>
<div class="action-chart-legend">
<span class="legend-chip">
<span class="legend-swatch bar buy" />
买入
</span>
<span class="legend-chip">
<span class="legend-swatch bar sell" />
卖出
</span>
<span class="legend-chip">
<span class="legend-swatch line net" />
当日净额
@ -311,32 +267,6 @@ onUnmounted(() => {
/>
</g>
<g v-for="(bar, index) in chartModel.buyBars" :key="`buy-${index}`">
<rect
:x="bar.x"
:y="bar.y"
width="10"
:height="bar.height"
rx="2"
fill="#ff7b7b"
>
<title>{{ bar.title }}</title>
</rect>
</g>
<g v-for="(bar, index) in chartModel.sellBars" :key="`sell-${index}`">
<rect
:x="bar.x"
:y="bar.y"
width="10"
:height="bar.height"
rx="2"
fill="#4ca8ff"
>
<title>{{ bar.title }}</title>
</rect>
</g>
<polyline
fill="none"
stroke="#f0c96a"
@ -351,10 +281,10 @@ onUnmounted(() => {
/>
<g v-for="(point, index) in chartModel.netPoints" :key="`net-point-${index}`">
<circle :cx="point.x" :cy="point.y" r="3.4" fill="#7de1b2" />
<circle :cx="point.x" :cy="point.y" r="3.6" fill="#7de1b2" />
</g>
<g v-for="(point, index) in chartModel.cumulativePoints" :key="`cumulative-point-${index}`">
<circle :cx="point.x" :cy="point.y" r="3.2" fill="#f0c96a" />
<circle :cx="point.x" :cy="point.y" r="3.4" fill="#f0c96a" />
</g>
<g v-if="activeTooltip && activeRow">
@ -370,18 +300,21 @@ onUnmounted(() => {
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 18" fill="#ffffff" font-size="11" font-weight="700">
{{ activeRow.trade_date }}
</text>
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 36" fill="#ff7b7b" font-size="11">
买入 {{ formatWanAmount(activeRow.buyTotalWan) }}
</text>
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 52" fill="#4ca8ff" font-size="11">
卖出 {{ formatWanAmount(activeRow.sellTotalWan) }}
</text>
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 68" fill="#7de1b2" font-size="11">
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 36" fill="#7de1b2" font-size="11">
当日净额 {{ formatSignedWanAmount(activeRow.netTotalWan) }}
</text>
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 84" fill="#f0c96a" font-size="11">
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 52" fill="#f0c96a" font-size="11">
累计净额 {{ formatSignedWanAmount(activeRow.cumulativeNetWan) }}
</text>
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 68" fill="#ff9b9b" font-size="11">
买入 {{ formatWanAmount(activeRow.buyTotalWan) }}
</text>
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 84" fill="#7fb5ff" font-size="11">
卖出 {{ formatWanAmount(activeRow.sellTotalWan) }}
</text>
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 100" fill="#93a2b5" font-size="10">
{{ activeRow.traderCount }} 个游资参与
</text>
</g>
<g v-for="(label, index) in chartModel.labels" :key="`label-${index}`">
@ -407,14 +340,14 @@ onUnmounted(() => {
<style scoped>
.action-chart-panel {
display: grid;
gap: 10px;
gap: 8px;
height: 100%;
min-height: 0;
}
.action-chart-head {
display: grid;
gap: 10px;
gap: 8px;
}
.action-chart-title {
@ -423,23 +356,23 @@ onUnmounted(() => {
}
.action-chart-note {
margin: 6px 0 0;
margin: 4px 0 0;
color: var(--color-muted);
font-size: 12px;
line-height: 1.55;
font-size: 11px;
line-height: 1.45;
}
.action-chart-legend {
display: flex;
flex-wrap: wrap;
gap: 8px;
gap: 6px;
}
.legend-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
padding: 5px 9px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
color: var(--color-muted);
@ -456,18 +389,6 @@ onUnmounted(() => {
height: 10px;
}
.legend-swatch.bar {
border-radius: 999px;
}
.legend-swatch.buy {
background: #ff7b7b;
}
.legend-swatch.sell {
background: #4ca8ff;
}
.legend-swatch.line::before {
content: '';
width: 18px;
@ -490,15 +411,15 @@ onUnmounted(() => {
}
.action-chart-scroll {
overflow-x: auto;
overflow-y: hidden;
overflow: hidden;
padding-bottom: 2px;
min-height: 228px;
min-height: 304px;
}
.action-chart-svg {
display: block;
min-height: 228px;
min-height: 304px;
width: 100%;
}
.action-chart-empty {

View File

@ -0,0 +1,567 @@
<script setup lang="ts">
import { computed, shallowRef, watch } from 'vue'
import type { MarketDailyRow, TraderAction } from '../types'
import { formatSignedWanAmount, formatWanAmount } from '../utils/format'
import {
buildActionSummaryMap,
buildKlineChartModel,
normalizeMarketRows,
} from '../utils/stockDetailKline'
const props = defineProps<{
marketDaily: MarketDailyRow[]
traderActions: TraderAction[]
zoomLevel: number
}>()
const emit = defineEmits<{
'update:zoomLevel': [value: number]
}>()
const zoomOptions = [20, 40, 60, 90, 120, 180, 9999] as const
const selectedIndex = shallowRef<number | null>(null)
const normalizedRows = computed(() => normalizeMarketRows(props.marketDaily ?? []))
const visibleRows = computed(() => {
if (props.zoomLevel >= normalizedRows.value.length) return normalizedRows.value
return normalizedRows.value.slice(-props.zoomLevel)
})
const actionSummaryMap = computed(() => buildActionSummaryMap(props.traderActions ?? []))
const chartModel = computed(() => buildKlineChartModel(visibleRows.value, actionSummaryMap.value))
const ma5Points = computed(() => chartModel.value.ma5.map((point) => `${point.x},${point.y}`).join(' '))
const selectedCandle = computed(() => {
if (selectedIndex.value === null) return null
return chartModel.value.candles[selectedIndex.value] ?? null
})
const selectedSummary = computed(() => selectedCandle.value?.actionSummary ?? null)
const selectedMarker = computed(() => {
const candle = selectedCandle.value
if (!candle) return null
return chartModel.value.markers.find((marker) => marker.tradeDate === candle.trade_date) ?? null
})
watch(chartModel, (model) => {
if (!model.candles.length) {
selectedIndex.value = null
return
}
if (selectedIndex.value !== null && model.candles[selectedIndex.value]) return
const lastActionIndex = [...model.candles]
.map((candle, index) => ({ candle, index }))
.reverse()
.find((entry) => entry.candle.actionSummary)?.index
selectedIndex.value = lastActionIndex ?? model.candles.length - 1
}, { immediate: true })
function handleWheelZoom(event: WheelEvent) {
event.preventDefault()
const currentIndex = zoomOptions.findIndex((option) => option === props.zoomLevel)
const safeIndex = currentIndex === -1 ? 2 : currentIndex
const nextIndex =
event.deltaY < 0
? Math.max(0, safeIndex - 1)
: Math.min(zoomOptions.length - 1, safeIndex + 1)
emit('update:zoomLevel', zoomOptions[nextIndex])
}
function selectCandle(index: number) {
selectedIndex.value = index
}
function clearSelection() {
selectedIndex.value = null
}
</script>
<template>
<section class="kline-panel">
<div v-if="chartModel.candles.length" class="chart-shell">
<div class="chart-stage">
<svg
:viewBox="`0 0 ${chartModel.width} ${chartModel.height}`"
preserveAspectRatio="none"
@wheel.prevent="handleWheelZoom"
>
<defs>
<linearGradient id="kline-bg" x1="0%" x2="0%" y1="0%" y2="100%">
<stop offset="0%" stop-color="rgba(255,255,255,0.04)" />
<stop offset="100%" stop-color="rgba(255,255,255,0.01)" />
</linearGradient>
</defs>
<rect
:x="chartModel.left"
:y="chartModel.top"
:width="chartModel.width - chartModel.left - chartModel.right"
:height="chartModel.innerHeight"
rx="18"
fill="url(#kline-bg)"
stroke="rgba(255,255,255,0.04)"
/>
<g opacity="0.1" stroke="#ffffff">
<line
v-for="tick in chartModel.axis"
:key="tick.label"
:x1="chartModel.left"
:y1="tick.y"
:x2="chartModel.width - chartModel.right"
:y2="tick.y"
/>
</g>
<g v-for="tick in chartModel.axis" :key="`axis-${tick.label}`">
<text x="4" :y="tick.y + 4" fill="#93a2b5" font-size="10">{{ tick.label }}</text>
</g>
<g v-if="selectedCandle">
<rect
:x="selectedCandle.x - chartModel.hoverBandWidth / 2"
:y="chartModel.top"
:width="chartModel.hoverBandWidth"
:height="chartModel.innerHeight"
fill="rgba(240, 192, 113, 0.08)"
/>
<line
:x1="selectedCandle.x"
:y1="chartModel.top"
:x2="selectedCandle.x"
:y2="chartModel.height - chartModel.bottom"
stroke="rgba(240, 192, 113, 0.18)"
stroke-dasharray="4 4"
/>
</g>
<polyline fill="none" stroke="#5ab8ff" stroke-width="2.2" :points="ma5Points" />
<g v-for="(candle, index) in chartModel.candles" :key="`${candle.trade_date}-${index}`">
<rect
:x="candle.x - chartModel.hoverBandWidth / 2"
:y="chartModel.top"
:width="chartModel.hoverBandWidth"
:height="chartModel.innerHeight"
fill="transparent"
class="hit-band"
:class="{ active: selectedIndex === index }"
@click="selectCandle(index)"
/>
<line
:x1="candle.x"
:y1="candle.wickTop"
:x2="candle.x"
:y2="candle.wickBottom"
:stroke="candle.direction === 'rise' ? '#ff5d5d' : '#2dbd7b'"
stroke-width="1.3"
/>
<rect
:x="candle.x - candle.bodyWidth / 2"
:y="candle.bodyTop"
:width="candle.bodyWidth"
:height="candle.bodyHeight"
rx="1.8"
:fill="candle.direction === 'rise' ? '#ff5d5d' : '#2dbd7b'"
/>
</g>
<g
v-for="marker in chartModel.markers"
:key="`${marker.tradeDate}-${marker.label}`"
class="marker-group"
:class="{ active: selectedIndex === marker.candleIndex }"
@click="selectCandle(marker.candleIndex)"
>
<line
:x1="marker.x"
:y1="marker.y + 12"
:x2="marker.x"
:y2="marker.y + 22"
:stroke="marker.dominantSide === 'buy' ? '#ff5d5d' : marker.dominantSide === 'sell' ? '#5ab8ff' : '#f0c071'"
stroke-width="1.1"
/>
<rect
:x="marker.x - marker.width / 2"
:y="marker.y - 10"
:width="marker.width"
height="20"
rx="10"
fill="rgba(8, 12, 18, 0.94)"
:stroke="marker.dominantSide === 'buy' ? '#ff5d5d' : marker.dominantSide === 'sell' ? '#5ab8ff' : '#f0c071'"
/>
<circle v-if="marker.hasBuy" :cx="marker.x - marker.width / 2 + 6" :cy="marker.y" r="2.6" fill="#ff5d5d" />
<circle v-if="marker.hasSell" :cx="marker.x + marker.width / 2 - 6" :cy="marker.y" r="2.6" fill="#5ab8ff" />
<text :x="marker.x" :y="marker.y + 4" text-anchor="middle" fill="#f5efe4" font-size="9.5" font-weight="700">
{{ marker.label }}
</text>
</g>
<g v-for="label in chartModel.labels" :key="label.label">
<text
v-if="label.visible"
:x="label.x"
:y="chartModel.height - 10"
text-anchor="middle"
fill="#93a2b5"
font-size="10"
>
{{ label.label }}
</text>
</g>
</svg>
</div>
<aside class="detail-card">
<template v-if="selectedCandle">
<div class="detail-head">
<div>
<h4>{{ selectedCandle.trade_date }}</h4>
<p v-if="selectedSummary" class="detail-caption">
{{ selectedSummary.traderCount }} 个游资 / {{ selectedSummary.seatCount }} 个营业部
</p>
<p v-else class="detail-caption">当日没有匹配到游资操作</p>
</div>
<div class="detail-actions">
<span v-if="selectedMarker" class="detail-badge">{{ selectedMarker.label }} 个游资</span>
<button class="detail-close" type="button" @click="clearSelection">关闭</button>
</div>
</div>
<div class="detail-grid">
<span> {{ selectedCandle.open }}</span>
<span> {{ selectedCandle.close }}</span>
<span> {{ selectedCandle.high }}</span>
<span> {{ selectedCandle.low }}</span>
<span>涨跌 {{ selectedCandle.pct_chg }}</span>
<span>成交额 {{ selectedCandle.amount }}</span>
</div>
<div v-if="selectedSummary" class="summary-strip">
<span class="buy">买入 {{ formatWanAmount(selectedSummary.totalBuyWan) }}</span>
<span class="sell">卖出 {{ formatWanAmount(selectedSummary.totalSellWan) }}</span>
<span class="net" :class="selectedSummary.totalNetWan > 0 ? 'rise' : selectedSummary.totalNetWan < 0 ? 'fall' : 'flat'">
净额 {{ formatSignedWanAmount(selectedSummary.totalNetWan) }}
</span>
</div>
<div class="detail-section-head">
<strong>营业部明细</strong>
<span v-if="selectedSummary">{{ selectedSummary.allActions.length }} </span>
</div>
<div v-if="selectedSummary?.allActions.length" class="detail-list">
<article
v-for="action in selectedSummary.allActions"
:key="`${action.tradeDate}-${action.seatName}-${action.tableTitle}`"
class="detail-item"
:class="action.netAmountWan > 0 ? 'positive' : action.netAmountWan < 0 ? 'negative' : 'neutral'"
>
<div class="item-top">
<strong>{{ action.matchedTraderName }}</strong>
<span class="net" :class="action.netAmountWan > 0 ? 'rise' : action.netAmountWan < 0 ? 'fall' : 'flat'">
{{ formatSignedWanAmount(action.netAmountWan) }}
</span>
</div>
<p class="seat-name">{{ action.seatName || '-' }}</p>
<div class="item-amounts">
<span class="buy"> {{ formatWanAmount(action.buyAmountWan) }}</span>
<span class="sell"> {{ formatWanAmount(action.sellAmountWan) }}</span>
</div>
</article>
</div>
</template>
<div v-else class="detail-empty">
<h4>点击查看明细</h4>
<p>点击K线或数字标记后右侧会锁定显示当日游资和营业部明细列表会在面板内部滚动不会溢出当前区域</p>
</div>
</aside>
</div>
<div v-else class="empty-state">
当前没有可展示的日K数据
</div>
</section>
</template>
<style scoped>
.kline-panel {
min-height: 0;
height: 100%;
}
.chart-shell {
display: grid;
grid-template-columns: minmax(0, 1fr) 400px;
gap: 12px;
min-height: 0;
height: 100%;
}
.chart-stage {
min-height: 0;
padding: 10px 10px 6px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.05);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0.01));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
}
.chart-shell svg {
width: 100%;
height: 100%;
min-height: 360px;
display: block;
}
.hit-band,
.marker-group {
cursor: pointer;
}
.hit-band.active {
fill: rgba(240, 192, 113, 0.06);
}
.marker-group.active rect {
stroke-width: 1.6;
filter: drop-shadow(0 0 10px rgba(240, 192, 113, 0.16));
}
.detail-card {
display: grid;
grid-template-rows: auto auto auto auto auto;
gap: 8px;
padding: 12px;
border-radius: 20px;
border: 1px solid rgba(240, 192, 113, 0.1);
background:
linear-gradient(180deg, rgba(18, 26, 36, 0.98), rgba(8, 12, 18, 0.98)),
radial-gradient(circle at top right, rgba(240, 192, 113, 0.08), transparent 32%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03),
0 20px 36px rgba(0, 0, 0, 0.16);
min-height: 0;
height: 100%;
overflow-y: auto;
padding-right: 8px;
}
.detail-card::-webkit-scrollbar {
width: 6px;
}
.detail-card::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
}
.detail-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.detail-head h4,
.detail-empty h4 {
margin: 0;
font-size: 14px;
}
.detail-caption {
margin: 2px 0 0;
color: var(--color-muted);
font-size: 11px;
}
.detail-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.detail-badge {
min-width: 72px;
height: 28px;
padding: 0 10px;
border-radius: 999px;
background: rgba(240, 192, 113, 0.12);
border: 1px solid rgba(240, 192, 113, 0.22);
color: var(--color-gold-soft);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
}
.detail-close {
height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: var(--color-muted);
font-size: 10px;
cursor: pointer;
transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
}
.detail-close:hover {
border-color: rgba(240, 192, 113, 0.16);
background: rgba(240, 192, 113, 0.08);
color: var(--color-text);
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 12px;
color: var(--color-muted);
font-size: 11px;
}
.summary-strip {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px 0;
border-top: 1px dashed rgba(255, 255, 255, 0.08);
border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
font-size: 11px;
}
.detail-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 11px;
}
.detail-list {
display: flex;
flex-direction: column;
gap: 8px;
min-height: auto;
overflow: visible;
padding: 2px 0 2px 0;
}
.detail-item {
position: relative;
padding: 10px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.04);
overflow: hidden;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02);
}
.detail-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: rgba(255, 255, 255, 0.12);
}
.detail-item.positive::before {
background: rgba(255, 93, 93, 0.72);
}
.detail-item.negative::before {
background: rgba(90, 184, 255, 0.72);
}
.detail-item.neutral::before {
background: rgba(255, 255, 255, 0.16);
}
.item-top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
font-size: 11px;
}
.seat-name {
margin: 5px 0 0;
color: var(--color-muted);
font-size: 10px;
line-height: 1.35;
overflow-wrap: anywhere;
}
.item-amounts {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 6px;
font-size: 10px;
}
.detail-empty {
display: grid;
gap: 10px;
align-content: start;
min-height: 100%;
padding-top: 4px;
}
.detail-empty p {
margin: 0;
color: var(--color-muted);
font-size: 11px;
line-height: 1.6;
}
.buy,
.rise {
color: var(--color-red);
}
.sell {
color: var(--color-blue);
}
.fall {
color: var(--color-green);
}
.flat {
color: var(--color-muted);
}
.empty-state {
color: var(--color-muted);
font-size: 11px;
line-height: 1.6;
padding: 24px 0 8px;
}
@media (max-width: 1260px) {
.chart-shell {
grid-template-columns: minmax(0, 1fr);
}
.detail-card {
grid-template-rows: auto auto auto auto minmax(180px, 1fr);
min-height: auto;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,427 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ActionItem, WatchlistItem } from '../../types'
import {
formatSignedWanAmount,
formatWanAmount,
numberFromText,
priceTone,
} from '../../utils/format'
const props = defineProps<{
actions: ActionItem[]
}>()
const emit = defineEmits<{
selectStock: [stockCode: string]
followStock: [payload: Pick<WatchlistItem, 'stock_code' | 'stock_name' | 'source_trade_date' | 'source_trader_name'>]
}>()
function candidateScore(action: ActionItem): number {
const buy = numberFromText(action.buy_amount_wan) ?? 0
const sell = numberFromText(action.sell_amount_wan) ?? 0
const net = numberFromText(action.net_amount_wan) ?? 0
return buy + Math.max(net, 0) * 1.4 - Math.max(-net, 0) * 0.35 - sell * 0.1
}
function analysisNote(action: ActionItem): string {
const buy = numberFromText(action.buy_amount_wan) ?? 0
const sell = numberFromText(action.sell_amount_wan) ?? 0
const net = numberFromText(action.net_amount_wan) ?? 0
if (buy > 0 && sell === 0) return '单边买入,动作更干净,适合优先跟踪。'
if (net > 0) return '净额为正,承接更强,可以先放进重点观察序列。'
if (sell > buy) return '分歧偏大,建议先结合盘口和次日承接再决定是否加入。'
return '买卖都有动作,建议继续跟踪后续是否出现一致性。'
}
const prioritizedActions = computed(() => {
return [...props.actions].sort((left, right) => {
const dateDiff = right.trade_date.localeCompare(left.trade_date)
if (dateDiff !== 0) return dateDiff
const scoreDiff = candidateScore(right) - candidateScore(left)
if (scoreDiff !== 0) return scoreDiff
return right.stock_code.localeCompare(left.stock_code)
})
})
const featuredCandidate = computed(() => prioritizedActions.value[0] ?? null)
const secondaryCandidates = computed(() => prioritizedActions.value.slice(1, 7))
const latestTradeDate = computed(() => prioritizedActions.value[0]?.trade_date ?? '')
function followAction(action: ActionItem) {
emit('followStock', {
stock_code: action.stock_code,
stock_name: action.stock_name,
source_trade_date: action.trade_date,
source_trader_name: action.trader_name,
})
}
</script>
<template>
<article class="card-panel focus-panel">
<div class="section-head">
<div>
<h3 class="section-title">待加入关注 · 重点分析</h3>
<p class="section-subtitle">按最近交易日与资金强度倒序排列先看最值得持续跟踪的候选</p>
</div>
<div class="head-pills">
<span class="pill active">{{ actions.length }} 个候选</span>
<span v-if="latestTradeDate" class="pill">最新 {{ latestTradeDate }}</span>
</div>
</div>
<div v-if="featuredCandidate" class="focus-stack">
<section class="featured-card" :class="priceTone(String(featuredCandidate.net_amount_wan ?? ''))">
<div class="featured-top">
<div class="featured-title">
<span class="rank-badge">TOP 1</span>
<strong>{{ featuredCandidate.stock_name }}</strong>
<span>{{ featuredCandidate.stock_code }} / {{ featuredCandidate.trader_name }}</span>
</div>
<div class="featured-actions">
<button class="ghost-button" type="button" @click="emit('selectStock', featuredCandidate.stock_code)">
查看详情
</button>
<button class="follow-button" type="button" @click="followAction(featuredCandidate)">
加入关注
</button>
</div>
</div>
<div class="featured-grid">
<div class="featured-metric">
<span>买入</span>
<strong class="buy">¥ {{ formatWanAmount(featuredCandidate.buy_amount_wan) }}</strong>
</div>
<div class="featured-metric">
<span>卖出</span>
<strong class="sell">¥ {{ formatWanAmount(featuredCandidate.sell_amount_wan) }}</strong>
</div>
<div class="featured-metric">
<span>净额</span>
<strong class="net" :class="priceTone(String(featuredCandidate.net_amount_wan ?? ''))">
{{ formatSignedWanAmount(featuredCandidate.net_amount_wan) }}
</strong>
</div>
<div class="featured-metric">
<span>现价 / 涨跌</span>
<strong :class="priceTone(String(featuredCandidate.pct_chg ?? ''))">
{{ featuredCandidate.current_price ?? '-' }} / {{ featuredCandidate.pct_chg ?? '-' }}
</strong>
</div>
</div>
<p class="featured-note">
{{ featuredCandidate.trade_date }} · {{ analysisNote(featuredCandidate) }}
</p>
</section>
<div v-if="secondaryCandidates.length" class="secondary-grid">
<article
v-for="(action, index) in secondaryCandidates"
:key="`${action.trade_date}-${action.stock_code}-${action.trader_name}`"
class="candidate-card"
>
<div class="candidate-top">
<div class="candidate-title">
<span class="rank-badge subtle">TOP {{ index + 2 }}</span>
<button class="stock-button" type="button" @click="emit('selectStock', action.stock_code)">
{{ action.stock_name }}
</button>
</div>
<button class="follow-button compact" type="button" @click="followAction(action)">
加入
</button>
</div>
<p class="candidate-meta">{{ action.stock_code }} · {{ action.trade_date }} · {{ action.trader_name }}</p>
<div class="candidate-metrics">
<span class="buy"> {{ formatWanAmount(action.buy_amount_wan) }}</span>
<span class="sell"> {{ formatWanAmount(action.sell_amount_wan) }}</span>
<span class="net" :class="priceTone(String(action.net_amount_wan ?? ''))">
{{ formatSignedWanAmount(action.net_amount_wan) }}
</span>
</div>
<p class="candidate-note">{{ analysisNote(action) }}</p>
</article>
</div>
</div>
<div v-else class="empty-state">当前筛选条件下没有新的候选股调整日期或游资筛选后再看</div>
</article>
</template>
<style scoped>
.focus-panel {
display: grid;
gap: 16px;
}
.section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.section-title {
margin: 0;
}
.section-subtitle,
.candidate-meta,
.candidate-note,
.featured-note {
margin: 0;
color: var(--color-muted);
}
.section-subtitle {
margin-top: 6px;
max-width: 560px;
font-size: 12px;
line-height: 1.7;
}
.head-pills {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.pill {
padding: 8px 12px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
color: var(--color-muted);
font-size: 12px;
background: rgba(255, 255, 255, 0.04);
}
.pill.active {
color: #090d14;
background: linear-gradient(180deg, var(--color-gold-soft), var(--color-gold));
border-color: transparent;
}
.focus-stack,
.secondary-grid,
.candidate-metrics {
display: grid;
gap: 14px;
}
.featured-card,
.candidate-card {
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 24px;
background: rgba(255, 255, 255, 0.03);
}
.featured-card {
padding: 20px;
background:
radial-gradient(circle at top right, rgba(212, 163, 92, 0.18), transparent 32%),
linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(7, 10, 16, 0.22));
}
.featured-card.rise {
box-shadow: inset 0 0 0 1px rgba(255, 93, 93, 0.12);
}
.featured-card.fall {
box-shadow: inset 0 0 0 1px rgba(45, 189, 123, 0.12);
}
.featured-top,
.candidate-top {
display: flex;
justify-content: space-between;
gap: 12px;
}
.featured-title,
.candidate-title {
display: flex;
flex-direction: column;
gap: 6px;
}
.featured-title strong {
font-size: 28px;
font-family: var(--font-display);
letter-spacing: 0.03em;
}
.featured-title span:last-child,
.candidate-meta {
font-size: 12px;
}
.featured-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.featured-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-top: 18px;
}
.featured-metric,
.candidate-card {
position: relative;
}
.featured-metric {
padding: 14px 16px;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(4, 7, 12, 0.34);
}
.featured-metric span {
display: block;
color: var(--color-muted);
font-size: 11px;
}
.featured-metric strong {
display: block;
margin-top: 8px;
font-size: 18px;
}
.featured-note {
margin-top: 16px;
padding-top: 16px;
border-top: 1px dashed rgba(255, 255, 255, 0.08);
line-height: 1.7;
}
.secondary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.candidate-card {
padding: 16px;
}
.stock-button,
.ghost-button,
.follow-button {
cursor: pointer;
}
.stock-button {
padding: 0;
color: var(--color-text);
text-align: left;
font-size: 16px;
font-weight: 700;
background: transparent;
}
.candidate-metrics {
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 12px;
font-size: 12px;
}
.candidate-note {
margin-top: 12px;
line-height: 1.7;
font-size: 12px;
}
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
padding: 4px 10px;
border-radius: 999px;
background: rgba(240, 192, 113, 0.14);
color: var(--color-gold-soft);
border: 1px solid rgba(240, 192, 113, 0.18);
font-size: 11px;
letter-spacing: 0.08em;
}
.rank-badge.subtle {
background: rgba(255, 255, 255, 0.04);
color: var(--color-muted);
border-color: rgba(255, 255, 255, 0.08);
}
.follow-button,
.ghost-button {
padding: 8px 12px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: var(--color-text);
}
.follow-button {
color: #090d14;
background: linear-gradient(180deg, var(--color-gold-soft), var(--color-gold));
border-color: transparent;
}
.follow-button.compact {
padding: 7px 12px;
}
.buy {
color: var(--color-red);
}
.sell {
color: var(--color-blue);
}
.net.rise {
color: var(--color-red);
}
.net.fall {
color: var(--color-green);
}
.net.flat {
color: var(--color-muted);
}
.empty-state {
padding: 18px;
border: 1px dashed rgba(255, 255, 255, 0.08);
border-radius: 18px;
color: var(--color-muted);
}
@media (max-width: 1180px) {
.featured-grid,
.secondary-grid,
.candidate-metrics {
grid-template-columns: 1fr;
}
.section-head,
.featured-top,
.candidate-top {
flex-direction: column;
}
}
</style>

View File

@ -23,6 +23,19 @@ const emit = defineEmits<{
const buyAmount = computed(() => numberFromText(props.action.buy_amount_wan) ?? 0)
const sellAmount = computed(() => numberFromText(props.action.sell_amount_wan) ?? 0)
const participantTraders = computed(() => {
const source = props.action.participant_traders?.length
? props.action.participant_traders
: props.action.trader_name.split(' / ')
return [...new Set(source.map((item) => item.trim()).filter(Boolean))]
})
const participantCount = computed(() => props.action.participant_trader_count ?? participantTraders.value.length)
const isMultiTraderFocus = computed(() => participantCount.value >= 2)
const participantLabel = computed(() => {
if (!participantTraders.value.length) return '游资待确认'
return participantTraders.value.join(' / ')
})
function inferMarketLabel(stockCode: string): string {
return stockCode.startsWith('6') || stockCode.startsWith('688') ? '沪A' : '深A'
@ -74,12 +87,16 @@ function followCandidate() {
</script>
<template>
<article class="candidate-card" :class="candidateTone">
<article class="candidate-card" :class="[candidateTone, { 'multi-trader-focus': isMultiTraderFocus }]">
<div class="candidate-shell">
<div class="candidate-row top-row">
<button class="candidate-main" type="button" @click="emit('selectStock', action.stock_code)">
<strong class="stock-name">{{ action.stock_name }}</strong>
<span class="flow-pill">{{ flowSummary }}</span>
<span v-if="isMultiTraderFocus" class="focus-wrap">
<span class="focus-pill">重点游资</span>
<span class="focus-tooltip">{{ participantCount }}个游资参与 · {{ participantLabel }}</span>
</span>
</button>
<button class="follow-button" type="button" @click="followCandidate">
@ -88,7 +105,7 @@ function followCandidate() {
</div>
<div class="candidate-row mid-row">
<span class="candidate-code">{{ action.stock_code }} · {{ action.trade_date }} · {{ action.trader_name }}</span>
<span class="candidate-code">{{ action.stock_code }} · {{ action.trade_date }}</span>
<div class="chip-row" v-if="detailChips.length">
<span v-for="chip in detailChips" :key="chip" class="chip">{{ chip }}</span>
</div>
@ -134,7 +151,7 @@ function followCandidate() {
<style scoped>
.candidate-card {
position: relative;
overflow: hidden;
overflow: visible;
height: 100%;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 18px;
@ -149,6 +166,13 @@ function followCandidate() {
background: var(--tone-gradient);
}
.candidate-card.multi-trader-focus {
border-color: rgba(240, 192, 113, 0.46);
box-shadow:
inset 0 0 0 1px rgba(240, 192, 113, 0.12),
0 10px 24px rgba(240, 192, 113, 0.08);
}
.candidate-card.buy-only {
--tone-main: #ff3048;
--tone-soft: rgba(255, 48, 72, 0.18);
@ -185,6 +209,7 @@ function followCandidate() {
gap: 6px;
height: 100%;
padding: 8px 12px;
border-radius: 18px;
background:
radial-gradient(circle at top right, var(--tone-soft), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(7, 11, 17, 0.16));
@ -228,6 +253,7 @@ function followCandidate() {
}
.flow-pill,
.focus-pill,
.chip {
display: inline-flex;
align-items: center;
@ -249,6 +275,47 @@ function followCandidate() {
border-color: rgba(255, 255, 255, 0.04);
}
.focus-pill {
flex: none;
color: #090d14;
background: linear-gradient(180deg, var(--color-gold-soft), var(--color-gold));
border-color: transparent;
font-weight: 800;
}
.focus-wrap {
position: relative;
display: inline-flex;
align-items: center;
}
.focus-tooltip {
position: absolute;
left: 50%;
top: calc(100% + 8px);
z-index: 8;
min-width: 220px;
max-width: 320px;
padding: 8px 10px;
border-radius: 12px;
border: 1px solid rgba(240, 192, 113, 0.24);
color: var(--color-text);
background: rgba(8, 12, 18, 0.96);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);
font-size: 10px;
line-height: 1.4;
white-space: normal;
opacity: 0;
pointer-events: none;
transform: translate(-50%, 4px);
transition: opacity 120ms ease, transform 120ms ease;
}
.focus-wrap:hover .focus-tooltip {
opacity: 1;
transform: translate(-50%, 0);
}
.chip-row {
display: flex;
align-items: center;

View File

@ -5,6 +5,7 @@ import type {
ActionsResponse,
PipelineStatus,
StockDetail,
StockSearchItem,
Summary,
TraderDetail,
TraderListItem,
@ -82,22 +83,30 @@ export function useDashboardData() {
const groups = new Map<string, ActionItem>()
for (const item of rows) {
const existing = groups.get(item.stock_code)
const groupKey = `${item.stock_code}::${item.trade_date}`
const existing = groups.get(groupKey)
if (!existing) {
groups.set(item.stock_code, { ...item })
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.trader_name, item.trader_name].filter(Boolean))
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(item.stock_code, {
groups.set(groupKey, {
...existing,
trader_name: [...traderNames].join(' / '),
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),
@ -107,7 +116,15 @@ export function useDashboardData() {
})
}
return [...groups.values()]
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(() => {
@ -226,6 +243,17 @@ export function useDashboardData() {
stockDetail.value = await api<StockDetail>(`/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<StockSearchItem[]>(`/api/stocks/search?${params.toString()}`)
}
async function selectWarning(item: WarningItem) {
selectedWarningCode.value = item.stock_code
await selectStock(item.stock_code)
@ -316,6 +344,7 @@ export function useDashboardData() {
loadWatchlist,
selectTrader,
selectStock,
searchStocks,
selectWarning,
selectTradeDateRange,
isWatched,

View File

@ -20,6 +20,13 @@ export interface WarningItem {
created_at?: string
}
export interface StockSearchItem {
stock_code: string
stock_name: string
market?: string | null
industry?: string | null
}
export interface ActionItem {
trade_date: string
stock_code: string
@ -38,6 +45,8 @@ export interface ActionItem {
total_market_value?: number | null
circulating_market_value?: number | null
action_side: 'buy' | 'sell' | 'net_buy' | 'net_sell'
participant_traders?: string[]
participant_trader_count?: number
}
export interface ActionsResponse {

View File

@ -42,6 +42,10 @@ export function compactMoney(value: string | number | null | undefined): string
return `${parsed.toFixed(0)}`
}
function trimDecimalText(value: string): string {
return value.replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1')
}
export function formatDate(value: string | null | undefined): string {
return value || '-'
}
@ -51,7 +55,8 @@ export function formatWanAmount(value: string | number | null | undefined): stri
if (parsed === null) return '-'
if (Math.abs(parsed) >= 10000) {
return `${(parsed / 10000).toFixed(Math.abs(parsed) >= 100000 ? 1 : 0)}亿`
const decimals = Math.abs(parsed) >= 100000 ? 1 : 2
return `${trimDecimalText((parsed / 10000).toFixed(decimals))}亿`
}
if (Math.abs(parsed) >= 1) {

View File

@ -0,0 +1,272 @@
import type { MarketDailyRow, TraderAction } from '../types'
import { numberFromText } from './format'
export type KlineActionDetail = {
tradeDate: string
matchedTraderName: string
seatName: string
tableTitle: string
buyAmountWan: number
sellAmountWan: number
netAmountWan: number
}
export type KlineActionSummary = {
tradeDate: string
traderCount: number
seatCount: number
totalBuyWan: number
totalSellWan: number
totalNetWan: number
hasBuy: boolean
hasSell: boolean
dominantSide: 'buy' | 'sell' | 'mixed'
allActions: KlineActionDetail[]
}
export type KlineCandle = MarketDailyRow & {
openNum: number
closeNum: number
highNum: number
lowNum: number
x: number
wickTop: number
wickBottom: number
bodyTop: number
bodyBottom: number
bodyHeight: number
bodyWidth: number
direction: 'rise' | 'fall'
actionSummary: KlineActionSummary | null
}
export type KlineMarker = {
tradeDate: string
x: number
y: number
label: string
width: number
candleIndex: number
hasBuy: boolean
hasSell: boolean
dominantSide: 'buy' | 'sell' | 'mixed'
}
export type KlineChartModel = {
candles: KlineCandle[]
markers: KlineMarker[]
axis: Array<{ y: number; label: string }>
labels: Array<{ x: number; label: string; visible: boolean }>
ma5: Array<{ x: number; y: number }>
width: number
height: number
left: number
right: number
top: number
bottom: number
innerHeight: number
hoverBandWidth: number
}
const CHART_WIDTH = 1120
const CHART_HEIGHT = 360
const PADDING_LEFT = 52
const PADDING_RIGHT = 16
const PADDING_TOP = 18
const PADDING_BOTTOM = 34
export function normalizeMarketRows(rows: MarketDailyRow[]): Array<
MarketDailyRow & {
openNum: number
closeNum: number
highNum: number
lowNum: number
}
> {
return rows.map((row) => ({
...row,
openNum: numberFromText(row.open) ?? 0,
closeNum: numberFromText(row.close) ?? 0,
highNum: numberFromText(row.high) ?? 0,
lowNum: numberFromText(row.low) ?? 0,
}))
}
export function buildActionSummaryMap(actions: TraderAction[]): Map<string, KlineActionSummary> {
const grouped = new Map<string, KlineActionDetail[]>()
for (const action of actions) {
const list = grouped.get(action.trade_date) ?? []
list.push({
tradeDate: action.trade_date,
matchedTraderName: action.matched_trader_name,
seatName: action.seat_name,
tableTitle: action.table_title,
buyAmountWan: numberFromText(action.buy_amount_wan) ?? 0,
sellAmountWan: numberFromText(action.sell_amount_wan) ?? 0,
netAmountWan: numberFromText(action.net_amount_wan) ?? 0,
})
grouped.set(action.trade_date, list)
}
const summaryMap = new Map<string, KlineActionSummary>()
for (const [tradeDate, list] of grouped.entries()) {
const sorted = [...list].sort((left, right) => {
const absDiff = Math.abs(right.netAmountWan) - Math.abs(left.netAmountWan)
if (absDiff !== 0) return absDiff
const flowDiff = right.buyAmountWan + right.sellAmountWan - (left.buyAmountWan + left.sellAmountWan)
if (flowDiff !== 0) return flowDiff
return left.matchedTraderName.localeCompare(right.matchedTraderName, 'zh-CN')
})
const totalBuyWan = sorted.reduce((sum, item) => sum + item.buyAmountWan, 0)
const totalSellWan = sorted.reduce((sum, item) => sum + item.sellAmountWan, 0)
const totalNetWan = sorted.reduce((sum, item) => sum + item.netAmountWan, 0)
const hasBuy = sorted.some((item) => item.buyAmountWan > 0)
const hasSell = sorted.some((item) => item.sellAmountWan > 0)
let dominantSide: 'buy' | 'sell' | 'mixed' = 'mixed'
if (totalBuyWan > totalSellWan) dominantSide = 'buy'
if (totalSellWan > totalBuyWan) dominantSide = 'sell'
summaryMap.set(tradeDate, {
tradeDate,
traderCount: new Set(sorted.map((item) => item.matchedTraderName)).size,
seatCount: sorted.length,
totalBuyWan,
totalSellWan,
totalNetWan,
hasBuy,
hasSell,
dominantSide,
allActions: sorted,
})
}
return summaryMap
}
export function buildKlineChartModel(
rows: Array<
MarketDailyRow & {
openNum: number
closeNum: number
highNum: number
lowNum: number
}
>,
actionSummaryMap: Map<string, KlineActionSummary>,
): KlineChartModel {
const emptyModel: KlineChartModel = {
candles: [],
markers: [],
axis: [],
labels: [],
ma5: [],
width: CHART_WIDTH,
height: CHART_HEIGHT,
left: PADDING_LEFT,
right: PADDING_RIGHT,
top: PADDING_TOP,
bottom: PADDING_BOTTOM,
innerHeight: CHART_HEIGHT - PADDING_TOP - PADDING_BOTTOM,
hoverBandWidth: 0,
}
if (!rows.length) return emptyModel
const innerWidth = CHART_WIDTH - PADDING_LEFT - PADDING_RIGHT
const innerHeight = CHART_HEIGHT - PADDING_TOP - PADDING_BOTTOM
const slotWidth = innerWidth / rows.length
const hoverBandWidth = Math.max(12, slotWidth)
const bodyWidth = Math.max(5, Math.min(14, slotWidth * 0.54))
const prices = rows.flatMap((item) => [item.highNum, item.lowNum])
const minPrice = Math.min(...prices)
const maxPrice = Math.max(...prices)
const yOf = (price: number) =>
PADDING_TOP +
(maxPrice === minPrice ? innerHeight / 2 : ((maxPrice - price) / (maxPrice - minPrice)) * innerHeight)
const candles = rows.map((row, index) => {
const x = PADDING_LEFT + slotWidth * index + slotWidth / 2
const openY = yOf(row.openNum)
const closeY = yOf(row.closeNum)
const bodyTop = Math.min(openY, closeY)
const bodyBottom = Math.max(openY, closeY)
return {
...row,
x,
wickTop: yOf(row.highNum),
wickBottom: yOf(row.lowNum),
bodyTop,
bodyBottom,
bodyHeight: Math.max(3, bodyBottom - bodyTop),
bodyWidth,
direction: row.closeNum >= row.openNum ? 'rise' : 'fall',
actionSummary: actionSummaryMap.get(row.trade_date) ?? null,
} satisfies KlineCandle
})
const ma5 = candles.map((candle, index) => {
const slice = candles.slice(Math.max(0, index - 4), index + 1)
const average = slice.reduce((sum, item) => sum + item.closeNum, 0) / slice.length
return { x: candle.x, y: yOf(average) }
})
const markers = candles
.map((candle, candleIndex) => {
const summary = candle.actionSummary
if (!summary) return null
const label = String(summary.traderCount)
return {
tradeDate: candle.trade_date,
x: candle.x,
y: Math.max(PADDING_TOP + 18, candle.wickTop - 24),
label,
width: label.length >= 2 ? 32 : 24,
candleIndex,
hasBuy: summary.hasBuy,
hasSell: summary.hasSell,
dominantSide: summary.dominantSide,
} satisfies KlineMarker
})
.filter((marker): marker is KlineMarker => Boolean(marker))
const labelStep = Math.max(1, Math.ceil(rows.length / 8))
const axis = Array.from({ length: 5 }, (_, index) => {
const price = maxPrice - ((maxPrice - minPrice) * index) / 4
return {
y: PADDING_TOP + (innerHeight * index) / 4,
label: price.toFixed(2),
}
})
const labels = candles.map((candle, index) => ({
x: candle.x,
label: candle.trade_date.slice(5),
visible: index % labelStep === 0 || index === candles.length - 1,
}))
return {
candles,
markers,
axis,
labels,
ma5,
width: CHART_WIDTH,
height: CHART_HEIGHT,
left: PADDING_LEFT,
right: PADDING_RIGHT,
top: PADDING_TOP,
bottom: PADDING_BOTTOM,
innerHeight,
hoverBandWidth,
}
}