chore: initialize lhbfx project and documentation
This commit is contained in:
188
frontend/src/App.vue
Normal file
188
frontend/src/App.vue
Normal file
@ -0,0 +1,188 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, shallowRef } from 'vue'
|
||||
|
||||
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'
|
||||
import type { WarningItem } from './types'
|
||||
|
||||
const dashboard = useDashboardData()
|
||||
|
||||
type PageKey = 'home' | 'trader' | 'stock' | 'warning'
|
||||
|
||||
const currentPage = shallowRef<PageKey>('home')
|
||||
const selectedTraderId = computed(() => dashboard.selectedTraderId.value)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
function navigate(page: PageKey) {
|
||||
const nextHash = `#/${page}`
|
||||
if (window.location.hash !== nextHash) {
|
||||
window.location.hash = nextHash
|
||||
}
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
async function handleSelectTrader(traderId: number) {
|
||||
await dashboard.selectTrader(traderId)
|
||||
navigate('trader')
|
||||
}
|
||||
|
||||
async function handleSelectStock(stockCode: string) {
|
||||
await dashboard.selectStock(stockCode)
|
||||
navigate('stock')
|
||||
}
|
||||
|
||||
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)
|
||||
void dashboard.initialize()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('hashchange', syncPageFromHash)
|
||||
})
|
||||
</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"
|
||||
@navigate="navigate"
|
||||
/>
|
||||
|
||||
<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>
|
||||
82
frontend/src/assets/main.css
Normal file
82
frontend/src/assets/main.css
Normal file
@ -0,0 +1,82 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@500;700;900&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
--color-bg: #090d14;
|
||||
--color-panel: rgba(16, 23, 33, 0.95);
|
||||
--color-line: rgba(214, 168, 95, 0.22);
|
||||
--color-text: #f5efe4;
|
||||
--color-muted: #93a2b5;
|
||||
--color-gold: #d4a35c;
|
||||
--color-gold-soft: #f0c071;
|
||||
--color-blue: #5ab8ff;
|
||||
--color-red: #ff5d5d;
|
||||
--color-green: #2dbd7b;
|
||||
--color-orange: #ffae42;
|
||||
--shadow-strong: 0 24px 80px rgba(0, 0, 0, 0.45);
|
||||
--font-display: 'Noto Serif SC', 'STZhongsong', serif;
|
||||
--font-body: 'IBM Plex Sans', 'Microsoft YaHei', sans-serif;
|
||||
--font-mono: 'Bahnschrift', 'IBM Plex Sans', sans-serif;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 360px;
|
||||
height: 100vh;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 10% 20%, rgba(212, 163, 92, 0.12), transparent 26%),
|
||||
radial-gradient(circle at 90% 10%, rgba(90, 184, 255, 0.1), transparent 24%),
|
||||
linear-gradient(180deg, #0b0e13 0%, #090c12 100%);
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
|
||||
background-size: 36px 36px;
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: min(1600px, calc(100vw - 32px));
|
||||
height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: 10px 0 10px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
#app {
|
||||
width: min(100vw - 20px, 1600px);
|
||||
padding-top: 8px;
|
||||
}
|
||||
}
|
||||
160
frontend/src/components/AppHero.vue
Normal file
160
frontend/src/components/AppHero.vue
Normal file
@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { PipelineStatus, 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'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [page: 'home' | 'trader' | 'stock' | 'warning']
|
||||
}>()
|
||||
|
||||
const statusBadges = computed(() => [
|
||||
`最新交易日 ${props.status?.latest_trade_date ?? '-'}`,
|
||||
`导入交易日 ${props.summary?.imported_days ?? 0}`,
|
||||
`目标游资 ${props.summary?.trader_total ?? 0}`,
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="hero-bar">
|
||||
<div class="hero-left">
|
||||
<div class="hero-title-wrap">
|
||||
<span class="hero-mark">龙虎</span>
|
||||
<div class="hero-copy">
|
||||
<h1 class="hero-title">顶级游资监控系统</h1>
|
||||
<p class="hero-subtitle">今日操作优先 / 关注池驱动</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="hero-nav" aria-label="页面导航">
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.key"
|
||||
class="hero-nav-item"
|
||||
:class="{ active: currentPage === item.key }"
|
||||
type="button"
|
||||
@click="emit('navigate', item.key)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="hero-badges">
|
||||
<span v-for="badge in statusBadges" :key="badge" class="hero-badge">
|
||||
{{ badge }}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hero-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(212, 163, 92, 0.1);
|
||||
border-radius: 16px;
|
||||
background: rgba(9, 13, 20, 0.56);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.hero-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-mark {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(145deg, rgba(227, 183, 112, 0.28), rgba(227, 183, 112, 0.08));
|
||||
border: 1px solid rgba(240, 192, 113, 0.24);
|
||||
color: var(--color-gold-soft);
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 14px;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
margin: 2px 0 0;
|
||||
color: var(--color-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.hero-nav {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hero-nav-item.active {
|
||||
color: #090d14;
|
||||
background: linear-gradient(180deg, var(--color-gold-soft), var(--color-gold));
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.hero-badges {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 999px;
|
||||
color: var(--color-muted);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.hero-bar,
|
||||
.hero-left {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hero-badges {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
697
frontend/src/components/HomeControlScreen.vue
Normal file
697
frontend/src/components/HomeControlScreen.vue
Normal file
@ -0,0 +1,697 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import CandidateWatchCard from './home/CandidateWatchCard.vue'
|
||||
|
||||
import type { ActionItem, TraderListItem, WatchlistItem } from '../types'
|
||||
import { formatDate, formatSignedWanAmount, formatWanAmount, numberFromText, priceTone } from '../utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
tradeDates: string[]
|
||||
selectedDateFrom: string
|
||||
selectedDateTo: string
|
||||
traders: TraderListItem[]
|
||||
selectedTraderFilter: string
|
||||
selectedActionFilter: 'all' | 'buy' | 'sell' | 'net_buy' | 'net_sell'
|
||||
watchedActions: ActionItem[]
|
||||
candidateActions: ActionItem[]
|
||||
watchlist: Array<WatchlistItem & { hasWarning: boolean }>
|
||||
metrics: {
|
||||
watchCount: number
|
||||
activeWatchCount: number
|
||||
buyTotalWan: number
|
||||
sellTotalWan: number
|
||||
netPositiveCount: number
|
||||
warningCount: number
|
||||
}
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectTradeDateRange: [payload: { dateFrom: string; dateTo: string }]
|
||||
updateTraderFilter: [traderName: string]
|
||||
updateActionFilter: [actionFilter: 'all' | 'buy' | 'sell' | 'net_buy' | 'net_sell']
|
||||
selectStock: [stockCode: string]
|
||||
followStock: [payload: { stock_code: string; stock_name: string; source_trade_date: string | null; source_trader_name: string | null }]
|
||||
unfollowStock: [stockCode: string]
|
||||
}>()
|
||||
|
||||
const actionOptions = [
|
||||
{ value: 'all', label: '全部方向' },
|
||||
{ value: 'buy', label: '只看买入' },
|
||||
{ value: 'sell', label: '只看卖出' },
|
||||
{ value: 'net_buy', label: '净买入' },
|
||||
{ value: 'net_sell', label: '净卖出' },
|
||||
] as const
|
||||
|
||||
function dedupeActions(actions: ActionItem[]) {
|
||||
const unique = new Map<string, ActionItem>()
|
||||
|
||||
for (const action of actions) {
|
||||
const key = [
|
||||
action.stock_code,
|
||||
action.trade_date,
|
||||
action.trader_name,
|
||||
action.table_title,
|
||||
action.seat_name,
|
||||
].join('::')
|
||||
|
||||
if (!unique.has(key)) {
|
||||
unique.set(key, action)
|
||||
}
|
||||
}
|
||||
|
||||
return [...unique.values()]
|
||||
}
|
||||
|
||||
function normalizeSeatName(value: string) {
|
||||
return value.replace(/\s+/g, '').trim()
|
||||
}
|
||||
|
||||
function mergedActionSide(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 aggregateWatchActions(actions: ActionItem[]) {
|
||||
const groups = new Map<string, ActionItem>()
|
||||
|
||||
for (const action of dedupeActions(actions)) {
|
||||
const key = [
|
||||
action.stock_code,
|
||||
action.trade_date,
|
||||
action.trader_name,
|
||||
normalizeSeatName(action.seat_name),
|
||||
].join('::')
|
||||
|
||||
const existing = groups.get(key)
|
||||
if (!existing) {
|
||||
groups.set(key, { ...action })
|
||||
continue
|
||||
}
|
||||
|
||||
const nextBuy = (numberFromText(existing.buy_amount_wan) ?? 0) + (numberFromText(action.buy_amount_wan) ?? 0)
|
||||
const nextSell = (numberFromText(existing.sell_amount_wan) ?? 0) + (numberFromText(action.sell_amount_wan) ?? 0)
|
||||
const nextNet = nextBuy - nextSell
|
||||
const mergedTitles = new Set(
|
||||
[existing.table_title, action.table_title].filter((item): item is string => Boolean(item)),
|
||||
)
|
||||
|
||||
groups.set(key, {
|
||||
...existing,
|
||||
table_title: [...mergedTitles].join(' / '),
|
||||
buy_amount_wan: nextBuy.toFixed(2),
|
||||
sell_amount_wan: nextSell.toFixed(2),
|
||||
net_amount_wan: nextNet.toFixed(2),
|
||||
action_side: mergedActionSide(nextBuy, nextSell, nextNet),
|
||||
})
|
||||
}
|
||||
|
||||
return [...groups.values()]
|
||||
}
|
||||
|
||||
const watchPanels = computed(() => {
|
||||
return props.watchlist.map((item) => ({
|
||||
...item,
|
||||
actions: aggregateWatchActions(
|
||||
props.watchedActions.filter((action) => action.stock_code === item.stock_code),
|
||||
),
|
||||
}))
|
||||
})
|
||||
|
||||
function updateDateRange(field: 'from' | 'to', value: string) {
|
||||
const nextFrom = field === 'from' ? value : props.selectedDateFrom
|
||||
const nextTo = field === 'to' ? value : props.selectedDateTo
|
||||
emit('selectTradeDateRange', {
|
||||
dateFrom: nextFrom <= nextTo ? nextFrom : nextTo,
|
||||
dateTo: nextFrom <= nextTo ? nextTo : nextFrom,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="screen">
|
||||
<header class="screen-head">
|
||||
<div>
|
||||
<p class="screen-kicker">01 Home Control</p>
|
||||
<h2 class="screen-title">今日游资操作台</h2>
|
||||
</div>
|
||||
<div class="screen-toolbar">
|
||||
<span class="pill active">关注池与操作流水已合并</span>
|
||||
<span class="pill">区间 {{ selectedDateFrom || '-' }} 至 {{ selectedDateTo || '-' }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="filter-bar">
|
||||
<label class="filter-item">
|
||||
<span>开始日期</span>
|
||||
<input
|
||||
type="date"
|
||||
:value="selectedDateFrom"
|
||||
:min="tradeDates[tradeDates.length - 1] || selectedDateFrom"
|
||||
:max="selectedDateTo || tradeDates[0] || selectedDateFrom"
|
||||
@change="updateDateRange('from', ($event.target as HTMLInputElement).value)"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="filter-item">
|
||||
<span>结束日期</span>
|
||||
<input
|
||||
type="date"
|
||||
:value="selectedDateTo"
|
||||
:min="selectedDateFrom || tradeDates[tradeDates.length - 1] || selectedDateTo"
|
||||
:max="tradeDates[0] || selectedDateTo"
|
||||
@change="updateDateRange('to', ($event.target as HTMLInputElement).value)"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="filter-item">
|
||||
<span>游资筛选</span>
|
||||
<select :value="selectedTraderFilter" @change="emit('updateTraderFilter', ($event.target as HTMLSelectElement).value)">
|
||||
<option value="all">全部游资</option>
|
||||
<option v-for="trader in traders" :key="trader.id" :value="trader.name">{{ trader.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="filter-item">
|
||||
<span>操作方向</span>
|
||||
<select
|
||||
:value="selectedActionFilter"
|
||||
@change="emit('updateActionFilter', ($event.target as HTMLSelectElement).value as 'all' | 'buy' | 'sell' | 'net_buy' | 'net_sell')"
|
||||
>
|
||||
<option v-for="item in actionOptions" :key="item.value" :value="item.value">{{ item.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="metric-grid">
|
||||
<article class="metric-card">
|
||||
<p class="metric-label">关注池股票数</p>
|
||||
<h3 class="metric-value gold">{{ metrics.watchCount }}</h3>
|
||||
<p class="metric-desc">关注池来自数据库,取消关注后直接删除对应记录。</p>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<p class="metric-label">当前区间有流水的关注股</p>
|
||||
<h3 class="metric-value blue">{{ metrics.activeWatchCount }}</h3>
|
||||
<p class="metric-desc">左侧卡片会同时展示股票本身和对应操作流水。</p>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<p class="metric-label">关注池买入金额</p>
|
||||
<h3 class="metric-value red">{{ formatWanAmount(metrics.buyTotalWan) }}</h3>
|
||||
<p class="metric-desc">汇总当前区间内关注池全部买入动作。</p>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<p class="metric-label">关注池卖出金额</p>
|
||||
<h3 class="metric-value orange">{{ formatWanAmount(metrics.sellTotalWan) }}</h3>
|
||||
<p class="metric-desc">汇总当前区间内关注池全部卖出动作。</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="home-grid">
|
||||
<article class="card-panel watch-panel">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h3 class="section-title">关注池与操作流水</h3>
|
||||
<p class="section-caption">点击右侧“关注”后会立即进入这里;取消关注会直接从数据库删除。</p>
|
||||
</div>
|
||||
<div class="head-pills">
|
||||
<span class="pill active">{{ watchPanels.length }} 只关注股</span>
|
||||
<span class="pill">{{ metrics.activeWatchCount }} 只有流水</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="watchPanels.length"
|
||||
class="watch-panel-list"
|
||||
>
|
||||
<article
|
||||
v-for="item in watchPanels"
|
||||
:key="item.stock_code"
|
||||
class="watch-stock-card"
|
||||
>
|
||||
<div class="watch-stock-head">
|
||||
<button class="watch-stock-main" type="button" @click="emit('selectStock', item.stock_code)">
|
||||
<div class="watch-stock-title">
|
||||
<strong>{{ item.stock_name }}</strong>
|
||||
<span v-if="item.hasWarning" class="watch-flag">预警中</span>
|
||||
</div>
|
||||
<span>{{ item.stock_code }} · 来源 {{ formatDate(item.source_trade_date) }} / {{ item.source_trader_name || '-' }}</span>
|
||||
</button>
|
||||
|
||||
<div class="watch-stock-tools">
|
||||
<span class="mini-pill">{{ item.actions.length }} 条流水</span>
|
||||
<button class="ghost-button" type="button" @click="emit('unfollowStock', item.stock_code)">
|
||||
取消关注
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="item.actions.length" class="watch-action-list">
|
||||
<button
|
||||
v-for="action in item.actions"
|
||||
:key="`${action.trade_date}-${action.trader_name}-${action.table_title}-${action.seat_name}`"
|
||||
class="action-row"
|
||||
type="button"
|
||||
@click="emit('selectStock', action.stock_code)"
|
||||
>
|
||||
<div class="action-top">
|
||||
<div class="stock-main">
|
||||
<strong>{{ action.trader_name }}</strong>
|
||||
<span>{{ action.trade_date }} · {{ action.table_title }}</span>
|
||||
</div>
|
||||
<span class="quote-line">{{ action.current_price ?? '-' }} / {{ action.pct_chg ?? '-' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="amounts">
|
||||
<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="row-desc">{{ action.seat_name }}</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="inline-empty">当前筛选区间内,这只股票暂时没有新的操作流水。</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">当前还没有关注股票,先从右侧候选区挑选重点标的。</div>
|
||||
</article>
|
||||
|
||||
<aside class="card-panel candidate-panel">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h3 class="section-title">待加入关注</h3>
|
||||
<p class="section-caption">净额已放大显示,同时补上板块、股价、市值和所属板块信息,方便你快速筛候选。</p>
|
||||
</div>
|
||||
<span class="pill">{{ candidateActions.length }} 个候选</span>
|
||||
</div>
|
||||
|
||||
<div v-if="candidateActions.length" class="candidate-list">
|
||||
<CandidateWatchCard
|
||||
v-for="action in candidateActions.slice(0, 18)"
|
||||
:key="`${action.trade_date}-${action.trader_name}-${action.stock_code}-${action.seat_name}`"
|
||||
:action="action"
|
||||
@select-stock="emit('selectStock', $event)"
|
||||
@follow-stock="emit('followStock', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">当前筛选条件下没有新的候选股,调整日期或游资条件后再看。</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.screen {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 30px;
|
||||
background: linear-gradient(180deg, rgba(16, 23, 33, 0.96), rgba(9, 14, 21, 0.98));
|
||||
box-shadow: var(--shadow-strong);
|
||||
}
|
||||
|
||||
.screen-head,
|
||||
.section-head,
|
||||
.watch-stock-head,
|
||||
.action-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.screen-head {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.screen-kicker {
|
||||
margin: 0 0 6px;
|
||||
color: var(--color-gold-soft);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.screen-title,
|
||||
.section-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.screen-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.screen-toolbar,
|
||||
.head-pills,
|
||||
.amounts {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pill,
|
||||
.mini-pill {
|
||||
padding: 7px 11px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: var(--color-muted);
|
||||
font-size: 11px;
|
||||
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;
|
||||
}
|
||||
|
||||
.mini-pill {
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.filter-bar,
|
||||
.metric-grid,
|
||||
.home-grid,
|
||||
.watch-panel-list,
|
||||
.watch-action-list,
|
||||
.candidate-list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--color-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.filter-item select,
|
||||
.filter-item input {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
color: var(--color-text);
|
||||
background: rgba(8, 12, 18, 0.95);
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: none;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
margin-bottom: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metric-card,
|
||||
.card-panel,
|
||||
.watch-stock-card,
|
||||
.action-row {
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
position: relative;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.metric-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
bottom: 14px;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, var(--color-gold-soft), transparent);
|
||||
}
|
||||
|
||||
.metric-label,
|
||||
.metric-desc,
|
||||
.row-desc,
|
||||
.empty-state,
|
||||
.inline-empty,
|
||||
.section-caption {
|
||||
margin: 0;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
margin: 4px 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.metric-value.red {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.metric-value.orange {
|
||||
color: var(--color-orange);
|
||||
}
|
||||
|
||||
.metric-value.gold {
|
||||
color: var(--color-gold-soft);
|
||||
}
|
||||
|
||||
.metric-value.blue {
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.home-grid {
|
||||
grid-template-columns: minmax(0, 1.56fr) minmax(660px, 1.16fr);
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.card-panel {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.watch-panel,
|
||||
.candidate-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.section-caption {
|
||||
margin-top: 6px;
|
||||
line-height: 1.7;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.watch-panel-list {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
align-content: start;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.watch-stock-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
min-height: 176px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));
|
||||
}
|
||||
|
||||
.watch-stock-main,
|
||||
.action-row {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
color: var(--color-text);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.watch-stock-main {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.watch-stock-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.watch-stock-title strong,
|
||||
.stock-main strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.watch-stock-main span,
|
||||
.stock-main span,
|
||||
.quote-line {
|
||||
color: var(--color-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.watch-stock-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.watch-flag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 93, 93, 0.12);
|
||||
border: 1px solid rgba(255, 93, 93, 0.18);
|
||||
color: #ffb4b4;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.watch-action-list {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
background: rgba(4, 8, 12, 0.38);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.amounts {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.row-desc {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.candidate-panel {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.candidate-list {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
align-content: start;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
grid-auto-rows: 124px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
padding: 7px 11px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--color-text);
|
||||
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;
|
||||
}
|
||||
|
||||
.watch-panel-list::-webkit-scrollbar-thumb,
|
||||
.candidate-list::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
@media (max-width: 1320px) {
|
||||
.filter-bar,
|
||||
.metric-grid,
|
||||
.home-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.candidate-panel {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.watch-panel-list,
|
||||
.candidate-list {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.screen-head,
|
||||
.section-head,
|
||||
.watch-stock-head,
|
||||
.action-top {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
957
frontend/src/components/StockDetailScreen.vue
Normal file
957
frontend/src/components/StockDetailScreen.vue
Normal file
@ -0,0 +1,957 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, shallowRef, useTemplateRef } from 'vue'
|
||||
|
||||
import type { StockDetail, WarningItem } from '../types'
|
||||
import {
|
||||
compactMoney,
|
||||
formatSignedWanAmount,
|
||||
formatWanAmount,
|
||||
numberFromText,
|
||||
priceTone,
|
||||
warningLabel,
|
||||
} from '../utils/format'
|
||||
|
||||
type Point = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
type ChartCandle = {
|
||||
trade_date: string
|
||||
open: string
|
||||
close: string
|
||||
high: string
|
||||
low: string
|
||||
pct_chg: string
|
||||
amount: string
|
||||
amplitude: string
|
||||
turnover: string
|
||||
openNum: number
|
||||
closeNum: number
|
||||
highNum: number
|
||||
lowNum: number
|
||||
x: number
|
||||
wickTop: number
|
||||
wickBottom: number
|
||||
bodyTop: number
|
||||
bodyHeight: number
|
||||
color: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
stockDetail: StockDetail | null
|
||||
activeWarning: WarningItem | null
|
||||
}>()
|
||||
|
||||
const chartRef = useTemplateRef<SVGSVGElement>('chartRef')
|
||||
const selectedZoom = shallowRef(80)
|
||||
const hoveredIndex = shallowRef<number | null>(null)
|
||||
|
||||
const zoomOptions = [
|
||||
{ label: '近40日', count: 40 },
|
||||
{ label: '近80日', count: 80 },
|
||||
{ label: '近160日', count: 160 },
|
||||
{ label: '全部', count: 9999 },
|
||||
] as const
|
||||
|
||||
const chartLegendItems = [
|
||||
{ label: 'MA5(5日线)', tone: '#5ab8ff', type: 'line' },
|
||||
{ label: 'B 买入', tone: '#ff5d5d', type: 'dot' },
|
||||
{ label: 'S 卖出', tone: '#5ab8ff', type: 'dot' },
|
||||
] as const
|
||||
|
||||
function validText(value: string | null | undefined): string | null {
|
||||
if (!value || value === '-') return null
|
||||
return value
|
||||
}
|
||||
|
||||
function formatPercentNumber(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) return '-'
|
||||
return `${value.toFixed(2)}%`
|
||||
}
|
||||
|
||||
function formatAmountNumber(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) return '-'
|
||||
return compactMoney(value)
|
||||
}
|
||||
|
||||
function valueOrFallback(primary: string | null | undefined, fallback: string | null | undefined): string {
|
||||
if (primary && primary !== '-') return primary
|
||||
if (fallback && fallback !== '-') return fallback
|
||||
return '-'
|
||||
}
|
||||
|
||||
const topWarning = computed(() => props.activeWarning ?? props.stockDetail?.warnings[0] ?? null)
|
||||
const latestOverview = computed(() => props.stockDetail?.overview?.[0] ?? null)
|
||||
const displayedTraderSummary = computed(() => (props.stockDetail?.trader_summary ?? []).slice(0, 4))
|
||||
const displayedTraderActions = computed(() => (props.stockDetail?.trader_actions ?? []).slice(0, 6))
|
||||
const displayedWarnings = computed(() => (props.stockDetail?.warnings ?? []).slice(0, 3))
|
||||
|
||||
const totalNetAmountWan = computed(() => {
|
||||
return (props.stockDetail?.trader_summary ?? []).reduce((sum, item) => sum + (item.total_net_amount_wan ?? 0), 0)
|
||||
})
|
||||
|
||||
const baseFacts = computed(() => {
|
||||
const stock = props.stockDetail?.stock
|
||||
const snapshot = props.stockDetail?.market_snapshot
|
||||
const latest = latestOverview.value
|
||||
if (!stock) return []
|
||||
|
||||
const latestPrice = snapshot?.latest_price ?? numberFromText(latest?.price) ?? null
|
||||
const latestPct = snapshot?.pct_chg ?? numberFromText(validText(latest?.pct_chg)) ?? null
|
||||
const latestAmount = snapshot?.amount ?? numberFromText(latest?.amount) ?? null
|
||||
|
||||
return [
|
||||
{ label: '代码', value: stock.stock_code },
|
||||
{ label: '市场', value: stock.market || 'A股' },
|
||||
{ label: '行业', value: stock.industry || snapshot?.industry || '-' },
|
||||
{ label: '最新价', value: latestPrice === null ? '-' : String((latestPrice / 100).toFixed(2)) },
|
||||
{ label: '涨跌幅', value: latestPct === null ? valueOrFallback(latest?.pct_chg, '-') : formatPercentNumber(latestPct / 100) },
|
||||
{ label: '成交额', value: latestAmount === null ? valueOrFallback(latest?.amount, '-') : formatAmountNumber(latestAmount) },
|
||||
{ label: '振幅', value: snapshot?.amplitude == null ? '-' : formatPercentNumber(snapshot.amplitude / 100) },
|
||||
{ label: '换手', value: snapshot?.turnover == null ? '-' : formatPercentNumber(snapshot.turnover / 100) },
|
||||
{ label: '总市值', value: compactMoney(stock.total_market_value ?? snapshot?.total_market_value ?? null) },
|
||||
{ label: '流通市值', value: compactMoney(stock.circulating_market_value ?? snapshot?.circulating_market_value ?? null) },
|
||||
]
|
||||
})
|
||||
|
||||
const marketRows = computed(() => {
|
||||
return (props.stockDetail?.market_daily ?? []).map((row) => ({
|
||||
...row,
|
||||
openNum: numberFromText(row.open) ?? 0,
|
||||
closeNum: numberFromText(row.close) ?? 0,
|
||||
highNum: numberFromText(row.high) ?? 0,
|
||||
lowNum: numberFromText(row.low) ?? 0,
|
||||
}))
|
||||
})
|
||||
|
||||
const visibleRows = computed(() => {
|
||||
if (!marketRows.value.length) return []
|
||||
if (selectedZoom.value >= marketRows.value.length) return marketRows.value
|
||||
return marketRows.value.slice(-selectedZoom.value)
|
||||
})
|
||||
|
||||
const actionMap = computed(() => {
|
||||
const map = new Map<string, Array<{ tone: string; shortLabel: string; title: string }>>()
|
||||
for (const action of props.stockDetail?.trader_actions ?? []) {
|
||||
const current = map.get(action.trade_date) ?? []
|
||||
const buy = numberFromText(action.buy_amount_wan) ?? 0
|
||||
const sell = numberFromText(action.sell_amount_wan) ?? 0
|
||||
const header = `${action.trade_date} ${action.matched_trader_name}`
|
||||
if (buy > 0) {
|
||||
current.push({
|
||||
tone: '#ff5d5d',
|
||||
shortLabel: 'B',
|
||||
title: `${header}\n买入 ${formatWanAmount(action.buy_amount_wan)}\n卖出 ${formatWanAmount(action.sell_amount_wan)}\n净额 ${formatSignedWanAmount(action.net_amount_wan)}`,
|
||||
})
|
||||
}
|
||||
if (sell > 0) {
|
||||
current.push({
|
||||
tone: '#5ab8ff',
|
||||
shortLabel: 'S',
|
||||
title: `${header}\n买入 ${formatWanAmount(action.buy_amount_wan)}\n卖出 ${formatWanAmount(action.sell_amount_wan)}\n净额 ${formatSignedWanAmount(action.net_amount_wan)}`,
|
||||
})
|
||||
}
|
||||
map.set(action.trade_date, current)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const chartModel = computed(() => {
|
||||
const rows = visibleRows.value
|
||||
const width = 980
|
||||
const height = 320
|
||||
const left = 52
|
||||
const right = 12
|
||||
const top = 16
|
||||
const bottom = 36
|
||||
|
||||
const emptyModel = {
|
||||
candles: [] as ChartCandle[],
|
||||
overlays: [] as Array<{ x: number; y: number; tone: string; shortLabel: string; title: string }>,
|
||||
ma5: [] as Point[],
|
||||
labels: [] as Array<{ x: number; label: string; visible: boolean }>,
|
||||
axis: [] as Array<{ y: number; label: string }>,
|
||||
width,
|
||||
height,
|
||||
left,
|
||||
right,
|
||||
top,
|
||||
bottom,
|
||||
stepX: 0,
|
||||
bodyWidth: 2,
|
||||
hoverWidth: 8,
|
||||
}
|
||||
|
||||
if (!rows.length) return emptyModel
|
||||
|
||||
const prices = rows.flatMap((item) => [item.highNum, item.lowNum])
|
||||
const min = Math.min(...prices)
|
||||
const max = Math.max(...prices)
|
||||
const innerWidth = width - left - right
|
||||
const innerHeight = height - top - bottom
|
||||
const stepX = innerWidth / Math.max(rows.length - 1, 1)
|
||||
const bodyWidth = Math.max(3, Math.min(9, stepX * 0.62))
|
||||
const hoverWidth = Math.max(8, stepX)
|
||||
const yOf = (price: number) =>
|
||||
top + (max === min ? innerHeight / 2 : ((max - price) / (max - min)) * innerHeight)
|
||||
|
||||
const ma5 = rows.map((_row, index) => {
|
||||
const slice = rows.slice(Math.max(0, index - 4), index + 1)
|
||||
const avg = slice.reduce((sum, current) => sum + current.closeNum, 0) / slice.length
|
||||
return { x: left + index * stepX, y: yOf(avg) }
|
||||
})
|
||||
|
||||
const labelStep = Math.max(1, Math.ceil(rows.length / 8))
|
||||
|
||||
return {
|
||||
candles: rows.map((item, index) => ({
|
||||
...item,
|
||||
x: left + index * stepX,
|
||||
wickTop: yOf(item.highNum),
|
||||
wickBottom: yOf(item.lowNum),
|
||||
bodyTop: yOf(Math.max(item.openNum, item.closeNum)),
|
||||
bodyHeight: Math.max(
|
||||
yOf(Math.min(item.openNum, item.closeNum)) - yOf(Math.max(item.openNum, item.closeNum)),
|
||||
3,
|
||||
),
|
||||
color: item.closeNum >= item.openNum ? '#ff5d5d' : '#2dbd7b',
|
||||
})),
|
||||
overlays: rows.flatMap((item, index) => {
|
||||
const actions = actionMap.value.get(item.trade_date) ?? []
|
||||
const x = left + index * stepX
|
||||
return actions.slice(0, 2).map((entry, overlayIndex) => ({
|
||||
x,
|
||||
y: yOf(item.highNum) - 18 - overlayIndex * 18,
|
||||
tone: entry.tone,
|
||||
shortLabel: entry.shortLabel,
|
||||
title: entry.title,
|
||||
}))
|
||||
}),
|
||||
ma5,
|
||||
labels: rows.map((item, index) => ({
|
||||
x: left + index * stepX,
|
||||
label: item.trade_date.slice(5),
|
||||
visible: index % labelStep === 0 || index === rows.length - 1,
|
||||
})),
|
||||
axis: Array.from({ length: 5 }, (_, index) => {
|
||||
const price = max - ((max - min) * index) / 4
|
||||
const y = top + (innerHeight * index) / 4
|
||||
return { y, label: price.toFixed(2) }
|
||||
}),
|
||||
width,
|
||||
height,
|
||||
left,
|
||||
right,
|
||||
top,
|
||||
bottom,
|
||||
stepX,
|
||||
bodyWidth,
|
||||
hoverWidth,
|
||||
}
|
||||
})
|
||||
|
||||
const ma5Points = computed(() => chartModel.value.ma5.map((point) => `${point.x},${point.y}`).join(' '))
|
||||
|
||||
const hoveredCandle = computed(() => {
|
||||
if (hoveredIndex.value === null) return null
|
||||
return chartModel.value.candles[hoveredIndex.value] ?? null
|
||||
})
|
||||
|
||||
const hoveredActions = computed(() => {
|
||||
const candle = hoveredCandle.value
|
||||
if (!candle) return []
|
||||
return props.stockDetail?.trader_actions.filter((item) => item.trade_date === candle.trade_date) ?? []
|
||||
})
|
||||
|
||||
function setZoom(count: number) {
|
||||
selectedZoom.value = count
|
||||
hoveredIndex.value = null
|
||||
}
|
||||
|
||||
function handleWheelZoom(event: WheelEvent) {
|
||||
event.preventDefault()
|
||||
const currentIndex = zoomOptions.findIndex((item) => item.count === selectedZoom.value)
|
||||
const safeIndex = currentIndex === -1 ? 1 : currentIndex
|
||||
const nextIndex = event.deltaY < 0
|
||||
? Math.max(0, safeIndex - 1)
|
||||
: Math.min(zoomOptions.length - 1, safeIndex + 1)
|
||||
selectedZoom.value = zoomOptions[nextIndex].count
|
||||
hoveredIndex.value = null
|
||||
}
|
||||
|
||||
function handleChartMove(event: MouseEvent) {
|
||||
const chart = chartRef.value
|
||||
if (!chart || !chartModel.value.candles.length) return
|
||||
const rect = chart.getBoundingClientRect()
|
||||
const ratio = (event.clientX - rect.left) / rect.width
|
||||
const svgX = ratio * chartModel.value.width
|
||||
const index = Math.round((svgX - chartModel.value.left) / chartModel.value.stepX)
|
||||
const clampedIndex = Math.max(0, Math.min(chartModel.value.candles.length - 1, index))
|
||||
hoveredIndex.value = clampedIndex
|
||||
}
|
||||
|
||||
function clearHover() {
|
||||
hoveredIndex.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="screen">
|
||||
<header class="screen-head">
|
||||
<div>
|
||||
<p class="screen-kicker">03 Stock Detail</p>
|
||||
<h2 class="screen-title">
|
||||
个股详情
|
||||
<span v-if="stockDetail">· {{ stockDetail.stock.stock_name }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="screen-toolbar">
|
||||
<span class="pill active">日K + 操作观点</span>
|
||||
<span class="pill" v-if="topWarning">{{ warningLabel(topWarning.warning_type) }}</span>
|
||||
<span class="pill" v-if="stockDetail">{{ stockDetail.stock.stock_code }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="stockDetail" class="stock-layout">
|
||||
<div class="main-stack">
|
||||
<article class="card-panel stock-summary">
|
||||
<div class="section-head">
|
||||
<h3 class="section-title">基础信息</h3>
|
||||
<span class="tag gold">{{ stockDetail.stock.industry || stockDetail.market_snapshot?.industry || '行业待补充' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-row">
|
||||
<div class="stock-heading">
|
||||
<strong>{{ stockDetail.stock.stock_name }}</strong>
|
||||
<span>{{ stockDetail.stock.stock_code }} / {{ stockDetail.stock.market || 'A股' }}</span>
|
||||
</div>
|
||||
<div class="summary-number" :class="priceTone(baseFacts[4]?.value)">
|
||||
{{ baseFacts[3]?.value || '-' }}
|
||||
</div>
|
||||
<div>{{ baseFacts[4]?.value || '-' }}</div>
|
||||
<div>总市值 {{ baseFacts[8]?.value || '-' }}</div>
|
||||
<div>流通 {{ baseFacts[9]?.value || '-' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="info-grid">
|
||||
<div v-for="fact in baseFacts" :key="fact.label" class="info-item">
|
||||
<span>{{ fact.label }}</span>
|
||||
<strong>{{ fact.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="chart-panel">
|
||||
<div class="section-head">
|
||||
<h3 class="section-title">日K走势与买卖操作</h3>
|
||||
<div class="zoom-bar">
|
||||
<button
|
||||
v-for="item in zoomOptions"
|
||||
:key="item.label"
|
||||
class="zoom-pill"
|
||||
:class="{ active: selectedZoom === item.count }"
|
||||
type="button"
|
||||
@click="setZoom(item.count)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-legend">
|
||||
<span
|
||||
v-for="item in chartLegendItems"
|
||||
:key="item.label"
|
||||
class="legend-item"
|
||||
>
|
||||
<span class="legend-mark" :class="item.type" :style="{ '--legend-tone': item.tone }" />
|
||||
<span>{{ item.label }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="chart-grid">
|
||||
<div class="chart-shell">
|
||||
<svg
|
||||
ref="chartRef"
|
||||
:viewBox="`0 0 ${chartModel.width} ${chartModel.height}`"
|
||||
preserveAspectRatio="none"
|
||||
@mousemove="handleChartMove"
|
||||
@mouseleave="clearHover"
|
||||
@wheel.prevent="handleWheelZoom"
|
||||
>
|
||||
<g opacity="0.08" 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="`label-${tick.label}`">
|
||||
<text x="4" :y="tick.y + 4" fill="#93a2b5" font-size="10">{{ tick.label }}</text>
|
||||
</g>
|
||||
|
||||
<polyline fill="none" stroke="#5ab8ff" stroke-width="2.2" :points="ma5Points" />
|
||||
|
||||
<g v-for="(candle, index) in chartModel.candles" :key="`${candle.trade_date}-${candle.x}`">
|
||||
<rect
|
||||
:x="Number(candle.x) - chartModel.hoverWidth / 2"
|
||||
y="0"
|
||||
:width="chartModel.hoverWidth"
|
||||
:height="chartModel.height"
|
||||
fill="transparent"
|
||||
@mouseenter="hoveredIndex = index"
|
||||
/>
|
||||
<line
|
||||
:x1="Number(candle.x)"
|
||||
:y1="Number(candle.wickTop)"
|
||||
:x2="Number(candle.x)"
|
||||
:y2="Number(candle.wickBottom)"
|
||||
:stroke="String(candle.color)"
|
||||
stroke-width="1.4"
|
||||
/>
|
||||
<rect
|
||||
:x="Number(candle.x) - chartModel.bodyWidth / 2"
|
||||
:y="Number(candle.bodyTop)"
|
||||
:width="chartModel.bodyWidth"
|
||||
:height="Number(candle.bodyHeight)"
|
||||
rx="1.4"
|
||||
:fill="String(candle.color)"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g v-for="overlay in chartModel.overlays" :key="`${overlay.x}-${overlay.y}-${overlay.title}`">
|
||||
<line :x1="overlay.x" :y1="overlay.y + 12" :x2="overlay.x" :y2="overlay.y + 4" :stroke="overlay.tone" stroke-width="1.2" />
|
||||
<circle :cx="overlay.x" :cy="overlay.y" r="7.5" :fill="overlay.tone">
|
||||
<title>{{ overlay.title }}</title>
|
||||
</circle>
|
||||
<text :x="overlay.x" :y="overlay.y + 3" text-anchor="middle" fill="#081018" font-size="8" font-weight="700">
|
||||
{{ overlay.shortLabel }}
|
||||
</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="hover-card">
|
||||
<template v-if="hoveredCandle">
|
||||
<h4>{{ hoveredCandle.trade_date }}</h4>
|
||||
<div class="hover-grid">
|
||||
<span>开 {{ hoveredCandle.open }}</span>
|
||||
<span>收 {{ hoveredCandle.close }}</span>
|
||||
<span>高 {{ hoveredCandle.high }}</span>
|
||||
<span>低 {{ hoveredCandle.low }}</span>
|
||||
<span>涨跌 {{ hoveredCandle.pct_chg }}</span>
|
||||
<span>成交额 {{ hoveredCandle.amount }}</span>
|
||||
</div>
|
||||
<div v-if="hoveredActions.length" class="hover-actions">
|
||||
<div
|
||||
v-for="action in hoveredActions"
|
||||
:key="`${action.trade_date}-${action.seat_name}-${action.table_title}`"
|
||||
class="hover-action"
|
||||
>
|
||||
<strong>{{ action.matched_trader_name }}</strong>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="hover-empty">移动鼠标到K线或买卖点上,查看当日买卖观点。</p>
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="side-stack">
|
||||
<article v-if="topWarning" class="judge-panel">
|
||||
<div class="section-head">
|
||||
<h3 class="section-title">预警判断</h3>
|
||||
<span class="tag" :class="topWarning.warning_level === 'high' ? 'red' : 'orange'">
|
||||
{{ warningLabel(topWarning.warning_type) }}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="judge-main">{{ topWarning.warning_level === 'high' ? '风险优先' : '继续观察' }}</h3>
|
||||
<p class="judge-desc">{{ topWarning.trigger_reason }}</p>
|
||||
<p class="judge-desc" v-if="topWarning.suggestion">{{ topWarning.suggestion }}</p>
|
||||
</article>
|
||||
|
||||
<article class="side-card">
|
||||
<h4 class="side-title">累计净额</h4>
|
||||
<div class="kv-row">
|
||||
<span>全部游资累计净额</span>
|
||||
<strong :class="priceTone(String(totalNetAmountWan))">{{ formatSignedWanAmount(totalNetAmountWan) }}</strong>
|
||||
</div>
|
||||
<div
|
||||
v-for="trader in displayedTraderSummary"
|
||||
:key="trader.matched_trader_name"
|
||||
class="trader-flow"
|
||||
>
|
||||
<div class="flow-top">
|
||||
<strong>{{ trader.matched_trader_name }}</strong>
|
||||
<span>{{ trader.action_count }} 次</span>
|
||||
</div>
|
||||
<div class="flow-line buy">买入 {{ formatWanAmount(trader.total_buy_amount_wan) }}</div>
|
||||
<div class="flow-line sell">卖出 {{ formatWanAmount(trader.total_sell_amount_wan) }}</div>
|
||||
<div class="flow-line net" :class="priceTone(String(trader.total_net_amount_wan))">
|
||||
累计净额 {{ formatSignedWanAmount(trader.total_net_amount_wan) }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="side-card">
|
||||
<div class="section-head">
|
||||
<h4 class="side-title">买卖明细</h4>
|
||||
<span class="pill">{{ displayedTraderActions.length }} 条</span>
|
||||
</div>
|
||||
<div class="detail-list">
|
||||
<div
|
||||
v-for="action in displayedTraderActions"
|
||||
:key="`${action.trade_date}-${action.matched_trader_name}-${action.seat_name}-${action.table_title}`"
|
||||
class="detail-item"
|
||||
>
|
||||
<div class="flow-top">
|
||||
<strong>{{ action.matched_trader_name }}</strong>
|
||||
<span>{{ action.trade_date }}</span>
|
||||
</div>
|
||||
<div class="flow-line buy">买入 {{ formatWanAmount(action.buy_amount_wan) }}</div>
|
||||
<div class="flow-line sell">卖出 {{ formatWanAmount(action.sell_amount_wan) }}</div>
|
||||
<div class="flow-line net" :class="priceTone(String(action.net_amount_wan))">
|
||||
净额 {{ formatSignedWanAmount(action.net_amount_wan) }}
|
||||
</div>
|
||||
<p class="detail-desc">{{ action.seat_name }} · {{ action.table_title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="side-card">
|
||||
<h4 class="side-title">预警情况</h4>
|
||||
<div
|
||||
v-for="warning in displayedWarnings"
|
||||
:key="`${warning.trade_date}-${warning.warning_type}-${warning.trader_name}`"
|
||||
class="timeline-entry"
|
||||
>
|
||||
<div class="timeline-top">
|
||||
<strong>{{ warning.trader_name }}</strong>
|
||||
<span class="tag" :class="warning.warning_level === 'high' ? 'red' : 'orange'">
|
||||
{{ warningLabel(warning.warning_type) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="timeline-desc">{{ warning.trade_date }} · {{ warning.trigger_reason }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.screen {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 30px;
|
||||
background: linear-gradient(180deg, rgba(16, 23, 33, 0.96), rgba(9, 14, 21, 0.98));
|
||||
box-shadow: var(--shadow-strong);
|
||||
}
|
||||
|
||||
.screen-head,
|
||||
.section-head,
|
||||
.timeline-top,
|
||||
.flow-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.screen-head {
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.screen-kicker {
|
||||
margin: 0 0 4px;
|
||||
color: var(--color-gold-soft);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.screen-title,
|
||||
.section-title,
|
||||
.side-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.screen-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.screen-toolbar,
|
||||
.zoom-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pill,
|
||||
.tag,
|
||||
.zoom-pill {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.pill,
|
||||
.zoom-pill {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: var(--color-muted);
|
||||
font-size: 11px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.pill.active,
|
||||
.zoom-pill.active {
|
||||
color: #090d14;
|
||||
background: linear-gradient(180deg, var(--color-gold-soft), var(--color-gold));
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.zoom-pill {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin: 10px 0 14px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: var(--color-muted);
|
||||
font-size: 11px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.legend-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 18px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.legend-mark.line::before {
|
||||
content: '';
|
||||
width: 18px;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: var(--legend-tone);
|
||||
}
|
||||
|
||||
.legend-mark.dot::before {
|
||||
content: '';
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--legend-tone);
|
||||
}
|
||||
|
||||
.stock-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.72fr) minmax(320px, 0.85fr);
|
||||
gap: 14px;
|
||||
height: calc(100% - 52px);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.main-stack,
|
||||
.side-stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.main-stack {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.side-stack {
|
||||
grid-template-rows: auto repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.card-panel,
|
||||
.chart-panel,
|
||||
.judge-panel,
|
||||
.side-card {
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.3fr 0.7fr 0.7fr 1fr 1fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stock-heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stock-heading strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.stock-heading span {
|
||||
color: var(--color-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.summary-number {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
padding: 10px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.info-item span {
|
||||
display: block;
|
||||
color: var(--color-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.info-item strong {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 250px;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chart-shell {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.chart-shell svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hover-card {
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
background: rgba(8, 12, 18, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.hover-card h4 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.hover-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hover-actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.hover-action {
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed rgba(255, 255, 255, 0.08);
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hover-empty {
|
||||
margin: 0;
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.judge-panel {
|
||||
background: linear-gradient(135deg, rgba(255, 174, 66, 0.16), rgba(255, 255, 255, 0.02));
|
||||
border-color: rgba(255, 174, 66, 0.24);
|
||||
}
|
||||
|
||||
.judge-main {
|
||||
margin: 8px 0 6px;
|
||||
color: #ffd08b;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.judge-desc,
|
||||
.timeline-desc,
|
||||
.detail-desc {
|
||||
margin: 0;
|
||||
color: #d6ceb9;
|
||||
line-height: 1.55;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.side-title {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.kv-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.trader-flow,
|
||||
.detail-item,
|
||||
.timeline-entry {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.trader-flow:last-child,
|
||||
.detail-item:last-child,
|
||||
.timeline-entry:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.flow-top {
|
||||
margin-bottom: 6px;
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.flow-line {
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.flow-line.buy {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.flow-line.sell {
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.flow-line.net.rise {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.flow-line.net.fall {
|
||||
color: var(--color-green);
|
||||
}
|
||||
|
||||
.flow-line.net.flat {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 68px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tag.red {
|
||||
color: #ffb4b4;
|
||||
background: rgba(255, 93, 93, 0.14);
|
||||
border-color: rgba(255, 93, 93, 0.18);
|
||||
}
|
||||
|
||||
.tag.orange {
|
||||
color: #ffd08b;
|
||||
background: rgba(255, 174, 66, 0.14);
|
||||
border-color: rgba(255, 174, 66, 0.18);
|
||||
}
|
||||
|
||||
.tag.gold {
|
||||
color: #f3c986;
|
||||
background: rgba(212, 163, 92, 0.14);
|
||||
border-color: rgba(212, 163, 92, 0.2);
|
||||
}
|
||||
|
||||
.rise {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.fall {
|
||||
color: var(--color-green);
|
||||
}
|
||||
|
||||
.flat {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 1260px) {
|
||||
.stock-layout,
|
||||
.info-grid,
|
||||
.chart-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
371
frontend/src/components/TraderDetailScreen.vue
Normal file
371
frontend/src/components/TraderDetailScreen.vue
Normal file
@ -0,0 +1,371 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { TraderDetail, TraderListItem } from '../types'
|
||||
import { formatDate, priceTone } from '../utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
traders: TraderListItem[]
|
||||
traderDetail: TraderDetail | null
|
||||
selectedTraderId: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectTrader: [traderId: number]
|
||||
selectStock: [stockCode: string]
|
||||
}>()
|
||||
|
||||
const profileText = computed(() => {
|
||||
const detail = props.traderDetail
|
||||
if (!detail) return '等待选择游资。'
|
||||
const tags = detail.trader.style_tags?.join('、') || '暂无风格标签'
|
||||
return `${detail.trader.name} 当前风格聚焦 ${tags}。本页只保留游资档案和近期参与股票列表,动作时间线已移除。`
|
||||
})
|
||||
|
||||
const summaryCards = computed(() => {
|
||||
const detail = props.traderDetail
|
||||
if (!detail) return []
|
||||
return [
|
||||
{ label: '近期参与股票数', value: detail.stocks.length, tone: '' },
|
||||
{
|
||||
label: '当前卖出预警',
|
||||
value: detail.stocks.filter((item) => item.has_sell_alert).length,
|
||||
tone: 'danger',
|
||||
},
|
||||
{
|
||||
label: '慢流出观察',
|
||||
value: detail.stocks.filter((item) => item.has_slow_exit).length,
|
||||
tone: 'watch',
|
||||
},
|
||||
{ label: '核心席位', value: detail.seats.length, tone: 'focus' },
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="screen">
|
||||
<header class="screen-head">
|
||||
<div>
|
||||
<p class="screen-kicker">02 Trader Detail</p>
|
||||
<h2 class="screen-title">
|
||||
游资详情页
|
||||
<span v-if="traderDetail">· {{ traderDetail.trader.name }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="screen-toolbar">
|
||||
<button
|
||||
v-for="trader in traders"
|
||||
:key="trader.id"
|
||||
class="pill-button"
|
||||
:class="{ active: trader.id === selectedTraderId }"
|
||||
type="button"
|
||||
@click="emit('selectTrader', trader.id)"
|
||||
>
|
||||
{{ trader.name }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="traderDetail" class="trader-layout">
|
||||
<div class="header-card">
|
||||
<article class="profile-card">
|
||||
<h3 class="profile-title">
|
||||
{{ traderDetail.trader.name }}
|
||||
<span v-if="traderDetail.trader.alias_name">
|
||||
/ {{ traderDetail.trader.alias_name }}
|
||||
</span>
|
||||
</h3>
|
||||
<p class="profile-desc">{{ profileText }}</p>
|
||||
<div class="chip-list">
|
||||
<span
|
||||
v-for="seat in traderDetail.seats"
|
||||
:key="seat.seat_name"
|
||||
class="chip"
|
||||
>
|
||||
{{ seat.seat_name }}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="summary-grid">
|
||||
<article
|
||||
v-for="item in summaryCards"
|
||||
:key="item.label"
|
||||
class="summary-box"
|
||||
:class="item.tone"
|
||||
>
|
||||
<p class="summary-label">{{ item.label }}</p>
|
||||
<h3 class="summary-value">{{ item.value }}</h3>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article class="card-panel">
|
||||
<div class="section-head">
|
||||
<h3 class="section-title">近期参与股票列表</h3>
|
||||
<span class="pill">点击任意股票进入详情</span>
|
||||
</div>
|
||||
|
||||
<div class="stock-table">
|
||||
<button
|
||||
v-for="stock in traderDetail.stocks.slice(0, 18)"
|
||||
:key="stock.stock_code"
|
||||
class="stock-row"
|
||||
type="button"
|
||||
@click="emit('selectStock', stock.stock_code)"
|
||||
>
|
||||
<div class="stock-core">
|
||||
<strong>{{ stock.stock_name }}</strong>
|
||||
<span>{{ stock.stock_code }}</span>
|
||||
</div>
|
||||
<div>{{ stock.latest_price ?? '-' }}</div>
|
||||
<div :class="priceTone(stock.pct_chg)">{{ stock.pct_chg ?? '-' }}</div>
|
||||
<div>{{ stock.action_count }} 次动作</div>
|
||||
<div>{{ formatDate(stock.last_trade_date) }}</div>
|
||||
<div class="tag-group">
|
||||
<span v-if="stock.has_sell_alert" class="tag red">卖出预警</span>
|
||||
<span v-if="stock.has_slow_exit" class="tag orange">慢流出</span>
|
||||
<span v-if="!stock.has_sell_alert && !stock.has_slow_exit" class="tag blue">跟踪中</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.screen {
|
||||
padding: 24px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 30px;
|
||||
background: linear-gradient(180deg, rgba(16, 23, 33, 0.96), rgba(9, 14, 21, 0.98));
|
||||
box-shadow: var(--shadow-strong);
|
||||
}
|
||||
|
||||
.screen-head,
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.screen-head {
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.screen-kicker {
|
||||
margin: 0 0 6px;
|
||||
color: var(--color-gold-soft);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.screen-title,
|
||||
.section-title,
|
||||
.profile-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.screen-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.screen-toolbar,
|
||||
.chip-list,
|
||||
.tag-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pill,
|
||||
.pill-button,
|
||||
.chip,
|
||||
.tag {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.pill,
|
||||
.pill-button {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.pill-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pill-button.active {
|
||||
color: #090d14;
|
||||
background: linear-gradient(180deg, var(--color-gold-soft), var(--color-gold));
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.header-card {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.profile-card,
|
||||
.summary-box,
|
||||
.card-panel,
|
||||
.stock-row {
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, rgba(212, 163, 92, 0.18), rgba(255, 255, 255, 0.02));
|
||||
}
|
||||
|
||||
.profile-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 34px;
|
||||
}
|
||||
|
||||
.profile-desc {
|
||||
margin: 10px 0 16px;
|
||||
color: #d6ceb9;
|
||||
line-height: 1.8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-box {
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
margin: 0;
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
margin-top: 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.summary-box.danger .summary-value {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.summary-box.watch .summary-value {
|
||||
color: var(--color-orange);
|
||||
}
|
||||
|
||||
.summary-box.focus .summary-value {
|
||||
color: var(--color-gold-soft);
|
||||
}
|
||||
|
||||
.card-panel {
|
||||
padding: 18px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.stock-table {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stock-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.7fr 0.7fr 0.8fr 0.9fr 1fr;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stock-core {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stock-core span {
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 74px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tag.red {
|
||||
color: #ffb4b4;
|
||||
background: rgba(255, 93, 93, 0.14);
|
||||
border-color: rgba(255, 93, 93, 0.18);
|
||||
}
|
||||
|
||||
.tag.orange {
|
||||
color: #ffd08b;
|
||||
background: rgba(255, 174, 66, 0.14);
|
||||
border-color: rgba(255, 174, 66, 0.18);
|
||||
}
|
||||
|
||||
.tag.blue {
|
||||
color: #acd8ff;
|
||||
background: rgba(90, 184, 255, 0.14);
|
||||
border-color: rgba(90, 184, 255, 0.18);
|
||||
}
|
||||
|
||||
.rise {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.fall {
|
||||
color: var(--color-green);
|
||||
}
|
||||
|
||||
.flat {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 1160px) {
|
||||
.header-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.stock-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
298
frontend/src/components/WarningCenterScreen.vue
Normal file
298
frontend/src/components/WarningCenterScreen.vue
Normal file
@ -0,0 +1,298 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { TraderListItem, WarningItem } from '../types'
|
||||
import { warningLabel, warningTone } from '../utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
warnings: WarningItem[]
|
||||
traders: TraderListItem[]
|
||||
activeWarning: WarningItem | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectWarning: [warning: WarningItem]
|
||||
}>()
|
||||
|
||||
const countsByType = computed(() => {
|
||||
return props.warnings.reduce<Record<string, number>>((acc, item) => {
|
||||
acc[item.warning_type] = (acc[item.warning_type] ?? 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="screen">
|
||||
<header class="screen-head">
|
||||
<div>
|
||||
<p class="screen-kicker">04 Warning Center</p>
|
||||
<h2 class="screen-title">预警与监控页</h2>
|
||||
</div>
|
||||
<div class="screen-toolbar">
|
||||
<span class="pill active">高风险优先</span>
|
||||
<span class="pill">重点观察</span>
|
||||
<span class="pill">滚动监控</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="warning-grid">
|
||||
<aside class="filter-column">
|
||||
<article class="filter-box">
|
||||
<h3 class="section-title">游资筛选</h3>
|
||||
<div
|
||||
v-for="trader in traders"
|
||||
:key="trader.id"
|
||||
class="check-row"
|
||||
>
|
||||
<span>{{ trader.name }}</span>
|
||||
<strong>{{ trader.stock_count }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="filter-box">
|
||||
<h3 class="section-title">预警类型</h3>
|
||||
<div class="check-row">
|
||||
<span>卖出预警</span>
|
||||
<strong>{{ countsByType.sell_alert ?? 0 }}</strong>
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<span>慢流出观察</span>
|
||||
<strong>{{ countsByType.slow_exit_watch ?? 0 }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
</aside>
|
||||
|
||||
<article class="card-panel">
|
||||
<div class="section-head">
|
||||
<h3 class="section-title">预警列表</h3>
|
||||
<span class="pill">按等级排序</span>
|
||||
</div>
|
||||
|
||||
<div class="monitor-list">
|
||||
<button
|
||||
v-for="warning in warnings"
|
||||
:key="`${warning.trade_date}-${warning.stock_code}-${warning.trader_name}`"
|
||||
class="monitor-item"
|
||||
:class="warning.warning_level === 'high' ? 'red' : 'orange'"
|
||||
type="button"
|
||||
@click="emit('selectWarning', warning)"
|
||||
>
|
||||
<div class="monitor-top">
|
||||
<strong>{{ warning.stock_name }} / {{ warning.trader_name }}</strong>
|
||||
<span class="tag" :class="warningTone(warning.warning_level)">
|
||||
{{ warningLabel(warning.warning_type) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="monitor-desc">{{ warning.trigger_reason }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card-panel detail-panel">
|
||||
<div class="section-head">
|
||||
<h3 class="section-title">预警详情</h3>
|
||||
<span class="pill active">{{ activeWarning?.stock_name ?? '等待选择' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="activeWarning" class="detail-stack">
|
||||
<article class="side-card">
|
||||
<h4 class="side-title">触发原因</h4>
|
||||
<p class="detail-text">{{ activeWarning.trigger_reason }}</p>
|
||||
</article>
|
||||
|
||||
<article class="side-card">
|
||||
<h4 class="side-title">关键信息</h4>
|
||||
<div class="kv-row">
|
||||
<span>股票</span>
|
||||
<strong>{{ activeWarning.stock_name }} ({{ activeWarning.stock_code }})</strong>
|
||||
</div>
|
||||
<div class="kv-row">
|
||||
<span>游资</span>
|
||||
<strong>{{ activeWarning.trader_name }}</strong>
|
||||
</div>
|
||||
<div class="kv-row">
|
||||
<span>日期</span>
|
||||
<strong>{{ activeWarning.trade_date }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="side-card">
|
||||
<h4 class="side-title">建议动作</h4>
|
||||
<div class="kv-row">
|
||||
<span>当前判断</span>
|
||||
<strong>{{ activeWarning.warning_level === 'high' ? '风险优先' : '继续观察' }}</strong>
|
||||
</div>
|
||||
<div class="kv-row">
|
||||
<span>执行建议</span>
|
||||
<strong>{{ activeWarning.suggestion || '等待进一步信号' }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.screen {
|
||||
padding: 24px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 30px;
|
||||
background: linear-gradient(180deg, rgba(16, 23, 33, 0.96), rgba(9, 14, 21, 0.98));
|
||||
box-shadow: var(--shadow-strong);
|
||||
}
|
||||
|
||||
.screen-head,
|
||||
.section-head,
|
||||
.monitor-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.screen-head {
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.screen-kicker {
|
||||
margin: 0 0 6px;
|
||||
color: var(--color-gold-soft);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.screen-title,
|
||||
.section-title,
|
||||
.side-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.screen-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.screen-toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pill,
|
||||
.tag {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
padding: 8px 12px;
|
||||
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;
|
||||
}
|
||||
|
||||
.warning-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr 360px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.filter-column,
|
||||
.monitor-list,
|
||||
.detail-stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-box,
|
||||
.card-panel,
|
||||
.side-card,
|
||||
.monitor-item {
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.check-row,
|
||||
.kv-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
color: var(--color-muted);
|
||||
border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.check-row:last-child,
|
||||
.kv-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.monitor-item {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.monitor-item.red {
|
||||
background: linear-gradient(90deg, rgba(255, 93, 93, 0.12), rgba(255, 255, 255, 0.02));
|
||||
}
|
||||
|
||||
.monitor-item.orange {
|
||||
background: linear-gradient(90deg, rgba(255, 174, 66, 0.12), rgba(255, 255, 255, 0.02));
|
||||
}
|
||||
|
||||
.monitor-desc,
|
||||
.detail-text {
|
||||
margin: 0;
|
||||
color: var(--color-muted);
|
||||
line-height: 1.8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 74px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tag.red {
|
||||
color: #ffb4b4;
|
||||
background: rgba(255, 93, 93, 0.14);
|
||||
border-color: rgba(255, 93, 93, 0.18);
|
||||
}
|
||||
|
||||
.tag.orange {
|
||||
color: #ffd08b;
|
||||
background: rgba(255, 174, 66, 0.14);
|
||||
border-color: rgba(255, 174, 66, 0.18);
|
||||
}
|
||||
|
||||
.tag.gold {
|
||||
color: #f3c986;
|
||||
background: rgba(212, 163, 92, 0.14);
|
||||
border-color: rgba(212, 163, 92, 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 1160px) {
|
||||
.warning-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
427
frontend/src/components/home/CandidateFocusPanel.vue
Normal file
427
frontend/src/components/home/CandidateFocusPanel.vue
Normal file
@ -0,0 +1,427 @@
|
||||
<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>
|
||||
345
frontend/src/components/home/CandidateWatchCard.vue
Normal file
345
frontend/src/components/home/CandidateWatchCard.vue
Normal file
@ -0,0 +1,345 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ActionItem, WatchlistItem } from '../../types'
|
||||
import {
|
||||
compactMoney,
|
||||
formatSignedWanAmount,
|
||||
formatWanAmount,
|
||||
numberFromText,
|
||||
priceTone,
|
||||
} from '../../utils/format'
|
||||
|
||||
type CandidateTone = 'buy-only' | 'sell-only' | 'mixed-buy' | 'mixed-sell' | 'mixed-flat'
|
||||
|
||||
const props = defineProps<{
|
||||
action: ActionItem
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectStock: [stockCode: string]
|
||||
followStock: [payload: Pick<WatchlistItem, 'stock_code' | 'stock_name' | 'source_trade_date' | 'source_trader_name'>]
|
||||
}>()
|
||||
|
||||
const buyAmount = computed(() => numberFromText(props.action.buy_amount_wan) ?? 0)
|
||||
const sellAmount = computed(() => numberFromText(props.action.sell_amount_wan) ?? 0)
|
||||
|
||||
function inferMarketLabel(stockCode: string): string {
|
||||
return stockCode.startsWith('6') || stockCode.startsWith('688') ? '沪A' : '深A'
|
||||
}
|
||||
|
||||
function inferBoardLabel(stockCode: string): string {
|
||||
if (stockCode.startsWith('688')) return '科创板'
|
||||
if (stockCode.startsWith('300') || stockCode.startsWith('301')) return '创业板'
|
||||
if (stockCode.startsWith('8') || stockCode.startsWith('4') || stockCode.startsWith('920')) return '北交所'
|
||||
if (stockCode.startsWith('60') || stockCode.startsWith('601') || stockCode.startsWith('603') || stockCode.startsWith('605')) return '沪主板'
|
||||
if (stockCode.startsWith('000') || stockCode.startsWith('001') || stockCode.startsWith('002') || stockCode.startsWith('003')) return '深主板'
|
||||
return 'A股'
|
||||
}
|
||||
|
||||
const candidateTone = computed<CandidateTone>(() => {
|
||||
if (buyAmount.value > 0 && sellAmount.value <= 0) return 'buy-only'
|
||||
if (sellAmount.value > 0 && buyAmount.value <= 0) return 'sell-only'
|
||||
if (buyAmount.value > sellAmount.value) return 'mixed-buy'
|
||||
if (sellAmount.value > buyAmount.value) return 'mixed-sell'
|
||||
return 'mixed-flat'
|
||||
})
|
||||
|
||||
const flowSummary = computed(() => {
|
||||
if (candidateTone.value === 'buy-only') return '只买入'
|
||||
if (candidateTone.value === 'sell-only') return '只卖出'
|
||||
if (candidateTone.value === 'mixed-buy') return '有买有卖 · 买更强'
|
||||
if (candidateTone.value === 'mixed-sell') return '有买有卖 · 卖更强'
|
||||
return '有买有卖 · 力度接近'
|
||||
})
|
||||
|
||||
const detailChips = computed(() => {
|
||||
const chips = [
|
||||
inferBoardLabel(props.action.stock_code),
|
||||
inferMarketLabel(props.action.stock_code),
|
||||
props.action.industry,
|
||||
]
|
||||
|
||||
return chips.filter((item): item is string => Boolean(item)).slice(0, 3)
|
||||
})
|
||||
|
||||
function followCandidate() {
|
||||
emit('followStock', {
|
||||
stock_code: props.action.stock_code,
|
||||
stock_name: props.action.stock_name,
|
||||
source_trade_date: props.action.trade_date,
|
||||
source_trader_name: props.action.trader_name,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="candidate-card" :class="candidateTone">
|
||||
<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>
|
||||
</button>
|
||||
|
||||
<button class="follow-button" type="button" @click="followCandidate">
|
||||
关注
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="candidate-row mid-row">
|
||||
<span class="candidate-code">{{ action.stock_code }} · {{ action.trade_date }} · {{ action.trader_name }}</span>
|
||||
<div class="chip-row" v-if="detailChips.length">
|
||||
<span v-for="chip in detailChips" :key="chip" class="chip">{{ chip }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metrics-row">
|
||||
<div class="metric-block net-block">
|
||||
<span class="metric-label">净额</span>
|
||||
<strong class="net-value" :class="priceTone(String(action.net_amount_wan ?? ''))">
|
||||
{{ formatSignedWanAmount(action.net_amount_wan) }}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<div class="metric-block">
|
||||
<span class="metric-label">买入</span>
|
||||
<strong class="buy">{{ formatWanAmount(action.buy_amount_wan) }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="metric-block">
|
||||
<span class="metric-label">卖出</span>
|
||||
<strong class="sell">{{ formatWanAmount(action.sell_amount_wan) }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="metric-block">
|
||||
<span class="metric-label">股价</span>
|
||||
<strong :class="priceTone(String(action.pct_chg ?? ''))">{{ action.current_price ?? '-' }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="metric-block">
|
||||
<span class="metric-label">涨跌</span>
|
||||
<strong :class="priceTone(String(action.pct_chg ?? ''))">{{ action.pct_chg ?? '-' }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="metric-block">
|
||||
<span class="metric-label">总市值</span>
|
||||
<strong>{{ compactMoney(action.total_market_value) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.candidate-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.candidate-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 4px;
|
||||
background: var(--tone-gradient);
|
||||
}
|
||||
|
||||
.candidate-card.buy-only {
|
||||
--tone-main: #ff3048;
|
||||
--tone-soft: rgba(255, 48, 72, 0.18);
|
||||
--tone-gradient: linear-gradient(180deg, #ff8fa0, #ff3048);
|
||||
}
|
||||
|
||||
.candidate-card.sell-only {
|
||||
--tone-main: #2dbd7b;
|
||||
--tone-soft: rgba(45, 189, 123, 0.16);
|
||||
--tone-gradient: linear-gradient(180deg, #6ed39f, #2dbd7b);
|
||||
}
|
||||
|
||||
.candidate-card.mixed-buy {
|
||||
--tone-main: #ff7a59;
|
||||
--tone-soft: rgba(255, 122, 89, 0.14);
|
||||
--tone-gradient: linear-gradient(180deg, #ff5d5d, #2dbd7b);
|
||||
}
|
||||
|
||||
.candidate-card.mixed-sell {
|
||||
--tone-main: #9dcf45;
|
||||
--tone-soft: rgba(157, 207, 69, 0.14);
|
||||
--tone-gradient: linear-gradient(180deg, #d6df67, #58b86f);
|
||||
}
|
||||
|
||||
.candidate-card.mixed-flat {
|
||||
--tone-main: #d4a35c;
|
||||
--tone-soft: rgba(212, 163, 92, 0.14);
|
||||
--tone-gradient: linear-gradient(180deg, #f0c071, #d4a35c);
|
||||
}
|
||||
|
||||
.candidate-shell {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
padding: 8px 12px;
|
||||
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));
|
||||
}
|
||||
|
||||
.candidate-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.candidate-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
color: var(--color-text);
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stock-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.candidate-code {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--color-muted);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.flow-pill,
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: var(--color-text);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.flow-pill {
|
||||
flex: none;
|
||||
color: var(--tone-main);
|
||||
background: var(--tone-soft);
|
||||
border-color: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.metrics-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 1.15fr) repeat(5, minmax(0, 0.72fr));
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.metric-block {
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.metric-block.net-block {
|
||||
background: rgba(4, 8, 12, 0.42);
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
display: block;
|
||||
color: var(--color-muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.metric-block strong {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.net-value {
|
||||
font-family: var(--font-display);
|
||||
font-size: 19px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.net-value.rise {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.net-value.fall {
|
||||
color: var(--color-green);
|
||||
}
|
||||
|
||||
.net-value.flat {
|
||||
color: var(--color-gold-soft);
|
||||
}
|
||||
|
||||
.buy {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.sell {
|
||||
color: var(--color-green);
|
||||
}
|
||||
|
||||
.follow-button {
|
||||
flex: none;
|
||||
padding: 6px 11px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, var(--color-gold-soft), var(--color-gold));
|
||||
color: #090d14;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.candidate-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.metrics-row {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.metrics-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
272
frontend/src/composables/useDashboardData.ts
Normal file
272
frontend/src/composables/useDashboardData.ts
Normal file
@ -0,0 +1,272 @@
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import type {
|
||||
ActionItem,
|
||||
ActionsResponse,
|
||||
PipelineStatus,
|
||||
StockDetail,
|
||||
Summary,
|
||||
TraderDetail,
|
||||
TraderListItem,
|
||||
WarningItem,
|
||||
WatchlistItem,
|
||||
} from '../types'
|
||||
import { numberFromText } from '../utils/format'
|
||||
|
||||
async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
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<T>
|
||||
}
|
||||
|
||||
export function useDashboardData() {
|
||||
const status = ref<PipelineStatus | null>(null)
|
||||
const summary = ref<Summary | null>(null)
|
||||
const warnings = ref<WarningItem[]>([])
|
||||
const traders = ref<TraderListItem[]>([])
|
||||
const traderDetail = ref<TraderDetail | null>(null)
|
||||
const stockDetail = ref<StockDetail | null>(null)
|
||||
const actions = ref<ActionItem[]>([])
|
||||
const watchlist = ref<WatchlistItem[]>([])
|
||||
|
||||
const selectedWarningCode = shallowRef('')
|
||||
const selectedTraderId = shallowRef<number | null>(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))
|
||||
})
|
||||
|
||||
const filteredActions = computed(() => {
|
||||
return actions.value.filter((item) => {
|
||||
if (selectedTraderFilter.value !== 'all' && item.trader_name !== selectedTraderFilter.value) {
|
||||
return false
|
||||
}
|
||||
if (selectedActionFilter.value !== 'all' && item.action_side !== selectedActionFilter.value) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
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 unique = new Map<string, ActionItem>()
|
||||
for (const item of filteredActions.value) {
|
||||
if (watchlistMap.value.has(item.stock_code)) continue
|
||||
if (!unique.has(item.stock_code)) {
|
||||
unique.set(item.stock_code, item)
|
||||
}
|
||||
}
|
||||
return [...unique.values()]
|
||||
})
|
||||
|
||||
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<WatchlistItem[]>('/api/watchlist')
|
||||
}
|
||||
|
||||
async function addToWatchlist(item: Pick<WatchlistItem, 'stock_code' | 'stock_name' | 'source_trade_date' | 'source_trader_name'>) {
|
||||
if (watchlistMap.value.has(item.stock_code)) return
|
||||
await api<WatchlistItem>('/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<ActionsResponse>(`/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) {
|
||||
selectedTraderId.value = traderId
|
||||
traderDetail.value = await api<TraderDetail>(`/api/traders/${traderId}`)
|
||||
}
|
||||
|
||||
async function selectStock(stockCode: string) {
|
||||
selectedStockCode.value = stockCode
|
||||
stockDetail.value = await api<StockDetail>(`/api/stocks/${encodeURIComponent(stockCode)}`)
|
||||
}
|
||||
|
||||
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<PipelineStatus>('/api/pipeline/status'),
|
||||
api<Summary>('/api/summary'),
|
||||
api<WarningItem[]>('/api/warnings?limit=40'),
|
||||
api<TraderListItem[]>('/api/traders'),
|
||||
api<WatchlistItem[]>('/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]) {
|
||||
await selectTrader(traderResult[0].id)
|
||||
}
|
||||
|
||||
const preferredStockCode = watchlist.value[0]?.stock_code ?? warningResult[0]?.stock_code
|
||||
if (preferredStockCode) {
|
||||
selectedWarningCode.value = preferredStockCode
|
||||
await selectStock(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,
|
||||
selectWarning,
|
||||
selectTradeDateRange,
|
||||
isWatched,
|
||||
addToWatchlist,
|
||||
removeFromWatchlist,
|
||||
}
|
||||
}
|
||||
6
frontend/src/main.ts
Normal file
6
frontend/src/main.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import App from './App.vue'
|
||||
import './assets/main.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
192
frontend/src/types.ts
Normal file
192
frontend/src/types.ts
Normal file
@ -0,0 +1,192 @@
|
||||
export interface Summary {
|
||||
warning_total: number
|
||||
warning_by_level: Record<string, number>
|
||||
trader_total: number
|
||||
stock_total: number
|
||||
imported_days: number
|
||||
}
|
||||
|
||||
export interface WarningItem {
|
||||
trade_date: string
|
||||
stock_code: string
|
||||
stock_name: string
|
||||
trader_name: string
|
||||
warning_type: string
|
||||
warning_level: string
|
||||
trigger_reason: string
|
||||
current_price: string | null
|
||||
pct_chg: string | null
|
||||
suggestion?: string | null
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface ActionItem {
|
||||
trade_date: string
|
||||
stock_code: string
|
||||
stock_name: string
|
||||
trader_name: string
|
||||
table_title: string
|
||||
seat_name: string
|
||||
buy_amount_wan: string | null
|
||||
sell_amount_wan: string | null
|
||||
net_amount_wan: string | null
|
||||
current_price: string | null
|
||||
pct_chg: string | null
|
||||
industry?: string | null
|
||||
market?: string | null
|
||||
board_label?: string | null
|
||||
total_market_value?: number | null
|
||||
circulating_market_value?: number | null
|
||||
action_side: 'buy' | 'sell' | 'net_buy' | 'net_sell'
|
||||
}
|
||||
|
||||
export interface ActionsResponse {
|
||||
trade_date: string | null
|
||||
date_from: string | null
|
||||
date_to: string | null
|
||||
actions: ActionItem[]
|
||||
}
|
||||
|
||||
export interface TraderListItem {
|
||||
id: number
|
||||
name: string
|
||||
alias_name?: string | null
|
||||
warning_weight: string
|
||||
style_tags: string[]
|
||||
stock_count: number
|
||||
sell_alert_count: number
|
||||
slow_exit_count: number
|
||||
}
|
||||
|
||||
export interface TraderSeat {
|
||||
seat_name: string
|
||||
seat_level: string
|
||||
}
|
||||
|
||||
export interface TraderStock {
|
||||
stock_code: string
|
||||
stock_name: string
|
||||
latest_price: string | null
|
||||
pct_chg: string | null
|
||||
action_count: number
|
||||
last_trade_date: string | null
|
||||
buy_action_count: number
|
||||
sell_action_count: number
|
||||
has_sell_alert: number
|
||||
has_slow_exit: number
|
||||
}
|
||||
|
||||
export interface TraderDetail {
|
||||
trader: {
|
||||
id: number
|
||||
name: string
|
||||
alias_name?: string | null
|
||||
warning_weight: string
|
||||
style_tags: string[]
|
||||
}
|
||||
seats: TraderSeat[]
|
||||
stocks: TraderStock[]
|
||||
warnings: WarningItem[]
|
||||
}
|
||||
|
||||
export interface StockOverview {
|
||||
trade_date: string
|
||||
price: string | null
|
||||
pct_chg: string | null
|
||||
amount: string | null
|
||||
net_buy: string | null
|
||||
flag: string | null
|
||||
}
|
||||
|
||||
export interface MarketDailyRow {
|
||||
trade_date: string
|
||||
open: string
|
||||
close: string
|
||||
high: string
|
||||
low: string
|
||||
volume: string
|
||||
amount: string
|
||||
amplitude: string
|
||||
pct_chg: string
|
||||
price_chg: string
|
||||
turnover: string
|
||||
}
|
||||
|
||||
export interface TraderAction {
|
||||
trade_date: string
|
||||
matched_trader_name: string
|
||||
table_title: string
|
||||
seat_name: string
|
||||
buy_amount_wan: string | null
|
||||
sell_amount_wan: string | null
|
||||
net_amount_wan: string | null
|
||||
}
|
||||
|
||||
export interface TraderSummaryItem {
|
||||
matched_trader_name: string
|
||||
action_count: number
|
||||
buy_count: number
|
||||
sell_count: number
|
||||
last_trade_date: string | null
|
||||
total_buy_amount_wan: number
|
||||
total_sell_amount_wan: number
|
||||
total_net_amount_wan: number
|
||||
}
|
||||
|
||||
export interface StockDetail {
|
||||
stock: {
|
||||
stock_code: string
|
||||
stock_name: string
|
||||
market: string | null
|
||||
industry: string | null
|
||||
total_market_value: number | null
|
||||
circulating_market_value: number | null
|
||||
}
|
||||
market_snapshot: {
|
||||
stock_code?: string
|
||||
stock_name?: string
|
||||
industry?: string | null
|
||||
circulating_shares?: number | null
|
||||
circulating_market_value?: number | null
|
||||
total_market_value?: number | null
|
||||
latest_price?: number | null
|
||||
high_price?: number | null
|
||||
low_price?: number | null
|
||||
open_price?: number | null
|
||||
volume?: number | null
|
||||
amount?: number | null
|
||||
previous_close?: number | null
|
||||
turnover?: number | null
|
||||
price_chg?: number | null
|
||||
pct_chg?: number | null
|
||||
amplitude?: number | null
|
||||
}
|
||||
overview: StockOverview[]
|
||||
market_daily: MarketDailyRow[]
|
||||
trader_actions: TraderAction[]
|
||||
trader_summary: TraderSummaryItem[]
|
||||
warnings: WarningItem[]
|
||||
}
|
||||
|
||||
export interface PipelineStatus {
|
||||
overview_total: number
|
||||
detail_total: number
|
||||
warning_total: number
|
||||
trader_total: number
|
||||
latest_trade_date: string | null
|
||||
recent_trade_days: Array<{
|
||||
trade_date: string | null
|
||||
overview_count: number
|
||||
}>
|
||||
}
|
||||
|
||||
export interface WatchlistItem {
|
||||
stock_code: string
|
||||
stock_name: string
|
||||
source_trade_date: string | null
|
||||
source_trader_name: string | null
|
||||
added_at: string
|
||||
status?: 'active' | 'archived'
|
||||
archived_at?: string | null
|
||||
updated_at?: string
|
||||
}
|
||||
69
frontend/src/utils/format.ts
Normal file
69
frontend/src/utils/format.ts
Normal file
@ -0,0 +1,69 @@
|
||||
export function warningLabel(type: string): string {
|
||||
if (type === 'sell_alert') return '卖出预警'
|
||||
if (type === 'slow_exit_watch') return '慢流出观察'
|
||||
return type || '提醒'
|
||||
}
|
||||
|
||||
export function warningTone(level: string): 'red' | 'orange' | 'gold' {
|
||||
if (level === 'high') return 'red'
|
||||
if (level === 'medium') return 'orange'
|
||||
return 'gold'
|
||||
}
|
||||
|
||||
export function priceTone(value: string | null | undefined): 'rise' | 'fall' | 'flat' {
|
||||
if (!value) return 'flat'
|
||||
if (String(value).startsWith('-')) return 'fall'
|
||||
if (String(value).startsWith('+') || Number(value) > 0) return 'rise'
|
||||
return 'flat'
|
||||
}
|
||||
|
||||
export function numberFromText(value: string | number | null | undefined): number | null {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
if (typeof value === 'number') return value
|
||||
|
||||
const trimmed = value.replace(/,/g, '').trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
let multiplier = 1
|
||||
if (trimmed.includes('亿')) multiplier = 100000000
|
||||
else if (trimmed.includes('万')) multiplier = 10000
|
||||
|
||||
const normalized = trimmed.replace('%', '').replace('亿', '').replace('万', '')
|
||||
const parsed = Number(normalized)
|
||||
return Number.isNaN(parsed) ? null : parsed * multiplier
|
||||
}
|
||||
|
||||
export function compactMoney(value: string | number | null | undefined): string {
|
||||
const parsed = numberFromText(value)
|
||||
if (parsed === null) return '-'
|
||||
if (Math.abs(parsed) >= 100000000) return `${(parsed / 100000000).toFixed(2)}亿`
|
||||
if (Math.abs(parsed) >= 10000) return `${(parsed / 10000).toFixed(0)}万`
|
||||
return `${parsed.toFixed(0)}`
|
||||
}
|
||||
|
||||
export function formatDate(value: string | null | undefined): string {
|
||||
return value || '-'
|
||||
}
|
||||
|
||||
export function formatWanAmount(value: string | number | null | undefined): string {
|
||||
const parsed = numberFromText(value)
|
||||
if (parsed === null) return '-'
|
||||
|
||||
if (Math.abs(parsed) >= 10000) {
|
||||
return `${(parsed / 10000).toFixed(Math.abs(parsed) >= 100000 ? 1 : 0)}亿`
|
||||
}
|
||||
|
||||
if (Math.abs(parsed) >= 1) {
|
||||
const fixed = Math.abs(parsed) >= 1000 ? 0 : 1
|
||||
return `${parsed.toFixed(fixed)}万`
|
||||
}
|
||||
|
||||
return `${parsed.toFixed(2)}万`
|
||||
}
|
||||
|
||||
export function formatSignedWanAmount(value: string | number | null | undefined): string {
|
||||
const parsed = numberFromText(value)
|
||||
if (parsed === null) return '-'
|
||||
const prefix = parsed > 0 ? '+' : ''
|
||||
return `${prefix}${formatWanAmount(parsed)}`
|
||||
}
|
||||
Reference in New Issue
Block a user