Compare commits

...

5 Commits

29 changed files with 2322 additions and 264 deletions

3
.gitignore vendored
View File

@ -24,9 +24,6 @@ frontend/dist/
frontend/.vite/ frontend/.vite/
frontend/.vite-temp/ frontend/.vite-temp/
# Sensitive or machine-local config
backend/config.yaml
# Temp / debug files # Temp / debug files
_tmp_*.json _tmp_*.json
_curl_*.json _curl_*.json

View File

@ -23,6 +23,21 @@
- 关注池写入数据库,支持新增和删除。 - 关注池写入数据库,支持新增和删除。
- 个股详情支持 K 线、MA5、买卖点与预警信息展示。 - 个股详情支持 K 线、MA5、买卖点与预警信息展示。
- 预警中心支持卖出预警、慢流出观察等风险信息查看。 - 预警中心支持卖出预警、慢流出观察等风险信息查看。
- 已明确新增“每日 17:00 自动更新 + 邮件日报 + PDF 附件”需求,待后续实现。
## 最近界面与数据调整
- 个股详情页的“买卖明细”已改为“买卖力度趋势”图:
- 柱形图按日期展示买入和卖出,买入为正、卖出为负。
- 折线展示“当日净额”和“累计净额”。
- 明细仅在鼠标悬浮图表时显示。
- 个股详情页顶部新增“预警列表”按钮:
- 有预警时红色高亮提示。
- 点击后以弹层方式展示当前个股预警,不再挤占右侧图表区域。
- 个股详情、首页候选股、游资详情页的数据补全逻辑已增强:
- 优先读取数据库中的股票元数据。
- 外部快照失败时增加备用行情源兜底。
- 收盘后更新流程会同步补全行业、市值、流通市值等字段。
## 环境要求 ## 环境要求
@ -35,6 +50,7 @@
1. 复制配置文件: 1. 复制配置文件:
-`backend/config.example.yaml` 复制为 `backend/config.yaml` -`backend/config.example.yaml` 复制为 `backend/config.yaml`
- 按实际数据库连接信息修改 - 按实际数据库连接信息修改
- 当前仓库已允许提交 `backend/config.yaml`,服务器部署默认读取该文件
2. 初始化数据库: 2. 初始化数据库:
```powershell ```powershell
@ -47,6 +63,18 @@ python backend/scripts/init_db.py
python backend/scripts/run_api.py python backend/scripts/run_api.py
``` ```
4. 收盘后更新并发送邮件:
```powershell
python backend/scripts/after_market_update.py --trade-date 2026-04-17 --send-email
```
默认约定:
-`Asia/Shanghai` 取当天日期
- 预期定时为 A 股交易日 `17:00`
- 只有当日成功拉到新的龙虎榜数据才会继续生成预警、PDF 并发邮件;否则自动跳过
默认地址: 默认地址:
- `http://127.0.0.1:8000` - `http://127.0.0.1:8000`

View File

@ -35,6 +35,15 @@ monitoring:
- 5 - 5
turnover_warning_threshold: 0.30 turnover_warning_threshold: 0.30
mail:
sender_email: "your_email@example.com"
smtp_username: "your_email@example.com"
smtp_password: "your_smtp_password"
smtp_host: "smtp.example.com"
smtp_port: 465
recipients:
- "recipient@example.com"
traders: traders:
- name: "章盟主" - name: "章盟主"
alias: "章建平" alias: "章建平"

132
backend/config.yaml Normal file
View File

@ -0,0 +1,132 @@
app:
name: "lhbfx"
timezone: "Asia/Shanghai"
environment: "local"
database:
driver: "mysql"
host: "152.136.100.182"
port: 3306
username: "root"
password: "4a3986024e6662f9e571782ece1587298291d18925b44f1f"
database: "lhbfx"
charset: "utf8mb4"
pool_size: 10
connect_timeout_seconds: 10
data_sources:
lhb_priority:
- "tonghuashun"
quote_priority:
- "tonghuashun"
fundamentals_priority:
- "tonghuashun"
- "akshare"
monitoring:
target_year: 2026
warning_days_without_action: 4
max_position_ratio: 0.10
fib_levels:
- 0.382
- 0.5
- 0.618
moving_average_days:
- 5
turnover_warning_threshold: 0.30
mail:
sender_email: "1807754808@qq.com"
smtp_username: "1807754808@qq.com"
smtp_password: "hfyltsqfbmvzegfe"
smtp_host: "smtp.qq.com"
smtp_port: 465
recipients:
- "1807754808@qq.com"
traders:
- name: "章盟主"
alias: "章建平"
style_tags:
- "板块中军"
- "权重大票"
- "趋势强化"
- "上亿级别资金体量"
warning_weight: "high"
match_keywords:
- "上海长宁区江苏路"
- "上海浦东新区海阳西路"
- "宁波彩虹北路"
- "宁波广福街"
- "杭州延安路"
- "杭州富春路"
- "杭州四季路"
- "杭州宜春路"
- "杭州解放东路"
seats:
core:
- "国泰君安上海江苏路"
active:
- "国泰君安上海海阳西路"
- "国泰君安宁波彩虹北路"
- "国泰君安宁波广福街"
- "中信证券杭州延安路"
- "中信证券杭州富春路"
- "中信证券杭州四季路"
- "中信证券杭州宜春路"
- "财通证券杭州解放东路"
history:
- "国信证券杭州保俶路"
- "新疆证券杭州庆春路"
- "中信金通证券杭州延安路"
- "义乌化工路"
- name: "炒股养家"
alias: ""
style_tags:
- "情绪周期"
- "题材确认"
- "高辨识度龙头"
warning_weight: "medium"
match_keywords:
- "上海宛平南路"
- "上海茅台路"
- "华鑫证券有限责任公司上海分公司"
seats:
core:
- "华鑫证券上海宛平南路"
active:
- "华鑫证券上海茅台路"
- "华鑫证券上海分公司"
history: []
- name: "欢乐海岸"
alias: ""
style_tags:
- "高位强势股"
- "妖股"
- "封板后锁仓"
- "高溢价品牌效应"
warning_weight: "high"
match_keywords:
- "中信证券股份有限公司深圳总部"
- "中信证券股份有限公司深圳后海证券营业部"
- "广发证券股份有限公司深圳福华一路证券营业部"
- "华泰证券股份有限公司深圳分公司"
- "华泰证券股份有限公司深圳科苑南路华润大厦证券营业部"
- "中国中金财富证券有限公司云浮新兴东堤北路证券营业部"
- "天府证券有限责任公司深圳福田金田路证券营业部"
- "中泰证券股份有限公司深圳宝源南路证券营业部"
seats:
core:
- "中信证券深圳总部"
- "中信证券深圳后海"
active:
- "广发证券深圳光彩路"
- "中信证券深圳分公司"
- "华泰证券深圳分公司"
- "华泰证券深圳科苑南路华润大厦证券营业部"
- "中国中金财富证券云浮新兴东堤北路证券营业部"
- "天府证券深圳福田金田路证券营业部"
- "中泰证券深圳宝源南路证券营业部"
history: []

View File

@ -11,6 +11,7 @@ dependencies = [
"fastapi>=0.116.1", "fastapi>=0.116.1",
"uvicorn>=0.35.0", "uvicorn>=0.35.0",
"jinja2>=3.1.6", "jinja2>=3.1.6",
"reportlab>=4.4.0",
] ]
[tool.setuptools] [tool.setuptools]

View File

@ -0,0 +1,116 @@
from __future__ import annotations
import argparse
from datetime import datetime
from zoneinfo import ZoneInfo
from _bootstrap import add_src_to_path
add_src_to_path()
from lhbfx.config import load_config
from lhbfx.mailer import send_email
from lhbfx.pdf_export import generate_daily_report_pdf
from lhbfx.pipeline import generate_warnings, import_daily, rematch_traders
from lhbfx.reporting import build_daily_report, build_email_body, default_report_output_path
SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run lhbfx after-market update workflow")
parser.add_argument("--trade-date", help="Trade date in YYYY-MM-DD format, defaults to Asia/Shanghai today")
parser.add_argument("--send-email", action="store_true", help="Send completion email after report generation")
parser.add_argument("--force", action="store_true", help="Run even if the target date is not a weekday")
return parser.parse_args()
def default_trade_date() -> str:
return datetime.now(SHANGHAI_TZ).date().isoformat()
def is_weekday(trade_date: str) -> bool:
return datetime.fromisoformat(trade_date).weekday() < 5
def build_update_email_body(
*,
trade_date: str,
overview_count: int,
detail_count: int,
rematch_updated: int,
warning_total: int,
report_body: str,
) -> str:
lines = [
f"lhbfx 收盘后更新完成 - {trade_date}",
"",
f"龙虎榜概览更新数: {overview_count}",
f"席位明细更新数: {detail_count}",
f"游资重新匹配数: {rematch_updated}",
f"预警总数: {warning_total}",
"",
]
lines.append(report_body)
return "\n".join(lines)
def main() -> None:
args = parse_args()
config = load_config()
trade_date = args.trade_date or default_trade_date()
if not args.force and not is_weekday(trade_date):
print(f"Skip {trade_date}: not a weekday, assumed non-trading day.")
return
import_result = import_daily(trade_date, config=config)
if import_result.overview_count <= 0:
print(f"Skip {trade_date}: no overview rows imported, source data may not be ready or market may be closed.")
return
rematch_result = rematch_traders(config=config)
warning_result = generate_warnings(config=config)
report = build_daily_report(config=config, trade_date=trade_date)
pdf_path = default_report_output_path(trade_date)
generate_daily_report_pdf(report, pdf_path)
print(
"After-market update finished:",
{
"trade_date": trade_date,
"overview_count": import_result.overview_count,
"detail_count": import_result.detail_count,
"rematch_updated": rematch_result["updated"],
"warning_total": warning_result["total"],
"pdf_path": str(pdf_path),
},
)
if args.send_email:
if config.mail is None:
raise RuntimeError("Mail config is missing")
report_body = build_email_body(report)
body_text = build_update_email_body(
trade_date=trade_date,
overview_count=import_result.overview_count,
detail_count=import_result.detail_count,
rematch_updated=rematch_result["updated"],
warning_total=warning_result["total"],
report_body=report_body,
)
subject = f"lhbfx 收盘后更新完成 - {trade_date}"
send_email(
mail_config=config.mail,
subject=subject,
body_text=body_text,
attachments=[pdf_path],
)
print(f"Email sent to: {', '.join(config.mail.recipients)}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,56 @@
from __future__ import annotations
import argparse
from pathlib import Path
from _bootstrap import add_src_to_path
add_src_to_path()
from lhbfx.config import load_config
from lhbfx.mailer import send_email
from lhbfx.pdf_export import generate_daily_report_pdf
from lhbfx.reporting import (
build_daily_report,
build_email_body,
default_report_output_path,
get_latest_trade_date,
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate and optionally send lhbfx daily report")
parser.add_argument("--trade-date", help="Trade date in YYYY-MM-DD format")
parser.add_argument("--send", action="store_true", help="Send email after generating report")
return parser.parse_args()
def main() -> None:
args = parse_args()
config = load_config()
trade_date = args.trade_date or get_latest_trade_date(config)
if not trade_date:
raise RuntimeError("No trade date available in database")
report = build_daily_report(config=config, trade_date=trade_date)
pdf_path = default_report_output_path(trade_date)
generate_daily_report_pdf(report, pdf_path)
body_text = build_email_body(report)
print(f"Generated PDF: {pdf_path}")
if args.send:
if config.mail is None:
raise RuntimeError("Mail config is missing")
subject = f"lhbfx 盘后日报 - {trade_date}"
send_email(
mail_config=config.mail,
subject=subject,
body_text=body_text,
attachments=[pdf_path],
)
print(f"Email sent to: {', '.join(config.mail.recipients)}")
if __name__ == "__main__":
main()

View File

@ -59,6 +59,15 @@ def apply_incremental_alters(config: AppConfig) -> None:
cursor.execute( cursor.execute(
"ALTER TABLE lhb_detail_seats ADD UNIQUE KEY uniq_lhb_detail_record (trade_date, stock_code, rid, table_title, seat_name)" "ALTER TABLE lhb_detail_seats ADD UNIQUE KEY uniq_lhb_detail_record (trade_date, stock_code, rid, table_title, seat_name)"
) )
if not _index_exists(cursor, schema_name, "lhb_detail_seats", "idx_lhb_detail_trader_stock_date"):
cursor.execute(
"ALTER TABLE lhb_detail_seats ADD KEY idx_lhb_detail_trader_stock_date (matched_trader_name, stock_code, trade_date)"
)
if not _index_exists(cursor, schema_name, "warning_events", "idx_warning_events_trader_type_date_code"):
cursor.execute(
"ALTER TABLE warning_events ADD KEY idx_warning_events_trader_type_date_code (trader_name, warning_type, trade_date, stock_code)"
)
def main() -> None: def main() -> None:

View File

@ -23,6 +23,16 @@ class DatabaseConfig:
connect_timeout_seconds: int = 10 connect_timeout_seconds: int = 10
@dataclass(slots=True)
class MailConfig:
sender_email: str
smtp_username: str
smtp_password: str
smtp_host: str
smtp_port: int
recipients: list[str]
class AppConfig: class AppConfig:
def __init__(self, raw: dict[str, Any], path: Path) -> None: def __init__(self, raw: dict[str, Any], path: Path) -> None:
self.raw = raw self.raw = raw
@ -55,9 +65,22 @@ class AppConfig:
def data_sources(self) -> dict[str, Any]: def data_sources(self) -> dict[str, Any]:
return self.raw.get("data_sources", {}) return self.raw.get("data_sources", {})
@property
def mail(self) -> MailConfig | None:
mail = self.raw.get("mail")
if not mail:
return None
return MailConfig(
sender_email=mail["sender_email"],
smtp_username=mail["smtp_username"],
smtp_password=mail["smtp_password"],
smtp_host=mail["smtp_host"],
smtp_port=int(mail["smtp_port"]),
recipients=list(mail.get("recipients", [])),
)
def load_config(path: str | Path | None = None) -> AppConfig: def load_config(path: str | Path | None = None) -> AppConfig:
config_path = Path(path) if path else DEFAULT_CONFIG_PATH config_path = Path(path) if path else DEFAULT_CONFIG_PATH
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
return AppConfig(raw=raw, path=config_path) return AppConfig(raw=raw, path=config_path)

View File

@ -0,0 +1,37 @@
from __future__ import annotations
import mimetypes
import smtplib
from email.message import EmailMessage
from pathlib import Path
from .config import MailConfig
def send_email(
*,
mail_config: MailConfig,
subject: str,
body_text: str,
attachments: list[Path] | None = None,
) -> None:
message = EmailMessage()
message["Subject"] = subject
message["From"] = mail_config.sender_email
message["To"] = ", ".join(mail_config.recipients)
message.set_content(body_text)
for attachment in attachments or []:
mime_type, _ = mimetypes.guess_type(str(attachment))
maintype, subtype = (mime_type or "application/octet-stream").split("/", 1)
with attachment.open("rb") as file_obj:
message.add_attachment(
file_obj.read(),
maintype=maintype,
subtype=subtype,
filename=attachment.name,
)
with smtplib.SMTP_SSL(mail_config.smtp_host, mail_config.smtp_port) as smtp:
smtp.login(mail_config.smtp_username, mail_config.smtp_password)
smtp.send_message(message)

View File

@ -0,0 +1,182 @@
from __future__ import annotations
from pathlib import Path
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
from reportlab.pdfbase.pdfmetrics import registerFont
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
from .reporting import DailyReport, _major_board_label, _sector_label
WINDOWS_FONT_CANDIDATES = [
(r"C:\Windows\Fonts\simhei.ttf", "SimHei"),
(r"C:\Windows\Fonts\simfang.ttf", "SimFang"),
]
def _register_font() -> str:
for font_path, font_name in WINDOWS_FONT_CANDIDATES:
if Path(font_path).exists():
registerFont(TTFont(font_name, font_path))
return font_name
registerFont(UnicodeCIDFont("STSong-Light"))
return "STSong-Light"
FONT_NAME = _register_font()
def _styles():
base = getSampleStyleSheet()
return {
"title": ParagraphStyle(
"ReportTitle",
parent=base["Title"],
fontName=FONT_NAME,
fontSize=18,
leading=24,
),
"heading": ParagraphStyle(
"ReportHeading",
parent=base["Heading2"],
fontName=FONT_NAME,
fontSize=13,
leading=18,
spaceAfter=6,
),
"body": ParagraphStyle(
"ReportBody",
parent=base["BodyText"],
fontName=FONT_NAME,
fontSize=10.5,
leading=15,
),
}
def _build_table(rows: list[list[str]], col_widths: list[float] | None = None) -> Table:
table = Table(rows, repeatRows=1, colWidths=col_widths)
table.setStyle(
TableStyle(
[
("FONTNAME", (0, 0), (-1, -1), FONT_NAME),
("FONTSIZE", (0, 0), (-1, -1), 9),
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#d6a85f")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#101721")),
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#303846")),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.HexColor("#f7f7f7"), colors.white]),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
]
)
)
return table
def generate_daily_report_pdf(report: DailyReport, output_path: Path) -> Path:
styles = _styles()
output_path.parent.mkdir(parents=True, exist_ok=True)
doc = SimpleDocTemplate(
str(output_path),
pagesize=A4,
leftMargin=15 * mm,
rightMargin=15 * mm,
topMargin=15 * mm,
bottomMargin=15 * mm,
)
body_style = ParagraphStyle(
"WrappedBody",
parent=styles["body"],
fontName=FONT_NAME,
fontSize=9,
leading=12,
wordWrap="CJK",
)
story = [
Paragraph(f"lhbfx 盘后日报 - {report.trade_date}", styles["title"]),
Spacer(1, 8),
Paragraph(
f"关注池股票数:{len(report.watchlist_items)} 关注池流水数:{len(report.watch_actions)} 待加入关注候选数:{len(report.candidate_actions)} 预警数:{len(report.warning_items)}",
styles["body"],
),
Spacer(1, 10),
Paragraph("关注池情况", styles["heading"]),
]
watch_rows = [["股票", "游资", "行业名称", "上市板块", "买入(万)", "卖出(万)", "净额(万)", "席位"]]
if report.watch_actions:
for action in report.watch_actions[:20]:
watch_rows.append(
[
f"{action['stock_name']} {action['stock_code']}",
action["trader_name"],
_sector_label(action),
_major_board_label(action),
str(action.get("buy_amount_wan", "-")),
str(action.get("sell_amount_wan", "-")),
str(action.get("net_amount_wan", "-")),
Paragraph(str(action.get("seat_name", "-")), body_style),
]
)
else:
watch_rows.append(["", "-", "-", "-", "-", "-", "-", "-"])
watch_col_widths = [
28 * mm,
18 * mm,
33 * mm,
18 * mm,
18 * mm,
18 * mm,
18 * mm,
52 * mm,
]
story.append(_build_table(watch_rows, watch_col_widths))
story.extend([Spacer(1, 10), Paragraph("今日待加入关注", styles["heading"])])
candidate_rows = [["股票", "游资", "行业名称", "上市板块", "买入(万)", "卖出(万)", "净额(万)", "股价", "涨跌"]]
if report.candidate_actions:
for action in report.candidate_actions[:20]:
candidate_rows.append(
[
f"{action['stock_name']} {action['stock_code']}",
action["trader_name"],
_sector_label(action),
_major_board_label(action),
str(action.get("buy_amount_wan", "-")),
str(action.get("sell_amount_wan", "-")),
str(action.get("net_amount_wan", "-")),
str(action.get("current_price", "-")),
str(action.get("pct_chg", "-")),
]
)
else:
candidate_rows.append(["", "-", "-", "-", "-", "-", "-", "-", "-"])
story.append(_build_table(candidate_rows))
if report.warning_items:
story.extend([Spacer(1, 10), Paragraph("风险与预警", styles["heading"])])
warning_rows = [["股票", "游资", "类型", "等级", "原因"]]
for item in report.warning_items[:20]:
warning_rows.append(
[
f"{item['stock_name']} {item['stock_code']}",
str(item.get("trader_name", "-")),
str(item.get("warning_type", "-")),
str(item.get("warning_level", "-")),
str(item.get("trigger_reason", "-")),
]
)
story.append(_build_table(warning_rows))
doc.build(story)
return output_path

View File

@ -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:
@ -36,22 +37,22 @@ def _parse_json_list(value: Any) -> list[Any]:
def _infer_market_label(stock_code: str) -> str: def _infer_market_label(stock_code: str) -> str:
if stock_code.startswith(("6", "9", "5", "688")): if stock_code.startswith(("6", "9", "5", "688")):
return "A" return "\u6caaA"
return "A" return "\u6df1A"
def _infer_board_label(stock_code: str) -> str: def _infer_board_label(stock_code: str) -> str:
if stock_code.startswith(("688", "689")): if stock_code.startswith(("688", "689")):
return "绉戝垱鏉?" return "\u79d1\u521b\u677f"
if stock_code.startswith(("300", "301")): if stock_code.startswith(("300", "301")):
return "鍒涗笟鏉?" return "\u521b\u4e1a\u677f"
if stock_code.startswith(("8", "4", "920")): if stock_code.startswith(("8", "4", "920")):
return "鍖椾氦鎵€" return "\u5317\u4ea4\u6240"
if stock_code.startswith(("60", "601", "603", "605", "900")): if stock_code.startswith(("60", "601", "603", "605", "900")):
return "娌富鏉?" return "\u6caa\u4e3b\u677f"
if stock_code.startswith(("000", "001", "002", "003", "200")): if stock_code.startswith(("000", "001", "002", "003", "200")):
return "娣变富鏉?" return "\u6df1\u4e3b\u677f"
return "A鑲?" return "A\u80a1"
def fetch_summary() -> dict[str, Any]: def fetch_summary() -> dict[str, Any]:
@ -121,15 +122,29 @@ def fetch_traders() -> list[dict[str, Any]]:
t.alias_name, t.alias_name,
t.warning_weight, t.warning_weight,
t.style_tags, t.style_tags,
COUNT(DISTINCT d.stock_code) AS stock_count, COALESCE(ds.stock_count, 0) AS stock_count,
COUNT(DISTINCT CASE WHEN w.warning_type = 'sell_alert' THEN CONCAT(w.trade_date, ':', w.stock_code) END) AS sell_alert_count, COALESCE(ws.sell_alert_count, 0) AS sell_alert_count,
COUNT(DISTINCT CASE WHEN w.warning_type = 'slow_exit_watch' THEN CONCAT(w.trade_date, ':', w.stock_code) END) AS slow_exit_count COALESCE(ws.slow_exit_count, 0) AS slow_exit_count
FROM traders t FROM traders t
LEFT JOIN lhb_detail_seats d LEFT JOIN (
ON d.matched_trader_name = t.name SELECT
LEFT JOIN warning_events w matched_trader_name,
ON w.trader_name = t.name COUNT(DISTINCT stock_code) AS stock_count
GROUP BY t.id, t.name, t.alias_name, t.warning_weight, t.style_tags FROM lhb_detail_seats
WHERE matched_trader_name IS NOT NULL
GROUP BY matched_trader_name
) ds
ON ds.matched_trader_name = t.name
LEFT JOIN (
SELECT
trader_name,
COUNT(DISTINCT CASE WHEN warning_type = 'sell_alert' THEN CONCAT(trade_date, ':', stock_code) END) AS sell_alert_count,
COUNT(DISTINCT CASE WHEN warning_type = 'slow_exit_watch' THEN CONCAT(trade_date, ':', stock_code) END) AS slow_exit_count
FROM warning_events
WHERE trader_name IS NOT NULL
GROUP BY trader_name
) ws
ON ws.trader_name = t.name
ORDER BY stock_count DESC, t.name ORDER BY stock_count DESC, t.name
""" """
) )
@ -274,6 +289,7 @@ def fetch_trader_actions(
o.price AS current_price, o.price AS current_price,
o.pct_chg, o.pct_chg,
s.industry, s.industry,
s.concept_tags,
s.market, s.market,
s.total_market_value, s.total_market_value,
s.circulating_market_value, s.circulating_market_value,
@ -313,6 +329,7 @@ def fetch_trader_actions(
actions = [_normalize_row(row) for row in cursor.fetchall()] actions = [_normalize_row(row) for row in cursor.fetchall()]
for action in actions: for action in actions:
action["concept_tags"] = _parse_json_list(action.get("concept_tags"))
action["market"] = action.get("market") or _infer_market_label(action["stock_code"]) action["market"] = action.get("market") or _infer_market_label(action["stock_code"])
action["board_label"] = _infer_board_label(action["stock_code"]) action["board_label"] = _infer_board_label(action["stock_code"])
@ -354,8 +371,7 @@ def fetch_trader_detail(trader_id: int) -> dict[str, Any]:
) )
seats = [_normalize_row(row) for row in cursor.fetchall()] seats = [_normalize_row(row) for row in cursor.fetchall()]
cursor.execute( stock_query = """
"""
SELECT SELECT
d.stock_code, d.stock_code,
MAX(COALESCE(o.stock_name, d.stock_name)) AS stock_name, MAX(COALESCE(o.stock_name, d.stock_name)) AS stock_name,
@ -365,22 +381,61 @@ def fetch_trader_detail(trader_id: int) -> dict[str, Any]:
MAX(d.trade_date) AS last_trade_date, MAX(d.trade_date) AS last_trade_date,
SUM(CASE WHEN CAST(COALESCE(NULLIF(d.buy_amount_wan, ''), '0') AS DECIMAL(18,2)) > 0 THEN 1 ELSE 0 END) AS buy_action_count, SUM(CASE WHEN CAST(COALESCE(NULLIF(d.buy_amount_wan, ''), '0') AS DECIMAL(18,2)) > 0 THEN 1 ELSE 0 END) AS buy_action_count,
SUM(CASE WHEN CAST(COALESCE(NULLIF(d.sell_amount_wan, ''), '0') AS DECIMAL(18,2)) > 0 THEN 1 ELSE 0 END) AS sell_action_count, SUM(CASE WHEN CAST(COALESCE(NULLIF(d.sell_amount_wan, ''), '0') AS DECIMAL(18,2)) > 0 THEN 1 ELSE 0 END) AS sell_action_count,
SUM(CAST(COALESCE(NULLIF(d.net_amount_wan, ''), '0') AS DECIMAL(18,2))) AS total_net_amount_wan,
MAX(CASE WHEN w.warning_type = 'sell_alert' THEN 1 ELSE 0 END) AS has_sell_alert, MAX(CASE WHEN w.warning_type = 'sell_alert' THEN 1 ELSE 0 END) AS has_sell_alert,
MAX(CASE WHEN w.warning_type = 'slow_exit_watch' THEN 1 ELSE 0 END) AS has_slow_exit MAX(CASE WHEN w.warning_type = 'slow_exit_watch' THEN 1 ELSE 0 END) AS has_slow_exit,
MAX(s.industry) AS industry,
MAX(s.market) AS market,
MAX(s.total_market_value) AS total_market_value,
MAX(s.circulating_market_value) AS circulating_market_value
FROM lhb_detail_seats d FROM lhb_detail_seats d
LEFT JOIN lhb_overview o LEFT JOIN lhb_overview o
ON o.stock_code = d.stock_code AND o.trade_date = d.trade_date ON o.stock_code = d.stock_code AND o.trade_date = d.trade_date
LEFT JOIN warning_events w LEFT JOIN warning_events w
ON w.stock_code = d.stock_code AND w.trader_name = d.matched_trader_name ON w.stock_code = d.stock_code AND w.trader_name = d.matched_trader_name
LEFT JOIN stocks s
ON s.stock_code = d.stock_code
WHERE d.matched_trader_name = %s WHERE d.matched_trader_name = %s
GROUP BY d.stock_code GROUP BY d.stock_code
ORDER BY last_trade_date DESC, action_count DESC ORDER BY last_trade_date DESC, action_count DESC
LIMIT 100 LIMIT 100
""", """
(trader_name,),
) cursor.execute(stock_query, (trader_name,))
stocks = [_normalize_row(row) for row in cursor.fetchall()] stocks = [_normalize_row(row) for row in cursor.fetchall()]
stock_codes = [row["stock_code"] for row in stocks if row.get("stock_code")]
increasing_by_stock: dict[str, bool] = {}
if stock_codes:
placeholders = ", ".join(["%s"] * len(stock_codes))
cursor.execute(
f"""
SELECT
stock_code,
trade_date,
SUM(CAST(COALESCE(NULLIF(net_amount_wan, ''), '0') AS DECIMAL(18,2))) AS daily_net_amount_wan
FROM lhb_detail_seats
WHERE matched_trader_name = %s
AND stock_code IN ({placeholders})
AND trade_date IS NOT NULL
GROUP BY stock_code, trade_date
ORDER BY stock_code, trade_date
""",
(trader_name, *stock_codes),
)
net_history_by_stock: dict[str, list[float]] = {}
for row in cursor.fetchall():
stock_code = row["stock_code"]
net_history_by_stock.setdefault(stock_code, []).append(float(row["daily_net_amount_wan"] or 0))
for stock_code, net_history in net_history_by_stock.items():
increasing_by_stock[stock_code] = len(net_history) >= 2 and all(
current > previous for previous, current in zip(net_history, net_history[1:])
)
for stock in stocks:
stock["is_net_amount_increasing"] = increasing_by_stock.get(stock["stock_code"], False)
cursor.execute( cursor.execute(
""" """
SELECT trade_date, stock_code, stock_name, warning_type, warning_level, trigger_reason SELECT trade_date, stock_code, stock_name, warning_type, warning_level, trigger_reason
@ -413,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)

View File

@ -0,0 +1,276 @@
from __future__ import annotations
import json
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .config import AppConfig
from .db import db_cursor
from .queries import fetch_trader_actions, fetch_watchlist, fetch_warnings
from .sources.eastmoney import EastMoneyClient
from .sources.tencent import TencentClient
@dataclass(slots=True)
class DailyReport:
trade_date: str
watchlist_items: list[dict[str, Any]]
watch_actions: list[dict[str, Any]]
candidate_actions: list[dict[str, Any]]
warning_items: list[dict[str, Any]]
def get_latest_trade_date(config: AppConfig) -> str | None:
with db_cursor(config=config) as (_, cursor):
cursor.execute("SELECT MAX(trade_date) AS latest_trade_date FROM lhb_overview")
row = cursor.fetchone()
latest_trade_date = row["latest_trade_date"] if row else None
return latest_trade_date.isoformat() if latest_trade_date else None
def _to_float(value: Any) -> float:
if value in (None, "", "-"):
return 0.0
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def _normalize_seat_name(value: str) -> str:
return "".join(value.split()).strip()
def _merged_action_side(buy_amount: float, sell_amount: float, net_amount: float) -> str:
if buy_amount > 0 and sell_amount <= 0:
return "buy"
if sell_amount > 0 and buy_amount <= 0:
return "sell"
if net_amount >= 0:
return "net_buy"
return "net_sell"
def aggregate_watch_actions(actions: list[dict[str, Any]]) -> list[dict[str, Any]]:
groups: dict[str, dict[str, Any]] = {}
for action in actions:
key = "::".join(
[
action["stock_code"],
action["trade_date"],
action["trader_name"],
_normalize_seat_name(action["seat_name"]),
]
)
existing = groups.get(key)
if existing is None:
groups[key] = dict(action)
continue
next_buy = _to_float(existing.get("buy_amount_wan")) + _to_float(action.get("buy_amount_wan"))
next_sell = _to_float(existing.get("sell_amount_wan")) + _to_float(action.get("sell_amount_wan"))
next_net = next_buy - next_sell
table_titles = [existing.get("table_title", ""), action.get("table_title", "")]
merged_titles = " / ".join(dict.fromkeys(title for title in table_titles if title))
existing.update(
{
"table_title": merged_titles,
"buy_amount_wan": f"{next_buy:.2f}",
"sell_amount_wan": f"{next_sell:.2f}",
"net_amount_wan": f"{next_net:.2f}",
"action_side": _merged_action_side(next_buy, next_sell, next_net),
}
)
return list(groups.values())
def unique_candidate_actions(actions: list[dict[str, Any]], watched_codes: set[str]) -> list[dict[str, Any]]:
unique: dict[str, dict[str, Any]] = {}
for action in actions:
if action["stock_code"] in watched_codes:
continue
unique.setdefault(action["stock_code"], action)
return list(unique.values())
def _sector_label(action: dict[str, Any]) -> str:
concept_tags = action.get("concept_tags") or []
if isinstance(concept_tags, str):
try:
concept_tags = json.loads(concept_tags)
except json.JSONDecodeError:
concept_tags = []
if concept_tags:
return " / ".join(concept_tags[:2])
return str(action.get("industry") or action.get("board_label") or "-")
def _major_board_label(action: dict[str, Any]) -> str:
return str(action.get("board_label") or action.get("market") or "-")
def enrich_stock_metadata(*, config: AppConfig, stock_codes: set[str]) -> None:
if not stock_codes:
return
placeholders = ", ".join(["%s"] * len(stock_codes))
with db_cursor(config=config) as (_, cursor):
cursor.execute(
f"""
SELECT stock_code, stock_name, industry, concept_tags, market
FROM stocks
WHERE stock_code IN ({placeholders})
""",
tuple(sorted(stock_codes)),
)
existing_rows = {row["stock_code"]: row for row in cursor.fetchall()}
client = EastMoneyClient()
quote_client = TencentClient()
for stock_code in sorted(stock_codes):
row = existing_rows.get(stock_code)
has_industry = bool(row and row.get("industry"))
has_concepts = bool(row and row.get("concept_tags"))
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
profile = client.fetch_company_profile(stock_code)
try:
quote_snapshot = quote_client.fetch_quote_snapshot(stock_code)
except Exception:
quote_snapshot = {}
cursor.execute(
"""
INSERT INTO stocks (
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
stock_name = COALESCE(NULLIF(VALUES(stock_name), ''), stock_name),
market = COALESCE(NULLIF(VALUES(market), ''), market),
industry = COALESCE(NULLIF(VALUES(industry), ''), industry),
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,
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("industry"),
json.dumps(profile.get("concept_tags") or [], ensure_ascii=False),
quote_snapshot.get("total_market_value"),
quote_snapshot.get("circulating_market_value"),
),
)
def build_daily_report(*, config: AppConfig, trade_date: str) -> DailyReport:
actions_response = fetch_trader_actions(
date_from=trade_date,
date_to=trade_date,
limit=500,
)
initial_actions = actions_response["actions"]
enrich_stock_metadata(
config=config,
stock_codes={action["stock_code"] for action in initial_actions},
)
all_actions = fetch_trader_actions(
date_from=trade_date,
date_to=trade_date,
limit=500,
)["actions"]
watchlist_items = fetch_watchlist()
watch_codes = {item["stock_code"] for item in watchlist_items}
watch_actions_raw = [action for action in all_actions if action["stock_code"] in watch_codes]
watch_actions = aggregate_watch_actions(watch_actions_raw)
candidate_actions = unique_candidate_actions(all_actions, watch_codes)
warning_items = [
warning
for warning in fetch_warnings(limit=100)
if warning.get("stock_code") in watch_codes
]
return DailyReport(
trade_date=trade_date,
watchlist_items=watchlist_items,
watch_actions=watch_actions,
candidate_actions=candidate_actions,
warning_items=warning_items,
)
def build_email_body(report: DailyReport) -> str:
lines = [
f"lhbfx 盘后日报 - {report.trade_date}",
"",
f"关注池股票数:{len(report.watchlist_items)}",
f"关注池当日流水数:{len(report.watch_actions)}",
f"待加入关注候选数:{len(report.candidate_actions)}",
f"风险预警数:{len(report.warning_items)}",
"",
"关注池情况:",
]
if report.watch_actions:
for action in report.watch_actions[:10]:
lines.append(
f"- {action['stock_name']} {action['stock_code']} | {action['trader_name']} | "
f"行业名称 {_sector_label(action)} | "
f"上市板块 {_major_board_label(action)} | "
f"买入 {action.get('buy_amount_wan', '-')}万 | "
f"卖出 {action.get('sell_amount_wan', '-')}万 | "
f"净额 {action.get('net_amount_wan', '-')}"
)
else:
lines.append("- 今日关注池暂无新增流水")
lines.extend(["", "今日待加入关注:"])
if report.candidate_actions:
for action in report.candidate_actions[:10]:
lines.append(
f"- {action['stock_name']} {action['stock_code']} | {action['trader_name']} | "
f"行业名称 {_sector_label(action)} | "
f"上市板块 {_major_board_label(action)} | "
f"买入 {action.get('buy_amount_wan', '-')}万 | "
f"卖出 {action.get('sell_amount_wan', '-')}万 | "
f"净额 {action.get('net_amount_wan', '-')}万 | "
f"股价 {action.get('current_price', '-')} | "
f"涨跌 {action.get('pct_chg', '-')}"
)
else:
lines.append("- 今日暂无候选股票")
if report.warning_items:
lines.extend(["", "关注池风险提示:"])
for warning in report.warning_items[:10]:
lines.append(
f"- {warning['stock_name']} {warning['stock_code']} | {warning['warning_type']} | {warning['trigger_reason']}"
)
lines.extend(["", "详见附件 PDF 日报。"])
return "\n".join(lines)
def default_report_output_path(trade_date: str) -> Path:
root = Path(__file__).resolve().parents[2]
output_dir = root.parent / "output" / "reports"
output_dir.mkdir(parents=True, exist_ok=True)
return output_dir / f"lhbfx-daily-report-{trade_date}.pdf"

View File

@ -75,6 +75,7 @@ CREATE TABLE IF NOT EXISTS lhb_detail_seats (
KEY idx_lhb_detail_code (stock_code), KEY idx_lhb_detail_code (stock_code),
KEY idx_lhb_detail_trade_date (trade_date), KEY idx_lhb_detail_trade_date (trade_date),
KEY idx_lhb_detail_trader (matched_trader_name), KEY idx_lhb_detail_trader (matched_trader_name),
KEY idx_lhb_detail_trader_stock_date (matched_trader_name, stock_code, trade_date),
UNIQUE KEY uniq_lhb_detail_record (trade_date, stock_code, rid, table_title, seat_name) UNIQUE KEY uniq_lhb_detail_record (trade_date, stock_code, rid, table_title, seat_name)
); );
@ -94,7 +95,8 @@ CREATE TABLE IF NOT EXISTS warning_events (
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_warning_events_code (stock_code), KEY idx_warning_events_code (stock_code),
KEY idx_warning_events_trade_date (trade_date), KEY idx_warning_events_trade_date (trade_date),
KEY idx_warning_events_trader (trader_name) KEY idx_warning_events_trader (trader_name),
KEY idx_warning_events_trader_type_date_code (trader_name, warning_type, trade_date, stock_code)
); );
CREATE TABLE IF NOT EXISTS watchlist_entries ( CREATE TABLE IF NOT EXISTS watchlist_entries (

View File

@ -21,7 +21,53 @@ def infer_secid(stock_code: str) -> str:
return f"0.{stock_code}" return f"0.{stock_code}"
def infer_symbol(stock_code: str) -> str:
if stock_code.startswith(("6", "9", "5", "688")):
return f"SH{stock_code}"
if stock_code.startswith(("8", "4")):
return f"BJ{stock_code}"
return f"SZ{stock_code}"
class EastMoneyClient: class EastMoneyClient:
def fetch_company_profile(self, stock_code: str) -> dict[str, Any]:
symbol = infer_symbol(stock_code)
survey_url = "https://emweb.securities.eastmoney.com/PC_HSF10/CompanySurvey/CompanySurveyAjax"
concept_url = "https://emweb.securities.eastmoney.com/PC_HSF10/CoreConception/PageAjax"
survey_response = requests.get(
survey_url,
params={"code": symbol},
headers=DEFAULT_HEADERS,
timeout=20,
)
survey_response.raise_for_status()
survey_payload = survey_response.json() or {}
basic = survey_payload.get("jbzl") or {}
concept_response = requests.get(
concept_url,
params={"code": symbol},
headers=DEFAULT_HEADERS,
timeout=20,
)
concept_response.raise_for_status()
concept_payload = concept_response.json() or {}
concept_rows = concept_payload.get("ssbk") or []
concept_tags = []
for row in concept_rows:
board_name = row.get("BOARD_NAME")
if board_name and board_name not in concept_tags:
concept_tags.append(board_name)
return {
"stock_code": stock_code,
"stock_name": basic.get("agjc"),
"market": basic.get("zqlb"),
"industry": basic.get("sshy") or basic.get("sszjhhy"),
"concept_tags": concept_tags,
}
def fetch_quote_snapshot(self, stock_code: str) -> dict[str, Any]: def fetch_quote_snapshot(self, stock_code: str) -> dict[str, Any]:
secid = infer_secid(stock_code) secid = infer_secid(stock_code)
url = "https://push2.eastmoney.com/api/qt/stock/get" url = "https://push2.eastmoney.com/api/qt/stock/get"

View 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,
}

View File

@ -140,7 +140,7 @@ longhubang/
### 4.3 页面组件 ### 4.3 页面组件
- `HomeControlScreen.vue`:首页总控台 - `HomeControlScreen.vue`:首页总控台
- `TraderDetailScreen.vue`:游资详情 - `TraderDetailScreen.vue`:游资详情,采用单列全宽股票列表,支持时间、名称、净额连续增大筛选,并展示行业、板块、总市值、净额和预警标签
- `StockDetailScreen.vue`:个股详情 - `StockDetailScreen.vue`:个股详情
- `WarningCenterScreen.vue`:预警中心 - `WarningCenterScreen.vue`:预警中心
@ -245,3 +245,96 @@ npm run dev -- --host 127.0.0.1 --port 5173
- 历史文档与部分配置存在中文乱码 - 历史文档与部分配置存在中文乱码
- 部分来源数据原始字段编码不稳定 - 部分来源数据原始字段编码不稳定
- 页面样式近期经历多轮快速调整,仍建议补视觉回归测试 - 页面样式近期经历多轮快速调整,仍建议补视觉回归测试
## 9. 定时更新与邮件报送方案
为满足“每天下午 17:00 自动更新并发送盘后邮件”的新增需求,建议增加一个独立的调度与报送模块。
### 9.1 调度方式
建议采用以下任一方式:
- 服务器 `cron`
- Windows 任务计划程序
- 后续统一接入独立任务调度器
默认调度时间:
- 每天下午 `17:00`
### 9.2 推荐执行流程
每日任务执行顺序建议如下:
1. 拉取当日龙虎榜与行情数据
2. 更新数据库
3. 重新生成预警数据
4. 统计关注池情况
5. 统计待加入关注候选列表
6. 生成 PDF 日报
7. 发送邮件正文与附件
8. 记录执行日志
### 9.3 建议新增模块
建议新增以下能力:
- `backend/scripts/daily_report.py`
- 串联数据更新、统计、PDF 生成、邮件发送
- `backend/src/lhbfx/reporting.py`
- 负责报表数据整理
- `backend/src/lhbfx/mailer.py`
- 负责 SMTP 或邮件服务发送
- `backend/src/lhbfx/pdf_export.py`
- 负责 PDF 生成
### 9.4 配置项建议
建议在配置文件中新增:
- 是否启用邮件报送
- SMTP 主机
- SMTP 端口
- 发件人账号
- 发件人密码或授权码
- 收件人列表
- 抄送列表
- 日报输出目录
- 调度时间
### 9.5 PDF 生成建议
PDF 附件可以通过以下方式生成:
- 基于 HTML 模板渲染后导出 PDF
- 或直接使用 Python PDF 库生成结构化报告
推荐内容结构:
1. 标题页
2. 数据更新时间
3. 关注池总览
4. 关注池流水摘要
5. 今日待加入关注列表
6. 风险与预警摘要
### 9.6 邮件正文建议
正文采用简洁摘要形式,便于手机查看:
- 今日关注池概况
- 今日关注池重点动作
- 今日待加入关注候选
- 风险提示
- 附件说明
### 9.7 测试建议
新增该需求后,应补充以下验证:
- 17:00 定时任务是否被正确触发
- 更新失败时是否生成错误日志
- PDF 是否成功生成
- 邮件正文是否包含关键摘要
- 附件是否能正常打开
- 多收件人场景是否发送成功

View File

@ -64,9 +64,11 @@
游资详情页支持: 游资详情页支持:
- 游资档案 - 游资档案
- 核心席位
- 近期参与股票
- 风格标签 - 风格标签
- 股票列表全宽展示
- 时间、名称、净额连续增大筛选
- 按时间、净额、动作数排序
- 行业、上市板块、总市值、净额和预警标签展示
### 2.6 预警中心 ### 2.6 预警中心
@ -152,7 +154,48 @@
- 关键页面在 1440px 及以上宽度下保持清晰稳定 - 关键页面在 1440px 及以上宽度下保持清晰稳定
- 文档、配置与代码要支持团队继续接手迭代 - 文档、配置与代码要支持团队继续接手迭代
## 8. 后续建议 ## 8. 新增定时报送需求
新增一项每日自动化需求:
- 每个交易日下午 17:00 自动更新最新龙虎榜与相关统计数据
- 更新完成后自动统计“关注池情况”和“今日待加入关注列表”
- 自动发送邮件给指定收件人
- 邮件需同时包含正文摘要和 PDF 附件
### 8.1 定时更新要求
- 默认执行时间为每天下午 `17:00`
- 若当天为非交易日或数据源尚未更新,需要在邮件正文中明确说明
- 若更新失败,需要输出失败原因并进入告警状态
### 8.2 邮件正文要求
邮件正文至少包含以下内容:
- 数据统计日期
- 关注池股票数量
- 关注池中今日有动作的股票列表
- 今日待加入关注列表
- 关键风险提示或卖出预警摘要
### 8.3 PDF 附件要求
PDF 附件建议作为“盘后日报”输出,至少包含:
- 当日数据概览
- 关注池汇总
- 关注池操作流水摘要
- 今日待加入关注候选列表
- 重点风险与预警说明
### 8.4 邮件收件要求
- 支持配置一个或多个收件人
- 邮件主题中应包含日期,例如:`lhbfx 盘后日报 - 2026-04-17`
- 邮件发送成功与失败都需要记录日志
## 9. 后续建议
后续可以继续迭代: 后续可以继续迭代:

View File

@ -144,9 +144,11 @@
游资详情页保留,但不再作为首页首要信息。 游资详情页保留,但不再作为首页首要信息。
游资详情页用于查看某个游资的长期参与情况、席位信息、风格标签和近期参与股票。 游资详情页用于查看某个游资的长期参与情况、风格标签和近期参与股票。
已移除“动作时间线”模块。 已移除“动作时间线”模块。
已移除“净额重点”和“席位概览”侧栏,股票列表改为单列全宽展示。
股票列表支持按时间、名称、净额连续增大筛选,并支持按时间、净额、动作数排序。
原因: 原因:
@ -206,3 +208,4 @@
6. 首页顶部指标与关注列表联动,取消关注后自动剔除统计。 6. 首页顶部指标与关注列表联动,取消关注后自动剔除统计。
7. 所有股票入口都能跳转到股票详情。 7. 所有股票入口都能跳转到股票详情。
8. 游资详情页去掉动作时间线。 8. 游资详情页去掉动作时间线。
9. 游资详情页去掉净额重点和席位概览侧栏,保留单列股票列表和净额趋势筛选。

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>顶级游资监控系统</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -0,0 +1,12 @@
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="128" height="128" rx="26" fill="#101721"/>
<rect x="14" y="14" width="100" height="100" rx="22" fill="url(#paint0_linear)"/>
<path d="M30 84L47 55L61 70L82 38L98 84H87L78 60L61 83L47 67L39 84H30Z" fill="#F5EFE4"/>
<circle cx="90" cy="40" r="7" fill="#FF5D5D"/>
<defs>
<linearGradient id="paint0_linear" x1="14" y1="14" x2="114" y2="114" gradientUnits="userSpaceOnUse">
<stop stop-color="#1A2330"/>
<stop offset="1" stop-color="#0D1117"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 616 B

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, shallowRef } from 'vue' import { computed, onMounted, onUnmounted, shallowRef, watch } from 'vue'
import AppHero from './components/AppHero.vue' import AppHero from './components/AppHero.vue'
import HomeControlScreen from './components/HomeControlScreen.vue' import HomeControlScreen from './components/HomeControlScreen.vue'
@ -33,12 +33,25 @@ function syncPageFromHash() {
currentPage.value = pageFromHash() currentPage.value = pageFromHash()
} }
async function ensurePageData(page: PageKey) {
if (dashboard.isBooting.value) return
if (page === 'trader' && dashboard.selectedTraderId.value !== null) {
await dashboard.selectTrader(dashboard.selectedTraderId.value)
}
if (page === 'stock' && dashboard.selectedStockCode.value) {
await dashboard.selectStock(dashboard.selectedStockCode.value)
}
}
function navigate(page: PageKey) { function navigate(page: PageKey) {
const nextHash = `#/${page}` const nextHash = `#/${page}`
if (window.location.hash !== nextHash) { if (window.location.hash !== nextHash) {
window.location.hash = nextHash window.location.hash = nextHash
} }
currentPage.value = page currentPage.value = page
void ensurePageData(page)
} }
async function handleSelectTrader(traderId: number) { async function handleSelectTrader(traderId: number) {
@ -84,12 +97,19 @@ async function handleUnfollowStock(stockCode: string) {
onMounted(() => { onMounted(() => {
syncPageFromHash() syncPageFromHash()
window.addEventListener('hashchange', syncPageFromHash) window.addEventListener('hashchange', syncPageFromHash)
void dashboard.initialize() void (async () => {
await dashboard.initialize()
await ensurePageData(currentPage.value)
})()
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('hashchange', syncPageFromHash) window.removeEventListener('hashchange', syncPageFromHash)
}) })
watch(currentPage, (page) => {
void ensurePageData(page)
})
</script> </script>
<template> <template>

View 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>

View File

@ -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;

View File

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, shallowRef } from 'vue'
import type { TraderDetail, TraderListItem } from '../types' import type { TraderDetail, TraderListItem, TraderStock } from '../types'
import { formatDate, priceTone } from '../utils/format' import { compactMoney, formatDate, formatSignedWanAmount, priceTone } from '../utils/format'
const props = defineProps<{ const props = defineProps<{
traders: TraderListItem[] traders: TraderListItem[]
@ -15,31 +15,78 @@ const emit = defineEmits<{
selectStock: [stockCode: string] selectStock: [stockCode: string]
}>() }>()
const profileText = computed(() => { const selectedDateFilter = shallowRef('')
const stockNameFilter = shallowRef('')
const netGrowthFilter = shallowRef<'all' | 'increasing'>('all')
const sortKey = shallowRef<'last_trade_date' | 'total_net_amount_wan' | 'action_count'>('last_trade_date')
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 compactProfileText = computed(() => {
const detail = props.traderDetail const detail = props.traderDetail
if (!detail) return '等待选择游资。' if (!detail) return ''
const tags = detail.trader.style_tags?.join('') || '暂无风格标签' const tags = detail.trader.style_tags?.slice(0, 3).join(' / ') || '暂无标签'
return `${detail.trader.name} 当前风格聚焦 ${tags}。本页只保留游资档案和近期参与股票列表,动作时间线已移除。` const seatCount = detail.seats.length
const stockCount = detail.stocks.length
const warningCount = detail.stocks.filter((item) => item.has_sell_alert).length
return `${detail.trader.name} · ${tags} · ${seatCount}席位 · ${stockCount}股票 · ${warningCount}预警`
}) })
const summaryCards = computed(() => { const summaryCards = computed(() => {
const detail = props.traderDetail const detail = props.traderDetail
if (!detail) return [] if (!detail) return []
return [ return [
{ label: '近期参与股票数', value: detail.stocks.length, tone: '' }, { label: '股票数', value: detail.stocks.length, tone: '' },
{ { label: '卖出预警', value: detail.stocks.filter((item) => item.has_sell_alert).length, tone: 'danger' },
label: '当前卖出预警', { label: '慢流出', value: detail.stocks.filter((item) => item.has_slow_exit).length, tone: 'watch' },
value: detail.stocks.filter((item) => item.has_sell_alert).length, { label: '席位数', value: detail.seats.length, tone: 'focus' },
tone: 'danger',
},
{
label: '慢流出观察',
value: detail.stocks.filter((item) => item.has_slow_exit).length,
tone: 'watch',
},
{ label: '核心席位', value: detail.seats.length, tone: 'focus' },
] ]
}) })
const filteredStocks = computed(() => {
const detail = props.traderDetail
if (!detail) return []
const keyword = stockNameFilter.value.trim().toLowerCase()
const filtered = detail.stocks.filter((stock) => {
const matchName =
!keyword ||
stock.stock_name.toLowerCase().includes(keyword) ||
stock.stock_code.toLowerCase().includes(keyword)
const matchDate = !selectedDateFilter.value || stock.last_trade_date === selectedDateFilter.value
const matchNetGrowth = netGrowthFilter.value === 'all' || stock.is_net_amount_increasing
return matchName && matchDate && matchNetGrowth
})
const sorted = [...filtered]
sorted.sort((left, right) => {
if (sortKey.value === 'total_net_amount_wan') {
return (right.total_net_amount_wan ?? 0) - (left.total_net_amount_wan ?? 0)
}
if (sortKey.value === 'action_count') {
return right.action_count - left.action_count
}
return (right.last_trade_date || '').localeCompare(left.last_trade_date || '')
})
return sorted
})
const availableDates = computed(() => {
const detail = props.traderDetail
if (!detail) return []
return [...new Set(detail.stocks.map((item) => item.last_trade_date).filter(Boolean))] as string[]
})
function netTone(stock: TraderStock) {
return priceTone(String(stock.total_net_amount_wan ?? ''))
}
</script> </script>
<template> <template>
@ -48,7 +95,7 @@ const summaryCards = computed(() => {
<div> <div>
<p class="screen-kicker">02 Trader Detail</p> <p class="screen-kicker">02 Trader Detail</p>
<h2 class="screen-title"> <h2 class="screen-title">
游资详情 游资详情
<span v-if="traderDetail">· {{ traderDetail.trader.name }}</span> <span v-if="traderDetail">· {{ traderDetail.trader.name }}</span>
</h2> </h2>
</div> </div>
@ -67,76 +114,113 @@ const summaryCards = computed(() => {
</header> </header>
<div v-if="traderDetail" class="trader-layout"> <div v-if="traderDetail" class="trader-layout">
<div class="header-card"> <article class="profile-card compact">
<article class="profile-card"> <div class="profile-head compact">
<h3 class="profile-title"> <div class="profile-line">
{{ traderDetail.trader.name }} <h3 class="profile-title compact">
<span v-if="traderDetail.trader.alias_name"> {{ traderDetail.trader.name }}
/ {{ traderDetail.trader.alias_name }} <span v-if="traderDetail.trader.alias_name">/ {{ traderDetail.trader.alias_name }}</span>
</span> </h3>
</h3> <p class="profile-desc compact">{{ compactProfileText }}</p>
<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> </div>
</article>
<div class="summary-grid"> <div class="summary-inline">
<article <div
v-for="item in summaryCards" v-for="item in summaryCards"
:key="item.label" :key="item.label"
class="summary-box" class="summary-chip"
:class="item.tone" :class="item.tone"
> >
<p class="summary-label">{{ item.label }}</p> <span>{{ item.label }}</span>
<h3 class="summary-value">{{ item.value }}</h3> <strong>{{ item.value }}</strong>
</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>
<div>{{ stock.latest_price ?? '-' }}</div> </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> </div>
</article> </article>
<div class="content-grid">
<article class="card-panel stock-panel">
<div class="section-head">
<div>
<h3 class="section-title">股票列表</h3>
<p class="section-caption">支持时间名称净额趋势筛选并展示行业上市板块总市值和净额</p>
</div>
<span class="pill">{{ filteredStocks.length }} </span>
</div>
<div class="filter-bar">
<label class="filter-item">
<span>时间</span>
<select v-model="selectedDateFilter">
<option value="">全部时间</option>
<option v-for="date in availableDates" :key="date" :value="date">{{ date }}</option>
</select>
</label>
<label class="filter-item">
<span>名称</span>
<input v-model="stockNameFilter" type="text" placeholder="股票名称 / 代码">
</label>
<label class="filter-item">
<span>排序</span>
<select v-model="sortKey">
<option value="last_trade_date">按时间</option>
<option value="total_net_amount_wan">按净额</option>
<option value="action_count">按动作数</option>
</select>
</label>
<label class="filter-item">
<span>净额趋势</span>
<select v-model="netGrowthFilter">
<option value="all">全部趋势</option>
<option value="increasing">净额连续增大</option>
</select>
</label>
</div>
<div class="stock-table">
<button
v-for="stock in filteredStocks"
: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.industry || '行业待补充' }}</div>
<div>{{ inferBoardLabel(stock.stock_code) }}</div>
<div>{{ compactMoney(stock.total_market_value) }}</div>
<div>{{ stock.latest_price ?? '-' }}</div>
<div :class="priceTone(stock.pct_chg)">{{ stock.pct_chg ?? '-' }}</div>
<div class="net-value-cell" :class="netTone(stock)">{{ formatSignedWanAmount(stock.total_net_amount_wan) }}</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.is_net_amount_increasing" class="tag gold">净额递增</span>
<span v-if="!stock.has_sell_alert && !stock.has_slow_exit" class="tag blue">跟踪中</span>
</div>
</button>
</div>
</article>
</div>
</div> </div>
</section> </section>
</template> </template>
<style scoped> <style scoped>
.screen { .screen {
padding: 24px; display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 14px;
height: 100%;
min-height: 0;
padding: 16px;
border: 1px solid var(--color-line); border: 1px solid var(--color-line);
border-radius: 30px; border-radius: 30px;
background: linear-gradient(180deg, rgba(16, 23, 33, 0.96), rgba(9, 14, 21, 0.98)); background: linear-gradient(180deg, rgba(16, 23, 33, 0.96), rgba(9, 14, 21, 0.98));
@ -144,21 +228,22 @@ const summaryCards = computed(() => {
} }
.screen-head, .screen-head,
.section-head { .section-head,
.profile-head {
display: flex; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
} }
.screen-head { .screen-head {
align-items: center; margin-bottom: 0;
margin-bottom: 18px;
} }
.screen-kicker { .screen-kicker {
margin: 0 0 6px; margin: 0 0 6px;
color: var(--color-gold-soft); color: var(--color-gold-soft);
font-size: 12px; font-size: 11px;
letter-spacing: 0.22em; letter-spacing: 0.22em;
text-transform: uppercase; text-transform: uppercase;
} }
@ -171,30 +256,28 @@ const summaryCards = computed(() => {
.screen-title { .screen-title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 28px; font-size: 24px;
} }
.screen-toolbar, .screen-toolbar,
.chip-list,
.tag-group { .tag-group {
display: flex; display: flex;
gap: 10px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.pill, .pill,
.pill-button, .pill-button,
.chip,
.tag { .tag {
border-radius: 999px; border-radius: 999px;
} }
.pill, .pill,
.pill-button { .pill-button {
padding: 8px 12px; padding: 7px 10px;
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
color: var(--color-muted); color: var(--color-muted);
font-size: 12px; font-size: 11px;
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
} }
@ -208,97 +291,155 @@ const summaryCards = computed(() => {
border-color: transparent; border-color: transparent;
} }
.header-card { .trader-layout,
.content-grid,
.stock-table {
display: grid; display: grid;
grid-template-columns: 1.1fr 0.9fr; gap: 12px;
gap: 16px; min-height: 0;
margin-bottom: 16px; }
.trader-layout {
grid-template-rows: auto minmax(0, 1fr);
}
.content-grid {
grid-template-columns: minmax(0, 1fr);
} }
.profile-card, .profile-card,
.summary-box,
.card-panel, .card-panel,
.stock-row { .stock-row {
border: 1px solid rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 20px; border-radius: 18px;
}
.profile-card,
.card-panel {
padding: 14px;
background: rgba(255, 255, 255, 0.03);
} }
.profile-card { .profile-card {
padding: 20px; background: linear-gradient(135deg, rgba(212, 163, 92, 0.16), rgba(255, 255, 255, 0.02));
background: linear-gradient(135deg, rgba(212, 163, 92, 0.18), rgba(255, 255, 255, 0.02)); }
.profile-card.compact {
padding: 10px 14px;
} }
.profile-title { .profile-title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 34px; font-size: 22px;
} }
.profile-desc { .profile-title.compact {
margin: 10px 0 16px; font-size: 15px;
color: #d6ceb9; white-space: nowrap;
line-height: 1.8; }
.profile-desc,
.section-caption {
margin: 6px 0 0;
color: var(--color-muted);
font-size: 11px;
line-height: 1.6;
}
.profile-desc.compact {
margin: 0;
font-size: 10px;
line-height: 1.35;
}
.profile-head.compact {
gap: 10px;
}
.profile-line {
display: grid;
gap: 4px;
min-width: 0;
}
.summary-inline {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.summary-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
color: var(--color-muted);
font-size: 10px;
}
.summary-chip strong {
font-family: var(--font-mono);
font-size: 14px; font-size: 14px;
} }
.chip { .summary-chip.danger strong {
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); color: var(--color-red);
} }
.summary-box.watch .summary-value { .summary-chip.watch strong {
color: var(--color-orange); color: var(--color-orange);
} }
.summary-box.focus .summary-value { .summary-chip.focus strong {
color: var(--color-gold-soft); color: var(--color-gold-soft);
} }
.card-panel { .stock-panel {
padding: 18px; display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
}
.filter-bar {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.filter-item {
display: grid;
gap: 6px;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 14px;
background: rgba(255, 255, 255, 0.03); 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: 10px;
color: var(--color-text);
background: rgba(8, 12, 18, 0.95);
} }
.stock-table { .stock-table {
display: grid; overflow: auto;
gap: 10px; padding-right: 4px;
} }
.stock-row { .stock-row {
display: grid; display: grid;
grid-template-columns: 1.2fr 0.7fr 0.7fr 0.8fr 0.9fr 1fr; grid-template-columns: 1.45fr 0.75fr 0.92fr 0.9fr 0.72fr 0.72fr 0.95fr 0.9fr 1fr;
gap: 10px; gap: 10px;
width: 100%; width: 100%;
padding: 14px; padding: 12px;
align-items: center; align-items: center;
text-align: left; text-align: left;
background: rgba(255, 255, 255, 0.02); background: rgba(255, 255, 255, 0.02);
@ -309,21 +450,35 @@ const summaryCards = computed(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
min-width: 0;
}
.stock-core strong {
font-size: 14px;
} }
.stock-core span { .stock-core span {
color: var(--color-muted); color: var(--color-muted);
font-size: 12px; font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.net-value-cell {
font-family: var(--font-display);
font-size: 17px;
line-height: 1;
} }
.tag { .tag {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 74px; min-width: 66px;
padding: 5px 10px; padding: 5px 10px;
border: 1px solid transparent; border: 1px solid transparent;
font-size: 11px; font-size: 10px;
font-weight: 700; font-weight: 700;
} }
@ -339,6 +494,12 @@ const summaryCards = computed(() => {
border-color: rgba(255, 174, 66, 0.18); border-color: rgba(255, 174, 66, 0.18);
} }
.tag.gold {
color: var(--color-gold-soft);
background: rgba(212, 163, 92, 0.16);
border-color: rgba(212, 163, 92, 0.24);
}
.tag.blue { .tag.blue {
color: #acd8ff; color: #acd8ff;
background: rgba(90, 184, 255, 0.14); background: rgba(90, 184, 255, 0.14);
@ -357,15 +518,27 @@ const summaryCards = computed(() => {
color: var(--color-muted); color: var(--color-muted);
} }
@media (max-width: 1160px) { @media (max-width: 1280px) {
.header-card { .content-grid,
.filter-bar {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
}
@media (max-width: 760px) {
.stock-row { .stock-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.stock-table {
overflow: auto;
}
.profile-head.compact {
align-items: flex-start;
flex-direction: column;
}
.summary-inline {
justify-content: flex-start;
}
} }
</style> </style>

View File

@ -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>
@ -41,14 +48,14 @@ const countsByType = computed(() => {
<article class="filter-box"> <article class="filter-box">
<h3 class="section-title">游资筛选</h3> <h3 class="section-title">游资筛选</h3>
<div <div
v-for="trader in traders" v-for="trader in traders"
:key="trader.id" :key="trader.id"
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>
<article class="filter-box"> <article class="filter-box">
<h3 class="section-title">预警类型</h3> <h3 class="section-title">预警类型</h3>

View File

@ -172,11 +172,19 @@ export function useDashboardData() {
} }
async function selectTrader(traderId: number) { async function selectTrader(traderId: number) {
if (traderDetail.value?.trader.id === traderId) {
selectedTraderId.value = traderId
return
}
selectedTraderId.value = traderId selectedTraderId.value = traderId
traderDetail.value = await api<TraderDetail>(`/api/traders/${traderId}`) traderDetail.value = await api<TraderDetail>(`/api/traders/${traderId}`)
} }
async function selectStock(stockCode: string) { async function selectStock(stockCode: string) {
if (stockDetail.value?.stock.stock_code === stockCode) {
selectedStockCode.value = stockCode
return
}
selectedStockCode.value = stockCode selectedStockCode.value = stockCode
stockDetail.value = await api<StockDetail>(`/api/stocks/${encodeURIComponent(stockCode)}`) stockDetail.value = await api<StockDetail>(`/api/stocks/${encodeURIComponent(stockCode)}`)
} }
@ -218,13 +226,21 @@ export function useDashboardData() {
await loadActions() await loadActions()
if (traderResult[0]) { if (traderResult[0]) {
await selectTrader(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
await selectStock(preferredStockCode)
} }
} catch (error) { } catch (error) {
errorMessage.value = String(error instanceof Error ? error.message : error) errorMessage.value = String(error instanceof Error ? error.message : error)

View File

@ -72,8 +72,14 @@ export interface TraderStock {
last_trade_date: string | null last_trade_date: string | null
buy_action_count: number buy_action_count: number
sell_action_count: number sell_action_count: number
total_net_amount_wan?: number | null
has_sell_alert: number has_sell_alert: number
has_slow_exit: number has_slow_exit: number
is_net_amount_increasing?: boolean
industry?: string | null
market?: string | null
total_market_value?: number | null
circulating_market_value?: number | null
} }
export interface TraderDetail { export interface TraderDetail {

View File

@ -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'
} }