Initial commit: stock market platform
This commit is contained in:
11
frontend/Dockerfile
Normal file
11
frontend/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>股票行情平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
15
frontend/nginx.conf
Normal file
15
frontend/nginx.conf
Normal file
@@ -0,0 +1,15 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
4986
frontend/package-lock.json
generated
Normal file
4986
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
frontend/package.json
Normal file
37
frontend/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "stock-platform",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --ext ts,tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"antd": "^5.22.2",
|
||||
"@ant-design/icons": "^5.5.2",
|
||||
"@ant-design/pro-components": "^2.8.2",
|
||||
"echarts": "^5.5.1",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"lightweight-charts": "^4.2.0",
|
||||
"zustand": "^5.0.2",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"axios": "^1.7.9",
|
||||
"dayjs": "^1.11.13",
|
||||
"numeral": "^2.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/numeral": "^2.0.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.3",
|
||||
"eslint": "^9.15.0"
|
||||
}
|
||||
}
|
||||
45
frontend/src/App.tsx
Normal file
45
frontend/src/App.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useEffect } from "react";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import MainLayout from "@/components/Layout/MainLayout";
|
||||
import LoginPage from "@/pages/Login/LoginPage";
|
||||
import HomePage from "@/pages/Home/HomePage";
|
||||
import StockDetailPage from "@/pages/StockDetail/StockDetailPage";
|
||||
import WatchlistPage from "@/pages/Watchlist/WatchlistPage";
|
||||
import AlertsPage from "@/pages/Alerts/AlertsPage";
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const fetchMe = useAuthStore((s) => s.fetchMe);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMe();
|
||||
}, [fetchMe]);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<MainLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="stock/:symbol" element={<StockDetailPage />} />
|
||||
<Route path="watchlist" element={<WatchlistPage />} />
|
||||
<Route path="alerts" element={<AlertsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
141
frontend/src/components/Charts/HeatMap.tsx
Normal file
141
frontend/src/components/Charts/HeatMap.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import ReactECharts from "echarts-for-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { StockQuote } from "@/types";
|
||||
|
||||
interface HeatMapProps {
|
||||
data: StockQuote[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function getColor(pct: number): string {
|
||||
if (pct >= 9) return "#8b0000";
|
||||
if (pct >= 6) return "#c0392b";
|
||||
if (pct >= 3) return "#e74c3c";
|
||||
if (pct >= 1) return "#e57373";
|
||||
if (pct > 0) return "#ef9a9a";
|
||||
if (pct === 0) return "#4a4a4a";
|
||||
if (pct > -1) return "#80cbc4";
|
||||
if (pct > -3) return "#26a69a";
|
||||
if (pct > -6) return "#00897b";
|
||||
if (pct > -9) return "#00695c";
|
||||
return "#004d40";
|
||||
}
|
||||
|
||||
export default function HeatMap({ data, loading }: HeatMapProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Group by sector
|
||||
const sectorMap: Record<string, StockQuote[]> = {};
|
||||
for (const stock of data) {
|
||||
const sector = stock.sector || "其他";
|
||||
if (!sectorMap[sector]) sectorMap[sector] = [];
|
||||
sectorMap[sector].push(stock);
|
||||
}
|
||||
|
||||
// Build ECharts treemap structure
|
||||
const treeData = Object.entries(sectorMap)
|
||||
.sort((a, b) => b[1].length - a[1].length)
|
||||
.slice(0, 20)
|
||||
.map(([sector, stocks]) => ({
|
||||
name: sector,
|
||||
value: stocks.reduce((sum, s) => sum + Math.abs(s.amount || s.volume), 0),
|
||||
children: stocks
|
||||
.sort((a, b) => Math.abs(b.amount || 0) - Math.abs(a.amount || 0))
|
||||
.slice(0, 30)
|
||||
.map((s) => ({
|
||||
name: s.name,
|
||||
value: Math.abs(s.amount || s.volume) || 1,
|
||||
symbol: s.symbol,
|
||||
change_pct: s.change_pct,
|
||||
price: s.price,
|
||||
itemStyle: { color: getColor(s.change_pct) },
|
||||
label: {
|
||||
formatter: () => `${s.name}\n${s.change_pct >= 0 ? "+" : ""}${s.change_pct.toFixed(2)}%`,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
const option = {
|
||||
backgroundColor: "transparent",
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
formatter: (params: any) => {
|
||||
const d = params.data;
|
||||
if (!d.symbol) return d.name;
|
||||
return `
|
||||
<div style="min-width:120px">
|
||||
<div style="font-weight:600;margin-bottom:4px">${d.name}(${d.symbol})</div>
|
||||
<div>当前价:<b>${d.price?.toFixed(2)}</b></div>
|
||||
<div style="color:${d.change_pct >= 0 ? "#f03e3e" : "#00b368"}">
|
||||
涨跌幅:${d.change_pct >= 0 ? "+" : ""}${d.change_pct?.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "treemap",
|
||||
data: treeData,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
roam: false,
|
||||
nodeClick: "zoomToNode",
|
||||
breadcrumb: { show: false },
|
||||
levels: [
|
||||
{
|
||||
// Sector level
|
||||
itemStyle: {
|
||||
borderColor: "#0d1117",
|
||||
borderWidth: 2,
|
||||
gapWidth: 2,
|
||||
},
|
||||
upperLabel: {
|
||||
show: true,
|
||||
color: "#e6edf3",
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
backgroundColor: "rgba(0,0,0,0.4)",
|
||||
padding: [2, 6],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Stock level
|
||||
itemStyle: {
|
||||
borderColor: "#0d1117",
|
||||
borderWidth: 1,
|
||||
gapWidth: 1,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
color: "#fff",
|
||||
fontSize: 11,
|
||||
fontWeight: "bold",
|
||||
align: "center",
|
||||
verticalAlign: "middle",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const onEvents = {
|
||||
click: (params: any) => {
|
||||
if (params.data?.symbol) {
|
||||
navigate(`/stock/${params.data.symbol}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ReactECharts
|
||||
option={option}
|
||||
showLoading={loading}
|
||||
loadingOption={{ color: "#1677ff", textColor: "#8b949e", maskColor: "#0d111788" }}
|
||||
onEvents={onEvents}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
notMerge
|
||||
/>
|
||||
);
|
||||
}
|
||||
175
frontend/src/components/Charts/IntraDayChart.tsx
Normal file
175
frontend/src/components/Charts/IntraDayChart.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import ReactECharts from "echarts-for-react";
|
||||
import type { IntraDayBar } from "@/types";
|
||||
|
||||
interface IntraDayChartProps {
|
||||
data: IntraDayBar[];
|
||||
prevClose?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export default function IntraDayChart({ data, prevClose, height = 380 }: IntraDayChartProps) {
|
||||
if (data.length === 0) return null;
|
||||
|
||||
const times = data.map((d) => d.time.slice(-5));
|
||||
const prices = data.map((d) => d.price);
|
||||
const volumes = data.map((d) => d.volume);
|
||||
const avgPrices = data.map((d) => d.avg_price || d.price);
|
||||
|
||||
const minPrice = Math.min(...prices) * 0.998;
|
||||
const maxPrice = Math.max(...prices) * 1.002;
|
||||
|
||||
const baseline = prevClose || prices[0];
|
||||
|
||||
const option = {
|
||||
backgroundColor: "transparent",
|
||||
animation: false,
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: { type: "cross", lineStyle: { color: "#484f58" } },
|
||||
backgroundColor: "#161b22",
|
||||
borderColor: "#30363d",
|
||||
textStyle: { color: "#e6edf3", fontSize: 12 },
|
||||
formatter: (params: any[]) => {
|
||||
const time = params[0]?.axisValue;
|
||||
const price = params.find((p) => p.seriesName === "价格")?.data;
|
||||
const avg = params.find((p) => p.seriesName === "均价")?.data;
|
||||
const vol = params.find((p) => p.seriesName === "成交量")?.data;
|
||||
const pct = price != null && baseline ? (((price - baseline) / baseline) * 100).toFixed(2) : "-";
|
||||
return `
|
||||
<div style="min-width:140px;padding:4px">
|
||||
<div style="color:#8b949e;margin-bottom:4px">${time}</div>
|
||||
<div>价格:<b style="color:${Number(pct) >= 0 ? "#f03e3e" : "#00b368"}">${price?.toFixed(2)}</b></div>
|
||||
<div>均价:<b>${avg?.toFixed(2)}</b></div>
|
||||
<div>涨跌幅:<span style="color:${Number(pct) >= 0 ? "#f03e3e" : "#00b368"}">${Number(pct) >= 0 ? "+" : ""}${pct}%</span></div>
|
||||
<div>成交量:<b>${((vol || 0) / 100).toFixed(0)}手</b></div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
},
|
||||
axisPointer: { link: [{ xAxisIndex: "all" }] },
|
||||
grid: [
|
||||
{ left: 60, right: 60, top: 12, bottom: 80, height: "60%" },
|
||||
{ left: 60, right: 60, top: "72%", bottom: 24 },
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: "category",
|
||||
data: times,
|
||||
gridIndex: 0,
|
||||
axisLine: { lineStyle: { color: "#30363d" } },
|
||||
axisLabel: { color: "#8b949e", fontSize: 11 },
|
||||
splitLine: { show: false },
|
||||
boundaryGap: false,
|
||||
},
|
||||
{
|
||||
type: "category",
|
||||
data: times,
|
||||
gridIndex: 1,
|
||||
axisLine: { lineStyle: { color: "#30363d" } },
|
||||
axisLabel: { show: false },
|
||||
splitLine: { show: false },
|
||||
boundaryGap: false,
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: "value",
|
||||
min: minPrice,
|
||||
max: maxPrice,
|
||||
gridIndex: 0,
|
||||
axisLine: { show: false },
|
||||
axisLabel: {
|
||||
color: "#8b949e",
|
||||
fontSize: 11,
|
||||
formatter: (v: number) => v.toFixed(2),
|
||||
},
|
||||
splitLine: { lineStyle: { color: "#21262d" } },
|
||||
position: "right",
|
||||
},
|
||||
{
|
||||
type: "value",
|
||||
gridIndex: 0,
|
||||
min: minPrice,
|
||||
max: maxPrice,
|
||||
axisLabel: {
|
||||
color: "#8b949e",
|
||||
fontSize: 11,
|
||||
formatter: (v: number) => {
|
||||
if (!baseline) return "";
|
||||
const pct = ((v - baseline) / baseline) * 100;
|
||||
return (pct >= 0 ? "+" : "") + pct.toFixed(2) + "%";
|
||||
},
|
||||
},
|
||||
axisLine: { show: false },
|
||||
splitLine: { show: false },
|
||||
position: "left",
|
||||
},
|
||||
{
|
||||
type: "value",
|
||||
gridIndex: 1,
|
||||
axisLine: { show: false },
|
||||
axisLabel: { color: "#8b949e", fontSize: 10 },
|
||||
splitLine: { lineStyle: { color: "#21262d" } },
|
||||
position: "right",
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: "价格",
|
||||
type: "line",
|
||||
data: prices,
|
||||
xAxisIndex: 0,
|
||||
yAxisIndex: 0,
|
||||
lineStyle: { color: "#1677ff", width: 1.5 },
|
||||
symbol: "none",
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: "linear",
|
||||
x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: "#1677ff33" },
|
||||
{ offset: 1, color: "#1677ff00" },
|
||||
],
|
||||
},
|
||||
},
|
||||
markLine: baseline ? {
|
||||
silent: true,
|
||||
data: [{ yAxis: baseline }],
|
||||
lineStyle: { color: "#484f58", type: "dashed" },
|
||||
label: { show: false },
|
||||
} : undefined,
|
||||
},
|
||||
{
|
||||
name: "均价",
|
||||
type: "line",
|
||||
data: avgPrices,
|
||||
xAxisIndex: 0,
|
||||
yAxisIndex: 0,
|
||||
lineStyle: { color: "#ffd700", width: 1, type: "dashed" },
|
||||
symbol: "none",
|
||||
},
|
||||
{
|
||||
name: "成交量",
|
||||
type: "bar",
|
||||
data: volumes,
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 2,
|
||||
itemStyle: {
|
||||
color: (params: any) => {
|
||||
const p = prices[params.dataIndex];
|
||||
const prev = params.dataIndex > 0 ? prices[params.dataIndex - 1] : baseline;
|
||||
return p >= (prev || baseline) ? "#f03e3e66" : "#00b36866";
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<ReactECharts
|
||||
option={option}
|
||||
style={{ width: "100%", height }}
|
||||
notMerge
|
||||
/>
|
||||
);
|
||||
}
|
||||
112
frontend/src/components/Charts/KLineChart.tsx
Normal file
112
frontend/src/components/Charts/KLineChart.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createChart, ColorType, LineStyle, CrosshairMode } from "lightweight-charts";
|
||||
import type { KLineBar } from "@/types";
|
||||
|
||||
interface KLineChartProps {
|
||||
data: KLineBar[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export default function KLineChart({ data, height = 420 }: KLineChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || data.length === 0) return;
|
||||
|
||||
const chart = createChart(containerRef.current, {
|
||||
width: containerRef.current.clientWidth,
|
||||
height,
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: "#161b22" },
|
||||
textColor: "#8b949e",
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: "#21262d" },
|
||||
horzLines: { color: "#21262d" },
|
||||
},
|
||||
crosshair: {
|
||||
mode: CrosshairMode.Normal,
|
||||
vertLine: { color: "#484f58", labelBackgroundColor: "#21262d" },
|
||||
horzLine: { color: "#484f58", labelBackgroundColor: "#21262d" },
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: "#30363d",
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: "#30363d",
|
||||
timeVisible: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Candlestick series
|
||||
const candleSeries = chart.addCandlestickSeries({
|
||||
upColor: "#f03e3e",
|
||||
downColor: "#00b368",
|
||||
borderUpColor: "#f03e3e",
|
||||
borderDownColor: "#00b368",
|
||||
wickUpColor: "#f03e3e",
|
||||
wickDownColor: "#00b368",
|
||||
});
|
||||
|
||||
const candleData = data.map((d) => ({
|
||||
time: d.date as any,
|
||||
open: d.open,
|
||||
high: d.high,
|
||||
low: d.low,
|
||||
close: d.close,
|
||||
}));
|
||||
candleSeries.setData(candleData);
|
||||
|
||||
// Volume histogram
|
||||
const volumeSeries = chart.addHistogramSeries({
|
||||
color: "#26a69a",
|
||||
priceFormat: { type: "volume" },
|
||||
priceScaleId: "volume",
|
||||
});
|
||||
|
||||
chart.priceScale("volume").applyOptions({
|
||||
scaleMargins: { top: 0.8, bottom: 0 },
|
||||
});
|
||||
|
||||
const volumeData = data.map((d) => ({
|
||||
time: d.date as any,
|
||||
value: d.volume,
|
||||
color: d.close >= d.open ? "#f03e3e44" : "#00b36844",
|
||||
}));
|
||||
volumeSeries.setData(volumeData);
|
||||
|
||||
// MA lines
|
||||
const maColors = { 5: "#ffd700", 10: "#ff69b4", 20: "#00bfff", 60: "#ffa500" };
|
||||
for (const [period, color] of Object.entries(maColors)) {
|
||||
const p = Number(period);
|
||||
const maSeries = chart.addLineSeries({
|
||||
color,
|
||||
lineWidth: 1,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false,
|
||||
});
|
||||
const maData = [];
|
||||
for (let i = p - 1; i < data.length; i++) {
|
||||
const avg = data.slice(i - p + 1, i + 1).reduce((s, d) => s + d.close, 0) / p;
|
||||
maData.push({ time: data[i].date as any, value: parseFloat(avg.toFixed(3)) });
|
||||
}
|
||||
maSeries.setData(maData);
|
||||
}
|
||||
|
||||
chart.timeScale().fitContent();
|
||||
|
||||
const handleResize = () => {
|
||||
if (containerRef.current) {
|
||||
chart.applyOptions({ width: containerRef.current.clientWidth });
|
||||
}
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
chart.remove();
|
||||
};
|
||||
}, [data, height]);
|
||||
|
||||
return <div ref={containerRef} style={{ width: "100%", height }} />;
|
||||
}
|
||||
250
frontend/src/components/Layout/MainLayout.tsx
Normal file
250
frontend/src/components/Layout/MainLayout.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { Outlet, useNavigate, useLocation, Link } from "react-router-dom";
|
||||
import { Layout, Menu, Input, Button, Badge, Avatar, Dropdown, Space, Tag } from "antd";
|
||||
import {
|
||||
LineChartOutlined,
|
||||
StarOutlined,
|
||||
BellOutlined,
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
SearchOutlined,
|
||||
HomeOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { stockService } from "@/services/stocks";
|
||||
import type { MarketIndex } from "@/types";
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
function IndexBar() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ["market-overview"],
|
||||
queryFn: stockService.getMarketOverview,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
return (
|
||||
<Space size={24} style={{ marginLeft: 24 }}>
|
||||
{(data || []).slice(0, 3).map((idx: MarketIndex) => (
|
||||
<span key={idx.index_code} style={{ fontSize: 12 }}>
|
||||
<span style={{ color: "#8b949e", marginRight: 4 }}>{idx.index_name}</span>
|
||||
<span style={{ color: idx.change_pct >= 0 ? "#f03e3e" : "#00b368", fontWeight: 600 }}>
|
||||
{idx.current.toFixed(2)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: idx.change_pct >= 0 ? "#f03e3e" : "#00b368",
|
||||
marginLeft: 4,
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{idx.change_pct >= 0 ? "+" : ""}
|
||||
{idx.change_pct.toFixed(2)}%
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MainLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuthStore();
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<
|
||||
{ symbol: string; name: string }[]
|
||||
>([]);
|
||||
|
||||
const handleSearch = useCallback(async (value: string) => {
|
||||
if (!value.trim()) return setSearchResults([]);
|
||||
const results = await stockService.searchStocks(value);
|
||||
setSearchResults(results.slice(0, 8));
|
||||
}, []);
|
||||
|
||||
const selectedKey =
|
||||
location.pathname === "/" ? "home"
|
||||
: location.pathname.startsWith("/stock") ? "home"
|
||||
: location.pathname.startsWith("/watchlist") ? "watchlist"
|
||||
: location.pathname.startsWith("/alerts") ? "alerts"
|
||||
: "home";
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: "100vh", background: "#0d1117" }}>
|
||||
{/* Header */}
|
||||
<Header
|
||||
style={{
|
||||
background: "#161b22",
|
||||
borderBottom: "1px solid #30363d",
|
||||
padding: "0 16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 100,
|
||||
height: 52,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
to="/"
|
||||
style={{
|
||||
color: "#58a6ff",
|
||||
fontWeight: 700,
|
||||
fontSize: 16,
|
||||
textDecoration: "none",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
股票行情
|
||||
</Link>
|
||||
|
||||
<IndexBar />
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Search */}
|
||||
<div style={{ position: "relative" }}>
|
||||
<Input
|
||||
placeholder="搜索股票代码/名称..."
|
||||
prefix={<SearchOutlined style={{ color: "#8b949e" }} />}
|
||||
value={searchValue}
|
||||
style={{ width: 220, background: "#0d1117", border: "1px solid #30363d" }}
|
||||
onChange={(e) => {
|
||||
setSearchValue(e.target.value);
|
||||
handleSearch(e.target.value);
|
||||
}}
|
||||
onPressEnter={() => {
|
||||
const first = searchResults[0];
|
||||
if (first) {
|
||||
navigate(`/stock/${first.symbol}`);
|
||||
setSearchValue("");
|
||||
setSearchResults([]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{searchResults.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "100%",
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: "#161b22",
|
||||
border: "1px solid #30363d",
|
||||
borderRadius: 6,
|
||||
zIndex: 200,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{searchResults.map((r) => (
|
||||
<div
|
||||
key={r.symbol}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
onClick={() => {
|
||||
navigate(`/stock/${r.symbol}`);
|
||||
setSearchValue("");
|
||||
setSearchResults([]);
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
((e.currentTarget as HTMLDivElement).style.background = "#21262d")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
((e.currentTarget as HTMLDivElement).style.background = "transparent")
|
||||
}
|
||||
>
|
||||
<span style={{ color: "#e6edf3", fontSize: 13 }}>{r.name}</span>
|
||||
<Tag color="default" style={{ fontSize: 11 }}>{r.symbol}</Tag>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Space style={{ marginLeft: 16 }}>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: "logout",
|
||||
icon: <LogoutOutlined />,
|
||||
label: "退出登录",
|
||||
onClick: () => {
|
||||
logout();
|
||||
navigate("/login");
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Space style={{ cursor: "pointer", color: "#e6edf3" }}>
|
||||
<Avatar size={28} icon={<UserOutlined />} style={{ background: "#1f6feb" }} />
|
||||
<span style={{ fontSize: 13 }}>{user?.username}</span>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Header>
|
||||
|
||||
<Layout style={{ marginTop: 52 }}>
|
||||
{/* Sider */}
|
||||
<Sider
|
||||
width={180}
|
||||
style={{
|
||||
background: "#161b22",
|
||||
borderRight: "1px solid #30363d",
|
||||
position: "fixed",
|
||||
top: 52,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
style={{ background: "transparent", border: "none", marginTop: 8 }}
|
||||
items={[
|
||||
{
|
||||
key: "home",
|
||||
icon: <HomeOutlined />,
|
||||
label: "大盘行情",
|
||||
onClick: () => navigate("/"),
|
||||
},
|
||||
{
|
||||
key: "watchlist",
|
||||
icon: <StarOutlined />,
|
||||
label: "自选股",
|
||||
onClick: () => navigate("/watchlist"),
|
||||
},
|
||||
{
|
||||
key: "alerts",
|
||||
icon: <BellOutlined />,
|
||||
label: "价格预警",
|
||||
onClick: () => navigate("/alerts"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Sider>
|
||||
|
||||
{/* Content */}
|
||||
<Content
|
||||
style={{
|
||||
marginLeft: 180,
|
||||
padding: 16,
|
||||
minHeight: "calc(100vh - 52px)",
|
||||
background: "#0d1117",
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
48
frontend/src/hooks/useWebSocket.ts
Normal file
48
frontend/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
|
||||
type MessageHandler = (data: unknown) => void;
|
||||
|
||||
export function useWebSocket(url: string, onMessage: MessageHandler) {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
const token = localStorage.getItem("access_token");
|
||||
const fullUrl = `${url}${token ? `?token=${token}` : ""}`;
|
||||
const ws = new WebSocket(fullUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
onMessage(data);
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (mountedRef.current) {
|
||||
reconnectTimer.current = setTimeout(connect, 3000) as ReturnType<typeof setTimeout>;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
}, [url, onMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [connect]);
|
||||
}
|
||||
37
frontend/src/index.css
Normal file
37
frontend/src/index.css
Normal file
@@ -0,0 +1,37 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #161b22;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #30363d;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #484f58;
|
||||
}
|
||||
|
||||
.up-color {
|
||||
color: #f03e3e;
|
||||
}
|
||||
.down-color {
|
||||
color: #00b368;
|
||||
}
|
||||
.flat-color {
|
||||
color: #8b949e;
|
||||
}
|
||||
35
frontend/src/main.tsx
Normal file
35
frontend/src/main.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ConfigProvider, theme } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
algorithm: theme.darkAlgorithm,
|
||||
token: {
|
||||
colorPrimary: "#1677ff",
|
||||
borderRadius: 6,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</ConfigProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
191
frontend/src/pages/Alerts/AlertsPage.tsx
Normal file
191
frontend/src/pages/Alerts/AlertsPage.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Table, Button, Card, Typography, Modal, Form, Input, Select, InputNumber,
|
||||
Switch, Popconfirm, message, Tag, Empty,
|
||||
} from "antd";
|
||||
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { stockService } from "@/services/stocks";
|
||||
import type { Alert } from "@/types";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const ALERT_TYPES = [
|
||||
{ value: "price_above", label: "价格高于" },
|
||||
{ value: "price_below", label: "价格低于" },
|
||||
{ value: "change_pct_above", label: "涨幅超过" },
|
||||
{ value: "change_pct_below", label: "跌幅超过" },
|
||||
];
|
||||
|
||||
export default function AlertsPage() {
|
||||
const qc = useQueryClient();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data: alerts = [], isLoading } = useQuery({
|
||||
queryKey: ["alerts"],
|
||||
queryFn: stockService.getAlerts,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: stockService.createAlert,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["alerts"] });
|
||||
message.success("预警已创建");
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
},
|
||||
onError: () => message.error("创建失败"),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: stockService.deleteAlert,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["alerts"] });
|
||||
message.success("已删除");
|
||||
},
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: stockService.toggleAlert,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["alerts"] }),
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "股票",
|
||||
key: "stock",
|
||||
render: (_: unknown, record: Alert) => (
|
||||
<span>
|
||||
<span style={{ color: "#e6edf3", fontWeight: 600 }}>{record.name}</span>
|
||||
<Tag style={{ marginLeft: 8, fontSize: 11 }} color="default">
|
||||
{record.symbol}
|
||||
</Tag>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "预警类型",
|
||||
dataIndex: "alert_type",
|
||||
key: "alert_type",
|
||||
render: (v: string) => {
|
||||
const t = ALERT_TYPES.find((t) => t.value === v);
|
||||
return <Tag color="blue">{t?.label || v}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "阈值",
|
||||
dataIndex: "threshold",
|
||||
key: "threshold",
|
||||
render: (v: number, record: Alert) => (
|
||||
<span style={{ color: "#e6edf3", fontWeight: 600 }}>
|
||||
{v}
|
||||
{record.alert_type.includes("pct") ? "%" : "元"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
render: (_: unknown, record: Alert) => {
|
||||
if (record.triggered) return <Tag color="warning">已触发</Tag>;
|
||||
return record.is_active ? <Tag color="success">监控中</Tag> : <Tag>已暂停</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "启用",
|
||||
key: "active",
|
||||
render: (_: unknown, record: Alert) => (
|
||||
<Switch
|
||||
checked={record.is_active}
|
||||
size="small"
|
||||
onChange={() => toggleMutation.mutate(record.id)}
|
||||
disabled={record.triggered}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
render: (_: unknown, record: Alert) => (
|
||||
<Popconfirm
|
||||
title="确认删除该预警?"
|
||||
onConfirm={() => deleteMutation.mutate(record.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
danger
|
||||
/>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<Title level={4} style={{ margin: 0, color: "#e6edf3" }}>
|
||||
价格预警
|
||||
</Title>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setModalOpen(true)}
|
||||
>
|
||||
新建预警
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
style={{ background: "#161b22", border: "1px solid #30363d" }}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={alerts}
|
||||
loading={isLoading}
|
||||
pagination={{ pageSize: 20 }}
|
||||
locale={{ emptyText: <Empty description="暂无预警" /> }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="新建价格预警"
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={createMutation.isPending}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
styles={{ content: { background: "#161b22" }, header: { background: "#161b22" } }}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={(values) => createMutation.mutate(values)}
|
||||
>
|
||||
<Form.Item name="symbol" label="股票代码" rules={[{ required: true }]}>
|
||||
<Input placeholder="如:600519" />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="股票名称" rules={[{ required: true }]}>
|
||||
<Input placeholder="如:贵州茅台" />
|
||||
</Form.Item>
|
||||
<Form.Item name="alert_type" label="预警类型" rules={[{ required: true }]}>
|
||||
<Select options={ALERT_TYPES} placeholder="选择预警类型" />
|
||||
</Form.Item>
|
||||
<Form.Item name="threshold" label="阈值" rules={[{ required: true }]}>
|
||||
<InputNumber
|
||||
style={{ width: "100%" }}
|
||||
placeholder="输入阈值(价格填元,涨跌幅填%数字)"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
frontend/src/pages/Home/HomePage.tsx
Normal file
153
frontend/src/pages/Home/HomePage.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Row, Col, Card, Tag, Spin, Space } from "antd";
|
||||
import { SyncOutlined } from "@ant-design/icons";
|
||||
import { stockService } from "@/services/stocks";
|
||||
import { useWebSocket } from "@/hooks/useWebSocket";
|
||||
import HeatMap from "@/components/Charts/HeatMap";
|
||||
import type { StockQuote, MarketIndex } from "@/types";
|
||||
|
||||
function IndexCard({ idx }: { idx: MarketIndex }) {
|
||||
const isUp = idx.change_pct > 0;
|
||||
const isDown = idx.change_pct < 0;
|
||||
const color = isUp ? "#f03e3e" : isDown ? "#00b368" : "#8b949e";
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
style={{ background: "#161b22", border: "1px solid #30363d", borderRadius: 8 }}
|
||||
bodyStyle={{ padding: "10px 14px" }}
|
||||
>
|
||||
<div style={{ color: "#8b949e", fontSize: 12 }}>{idx.index_name}</div>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 8, marginTop: 2 }}>
|
||||
<span style={{ color, fontSize: 20, fontWeight: 700 }}>{idx.current.toFixed(2)}</span>
|
||||
<span style={{ color, fontSize: 12 }}>
|
||||
{idx.change_pct >= 0 ? "+" : ""}{idx.change_pct.toFixed(2)}%
|
||||
</span>
|
||||
<span style={{ color, fontSize: 12 }}>
|
||||
{idx.change >= 0 ? "+" : ""}{idx.change.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [heatmapData, setHeatmapData] = useState<StockQuote[]>([]);
|
||||
|
||||
const { data: overview } = useQuery<MarketIndex[]>({
|
||||
queryKey: ["market-overview"],
|
||||
queryFn: stockService.getMarketOverview,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const { data: initialHeatmap, isLoading: heatmapLoading } = useQuery<StockQuote[]>({
|
||||
queryKey: ["market-heatmap"],
|
||||
queryFn: stockService.getHeatmapData,
|
||||
});
|
||||
|
||||
// Sync initial data into state
|
||||
if (initialHeatmap && heatmapData.length === 0) {
|
||||
setHeatmapData(initialHeatmap);
|
||||
}
|
||||
|
||||
// Real-time WebSocket updates
|
||||
const wsProto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsHost = window.location.host;
|
||||
|
||||
const handleWsMessage = useCallback((msg: unknown) => {
|
||||
const m = msg as { type: string; data: StockQuote[] };
|
||||
if (m.type === "heatmap" && Array.isArray(m.data)) {
|
||||
setHeatmapData(m.data);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useWebSocket(`${wsProto}//${wsHost}/ws/heatmap`, handleWsMessage);
|
||||
|
||||
const displayData: StockQuote[] = heatmapData.length > 0 ? heatmapData : (initialHeatmap ?? []);
|
||||
|
||||
const upCount = displayData.filter((s) => s.change_pct > 0).length;
|
||||
const downCount = displayData.filter((s) => s.change_pct < 0).length;
|
||||
const flatCount = displayData.filter((s) => s.change_pct === 0).length;
|
||||
|
||||
const COLOR_LEGEND = [
|
||||
{ label: "+9%", color: "#8b0000" },
|
||||
{ label: "+6%", color: "#c0392b" },
|
||||
{ label: "+3%", color: "#e74c3c" },
|
||||
{ label: "+1%", color: "#e57373" },
|
||||
{ label: "0%", color: "#4a4a4a" },
|
||||
{ label: "-1%", color: "#80cbc4" },
|
||||
{ label: "-3%", color: "#26a69a" },
|
||||
{ label: "-6%", color: "#00897b" },
|
||||
{ label: "-9%", color: "#004d40" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12, height: "calc(100vh - 84px)" }}>
|
||||
{/* Index bar */}
|
||||
<Row gutter={12} wrap={false}>
|
||||
{(overview ?? []).map((idx) => (
|
||||
<Col key={idx.index_code} flex="1 1 0">
|
||||
<IndexCard idx={idx} />
|
||||
</Col>
|
||||
))}
|
||||
<Col style={{ display: "flex", alignItems: "center", gap: 8, flexShrink: 0 }}>
|
||||
<Tag color="red" style={{ marginRight: 0 }}>涨 {upCount}</Tag>
|
||||
<Tag color="default" style={{ marginRight: 0 }}>平 {flatCount}</Tag>
|
||||
<Tag color="green" style={{ marginRight: 0 }}>跌 {downCount}</Tag>
|
||||
{heatmapLoading && <SyncOutlined spin style={{ color: "#1677ff", fontSize: 14 }} />}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Heatmap */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
background: "#161b22",
|
||||
border: "1px solid #30363d",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
borderBottom: "1px solid #30363d",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "#e6edf3", fontWeight: 600, fontSize: 14 }}>大盘云图</span>
|
||||
<span style={{ color: "#8b949e", fontSize: 12 }}>A股实时涨跌幅 · 按成交额加权</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Space size={6}>
|
||||
{COLOR_LEGEND.map(({ label, color }) => (
|
||||
<span key={label} style={{ fontSize: 10, color: "#8b949e", display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 10,
|
||||
height: 10,
|
||||
background: color,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<HeatMap data={displayData} loading={heatmapLoading && displayData.length === 0} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
frontend/src/pages/Login/LoginPage.tsx
Normal file
188
frontend/src/pages/Login/LoginPage.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { Form, Input, Button, Card, Tabs, message, Typography } from "antd";
|
||||
import { UserOutlined, LockOutlined, MailOutlined } from "@ant-design/icons";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { authService } from "@/services/auth";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { login, loading } = useAuthStore();
|
||||
const [activeTab, setActiveTab] = useState("login");
|
||||
const [registerLoading, setRegisterLoading] = useState(false);
|
||||
|
||||
const handleLogin = async (values: { username: string; password: string }) => {
|
||||
try {
|
||||
await login(values.username, values.password);
|
||||
message.success("登录成功");
|
||||
navigate("/");
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.detail || "登录失败,请检查用户名和密码");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async (values: {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirm: string;
|
||||
}) => {
|
||||
if (values.password !== values.confirm) {
|
||||
return message.error("两次密码不一致");
|
||||
}
|
||||
setRegisterLoading(true);
|
||||
try {
|
||||
await authService.register(values.username, values.email, values.password);
|
||||
message.success("注册成功,请登录");
|
||||
setActiveTab("login");
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.detail || "注册失败");
|
||||
} finally {
|
||||
setRegisterLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
background: "#0d1117",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 400 }}>
|
||||
<div style={{ textAlign: "center", marginBottom: 32 }}>
|
||||
<Title level={2} style={{ color: "#58a6ff", marginBottom: 4 }}>
|
||||
股票行情平台
|
||||
</Title>
|
||||
<Text type="secondary">实时行情 · K线分析 · 自选预警</Text>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
style={{
|
||||
background: "#161b22",
|
||||
border: "1px solid #30363d",
|
||||
borderRadius: 12,
|
||||
}}
|
||||
bodyStyle={{ padding: 32 }}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
centered
|
||||
items={[
|
||||
{
|
||||
key: "login",
|
||||
label: "登录",
|
||||
children: (
|
||||
<Form layout="vertical" onFinish={handleLogin} autoComplete="off">
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: "请输入用户名" }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="用户名"
|
||||
size="large"
|
||||
style={{ background: "#0d1117", border: "1px solid #30363d" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: "请输入密码" }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="密码"
|
||||
size="large"
|
||||
style={{ background: "#0d1117", border: "1px solid #30363d" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
block
|
||||
loading={loading}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "register",
|
||||
label: "注册",
|
||||
children: (
|
||||
<Form layout="vertical" onFinish={handleRegister} autoComplete="off">
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: "请输入用户名" }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="用户名(3-20位字母数字)"
|
||||
size="large"
|
||||
style={{ background: "#0d1117", border: "1px solid #30363d" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="email"
|
||||
rules={[{ required: true, type: "email", message: "请输入有效邮箱" }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<MailOutlined />}
|
||||
placeholder="邮箱"
|
||||
size="large"
|
||||
style={{ background: "#0d1117", border: "1px solid #30363d" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, min: 6, message: "密码至少6位" }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="密码(至少6位)"
|
||||
size="large"
|
||||
style={{ background: "#0d1117", border: "1px solid #30363d" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirm"
|
||||
rules={[{ required: true, message: "请确认密码" }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="确认密码"
|
||||
size="large"
|
||||
style={{ background: "#0d1117", border: "1px solid #30363d" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
block
|
||||
loading={registerLoading}
|
||||
>
|
||||
注册
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
247
frontend/src/pages/StockDetail/StockDetailPage.tsx
Normal file
247
frontend/src/pages/StockDetail/StockDetailPage.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Row, Col, Card, Button, Tag, Spin, Tooltip, message, Statistic, Space, Segmented,
|
||||
} from "antd";
|
||||
import {
|
||||
StarOutlined, StarFilled, BellOutlined, ArrowLeftOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { stockService } from "@/services/stocks";
|
||||
import { useWebSocket } from "@/hooks/useWebSocket";
|
||||
import KLineChart from "@/components/Charts/KLineChart";
|
||||
import IntraDayChart from "@/components/Charts/IntraDayChart";
|
||||
import type { StockQuote, ChartType } from "@/types";
|
||||
|
||||
const CHART_OPTIONS: { label: string; value: ChartType }[] = [
|
||||
{ label: "分时", value: "intraday" },
|
||||
{ label: "五日", value: "fiveday" },
|
||||
{ label: "日K", value: "daily" },
|
||||
{ label: "周K", value: "weekly" },
|
||||
{ label: "月K", value: "monthly" },
|
||||
];
|
||||
|
||||
export default function StockDetailPage() {
|
||||
const { symbol = "" } = useParams<{ symbol: string }>();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [chartType, setChartType] = useState<ChartType>("daily");
|
||||
const [liveQuote, setLiveQuote] = useState<StockQuote | null>(null);
|
||||
|
||||
// Quote
|
||||
const { data: quote, isLoading: quoteLoading } = useQuery({
|
||||
queryKey: ["quote", symbol],
|
||||
queryFn: () => stockService.getQuote(symbol),
|
||||
enabled: !!symbol,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const displayQuote = liveQuote || quote;
|
||||
|
||||
// Chart data
|
||||
const { data: intraDayData = [], isLoading: intraDayLoading } = useQuery({
|
||||
queryKey: ["intraday", symbol],
|
||||
queryFn: () => stockService.getIntraday(symbol),
|
||||
enabled: !!symbol && chartType === "intraday",
|
||||
});
|
||||
|
||||
const { data: fiveDayData = [], isLoading: fiveDayLoading } = useQuery({
|
||||
queryKey: ["fiveday", symbol],
|
||||
queryFn: () => stockService.getFiveDay(symbol),
|
||||
enabled: !!symbol && chartType === "fiveday",
|
||||
});
|
||||
|
||||
const { data: klineData = [], isLoading: klineLoading } = useQuery({
|
||||
queryKey: ["kline", symbol, chartType],
|
||||
queryFn: () =>
|
||||
stockService.getKLine(symbol, chartType === "daily" ? "daily" : chartType === "weekly" ? "weekly" : "monthly"),
|
||||
enabled: !!symbol && ["daily", "weekly", "monthly"].includes(chartType),
|
||||
});
|
||||
|
||||
// Watchlist status
|
||||
const { data: watchlist = [] } = useQuery({
|
||||
queryKey: ["watchlist"],
|
||||
queryFn: stockService.getWatchlist,
|
||||
});
|
||||
const isWatched = watchlist.some((w) => w.symbol === symbol);
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () => stockService.addToWatchlist(symbol, displayQuote?.name || symbol),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["watchlist"] });
|
||||
message.success("已加入自选股");
|
||||
},
|
||||
onError: () => message.error("添加失败"),
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: () => stockService.removeFromWatchlist(symbol),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["watchlist"] });
|
||||
message.success("已移除自选股");
|
||||
},
|
||||
});
|
||||
|
||||
// WebSocket real-time quote
|
||||
const wsOrigin = window.location.protocol === "https:" ? "wss://" : "ws://";
|
||||
const wsHost = window.location.host;
|
||||
|
||||
const handleWsMessage = useCallback((msg: unknown) => {
|
||||
const m = msg as { type: string; symbol: string; data: StockQuote };
|
||||
if (m.type === "quote" && m.symbol === symbol) {
|
||||
setLiveQuote(m.data);
|
||||
}
|
||||
}, [symbol]);
|
||||
|
||||
useWebSocket(`${wsOrigin}${wsHost}/ws/quote/${symbol}`, handleWsMessage);
|
||||
|
||||
const isUp = (displayQuote?.change_pct || 0) > 0;
|
||||
const isDown = (displayQuote?.change_pct || 0) < 0;
|
||||
const priceColor = isUp ? "#f03e3e" : isDown ? "#00b368" : "#8b949e";
|
||||
|
||||
const chartLoading =
|
||||
chartType === "intraday" ? intraDayLoading
|
||||
: chartType === "fiveday" ? fiveDayLoading
|
||||
: klineLoading;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{/* Header */}
|
||||
<Card
|
||||
size="small"
|
||||
style={{ background: "#161b22", border: "1px solid #30363d" }}
|
||||
bodyStyle={{ padding: "12px 16px" }}
|
||||
>
|
||||
<Row align="middle" gutter={16} wrap={false}>
|
||||
<Col>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate(-1)}
|
||||
style={{ color: "#8b949e" }}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
{quoteLoading ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 8 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 700, color: "#e6edf3" }}>
|
||||
{displayQuote?.name || symbol}
|
||||
</span>
|
||||
<Tag color="default" style={{ fontSize: 12 }}>
|
||||
{symbol}
|
||||
</Tag>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
<Col>
|
||||
<span style={{ fontSize: 28, fontWeight: 700, color: priceColor }}>
|
||||
{displayQuote?.price?.toFixed(2) ?? "--"}
|
||||
</span>
|
||||
<span style={{ marginLeft: 10, color: priceColor, fontSize: 14 }}>
|
||||
{(displayQuote?.change_pct || 0) >= 0 ? "+" : ""}
|
||||
{displayQuote?.change_pct?.toFixed(2) ?? "--"}%
|
||||
</span>
|
||||
<span style={{ marginLeft: 6, color: priceColor, fontSize: 14 }}>
|
||||
{(displayQuote?.change || 0) >= 0 ? "+" : ""}
|
||||
{displayQuote?.change?.toFixed(2) ?? "--"}
|
||||
</span>
|
||||
</Col>
|
||||
|
||||
<Col flex="auto" />
|
||||
|
||||
<Col>
|
||||
<Space>
|
||||
<Button
|
||||
icon={isWatched ? <StarFilled style={{ color: "#ffd700" }} /> : <StarOutlined />}
|
||||
onClick={() => isWatched ? removeMutation.mutate() : addMutation.mutate()}
|
||||
style={{
|
||||
background: "#21262d",
|
||||
border: "1px solid #30363d",
|
||||
color: isWatched ? "#ffd700" : "#8b949e",
|
||||
}}
|
||||
>
|
||||
{isWatched ? "已自选" : "加自选"}
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Key stats */}
|
||||
<Row gutter={16} style={{ marginTop: 12 }}>
|
||||
{[
|
||||
{ label: "今开", value: displayQuote?.open?.toFixed(2) ?? "--" },
|
||||
{ label: "最高", value: displayQuote?.high?.toFixed(2) ?? "--" },
|
||||
{ label: "最低", value: displayQuote?.low?.toFixed(2) ?? "--" },
|
||||
{ label: "昨收", value: displayQuote?.prev_close?.toFixed(2) ?? "--" },
|
||||
{
|
||||
label: "成交量",
|
||||
value: displayQuote?.volume
|
||||
? (displayQuote.volume / 10000).toFixed(0) + "万手"
|
||||
: "--",
|
||||
},
|
||||
{
|
||||
label: "成交额",
|
||||
value: displayQuote?.amount
|
||||
? (displayQuote.amount / 100000000).toFixed(2) + "亿"
|
||||
: "--",
|
||||
},
|
||||
].map(({ label, value }) => (
|
||||
<Col key={label}>
|
||||
<span style={{ color: "#8b949e", fontSize: 12 }}>{label} </span>
|
||||
<span style={{ color: "#e6edf3", fontSize: 12, fontWeight: 600 }}>{value}</span>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Chart */}
|
||||
<Card
|
||||
style={{ background: "#161b22", border: "1px solid #30363d" }}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 16px",
|
||||
borderBottom: "1px solid #30363d",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Segmented
|
||||
value={chartType}
|
||||
onChange={(v) => setChartType(v as ChartType)}
|
||||
options={CHART_OPTIONS}
|
||||
style={{ background: "#21262d" }}
|
||||
/>
|
||||
{chartLoading && <Spin size="small" />}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "8px 8px 0" }}>
|
||||
{chartType === "intraday" && (
|
||||
<IntraDayChart
|
||||
data={intraDayData}
|
||||
prevClose={displayQuote?.prev_close}
|
||||
height={420}
|
||||
/>
|
||||
)}
|
||||
{chartType === "fiveday" && (
|
||||
<IntraDayChart
|
||||
data={fiveDayData}
|
||||
prevClose={displayQuote?.prev_close}
|
||||
height={420}
|
||||
/>
|
||||
)}
|
||||
{["daily", "weekly", "monthly"].includes(chartType) && (
|
||||
<KLineChart data={klineData} height={420} />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
frontend/src/pages/Watchlist/WatchlistPage.tsx
Normal file
168
frontend/src/pages/Watchlist/WatchlistPage.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Table, Button, Tag, Card, Typography, Popconfirm, message, Empty } from "antd";
|
||||
import { DeleteOutlined, LineChartOutlined } from "@ant-design/icons";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { stockService } from "@/services/stocks";
|
||||
import type { WatchlistItem, StockQuote } from "@/types";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export default function WatchlistPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: watchlist = [], isLoading } = useQuery({
|
||||
queryKey: ["watchlist"],
|
||||
queryFn: stockService.getWatchlist,
|
||||
});
|
||||
|
||||
// Batch fetch quotes for watchlist symbols
|
||||
const { data: heatmap = [] } = useQuery({
|
||||
queryKey: ["market-heatmap"],
|
||||
queryFn: stockService.getHeatmapData,
|
||||
enabled: watchlist.length > 0,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const quoteMap: Record<string, StockQuote> = {};
|
||||
for (const q of heatmap) quoteMap[q.symbol] = q;
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (symbol: string) => stockService.removeFromWatchlist(symbol),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["watchlist"] });
|
||||
message.success("已移除");
|
||||
},
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "股票",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
render: (_: string, record: WatchlistItem) => (
|
||||
<span>
|
||||
<span style={{ color: "#e6edf3", fontWeight: 600 }}>{record.name}</span>
|
||||
<Tag style={{ marginLeft: 8, fontSize: 11 }} color="default">
|
||||
{record.symbol}
|
||||
</Tag>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "最新价",
|
||||
key: "price",
|
||||
render: (_: unknown, record: WatchlistItem) => {
|
||||
const q = quoteMap[record.symbol];
|
||||
return q ? (
|
||||
<span style={{ fontWeight: 600, color: "#e6edf3" }}>{q.price.toFixed(2)}</span>
|
||||
) : (
|
||||
<span style={{ color: "#8b949e" }}>--</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "涨跌幅",
|
||||
key: "change_pct",
|
||||
sorter: (a: WatchlistItem, b: WatchlistItem) =>
|
||||
(quoteMap[a.symbol]?.change_pct || 0) - (quoteMap[b.symbol]?.change_pct || 0),
|
||||
render: (_: unknown, record: WatchlistItem) => {
|
||||
const q = quoteMap[record.symbol];
|
||||
if (!q) return "--";
|
||||
const color = q.change_pct > 0 ? "#f03e3e" : q.change_pct < 0 ? "#00b368" : "#8b949e";
|
||||
return (
|
||||
<span style={{ color, fontWeight: 600 }}>
|
||||
{q.change_pct >= 0 ? "+" : ""}
|
||||
{q.change_pct.toFixed(2)}%
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "涨跌额",
|
||||
key: "change",
|
||||
render: (_: unknown, record: WatchlistItem) => {
|
||||
const q = quoteMap[record.symbol];
|
||||
if (!q) return "--";
|
||||
const color = q.change > 0 ? "#f03e3e" : q.change < 0 ? "#00b368" : "#8b949e";
|
||||
return (
|
||||
<span style={{ color }}>
|
||||
{q.change >= 0 ? "+" : ""}
|
||||
{q.change.toFixed(2)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "成交额",
|
||||
key: "amount",
|
||||
render: (_: unknown, record: WatchlistItem) => {
|
||||
const q = quoteMap[record.symbol];
|
||||
if (!q?.amount) return "--";
|
||||
return <span style={{ color: "#8b949e" }}>{(q.amount / 1e8).toFixed(2)}亿</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
render: (_: unknown, record: WatchlistItem) => (
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<LineChartOutlined />}
|
||||
onClick={() => navigate(`/stock/${record.symbol}`)}
|
||||
style={{ color: "#58a6ff" }}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认移除该自选股?"
|
||||
onConfirm={() => removeMutation.mutate(record.symbol)}
|
||||
okText="移除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
danger
|
||||
loading={removeMutation.isPending}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Title level={4} style={{ margin: 0, color: "#e6edf3" }}>
|
||||
自选股
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
style={{ background: "#161b22", border: "1px solid #30363d" }}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<Table
|
||||
rowKey="symbol"
|
||||
columns={columns}
|
||||
dataSource={watchlist}
|
||||
loading={isLoading}
|
||||
pagination={false}
|
||||
locale={{ emptyText: <Empty description="暂无自选股,去大盘云图选股吧" /> }}
|
||||
onRow={(record) => ({
|
||||
style: { cursor: "pointer" },
|
||||
onDoubleClick: () => navigate(`/stock/${record.symbol}`),
|
||||
})}
|
||||
style={{ background: "transparent" }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
frontend/src/services/api.ts
Normal file
47
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
|
||||
const BASE_URL = (import.meta as any).env?.VITE_API_BASE_URL || "/api/v1";
|
||||
|
||||
const api: AxiosInstance = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
timeout: 15000,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
// Attach access token to every request
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Auto-refresh on 401
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
async (error) => {
|
||||
const original = error.config;
|
||||
if (error.response?.status === 401 && !original._retry) {
|
||||
original._retry = true;
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const { data } = await axios.post(`${BASE_URL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
localStorage.setItem("access_token", data.access_token);
|
||||
localStorage.setItem("refresh_token", data.refresh_token);
|
||||
original.headers.Authorization = `Bearer ${data.access_token}`;
|
||||
return api(original);
|
||||
} catch {
|
||||
localStorage.clear();
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
24
frontend/src/services/auth.ts
Normal file
24
frontend/src/services/auth.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import api from "./api";
|
||||
import type { User, TokenResponse } from "@/types";
|
||||
|
||||
export const authService = {
|
||||
async login(username: string, password: string): Promise<TokenResponse> {
|
||||
const { data } = await api.post<TokenResponse>("/auth/login", { username, password });
|
||||
return data;
|
||||
},
|
||||
|
||||
async register(username: string, email: string, password: string): Promise<User> {
|
||||
const { data } = await api.post<User>("/auth/register", { username, email, password });
|
||||
return data;
|
||||
},
|
||||
|
||||
async getMe(): Promise<User> {
|
||||
const { data } = await api.get<User>("/auth/me");
|
||||
return data;
|
||||
},
|
||||
|
||||
async refresh(refresh_token: string): Promise<TokenResponse> {
|
||||
const { data } = await api.post<TokenResponse>("/auth/refresh", { refresh_token });
|
||||
return data;
|
||||
},
|
||||
};
|
||||
88
frontend/src/services/stocks.ts
Normal file
88
frontend/src/services/stocks.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import api from "./api";
|
||||
import type { StockQuote, KLineBar, IntraDayBar, MarketIndex, WatchlistItem, Alert } from "@/types";
|
||||
|
||||
export const stockService = {
|
||||
// Market
|
||||
async getMarketOverview(): Promise<MarketIndex[]> {
|
||||
const { data } = await api.get("/stocks/market/overview");
|
||||
return data;
|
||||
},
|
||||
|
||||
async getHeatmapData(): Promise<StockQuote[]> {
|
||||
const { data } = await api.get("/stocks/market/heatmap");
|
||||
return data;
|
||||
},
|
||||
|
||||
async getSectors(): Promise<{ sector: string; change_pct: number }[]> {
|
||||
const { data } = await api.get("/stocks/market/sectors");
|
||||
return data;
|
||||
},
|
||||
|
||||
// Stock detail
|
||||
async getQuote(symbol: string): Promise<StockQuote> {
|
||||
const { data } = await api.get(`/stocks/${symbol}/quote`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async getKLine(symbol: string, period = "daily", adjust = "qfq", limit = 250): Promise<KLineBar[]> {
|
||||
const { data } = await api.get(`/stocks/${symbol}/kline`, {
|
||||
params: { period, adjust, limit },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async getIntraday(symbol: string): Promise<IntraDayBar[]> {
|
||||
const { data } = await api.get(`/stocks/${symbol}/intraday`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async getFiveDay(symbol: string): Promise<IntraDayBar[]> {
|
||||
const { data } = await api.get(`/stocks/${symbol}/fiveday`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async searchStocks(q: string): Promise<{ symbol: string; name: string; market: string }[]> {
|
||||
const { data } = await api.get("/stocks/search", { params: { q } });
|
||||
return data;
|
||||
},
|
||||
|
||||
// Watchlist
|
||||
async getWatchlist(): Promise<WatchlistItem[]> {
|
||||
const { data } = await api.get("/watchlist");
|
||||
return data;
|
||||
},
|
||||
|
||||
async addToWatchlist(symbol: string, name: string): Promise<WatchlistItem> {
|
||||
const { data } = await api.post("/watchlist", { symbol, name });
|
||||
return data;
|
||||
},
|
||||
|
||||
async removeFromWatchlist(symbol: string): Promise<void> {
|
||||
await api.delete(`/watchlist/${symbol}`);
|
||||
},
|
||||
|
||||
// Alerts
|
||||
async getAlerts(): Promise<Alert[]> {
|
||||
const { data } = await api.get("/alerts");
|
||||
return data;
|
||||
},
|
||||
|
||||
async createAlert(payload: {
|
||||
symbol: string;
|
||||
name: string;
|
||||
alert_type: string;
|
||||
threshold: number;
|
||||
}): Promise<Alert> {
|
||||
const { data } = await api.post("/alerts", payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async deleteAlert(id: number): Promise<void> {
|
||||
await api.delete(`/alerts/${id}`);
|
||||
},
|
||||
|
||||
async toggleAlert(id: number): Promise<Alert> {
|
||||
const { data } = await api.patch(`/alerts/${id}/toggle`);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
49
frontend/src/stores/authStore.ts
Normal file
49
frontend/src/stores/authStore.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { create } from "zustand";
|
||||
import type { User } from "@/types";
|
||||
import { authService } from "@/services/auth";
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
fetchMe: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
isAuthenticated: !!localStorage.getItem("access_token"),
|
||||
loading: false,
|
||||
|
||||
login: async (username, password) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const tokens = await authService.login(username, password);
|
||||
localStorage.setItem("access_token", tokens.access_token);
|
||||
localStorage.setItem("refresh_token", tokens.refresh_token);
|
||||
const user = await authService.getMe();
|
||||
set({ user, isAuthenticated: true });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
set({ user: null, isAuthenticated: false });
|
||||
},
|
||||
|
||||
fetchMe: async () => {
|
||||
if (!localStorage.getItem("access_token")) return;
|
||||
try {
|
||||
const user = await authService.getMe();
|
||||
set({ user, isAuthenticated: true });
|
||||
} catch {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
set({ user: null, isAuthenticated: false });
|
||||
}
|
||||
},
|
||||
}));
|
||||
74
frontend/src/types/index.ts
Normal file
74
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export interface StockQuote {
|
||||
symbol: string;
|
||||
name: string;
|
||||
price: number;
|
||||
change: number;
|
||||
change_pct: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
prev_close: number;
|
||||
volume: number;
|
||||
amount: number;
|
||||
sector?: string;
|
||||
}
|
||||
|
||||
export interface KLineBar {
|
||||
date: string;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
amount?: number;
|
||||
change_pct?: number;
|
||||
}
|
||||
|
||||
export interface IntraDayBar {
|
||||
time: string;
|
||||
price: number;
|
||||
volume: number;
|
||||
amount?: number;
|
||||
avg_price?: number;
|
||||
}
|
||||
|
||||
export interface MarketIndex {
|
||||
index_code: string;
|
||||
index_name: string;
|
||||
current: number;
|
||||
change: number;
|
||||
change_pct: number;
|
||||
}
|
||||
|
||||
export interface WatchlistItem {
|
||||
id: number;
|
||||
symbol: string;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
id: number;
|
||||
symbol: string;
|
||||
name: string;
|
||||
alert_type: string;
|
||||
threshold: number;
|
||||
is_active: boolean;
|
||||
triggered: boolean;
|
||||
}
|
||||
|
||||
export type ChartType = "intraday" | "fiveday" | "daily" | "weekly" | "monthly";
|
||||
24
frontend/tsconfig.json
Normal file
24
frontend/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
26
frontend/vite.config.ts
Normal file
26
frontend/vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/ws": {
|
||||
target: "ws://localhost:8000",
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user