feat: improve stock detail charts and quote fallbacks
This commit is contained in:
14
README.md
14
README.md
@ -25,6 +25,20 @@
|
|||||||
- 预警中心支持卖出预警、慢流出观察等风险信息查看。
|
- 预警中心支持卖出预警、慢流出观察等风险信息查看。
|
||||||
- 已明确新增“每日 17:00 自动更新 + 邮件日报 + PDF 附件”需求,待后续实现。
|
- 已明确新增“每日 17:00 自动更新 + 邮件日报 + PDF 附件”需求,待后续实现。
|
||||||
|
|
||||||
|
## 最近界面与数据调整
|
||||||
|
|
||||||
|
- 个股详情页的“买卖明细”已改为“买卖力度趋势”图:
|
||||||
|
- 柱形图按日期展示买入和卖出,买入为正、卖出为负。
|
||||||
|
- 折线展示“当日净额”和“累计净额”。
|
||||||
|
- 明细仅在鼠标悬浮图表时显示。
|
||||||
|
- 个股详情页顶部新增“预警列表”按钮:
|
||||||
|
- 有预警时红色高亮提示。
|
||||||
|
- 点击后以弹层方式展示当前个股预警,不再挤占右侧图表区域。
|
||||||
|
- 个股详情、首页候选股、游资详情页的数据补全逻辑已增强:
|
||||||
|
- 优先读取数据库中的股票元数据。
|
||||||
|
- 外部快照失败时增加备用行情源兜底。
|
||||||
|
- 收盘后更新流程会同步补全行业、市值、流通市值等字段。
|
||||||
|
|
||||||
## 环境要求
|
## 环境要求
|
||||||
|
|
||||||
- Python 3.11+
|
- Python 3.11+
|
||||||
|
|||||||
@ -8,6 +8,7 @@ from typing import Any
|
|||||||
from .db import db_cursor
|
from .db import db_cursor
|
||||||
from .sources.eastmoney import EastMoneyClient
|
from .sources.eastmoney import EastMoneyClient
|
||||||
from .sources.sina import SinaClient
|
from .sources.sina import SinaClient
|
||||||
|
from .sources.tencent import TencentClient
|
||||||
|
|
||||||
|
|
||||||
def _normalize_value(value: Any) -> Any:
|
def _normalize_value(value: Any) -> Any:
|
||||||
@ -467,6 +468,11 @@ def fetch_stock_detail(stock_code: str) -> dict[str, Any]:
|
|||||||
quote_snapshot = eastmoney.fetch_quote_snapshot(stock_code)
|
quote_snapshot = eastmoney.fetch_quote_snapshot(stock_code)
|
||||||
except Exception:
|
except Exception:
|
||||||
quote_snapshot = {}
|
quote_snapshot = {}
|
||||||
|
if not quote_snapshot:
|
||||||
|
try:
|
||||||
|
quote_snapshot = TencentClient().fetch_quote_snapshot(stock_code)
|
||||||
|
except Exception:
|
||||||
|
quote_snapshot = {}
|
||||||
if not market_daily:
|
if not market_daily:
|
||||||
try:
|
try:
|
||||||
market_daily = SinaClient().fetch_daily_kline(stock_code)
|
market_daily = SinaClient().fetch_daily_kline(stock_code)
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from .config import AppConfig
|
|||||||
from .db import db_cursor
|
from .db import db_cursor
|
||||||
from .queries import fetch_trader_actions, fetch_watchlist, fetch_warnings
|
from .queries import fetch_trader_actions, fetch_watchlist, fetch_warnings
|
||||||
from .sources.eastmoney import EastMoneyClient
|
from .sources.eastmoney import EastMoneyClient
|
||||||
|
from .sources.tencent import TencentClient
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@ -130,30 +131,51 @@ def enrich_stock_metadata(*, config: AppConfig, stock_codes: set[str]) -> None:
|
|||||||
existing_rows = {row["stock_code"]: row for row in cursor.fetchall()}
|
existing_rows = {row["stock_code"]: row for row in cursor.fetchall()}
|
||||||
|
|
||||||
client = EastMoneyClient()
|
client = EastMoneyClient()
|
||||||
|
quote_client = TencentClient()
|
||||||
for stock_code in sorted(stock_codes):
|
for stock_code in sorted(stock_codes):
|
||||||
row = existing_rows.get(stock_code)
|
row = existing_rows.get(stock_code)
|
||||||
has_industry = bool(row and row.get("industry"))
|
has_industry = bool(row and row.get("industry"))
|
||||||
has_concepts = bool(row and row.get("concept_tags"))
|
has_concepts = bool(row and row.get("concept_tags"))
|
||||||
if has_industry and has_concepts:
|
has_market_value = bool(row and row.get("total_market_value") is not None)
|
||||||
|
has_circulating_market_value = bool(row and row.get("circulating_market_value") is not None)
|
||||||
|
if has_industry and has_concepts and has_market_value and has_circulating_market_value:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
profile = client.fetch_company_profile(stock_code)
|
profile = client.fetch_company_profile(stock_code)
|
||||||
|
try:
|
||||||
|
quote_snapshot = quote_client.fetch_quote_snapshot(stock_code)
|
||||||
|
except Exception:
|
||||||
|
quote_snapshot = {}
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO stocks (stock_code, stock_name, market, industry, concept_tags)
|
INSERT INTO stocks (
|
||||||
VALUES (%s, %s, %s, %s, %s)
|
stock_code,
|
||||||
|
stock_name,
|
||||||
|
market,
|
||||||
|
industry,
|
||||||
|
concept_tags,
|
||||||
|
total_market_value,
|
||||||
|
circulating_market_value
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
stock_name = COALESCE(NULLIF(VALUES(stock_name), ''), stock_name),
|
stock_name = COALESCE(NULLIF(VALUES(stock_name), ''), stock_name),
|
||||||
market = COALESCE(NULLIF(VALUES(market), ''), market),
|
market = COALESCE(NULLIF(VALUES(market), ''), market),
|
||||||
industry = COALESCE(NULLIF(VALUES(industry), ''), industry),
|
industry = COALESCE(NULLIF(VALUES(industry), ''), industry),
|
||||||
concept_tags = COALESCE(VALUES(concept_tags), concept_tags)
|
concept_tags = COALESCE(VALUES(concept_tags), concept_tags),
|
||||||
|
total_market_value = COALESCE(VALUES(total_market_value), total_market_value),
|
||||||
|
circulating_market_value = COALESCE(VALUES(circulating_market_value), circulating_market_value)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
stock_code,
|
stock_code,
|
||||||
profile.get("stock_name") or (row.get("stock_name") if row else stock_code),
|
profile.get("stock_name")
|
||||||
|
or quote_snapshot.get("stock_name")
|
||||||
|
or (row.get("stock_name") if row else stock_code),
|
||||||
profile.get("market"),
|
profile.get("market"),
|
||||||
profile.get("industry"),
|
profile.get("industry"),
|
||||||
json.dumps(profile.get("concept_tags") or [], ensure_ascii=False),
|
json.dumps(profile.get("concept_tags") or [], ensure_ascii=False),
|
||||||
|
quote_snapshot.get("total_market_value"),
|
||||||
|
quote_snapshot.get("circulating_market_value"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
81
backend/src/lhbfx/sources/tencent.py
Normal file
81
backend/src/lhbfx/sources/tencent.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_HEADERS = {
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/135.0.0.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
"Referer": "https://finance.qq.com/",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def infer_symbol(stock_code: str) -> str:
|
||||||
|
if stock_code.startswith(("6", "9", "5", "688")):
|
||||||
|
return f"sh{stock_code}"
|
||||||
|
return f"sz{stock_code}"
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float(value: str | None) -> float | None:
|
||||||
|
if value in (None, "", "-"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class TencentClient:
|
||||||
|
def fetch_quote_snapshot(self, stock_code: str) -> dict[str, Any]:
|
||||||
|
symbol = infer_symbol(stock_code)
|
||||||
|
response = requests.get(
|
||||||
|
"https://qt.gtimg.cn/q=" + symbol,
|
||||||
|
headers=DEFAULT_HEADERS,
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
text = response.text
|
||||||
|
if '"' not in text:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
payload = text.split('"', 1)[1].rsplit('"', 1)[0]
|
||||||
|
parts = payload.split("~")
|
||||||
|
if len(parts) < 49:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
latest_price = _to_float(parts[3])
|
||||||
|
previous_close = _to_float(parts[4])
|
||||||
|
open_price = _to_float(parts[5])
|
||||||
|
price_chg = _to_float(parts[31])
|
||||||
|
pct_chg = _to_float(parts[32])
|
||||||
|
high_price = _to_float(parts[33])
|
||||||
|
low_price = _to_float(parts[34])
|
||||||
|
amount_wan = _to_float(parts[37])
|
||||||
|
turnover_pct = _to_float(parts[38])
|
||||||
|
amplitude_pct = _to_float(parts[43])
|
||||||
|
circulating_market_value_yi = _to_float(parts[44])
|
||||||
|
total_market_value_yi = _to_float(parts[45])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"stock_code": parts[2] or stock_code,
|
||||||
|
"stock_name": parts[1] or None,
|
||||||
|
"latest_price": None if latest_price is None else latest_price * 100,
|
||||||
|
"high_price": None if high_price is None else high_price * 100,
|
||||||
|
"low_price": None if low_price is None else low_price * 100,
|
||||||
|
"open_price": None if open_price is None else open_price * 100,
|
||||||
|
"amount": None if amount_wan is None else amount_wan * 10000,
|
||||||
|
"previous_close": None if previous_close is None else previous_close * 100,
|
||||||
|
"turnover": None if turnover_pct is None else turnover_pct * 100,
|
||||||
|
"price_chg": None if price_chg is None else price_chg * 100,
|
||||||
|
"pct_chg": None if pct_chg is None else pct_chg * 100,
|
||||||
|
"amplitude": None if amplitude_pct is None else amplitude_pct * 100,
|
||||||
|
"circulating_market_value": (
|
||||||
|
None if circulating_market_value_yi is None else circulating_market_value_yi * 100000000
|
||||||
|
),
|
||||||
|
"total_market_value": None if total_market_value_yi is None else total_market_value_yi * 100000000,
|
||||||
|
}
|
||||||
522
frontend/src/components/StockActionTimelineChart.vue
Normal file
522
frontend/src/components/StockActionTimelineChart.vue
Normal file
@ -0,0 +1,522 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, shallowRef, useTemplateRef } from 'vue'
|
||||||
|
|
||||||
|
import type { TraderAction } from '../types'
|
||||||
|
import { formatSignedWanAmount, formatWanAmount, numberFromText } from '../utils/format'
|
||||||
|
|
||||||
|
type AggregatedActionRow = {
|
||||||
|
trade_date: string
|
||||||
|
buyTotalWan: number
|
||||||
|
sellTotalWan: number
|
||||||
|
netTotalWan: number
|
||||||
|
cumulativeNetWan: number
|
||||||
|
traderCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
actions: TraderAction[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const chartContainerRef = useTemplateRef<HTMLDivElement>('chartContainerRef')
|
||||||
|
const hoveredIndex = shallowRef<number | null>(null)
|
||||||
|
const containerWidth = shallowRef(0)
|
||||||
|
|
||||||
|
const aggregatedRows = computed<AggregatedActionRow[]>(() => {
|
||||||
|
const grouped = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
trade_date: string
|
||||||
|
buyTotalWan: number
|
||||||
|
sellTotalWan: number
|
||||||
|
netTotalWan: number
|
||||||
|
traders: Set<string>
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
|
||||||
|
for (const action of props.actions) {
|
||||||
|
const buyTotalWan = numberFromText(action.buy_amount_wan) ?? 0
|
||||||
|
const sellTotalWan = numberFromText(action.sell_amount_wan) ?? 0
|
||||||
|
const netTotalWan = numberFromText(action.net_amount_wan) ?? buyTotalWan - sellTotalWan
|
||||||
|
const current = grouped.get(action.trade_date) ?? {
|
||||||
|
trade_date: action.trade_date,
|
||||||
|
buyTotalWan: 0,
|
||||||
|
sellTotalWan: 0,
|
||||||
|
netTotalWan: 0,
|
||||||
|
traders: new Set<string>(),
|
||||||
|
}
|
||||||
|
|
||||||
|
current.buyTotalWan += buyTotalWan
|
||||||
|
current.sellTotalWan += sellTotalWan
|
||||||
|
current.netTotalWan += netTotalWan
|
||||||
|
if (action.matched_trader_name) {
|
||||||
|
current.traders.add(action.matched_trader_name)
|
||||||
|
}
|
||||||
|
grouped.set(action.trade_date, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
let cumulativeNetWan = 0
|
||||||
|
return [...grouped.values()]
|
||||||
|
.sort((left, right) => left.trade_date.localeCompare(right.trade_date))
|
||||||
|
.map((item) => {
|
||||||
|
cumulativeNetWan += item.netTotalWan
|
||||||
|
return {
|
||||||
|
trade_date: item.trade_date,
|
||||||
|
buyTotalWan: item.buyTotalWan,
|
||||||
|
sellTotalWan: item.sellTotalWan,
|
||||||
|
netTotalWan: item.netTotalWan,
|
||||||
|
cumulativeNetWan,
|
||||||
|
traderCount: item.traders.size,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartModel = computed(() => {
|
||||||
|
const rows = aggregatedRows.value
|
||||||
|
const measuredWidth = containerWidth.value || 0
|
||||||
|
const width = Math.max(measuredWidth, 320, rows.length * 54)
|
||||||
|
const height = 260
|
||||||
|
const left = 56
|
||||||
|
const right = 64
|
||||||
|
const top = 18
|
||||||
|
const bottom = 34
|
||||||
|
const innerWidth = width - left - right
|
||||||
|
const innerHeight = height - top - bottom
|
||||||
|
|
||||||
|
const emptyModel = {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
top,
|
||||||
|
bottom,
|
||||||
|
zeroY: top + innerHeight / 2,
|
||||||
|
stepX: 0,
|
||||||
|
buyBars: [] as Array<{ x: number; y: number; height: number; title: string }>,
|
||||||
|
sellBars: [] as Array<{ x: number; y: number; height: number; title: string }>,
|
||||||
|
netPoints: [] as Array<{ x: number; y: number }>,
|
||||||
|
cumulativePoints: [] as Array<{ x: number; y: number }>,
|
||||||
|
labels: [] as Array<{ x: number; label: string; visible: boolean }>,
|
||||||
|
leftAxis: [] as Array<{ y: number; label: string }>,
|
||||||
|
rightAxis: [] as Array<{ y: number; label: string }>,
|
||||||
|
hoverColumns: [] as Array<{ x: number; width: number }>,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
return emptyModel
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftMax = Math.max(
|
||||||
|
...rows.flatMap((row) => [Math.abs(row.buyTotalWan), Math.abs(row.sellTotalWan), Math.abs(row.netTotalWan)]),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
const zeroY = top + innerHeight / 2
|
||||||
|
const yOfLeft = (value: number) => zeroY - (value / leftMax) * (innerHeight / 2)
|
||||||
|
|
||||||
|
const cumulativeValues = rows.map((row) => row.cumulativeNetWan)
|
||||||
|
const rightMin = Math.min(0, ...cumulativeValues)
|
||||||
|
const rightMax = Math.max(0, ...cumulativeValues)
|
||||||
|
const rightRange = rightMax - rightMin || 1
|
||||||
|
const yOfRight = (value: number) => top + ((rightMax - value) / rightRange) * innerHeight
|
||||||
|
|
||||||
|
const stepX = rows.length === 1 ? innerWidth / 2 : innerWidth / (rows.length - 1)
|
||||||
|
const groupWidth = Math.max(20, Math.min(28, stepX * 0.58))
|
||||||
|
const barWidth = Math.max(7, groupWidth / 2 - 2)
|
||||||
|
const labelStep = Math.max(1, Math.ceil(rows.length / 6))
|
||||||
|
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
top,
|
||||||
|
bottom,
|
||||||
|
zeroY,
|
||||||
|
stepX,
|
||||||
|
buyBars: rows.map((row, index) => {
|
||||||
|
const x = left + (rows.length === 1 ? innerWidth / 2 : index * stepX)
|
||||||
|
const y = yOfLeft(row.buyTotalWan)
|
||||||
|
return {
|
||||||
|
x: x - barWidth - 2,
|
||||||
|
y,
|
||||||
|
height: Math.max(2, zeroY - y),
|
||||||
|
title: `${row.trade_date}\n买入 ${formatWanAmount(row.buyTotalWan)}\n卖出 ${formatWanAmount(row.sellTotalWan)}\n净额 ${formatSignedWanAmount(row.netTotalWan)}`,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
sellBars: rows.map((row, index) => {
|
||||||
|
const x = left + (rows.length === 1 ? innerWidth / 2 : index * stepX)
|
||||||
|
const y = zeroY
|
||||||
|
const sellY = yOfLeft(-row.sellTotalWan)
|
||||||
|
return {
|
||||||
|
x: x + 2,
|
||||||
|
y,
|
||||||
|
height: Math.max(2, sellY - zeroY),
|
||||||
|
title: `${row.trade_date}\n买入 ${formatWanAmount(row.buyTotalWan)}\n卖出 ${formatWanAmount(row.sellTotalWan)}\n净额 ${formatSignedWanAmount(row.netTotalWan)}`,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
netPoints: rows.map((row, index) => ({
|
||||||
|
x: left + (rows.length === 1 ? innerWidth / 2 : index * stepX),
|
||||||
|
y: yOfLeft(row.netTotalWan),
|
||||||
|
})),
|
||||||
|
cumulativePoints: rows.map((row, index) => ({
|
||||||
|
x: left + (rows.length === 1 ? innerWidth / 2 : index * stepX),
|
||||||
|
y: yOfRight(row.cumulativeNetWan),
|
||||||
|
})),
|
||||||
|
labels: rows.map((row, index) => ({
|
||||||
|
x: left + (rows.length === 1 ? innerWidth / 2 : index * stepX),
|
||||||
|
label: row.trade_date.slice(5),
|
||||||
|
visible: index % labelStep === 0 || index === rows.length - 1,
|
||||||
|
})),
|
||||||
|
leftAxis: Array.from({ length: 5 }, (_, index) => {
|
||||||
|
const value = leftMax - (leftMax * 2 * index) / 4
|
||||||
|
return { y: yOfLeft(value), label: formatSignedWanAmount(value) }
|
||||||
|
}),
|
||||||
|
rightAxis: Array.from({ length: 5 }, (_, index) => {
|
||||||
|
const value = rightMax - (rightRange * index) / 4
|
||||||
|
return { y: yOfRight(value), label: formatSignedWanAmount(value) }
|
||||||
|
}),
|
||||||
|
hoverColumns: rows.map((_row, index) => ({
|
||||||
|
x: left + (rows.length === 1 ? innerWidth / 2 : index * stepX) - Math.max(stepX, 20) / 2,
|
||||||
|
width: Math.max(stepX, 20),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const netLinePoints = computed(() => chartModel.value.netPoints.map((point) => `${point.x},${point.y}`).join(' '))
|
||||||
|
const cumulativeLinePoints = computed(() =>
|
||||||
|
chartModel.value.cumulativePoints.map((point) => `${point.x},${point.y}`).join(' '),
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeRow = computed(() => {
|
||||||
|
if (!aggregatedRows.value.length) return null
|
||||||
|
if (hoveredIndex.value === null) return null
|
||||||
|
return aggregatedRows.value[hoveredIndex.value] ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeTooltip = computed(() => {
|
||||||
|
if (hoveredIndex.value === null || !activeRow.value) return null
|
||||||
|
const point = chartModel.value.netPoints[hoveredIndex.value]
|
||||||
|
if (!point) return null
|
||||||
|
|
||||||
|
const tooltipWidth = 168
|
||||||
|
const tooltipHeight = 90
|
||||||
|
const x = Math.min(
|
||||||
|
Math.max(point.x - tooltipWidth / 2, chartModel.value.left + 6),
|
||||||
|
chartModel.value.width - chartModel.value.right - tooltipWidth - 6,
|
||||||
|
)
|
||||||
|
const prefersBelow = point.y < chartModel.value.top + tooltipHeight + 24
|
||||||
|
const y = prefersBelow
|
||||||
|
? Math.min(point.y + 12, chartModel.value.height - chartModel.value.bottom - tooltipHeight - 6)
|
||||||
|
: Math.max(point.y - tooltipHeight - 12, chartModel.value.top + 6)
|
||||||
|
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: tooltipWidth,
|
||||||
|
height: tooltipHeight,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function setHoveredIndex(index: number) {
|
||||||
|
hoveredIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHoveredIndex() {
|
||||||
|
hoveredIndex.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
|
function syncContainerWidth() {
|
||||||
|
containerWidth.value = chartContainerRef.value?.clientWidth ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
syncContainerWidth()
|
||||||
|
if (chartContainerRef.value) {
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
syncContainerWidth()
|
||||||
|
})
|
||||||
|
resizeObserver.observe(chartContainerRef.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
resizeObserver = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="action-chart-panel">
|
||||||
|
<div class="action-chart-head">
|
||||||
|
<div>
|
||||||
|
<h4 class="action-chart-title">买卖力度趋势</h4>
|
||||||
|
<p class="action-chart-note">柱形图显示买卖,折线显示单日净额和累计净额。</p>
|
||||||
|
</div>
|
||||||
|
<div class="action-chart-legend">
|
||||||
|
<span class="legend-chip">
|
||||||
|
<span class="legend-swatch bar buy" />
|
||||||
|
买入
|
||||||
|
</span>
|
||||||
|
<span class="legend-chip">
|
||||||
|
<span class="legend-swatch bar sell" />
|
||||||
|
卖出
|
||||||
|
</span>
|
||||||
|
<span class="legend-chip">
|
||||||
|
<span class="legend-swatch line net" />
|
||||||
|
当日净额
|
||||||
|
</span>
|
||||||
|
<span class="legend-chip">
|
||||||
|
<span class="legend-swatch line cumulative" />
|
||||||
|
累计净额
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="aggregatedRows.length" class="action-chart-body">
|
||||||
|
<div ref="chartContainerRef" class="action-chart-scroll">
|
||||||
|
<svg
|
||||||
|
class="action-chart-svg"
|
||||||
|
:viewBox="`0 0 ${chartModel.width} ${chartModel.height}`"
|
||||||
|
:style="{ width: `${chartModel.width}px`, height: `${chartModel.height}px` }"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
@mouseleave="clearHoveredIndex"
|
||||||
|
>
|
||||||
|
<g opacity="0.08" stroke="#ffffff">
|
||||||
|
<line
|
||||||
|
v-for="tick in chartModel.leftAxis"
|
||||||
|
:key="`left-grid-${tick.label}`"
|
||||||
|
:x1="chartModel.left"
|
||||||
|
:y1="tick.y"
|
||||||
|
:x2="chartModel.width - chartModel.right"
|
||||||
|
:y2="tick.y"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<line
|
||||||
|
:x1="chartModel.left"
|
||||||
|
:y1="chartModel.zeroY"
|
||||||
|
:x2="chartModel.width - chartModel.right"
|
||||||
|
:y2="chartModel.zeroY"
|
||||||
|
stroke="rgba(255,255,255,0.22)"
|
||||||
|
stroke-width="1.2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<g v-for="(tick, index) in chartModel.leftAxis" :key="`left-axis-${index}`">
|
||||||
|
<text x="0" :y="tick.y + 4" fill="#93a2b5" font-size="10">{{ tick.label }}</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g v-for="(tick, index) in chartModel.rightAxis" :key="`right-axis-${index}`">
|
||||||
|
<text :x="chartModel.width - chartModel.right + 8" :y="tick.y + 4" fill="#c9ad68" font-size="10">
|
||||||
|
{{ tick.label }}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g v-for="(column, index) in chartModel.hoverColumns" :key="`hover-${index}`">
|
||||||
|
<rect
|
||||||
|
:x="column.x"
|
||||||
|
y="0"
|
||||||
|
:width="column.width"
|
||||||
|
:height="chartModel.height"
|
||||||
|
:fill="hoveredIndex === index ? 'rgba(255,255,255,0.04)' : 'transparent'"
|
||||||
|
@mouseenter="setHoveredIndex(index)"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g v-for="(bar, index) in chartModel.buyBars" :key="`buy-${index}`">
|
||||||
|
<rect
|
||||||
|
:x="bar.x"
|
||||||
|
:y="bar.y"
|
||||||
|
width="10"
|
||||||
|
:height="bar.height"
|
||||||
|
rx="2"
|
||||||
|
fill="#ff7b7b"
|
||||||
|
>
|
||||||
|
<title>{{ bar.title }}</title>
|
||||||
|
</rect>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g v-for="(bar, index) in chartModel.sellBars" :key="`sell-${index}`">
|
||||||
|
<rect
|
||||||
|
:x="bar.x"
|
||||||
|
:y="bar.y"
|
||||||
|
width="10"
|
||||||
|
:height="bar.height"
|
||||||
|
rx="2"
|
||||||
|
fill="#4ca8ff"
|
||||||
|
>
|
||||||
|
<title>{{ bar.title }}</title>
|
||||||
|
</rect>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<polyline
|
||||||
|
fill="none"
|
||||||
|
stroke="#f0c96a"
|
||||||
|
stroke-width="2.4"
|
||||||
|
:points="cumulativeLinePoints"
|
||||||
|
/>
|
||||||
|
<polyline
|
||||||
|
fill="none"
|
||||||
|
stroke="#7de1b2"
|
||||||
|
stroke-width="2.2"
|
||||||
|
:points="netLinePoints"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<g v-for="(point, index) in chartModel.netPoints" :key="`net-point-${index}`">
|
||||||
|
<circle :cx="point.x" :cy="point.y" r="3.4" fill="#7de1b2" />
|
||||||
|
</g>
|
||||||
|
<g v-for="(point, index) in chartModel.cumulativePoints" :key="`cumulative-point-${index}`">
|
||||||
|
<circle :cx="point.x" :cy="point.y" r="3.2" fill="#f0c96a" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g v-if="activeTooltip && activeRow">
|
||||||
|
<rect
|
||||||
|
:x="activeTooltip.x"
|
||||||
|
:y="activeTooltip.y"
|
||||||
|
:width="activeTooltip.width"
|
||||||
|
:height="activeTooltip.height"
|
||||||
|
rx="10"
|
||||||
|
fill="rgba(8, 12, 18, 0.96)"
|
||||||
|
stroke="rgba(255,255,255,0.08)"
|
||||||
|
/>
|
||||||
|
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 18" fill="#ffffff" font-size="11" font-weight="700">
|
||||||
|
{{ activeRow.trade_date }}
|
||||||
|
</text>
|
||||||
|
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 36" fill="#ff7b7b" font-size="11">
|
||||||
|
买入 {{ formatWanAmount(activeRow.buyTotalWan) }}
|
||||||
|
</text>
|
||||||
|
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 52" fill="#4ca8ff" font-size="11">
|
||||||
|
卖出 {{ formatWanAmount(activeRow.sellTotalWan) }}
|
||||||
|
</text>
|
||||||
|
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 68" fill="#7de1b2" font-size="11">
|
||||||
|
当日净额 {{ formatSignedWanAmount(activeRow.netTotalWan) }}
|
||||||
|
</text>
|
||||||
|
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 84" fill="#f0c96a" font-size="11">
|
||||||
|
累计净额 {{ formatSignedWanAmount(activeRow.cumulativeNetWan) }}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g v-for="(label, index) in chartModel.labels" :key="`label-${index}`">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else class="action-chart-empty">暂无可展示的买卖明细。</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.action-chart-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-chart-head {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-chart-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-chart-note {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-chart-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 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-swatch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
width: 18px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-swatch.bar {
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-swatch.buy {
|
||||||
|
background: #ff7b7b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-swatch.sell {
|
||||||
|
background: #4ca8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-swatch.line::before {
|
||||||
|
content: '';
|
||||||
|
width: 18px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-swatch.line.net::before {
|
||||||
|
background: #7de1b2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-swatch.line.cumulative::before {
|
||||||
|
background: #f0c96a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-chart-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-chart-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
min-height: 228px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-chart-svg {
|
||||||
|
display: block;
|
||||||
|
min-height: 228px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-chart-empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, shallowRef, useTemplateRef } from 'vue'
|
import { computed, onMounted, onUnmounted, shallowRef, useTemplateRef } from 'vue'
|
||||||
|
|
||||||
|
import StockActionTimelineChart from './StockActionTimelineChart.vue'
|
||||||
import type { StockDetail, WarningItem } from '../types'
|
import type { StockDetail, WarningItem } from '../types'
|
||||||
import {
|
import {
|
||||||
compactMoney,
|
compactMoney,
|
||||||
@ -44,8 +45,10 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const chartRef = useTemplateRef<SVGSVGElement>('chartRef')
|
const chartRef = useTemplateRef<SVGSVGElement>('chartRef')
|
||||||
|
const warningPopoverRef = useTemplateRef<HTMLDivElement>('warningPopoverRef')
|
||||||
const selectedZoom = shallowRef(80)
|
const selectedZoom = shallowRef(80)
|
||||||
const hoveredIndex = shallowRef<number | null>(null)
|
const hoveredIndex = shallowRef<number | null>(null)
|
||||||
|
const showWarningPanel = shallowRef(false)
|
||||||
|
|
||||||
const zoomOptions = [
|
const zoomOptions = [
|
||||||
{ label: '近40日', count: 40 },
|
{ label: '近40日', count: 40 },
|
||||||
@ -60,11 +63,6 @@ const chartLegendItems = [
|
|||||||
{ label: 'S 卖出', tone: '#5ab8ff', type: 'dot' },
|
{ label: 'S 卖出', tone: '#5ab8ff', type: 'dot' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
function validText(value: string | null | undefined): string | null {
|
|
||||||
if (!value || value === '-') return null
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPercentNumber(value: number | null | undefined): string {
|
function formatPercentNumber(value: number | null | undefined): string {
|
||||||
if (value === null || value === undefined) return '-'
|
if (value === null || value === undefined) return '-'
|
||||||
return `${value.toFixed(2)}%`
|
return `${value.toFixed(2)}%`
|
||||||
@ -82,10 +80,9 @@ function valueOrFallback(primary: string | null | undefined, fallback: string |
|
|||||||
}
|
}
|
||||||
|
|
||||||
const topWarning = computed(() => props.activeWarning ?? props.stockDetail?.warnings[0] ?? null)
|
const topWarning = computed(() => props.activeWarning ?? props.stockDetail?.warnings[0] ?? null)
|
||||||
|
const allWarnings = computed(() => props.stockDetail?.warnings ?? [])
|
||||||
const latestOverview = computed(() => props.stockDetail?.overview?.[0] ?? null)
|
const latestOverview = computed(() => props.stockDetail?.overview?.[0] ?? null)
|
||||||
const displayedTraderSummary = computed(() => (props.stockDetail?.trader_summary ?? []).slice(0, 4))
|
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(() => {
|
const totalNetAmountWan = computed(() => {
|
||||||
return (props.stockDetail?.trader_summary ?? []).reduce((sum, item) => sum + (item.total_net_amount_wan ?? 0), 0)
|
return (props.stockDetail?.trader_summary ?? []).reduce((sum, item) => sum + (item.total_net_amount_wan ?? 0), 0)
|
||||||
@ -97,17 +94,26 @@ const baseFacts = computed(() => {
|
|||||||
const latest = latestOverview.value
|
const latest = latestOverview.value
|
||||||
if (!stock) return []
|
if (!stock) return []
|
||||||
|
|
||||||
const latestPrice = snapshot?.latest_price ?? numberFromText(latest?.price) ?? null
|
const latestPriceValue =
|
||||||
const latestPct = snapshot?.pct_chg ?? numberFromText(validText(latest?.pct_chg)) ?? null
|
snapshot?.latest_price != null
|
||||||
const latestAmount = snapshot?.amount ?? numberFromText(latest?.amount) ?? null
|
? (snapshot.latest_price / 100).toFixed(2)
|
||||||
|
: valueOrFallback(latest?.price, '-')
|
||||||
|
const latestPctValue =
|
||||||
|
snapshot?.pct_chg != null
|
||||||
|
? formatPercentNumber(snapshot.pct_chg / 100)
|
||||||
|
: valueOrFallback(latest?.pct_chg, '-')
|
||||||
|
const latestAmountValue =
|
||||||
|
snapshot?.amount != null
|
||||||
|
? formatAmountNumber(snapshot.amount)
|
||||||
|
: valueOrFallback(latest?.amount, '-')
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ label: '代码', value: stock.stock_code },
|
{ label: '代码', value: stock.stock_code },
|
||||||
{ label: '市场', value: stock.market || 'A股' },
|
{ label: '市场', value: stock.market || 'A股' },
|
||||||
{ label: '行业', value: stock.industry || snapshot?.industry || '-' },
|
{ label: '行业', value: stock.industry || snapshot?.industry || '-' },
|
||||||
{ label: '最新价', value: latestPrice === null ? '-' : String((latestPrice / 100).toFixed(2)) },
|
{ label: '最新价', value: latestPriceValue },
|
||||||
{ label: '涨跌幅', value: latestPct === null ? valueOrFallback(latest?.pct_chg, '-') : formatPercentNumber(latestPct / 100) },
|
{ label: '涨跌幅', value: latestPctValue },
|
||||||
{ label: '成交额', value: latestAmount === null ? valueOrFallback(latest?.amount, '-') : formatAmountNumber(latestAmount) },
|
{ label: '成交额', value: latestAmountValue },
|
||||||
{ label: '振幅', value: snapshot?.amplitude == null ? '-' : formatPercentNumber(snapshot.amplitude / 100) },
|
{ label: '振幅', value: snapshot?.amplitude == null ? '-' : formatPercentNumber(snapshot.amplitude / 100) },
|
||||||
{ label: '换手', value: snapshot?.turnover == null ? '-' : formatPercentNumber(snapshot.turnover / 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.total_market_value ?? snapshot?.total_market_value ?? null) },
|
||||||
@ -294,6 +300,31 @@ function handleChartMove(event: MouseEvent) {
|
|||||||
function clearHover() {
|
function clearHover() {
|
||||||
hoveredIndex.value = null
|
hoveredIndex.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleWarningPanel() {
|
||||||
|
showWarningPanel.value = !showWarningPanel.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeWarningPanel() {
|
||||||
|
showWarningPanel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDocumentPointerDown(event: PointerEvent) {
|
||||||
|
const root = warningPopoverRef.value
|
||||||
|
const target = event.target
|
||||||
|
if (!root || !(target instanceof Node)) return
|
||||||
|
if (!root.contains(target)) {
|
||||||
|
closeWarningPanel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('pointerdown', handleDocumentPointerDown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('pointerdown', handleDocumentPointerDown)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -308,6 +339,42 @@ function clearHover() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="screen-toolbar">
|
<div class="screen-toolbar">
|
||||||
<span class="pill active">日K + 操作观点</span>
|
<span class="pill active">日K + 操作观点</span>
|
||||||
|
<div ref="warningPopoverRef" class="warning-popover-wrap">
|
||||||
|
<button
|
||||||
|
class="pill warning-trigger"
|
||||||
|
:class="{ alert: allWarnings.length > 0, open: showWarningPanel }"
|
||||||
|
type="button"
|
||||||
|
@click.stop="toggleWarningPanel"
|
||||||
|
>
|
||||||
|
预警列表
|
||||||
|
<span v-if="allWarnings.length">· {{ allWarnings.length }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="showWarningPanel" class="warning-popover">
|
||||||
|
<div class="warning-popover-head">
|
||||||
|
<strong>当前预警情况</strong>
|
||||||
|
<span>{{ allWarnings.length }} 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="allWarnings.length" class="warning-popover-list">
|
||||||
|
<div
|
||||||
|
v-for="warning in allWarnings"
|
||||||
|
:key="`${warning.trade_date}-${warning.warning_type}-${warning.trader_name}`"
|
||||||
|
class="warning-popover-item"
|
||||||
|
>
|
||||||
|
<div class="warning-popover-top">
|
||||||
|
<strong>{{ warning.trader_name }}</strong>
|
||||||
|
<span class="tag" :class="warning.warning_level === 'high' ? 'red' : 'orange'">
|
||||||
|
{{ warningLabel(warning.warning_type) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p>{{ warning.trade_date }} · {{ warning.trigger_reason }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else class="warning-popover-empty">当前没有预警,状态正常。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<span class="pill" v-if="topWarning">{{ warningLabel(topWarning.warning_type) }}</span>
|
<span class="pill" v-if="topWarning">{{ warningLabel(topWarning.warning_type) }}</span>
|
||||||
<span class="pill" v-if="stockDetail">{{ stockDetail.stock.stock_code }}</span>
|
<span class="pill" v-if="stockDetail">{{ stockDetail.stock.stock_code }}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -515,46 +582,8 @@ function clearHover() {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="side-card">
|
<article class="side-card action-chart-card">
|
||||||
<div class="section-head">
|
<StockActionTimelineChart :actions="stockDetail.trader_actions" />
|
||||||
<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>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -564,7 +593,7 @@ function clearHover() {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.screen {
|
.screen {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
border: 1px solid var(--color-line);
|
border: 1px solid var(--color-line);
|
||||||
border-radius: 30px;
|
border-radius: 30px;
|
||||||
@ -610,6 +639,7 @@ function clearHover() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill,
|
.pill,
|
||||||
@ -638,6 +668,83 @@ function clearHover() {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.warning-popover-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-trigger {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-trigger.alert {
|
||||||
|
color: #ffb4b4;
|
||||||
|
background: rgba(255, 93, 93, 0.12);
|
||||||
|
border-color: rgba(255, 93, 93, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-trigger.alert.open {
|
||||||
|
background: rgba(255, 93, 93, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 10px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
width: min(380px, calc(100vw - 48px));
|
||||||
|
max-width: calc(100vw - 48px);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(8, 12, 18, 0.98);
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.warning-popover {
|
||||||
|
left: auto;
|
||||||
|
right: 0;
|
||||||
|
transform: translateX(-22%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-popover-head,
|
||||||
|
.warning-popover-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-popover-head {
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-popover-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-popover-item {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-popover-item p,
|
||||||
|
.warning-popover-empty {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: #d6ceb9;
|
||||||
|
line-height: 1.55;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.chart-legend {
|
.chart-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@ -688,6 +795,8 @@ function clearHover() {
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
height: calc(100% - 52px);
|
height: calc(100% - 52px);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-stack,
|
.main-stack,
|
||||||
@ -702,7 +811,7 @@ function clearHover() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.side-stack {
|
.side-stack {
|
||||||
grid-template-rows: auto repeat(3, minmax(0, 1fr));
|
grid-template-rows: auto auto minmax(280px, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-panel,
|
.card-panel,
|
||||||
@ -840,9 +949,7 @@ function clearHover() {
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.judge-desc,
|
.judge-desc {
|
||||||
.timeline-desc,
|
|
||||||
.detail-desc {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #d6ceb9;
|
color: #d6ceb9;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
@ -862,16 +969,12 @@ function clearHover() {
|
|||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trader-flow,
|
.trader-flow {
|
||||||
.detail-item,
|
|
||||||
.timeline-entry {
|
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
|
border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trader-flow:last-child,
|
.trader-flow:last-child {
|
||||||
.detail-item:last-child,
|
|
||||||
.timeline-entry:last-child {
|
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -906,6 +1009,10 @@ function clearHover() {
|
|||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-chart-card {
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -20,6 +20,13 @@ const countsByType = computed(() => {
|
|||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const countsByTrader = computed(() => {
|
||||||
|
return props.warnings.reduce<Record<string, number>>((acc, item) => {
|
||||||
|
acc[item.trader_name] = (acc[item.trader_name] ?? 0) + 1
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -46,7 +53,7 @@ const countsByType = computed(() => {
|
|||||||
class="check-row"
|
class="check-row"
|
||||||
>
|
>
|
||||||
<span>{{ trader.name }}</span>
|
<span>{{ trader.name }}</span>
|
||||||
<strong>{{ trader.stock_count }}</strong>
|
<strong>{{ countsByTrader[trader.name] ?? 0 }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|||||||
@ -229,9 +229,17 @@ export function useDashboardData() {
|
|||||||
selectedTraderId.value = traderResult[0].id
|
selectedTraderId.value = traderResult[0].id
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferredStockCode = watchlist.value[0]?.stock_code ?? warningResult[0]?.stock_code
|
const watchlistCodeSet = new Set(watchlistResult.map((item) => item.stock_code))
|
||||||
|
const preferredWarningCode =
|
||||||
|
warningResult.find((item) => watchlistCodeSet.has(item.stock_code))?.stock_code ??
|
||||||
|
warningResult[0]?.stock_code ??
|
||||||
|
''
|
||||||
|
const preferredStockCode = watchlist.value[0]?.stock_code ?? preferredWarningCode
|
||||||
|
|
||||||
|
if (preferredWarningCode) {
|
||||||
|
selectedWarningCode.value = preferredWarningCode
|
||||||
|
}
|
||||||
if (preferredStockCode) {
|
if (preferredStockCode) {
|
||||||
selectedWarningCode.value = preferredStockCode
|
|
||||||
selectedStockCode.value = preferredStockCode
|
selectedStockCode.value = preferredStockCode
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -11,9 +11,10 @@ export function warningTone(level: string): 'red' | 'orange' | 'gold' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function priceTone(value: string | null | undefined): 'rise' | 'fall' | 'flat' {
|
export function priceTone(value: string | null | undefined): 'rise' | 'fall' | 'flat' {
|
||||||
if (!value) return 'flat'
|
const parsed = numberFromText(value)
|
||||||
if (String(value).startsWith('-')) return 'fall'
|
if (parsed === null) return 'flat'
|
||||||
if (String(value).startsWith('+') || Number(value) > 0) return 'rise'
|
if (parsed < 0) return 'fall'
|
||||||
|
if (parsed > 0) return 'rise'
|
||||||
return 'flat'
|
return 'flat'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user