Initial commit: stock market platform

This commit is contained in:
admin
2026-06-11 01:41:47 +08:00
commit 63718906e9
62 changed files with 8962 additions and 0 deletions

11
frontend/Dockerfile Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

37
frontend/package.json Normal file
View 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
View 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>
);
}

View 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
/>
);
}

View 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
/>
);
}

View 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 }} />;
}

View 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>
);
}

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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

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

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

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

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

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