This commit is contained in:
aaron 2026-04-11 08:24:50 +08:00
parent f7fca2e0b9
commit 52548b4e4c
32 changed files with 823 additions and 222 deletions

View File

@ -46,7 +46,7 @@ async def get_signals(ts_code: str):
@router.get("/{ts_code}/capital_flow") @router.get("/{ts_code}/capital_flow")
async def get_capital_flow(ts_code: str, days: int = 10): async def get_capital_flow(ts_code: str, days: int = 10):
"""获取个股资金流向""" """获取个股资金流向(含大/中/小单分拆)"""
df = tushare_client.get_stock_moneyflow(ts_code, days=days) df = tushare_client.get_stock_moneyflow(ts_code, days=days)
if df.empty: if df.empty:
return [] return []
@ -61,6 +61,18 @@ async def get_capital_flow(ts_code: str, days: int = 10):
"trade_date": row["trade_date"], "trade_date": row["trade_date"],
"main_net_inflow": round(main_net, 2), "main_net_inflow": round(main_net, 2),
"net_mf_amount": round(float(row.get("net_mf_amount", 0) or 0), 2), "net_mf_amount": round(float(row.get("net_mf_amount", 0) or 0), 2),
"elg_net": round(
(row.get("buy_elg_amount", 0) or 0) - (row.get("sell_elg_amount", 0) or 0), 2
),
"lg_net": round(
(row.get("buy_lg_amount", 0) or 0) - (row.get("sell_lg_amount", 0) or 0), 2
),
"md_net": round(
(row.get("buy_md_amount", 0) or 0) - (row.get("sell_md_amount", 0) or 0), 2
),
"sm_net": round(
(row.get("buy_sm_amount", 0) or 0) - (row.get("sell_sm_amount", 0) or 0), 2
),
}) })
return records return records

View File

@ -1,10 +1,5 @@
{ {
"pages": { "pages": {
"/sectors/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/sectors/page.js"
],
"/layout": [ "/layout": [
"static/chunks/webpack.js", "static/chunks/webpack.js",
"static/chunks/main-app.js", "static/chunks/main-app.js",
@ -20,6 +15,21 @@
"static/chunks/webpack.js", "static/chunks/webpack.js",
"static/chunks/main-app.js", "static/chunks/main-app.js",
"static/chunks/app/recommendations/page.js" "static/chunks/app/recommendations/page.js"
],
"/stock/[code]/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/stock/[code]/page.js"
],
"/sectors/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/sectors/page.js"
],
"/_not-found/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/_not-found/page.js"
] ]
} }
} }

File diff suppressed because one or more lines are too long

View File

@ -1 +1,20 @@
{} {
"components/capital-flow.tsx -> echarts": {
"id": "components/capital-flow.tsx -> echarts",
"files": [
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
]
},
"components/kline-chart.tsx -> echarts": {
"id": "components/kline-chart.tsx -> echarts",
"files": [
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
]
},
"components/score-radar.tsx -> echarts": {
"id": "components/score-radar.tsx -> echarts",
"files": [
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
]
}
}

View File

@ -1,5 +1,7 @@
{ {
"/recommendations/page": "app/recommendations/page.js",
"/page": "app/page.js", "/page": "app/page.js",
"/sectors/page": "app/sectors/page.js" "/_not-found/page": "app/_not-found/page.js",
"/sectors/page": "app/sectors/page.js",
"/recommendations/page": "app/recommendations/page.js",
"/stock/[code]/page": "app/stock/[code]/page.js"
} }

View File

@ -1 +1 @@
self.__REACT_LOADABLE_MANIFEST="{}" self.__REACT_LOADABLE_MANIFEST="{\"components/capital-flow.tsx -> echarts\":{\"id\":\"components/capital-flow.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]},\"components/kline-chart.tsx -> echarts\":{\"id\":\"components/kline-chart.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]},\"components/score-radar.tsx -> echarts\":{\"id\":\"components/score-radar.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]}}"

View File

@ -1,5 +1,5 @@
{ {
"node": {}, "node": {},
"edge": {}, "edge": {},
"encryptionKey": "IAps6Nn+QS9GVH+sOr6laVRGfUJrD0aLxGy9A/+XkIs=" "encryptionKey": "to26WE9kY05Cd2g+GH2pcshXDAS7bZksw3lMjIYHGFQ="
} }

View File

@ -125,7 +125,7 @@
/******/ /******/
/******/ /* webpack/runtime/getFullHash */ /******/ /* webpack/runtime/getFullHash */
/******/ (() => { /******/ (() => {
/******/ __webpack_require__.h = () => ("a035f49818643978") /******/ __webpack_require__.h = () => ("5a8081291a3a3a33")
/******/ })(); /******/ })();
/******/ /******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */ /******/ /* webpack/runtime/hasOwnProperty shorthand */

File diff suppressed because one or more lines are too long

View File

@ -11,6 +11,7 @@
"echarts": "^5.5.0", "echarts": "^5.5.0",
"echarts-for-react": "^3.0.2", "echarts-for-react": "^3.0.2",
"next": "^14.2.0", "next": "^14.2.0",
"next-themes": "^0.4.6",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0" "react-dom": "^18.3.0"
}, },
@ -3706,6 +3707,16 @@
} }
} }
}, },
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/next/node_modules/postcss": { "node_modules/next/node_modules/postcss": {
"version": "8.4.31", "version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",

View File

@ -9,21 +9,22 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"echarts": "^5.5.0", "echarts": "^5.5.0",
"echarts-for-react": "^3.0.2" "echarts-for-react": "^3.0.2",
"next": "^14.2.0",
"next-themes": "^0.4.6",
"react": "^18.3.0",
"react-dom": "^18.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@types/react": "^18.3.0", "@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"typescript": "^5.0.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0", "autoprefixer": "^10.4.0",
"eslint": "^8.0.0", "eslint": "^8.0.0",
"eslint-config-next": "^14.2.0" "eslint-config-next": "^14.2.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.0.0"
} }
} }

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { useTheme } from "next-themes";
import { streamChat, type ChatMessage } from "@/lib/api"; import { streamChat, type ChatMessage } from "@/lib/api";
interface DisplayMessage { interface DisplayMessage {
@ -15,6 +16,7 @@ const QUICK_QUESTIONS = [
]; ];
export default function ChatPage() { export default function ChatPage() {
const { theme } = useTheme();
const [messages, setMessages] = useState<DisplayMessage[]>([]); const [messages, setMessages] = useState<DisplayMessage[]>([]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false); const [streaming, setStreaming] = useState(false);
@ -87,7 +89,7 @@ export default function ChatPage() {
return ( return (
<div className="max-w-3xl mx-auto flex flex-col md:h-[calc(100dvh)]"> <div className="max-w-3xl mx-auto flex flex-col md:h-[calc(100dvh)]">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-white/[0.04] bg-bg-primary/80 backdrop-blur-xl"> <div className="flex items-center justify-between px-5 py-3.5 border-b border-border-subtle bg-bg-primary/80 backdrop-blur-xl">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-accent-cyan/30 to-accent-cyan/10 flex items-center justify-center border border-accent-cyan/20"> <div className="w-7 h-7 rounded-lg bg-gradient-to-br from-accent-cyan/30 to-accent-cyan/10 flex items-center justify-center border border-accent-cyan/20">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-accent-cyan/70"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-accent-cyan/70">
@ -104,7 +106,7 @@ export default function ChatPage() {
{messages.length > 0 && ( {messages.length > 0 && (
<button <button
onClick={() => setMessages([])} onClick={() => setMessages([])}
className="text-xs text-text-muted hover:text-text-primary px-3 py-1.5 rounded-lg hover:bg-white/[0.04] transition-all duration-200" className="text-xs text-text-muted hover:text-text-primary px-3 py-1.5 rounded-lg hover:bg-surface-3 transition-all duration-200"
> >
</button> </button>
@ -129,7 +131,7 @@ export default function ChatPage() {
<button <button
key={q} key={q}
onClick={() => sendMessage(q)} onClick={() => sendMessage(q)}
className="text-xs px-4 py-2.5 bg-white/[0.03] rounded-xl text-text-secondary hover:text-text-primary hover:bg-white/[0.06] transition-all duration-200 border border-white/[0.04] text-left" className="text-xs px-4 py-2.5 bg-surface-2 rounded-xl text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all duration-200 border border-border-subtle text-left"
> >
{q} {q}
</button> </button>
@ -153,7 +155,7 @@ export default function ChatPage() {
{msg.role === "assistant" ? ( {msg.role === "assistant" ? (
msg.content ? ( msg.content ? (
<div <div
className="prose prose-invert prose-sm max-w-none [&_p]:my-1 [&_ul]:my-1 [&_li]:my-0.5 [&_strong]:text-orange-300" className={`prose ${theme !== "light" ? "prose-invert" : ""} prose-sm max-w-none [&_p]:my-1 [&_ul]:my-1 [&_li]:my-0.5 [&_strong]:${theme !== "light" ? "text-orange-300" : "text-orange-700"}`}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: formatMarkdown(msg.content), __html: formatMarkdown(msg.content),
}} }}
@ -186,7 +188,7 @@ export default function ChatPage() {
</div> </div>
{/* Input */} {/* Input */}
<div className="px-5 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] md:pb-3 border-t border-white/[0.04] bg-bg-primary/80 backdrop-blur-xl"> <div className="px-5 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] md:pb-3 border-t border-border-subtle bg-bg-primary/80 backdrop-blur-xl">
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<textarea <textarea
ref={inputRef} ref={inputRef}
@ -195,7 +197,7 @@ export default function ChatPage() {
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="输入问题..." placeholder="输入问题..."
rows={1} rows={1}
className="flex-1 bg-white/[0.03] rounded-xl px-4 py-2.5 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-accent-cyan/30 placeholder-text-muted/40 border border-white/[0.04] transition-all duration-200" className="flex-1 bg-surface-2 rounded-xl px-4 py-2.5 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-accent-cyan/30 placeholder-text-muted/40 border border-border-subtle transition-all duration-200"
disabled={streaming} disabled={streaming}
/> />
<button <button

View File

@ -4,64 +4,180 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* ========================================
DARK THEME (default)
======================================== */
:root { :root {
/* Background */
--bg-primary: #0a0a0c;
--bg-secondary: #101013;
--bg-card: #161619;
--bg-elevated: #1e1e22;
/* Text */
--text-primary: #f5f5f7;
--text-secondary: #9898a4;
--text-muted: #58585f;
/* Accent */
--accent-amber: #f59e0b;
--accent-cyan: #22d3ee;
/* Financial */
--color-up: #ff6b6b; --color-up: #ff6b6b;
--color-down: #34d399; --color-down: #34d399;
--color-hot: #f59e0b;
/* Surface (replaces bg-white/[0.0x]) */
--surface-1: rgba(255, 255, 255, 0.02);
--surface-2: rgba(255, 255, 255, 0.03);
--surface-3: rgba(255, 255, 255, 0.04);
--surface-4: rgba(255, 255, 255, 0.06);
/* Border (replaces border-white/[0.0x]) */
--border-subtle: rgba(255, 255, 255, 0.04);
--border-default: rgba(255, 255, 255, 0.06);
/* Shadows */
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.3);
--shadow-glow: 0 0 20px rgba(245, 158, 11, 0.12);
--shadow-glow-sm: 0 0 10px rgba(245, 158, 11, 0.08);
/* Glass */
--glass-bg-from: rgba(22, 22, 25, 0.85);
--glass-bg-to: rgba(10, 10, 12, 0.95);
--glass-border: rgba(255, 255, 255, 0.05);
--glass-inset: rgba(255, 255, 255, 0.03);
--glass-hover-border: rgba(255, 255, 255, 0.10);
--glass-hover-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
--glass-hover-inset: rgba(255, 255, 255, 0.05);
--glass-sidebar-from: rgba(16, 16, 19, 0.97);
--glass-sidebar-to: rgba(10, 10, 12, 0.99);
--glass-sidebar-border: rgba(255, 255, 255, 0.04);
/* Effects */
--noise-opacity: 0.018;
--ambient-glow: rgba(245, 158, 11, 0.035);
--shimmer-color: rgba(255, 255, 255, 0.04);
--selection-bg: rgba(245, 158, 11, 0.25);
/* Scrollbar */
--scrollbar-thumb: rgba(255, 255, 255, 0.10);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.18);
/* Fonts */
--font-display: 'Outfit', -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; --font-display: 'Outfit', -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
--font-body: 'Outfit', -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; --font-body: 'Outfit', -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
--font-mono: 'SF Mono', 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; --font-mono: 'SF Mono', 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
} }
/* ========================================
LIGHT THEME
======================================== */
:root.light {
--bg-primary: #f5f6f8;
--bg-secondary: #ffffff;
--bg-card: #ffffff;
--bg-elevated: #eef0f3;
--text-primary: #1a1a2e;
--text-secondary: #4a4a5e;
--text-muted: #8a8a9e;
--accent-amber: #d97706;
--accent-cyan: #0891b2;
--color-up: #ef4444;
--color-down: #22c55e;
--color-hot: #d97706;
--surface-1: rgba(0, 0, 0, 0.02);
--surface-2: rgba(0, 0, 0, 0.03);
--surface-3: rgba(0, 0, 0, 0.05);
--surface-4: rgba(0, 0, 0, 0.07);
--border-subtle: rgba(0, 0, 0, 0.07);
--border-default: rgba(0, 0, 0, 0.10);
--shadow-card: 0 2px 12px rgba(0, 0, 0, 0.07);
--shadow-glow: 0 0 16px rgba(217, 119, 6, 0.06);
--shadow-glow-sm: 0 0 8px rgba(217, 119, 6, 0.04);
--glass-bg-from: rgba(255, 255, 255, 0.92);
--glass-bg-to: rgba(255, 255, 255, 0.98);
--glass-border: rgba(0, 0, 0, 0.08);
--glass-inset: rgba(0, 0, 0, 0.02);
--glass-hover-border: rgba(0, 0, 0, 0.14);
--glass-hover-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
--glass-hover-inset: rgba(0, 0, 0, 0.03);
--glass-sidebar-from: rgba(255, 255, 255, 0.97);
--glass-sidebar-to: rgba(255, 255, 255, 0.99);
--glass-sidebar-border: rgba(0, 0, 0, 0.07);
--noise-opacity: 0;
--ambient-glow: transparent;
--shimmer-color: rgba(0, 0, 0, 0.03);
--selection-bg: rgba(217, 119, 6, 0.18);
--scrollbar-thumb: rgba(0, 0, 0, 0.12);
--scrollbar-thumb-hover: rgba(0, 0, 0, 0.22);
}
/* ========================================
BASE
======================================== */
body { body {
background-color: #0a0a0c; background-color: var(--bg-primary);
color: #f5f5f7; color: var(--text-primary);
font-family: var(--font-body); font-family: var(--font-body);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
transition: background-color 0.3s ease, color 0.3s ease;
} }
/* Tabular numbers for financial data */
.tabular-nums { .tabular-nums {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum'; font-feature-settings: 'tnum';
} }
/* Glass morphism card */ /* ========================================
GLASS MORPHISM (theme-aware)
======================================== */
@layer components { @layer components {
.glass-card { .glass-card {
background: linear-gradient(135deg, rgba(22, 22, 25, 0.85) 0%, rgba(10, 10, 12, 0.95) 100%); background: linear-gradient(135deg, var(--glass-bg-from) 0%, var(--glass-bg-to) 100%);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.05); border: 1px solid var(--glass-border);
border-radius: 16px; border-radius: 16px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); box-shadow: inset 0 1px 0 var(--glass-inset);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
} }
.glass-card:hover { .glass-card:hover {
border-color: rgba(255, 255, 255, 0.10); border-color: var(--glass-hover-border);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.05); box-shadow: var(--glass-hover-shadow), inset 0 1px 0 var(--glass-hover-inset);
transform: translateY(-1px); transform: translateY(-1px);
} }
.glass-card-static { .glass-card-static {
background: linear-gradient(135deg, rgba(22, 22, 25, 0.85) 0%, rgba(10, 10, 12, 0.95) 100%); background: linear-gradient(135deg, var(--glass-bg-from) 0%, var(--glass-bg-to) 100%);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.05); border: 1px solid var(--glass-border);
border-radius: 16px; border-radius: 16px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); box-shadow: inset 0 1px 0 var(--glass-inset);
} }
.glass-sidebar { .glass-sidebar {
background: linear-gradient(180deg, rgba(16, 16, 19, 0.97) 0%, rgba(10, 10, 12, 0.99) 100%); background: linear-gradient(180deg, var(--glass-sidebar-from) 0%, var(--glass-sidebar-to) 100%);
backdrop-filter: blur(30px); backdrop-filter: blur(30px);
-webkit-backdrop-filter: blur(30px); -webkit-backdrop-filter: blur(30px);
border-right: 1px solid rgba(255, 255, 255, 0.04); border-right: 1px solid var(--glass-sidebar-border);
} }
.glow-accent { .glow-accent {
box-shadow: 0 0 20px rgba(245, 158, 11, 0.12), 0 0 60px rgba(245, 158, 11, 0.04); box-shadow: var(--shadow-glow), 0 0 60px rgba(245, 158, 11, 0.04);
} }
.gradient-border { .gradient-border {
@ -82,18 +198,22 @@ body {
} }
} }
/* ========================================
EFFECTS (theme-aware)
======================================== */
/* Noise texture overlay */ /* Noise texture overlay */
body::before { body::before {
content: ''; content: '';
position: fixed; position: fixed;
inset: 0; inset: 0;
opacity: 0.018; opacity: var(--noise-opacity);
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
pointer-events: none; pointer-events: none;
z-index: 9999; z-index: 9999;
} }
/* Ambient glow effect - top right (amber) */ /* Ambient glow - top right (amber) */
body::after { body::after {
content: ''; content: '';
position: fixed; position: fixed;
@ -101,7 +221,7 @@ body::after {
right: -200px; right: -200px;
width: 600px; width: 600px;
height: 600px; height: 600px;
background: radial-gradient(circle, rgba(245, 158, 11, 0.035) 0%, transparent 70%); background: radial-gradient(circle, var(--ambient-glow) 0%, transparent 70%);
pointer-events: none; pointer-events: none;
z-index: 0; z-index: 0;
} }
@ -114,14 +234,22 @@ body::after {
background: transparent; background: transparent;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.10); background: var(--scrollbar-thumb);
border-radius: 10px; border-radius: 10px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.18); background: var(--scrollbar-thumb-hover);
} }
/* Animations */ /* Selection */
::selection {
background: var(--selection-bg);
color: var(--text-primary);
}
/* ========================================
ANIMATIONS
======================================== */
@keyframes fadeInUp { @keyframes fadeInUp {
from { from {
opacity: 0; opacity: 0;
@ -162,7 +290,7 @@ body::after {
} }
.animate-shimmer { .animate-shimmer {
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.04), transparent); background: linear-gradient(90deg, transparent, var(--shimmer-color), transparent);
background-size: 200% 100%; background-size: 200% 100%;
animation: shimmer 2s infinite; animation: shimmer 2s infinite;
} }
@ -186,9 +314,3 @@ body::after {
.score-bar-gradient-low { .score-bar-gradient-low {
background: linear-gradient(90deg, #585860, #3a3a40); background: linear-gradient(90deg, #585860, #3a3a40);
} }
/* Selection color */
::selection {
background: rgba(245, 158, 11, 0.25);
color: #f5f5f7;
}

View File

@ -4,6 +4,7 @@ import { AuthProvider } from "@/hooks/use-auth";
import { AuthGuard } from "@/components/auth-guard"; import { AuthGuard } from "@/components/auth-guard";
import { UserMenu } from "@/components/user-menu"; import { UserMenu } from "@/components/user-menu";
import { SidebarNav, MobileBottomNav } from "@/components/nav"; import { SidebarNav, MobileBottomNav } from "@/components/nav";
import { ThemeProvider } from "next-themes";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Dragon AI Agent", title: "Dragon AI Agent",
@ -23,49 +24,51 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<html lang="zh-CN"> <html lang="zh-CN" suppressHydrationWarning>
<body className="min-h-screen bg-bg-primary text-text-primary font-display"> <body className="min-h-screen bg-bg-primary text-text-primary font-display">
<AuthProvider> <ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}>
<AuthGuard> <AuthProvider>
{/* Desktop: sidebar + main */} <AuthGuard>
<div className="flex min-h-screen"> {/* Desktop: sidebar + main */}
{/* Desktop sidebar */} <div className="flex min-h-screen">
<aside className="hidden md:flex flex-col w-60 glass-sidebar fixed inset-y-0 left-0 z-40"> {/* Desktop sidebar */}
{/* Brand */} <aside className="hidden md:flex flex-col w-60 glass-sidebar fixed inset-y-0 left-0 z-40">
<div className="px-6 pt-7 pb-5"> {/* Brand */}
<div className="flex items-center gap-3"> <div className="px-6 pt-7 pb-5">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center text-sm font-bold text-white shadow-glow-sm"> <div className="flex items-center gap-3">
D <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center text-sm font-bold text-white shadow-glow-sm">
</div> D
<div> </div>
<h1 className="text-sm font-semibold tracking-tight">Dragon AI Agent</h1> <div>
<p className="text-xs text-text-muted mt-0.5 font-light tracking-wide"> · </p> <h1 className="text-sm font-semibold tracking-tight">Dragon AI Agent</h1>
<p className="text-xs text-text-muted mt-0.5 font-light tracking-wide"> · </p>
</div>
</div> </div>
</div> </div>
</div>
{/* Divider */} {/* Divider */}
<div className="mx-5 h-px bg-gradient-to-r from-transparent via-white/[0.06] to-transparent" /> <div className="mx-5 h-px bg-gradient-to-r from-transparent via-border-default to-transparent" />
{/* Nav */} {/* Nav */}
<SidebarNav /> <SidebarNav />
{/* Footer */} {/* Footer */}
<div className="px-6 py-5 border-t border-white/[0.04]"> <div className="px-6 py-5 border-t border-border-subtle">
<UserMenu /> <UserMenu />
</div> </div>
</aside> </aside>
{/* Main content area */} {/* Main content area */}
<main className="flex-1 md:ml-60 pb-16 md:pb-0 min-h-screen"> <main className="flex-1 md:ml-60 pb-16 md:pb-0 min-h-screen">
{children} {children}
</main> </main>
</div> </div>
{/* Mobile bottom nav */} {/* Mobile bottom nav */}
<MobileBottomNav /> <MobileBottomNav />
</AuthGuard> </AuthGuard>
</AuthProvider> </AuthProvider>
</ThemeProvider>
</body> </body>
</html> </html>
); );

View File

@ -34,7 +34,7 @@ export default function LoginPage() {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-bg-primary"> <div className="min-h-screen flex items-center justify-center bg-bg-primary">
<div className="max-w-sm w-full mx-4 p-8 rounded-2xl bg-white/[0.02] border border-white/[0.06] backdrop-blur-sm"> <div className="max-w-sm w-full mx-4 p-8 rounded-2xl bg-surface-1 border border-border-default backdrop-blur-sm">
{/* Brand */} {/* Brand */}
<div className="flex flex-col items-center mb-8"> <div className="flex flex-col items-center mb-8">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center text-base font-bold text-white shadow-glow-sm mb-4"> <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center text-base font-bold text-white shadow-glow-sm mb-4">
@ -53,7 +53,7 @@ export default function LoginPage() {
placeholder="用户名" placeholder="用户名"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
className="w-full bg-white/[0.03] border border-white/[0.06] rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40" className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40"
autoComplete="username" autoComplete="username"
/> />
<input <input
@ -61,7 +61,7 @@ export default function LoginPage() {
placeholder="密码" placeholder="密码"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="w-full bg-white/[0.03] border border-white/[0.06] rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40" className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40"
autoComplete="current-password" autoComplete="current-password"
/> />
<button <button

View File

@ -156,7 +156,7 @@ export default function RecommendationsPage() {
className={`text-xs px-4 py-1.5 rounded-xl whitespace-nowrap transition-all duration-200 font-medium ${ className={`text-xs px-4 py-1.5 rounded-xl whitespace-nowrap transition-all duration-200 font-medium ${
filter === key filter === key
? "bg-gradient-to-r from-amber-500/25 to-amber-600/20 text-amber-400 border border-amber-500/15" ? "bg-gradient-to-r from-amber-500/25 to-amber-600/20 text-amber-400 border border-amber-500/15"
: "bg-white/[0.03] text-text-muted hover:text-text-secondary hover:bg-white/[0.06] border border-transparent" : "bg-surface-2 text-text-muted hover:text-text-secondary hover:bg-surface-4 border border-transparent"
}`} }`}
> >
{label} {label}
@ -184,7 +184,7 @@ export default function RecommendationsPage() {
{/* Date header */} {/* Date header */}
<button <button
onClick={() => toggleDay(group.date)} onClick={() => toggleDay(group.date)}
className="w-full flex items-center gap-3 px-4 py-3 glass-card-static hover:bg-white/[0.03] transition-colors group" className="w-full flex items-center gap-3 px-4 py-3 glass-card-static hover:bg-surface-2 transition-colors group"
> >
<svg <svg
width="14" width="14"

View File

@ -17,7 +17,7 @@ function getStageInfo(stage: string) {
case "end": case "end":
return { label: "尾声", color: "text-red-400", bg: "bg-red-500/10 border-red-500/15" }; return { label: "尾声", color: "text-red-400", bg: "bg-red-500/10 border-red-500/15" };
default: default:
return { label: "—", color: "text-text-muted", bg: "bg-white/[0.03] border-white/[0.06]" }; return { label: "—", color: "text-text-muted", bg: "bg-surface-2 border-border-default" };
} }
} }
@ -48,7 +48,7 @@ function LeadingStockTag({ stock }: { stock: LeadingStock }) {
return ( return (
<a <a
href={`/stock/${stock.ts_code}`} href={`/stock/${stock.ts_code}`}
className="inline-flex items-center gap-1 text-[11px] px-2 py-1 rounded-lg bg-white/[0.03] border border-white/[0.06] hover:bg-white/[0.06] transition-colors" className="inline-flex items-center gap-1 text-[11px] px-2 py-1 rounded-lg bg-surface-2 border border-border-default hover:bg-surface-4 transition-colors"
> >
<span className="text-text-secondary font-medium">{stock.name}</span> <span className="text-text-secondary font-medium">{stock.name}</span>
<span className={`font-mono tabular-nums ${isLimitUp ? "text-red-400 font-bold" : stock.pct_chg > 0 ? "text-red-400/80" : "text-emerald-400/80"}`}> <span className={`font-mono tabular-nums ${isLimitUp ? "text-red-400 font-bold" : stock.pct_chg > 0 ? "text-red-400/80" : "text-emerald-400/80"}`}>
@ -88,7 +88,7 @@ function SectorDetailCard({ sector, index }: { sector: SectorData; index: number
? "bg-gradient-to-br from-slate-400/20 to-slate-500/15 text-slate-300 border border-slate-400/15" ? "bg-gradient-to-br from-slate-400/20 to-slate-500/15 text-slate-300 border border-slate-400/15"
: index === 2 : index === 2
? "bg-gradient-to-br from-amber-700/20 to-amber-800/15 text-amber-400 border border-amber-600/15" ? "bg-gradient-to-br from-amber-700/20 to-amber-800/15 text-amber-400 border border-amber-600/15"
: "bg-white/[0.03] text-text-muted border border-white/[0.04]" : "bg-surface-2 text-text-muted border border-border-subtle"
}`}> }`}>
{index + 1} {index + 1}
</span> </span>
@ -127,19 +127,19 @@ function SectorDetailCard({ sector, index }: { sector: SectorData; index: number
{/* Metrics row */} {/* Metrics row */}
<div className="grid grid-cols-3 gap-3 mb-3"> <div className="grid grid-cols-3 gap-3 mb-3">
<div className="bg-white/[0.02] rounded-lg px-3 py-2"> <div className="bg-surface-1 rounded-lg px-3 py-2">
<div className="text-[10px] text-text-muted/50 mb-0.5"></div> <div className="text-[10px] text-text-muted/50 mb-0.5"></div>
<div className={`text-xs font-mono tabular-nums font-semibold ${sector.capital_inflow > 0 ? "text-red-400" : "text-emerald-400"}`}> <div className={`text-xs font-mono tabular-nums font-semibold ${sector.capital_inflow > 0 ? "text-red-400" : "text-emerald-400"}`}>
{sector.capital_inflow > 0 ? "+" : ""}{formatNumber(sector.capital_inflow)} {sector.capital_inflow > 0 ? "+" : ""}{formatNumber(sector.capital_inflow)}
</div> </div>
</div> </div>
<div className="bg-white/[0.02] rounded-lg px-3 py-2"> <div className="bg-surface-1 rounded-lg px-3 py-2">
<div className="text-[10px] text-text-muted/50 mb-0.5"></div> <div className="text-[10px] text-text-muted/50 mb-0.5"></div>
<div className="text-xs font-mono tabular-nums font-semibold text-text-secondary"> <div className="text-xs font-mono tabular-nums font-semibold text-text-secondary">
{displayLimitUp}<span className="text-text-muted/40"> </span> {displayLimitUp}<span className="text-text-muted/40"> </span>
</div> </div>
</div> </div>
<div className="bg-white/[0.02] rounded-lg px-3 py-2"> <div className="bg-surface-1 rounded-lg px-3 py-2">
<div className="text-[10px] text-text-muted/50 mb-0.5"></div> <div className="text-[10px] text-text-muted/50 mb-0.5"></div>
<div className={`text-xs font-mono tabular-nums font-semibold ${ <div className={`text-xs font-mono tabular-nums font-semibold ${
sector.heat_score >= 70 ? "text-amber-400" : sector.heat_score >= 50 ? "text-text-secondary" : "text-text-muted" sector.heat_score >= 70 ? "text-amber-400" : sector.heat_score >= 50 ? "text-text-secondary" : "text-text-muted"

View File

@ -3,7 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { fetchAPI } from "@/lib/api"; import { fetchAPI } from "@/lib/api";
import { getScoreColor, getSignalColor } from "@/lib/utils"; import { getScoreColor } from "@/lib/utils";
import KlineChart from "@/components/kline-chart"; import KlineChart from "@/components/kline-chart";
import CapitalFlowChart from "@/components/capital-flow"; import CapitalFlowChart from "@/components/capital-flow";
import ScoreRadar from "@/components/score-radar"; import ScoreRadar from "@/components/score-radar";
@ -23,6 +23,10 @@ interface StockSignals {
support_price: number | null; support_price: number | null;
resist_price: number | null; resist_price: number | null;
stop_loss_price: number | null; stop_loss_price: number | null;
rally_pct_5d: number;
rally_pct_10d: number;
distance_from_high: number;
position_score: number;
} }
interface QuoteData { interface QuoteData {
@ -36,8 +40,25 @@ interface QuoteData {
pe: number | null; pe: number | null;
pb: number | null; pb: number | null;
circ_mv: number | null; circ_mv: number | null;
total_mv: number | null;
volume_ratio: number | null;
high: number | null; high: number | null;
low: number | null; low: number | null;
open: number | null;
pre_close: number | null;
limit_up: number | null;
limit_down: number | null;
amplitude: number | null;
}
interface FlowRecord {
trade_date: string;
main_net_inflow: number;
net_mf_amount: number;
elg_net: number;
lg_net: number;
md_net: number;
sm_net: number;
} }
export default function StockDetailPage() { export default function StockDetailPage() {
@ -48,29 +69,42 @@ export default function StockDetailPage() {
const [signals, setSignals] = useState<StockSignals | null>(null); const [signals, setSignals] = useState<StockSignals | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const [kline, setKline] = useState<any[]>([]); const [kline, setKline] = useState<any[]>([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any const [capitalFlow, setCapitalFlow] = useState<FlowRecord[]>([]);
const [capitalFlow, setCapitalFlow] = useState<any[]>([]);
useEffect(() => { useEffect(() => {
if (!code) return; if (!code) return;
Promise.all([ Promise.all([
fetchAPI<QuoteData>(`/api/stocks/${code}/quote`).catch(() => null), fetchAPI<QuoteData>(`/api/stocks/${code}/quote`).catch(() => null),
fetchAPI<StockSignals>(`/api/stocks/${code}/signals`).catch(() => null), fetchAPI<StockSignals>(`/api/stocks/${code}/signals`).catch(() => null),
fetchAPI<unknown[]>(`/api/stocks/${code}/kline?days=60`).catch(() => []), fetchAPI<unknown[]>(`/api/stocks/${code}/kline?days=120`).catch(() => []),
fetchAPI<unknown[]>(`/api/stocks/${code}/capital_flow?days=10`).catch(() => []), fetchAPI<FlowRecord[]>(`/api/stocks/${code}/capital_flow?days=10`).catch(() => []),
]).then(([q, s, k, c]) => { ]).then(([q, s, k, c]) => {
setQuote(q); setQuote(q);
setSignals(s); setSignals(s);
setKline(k); setKline(k);
setCapitalFlow(c); setCapitalFlow(c as FlowRecord[]);
}); });
}, [code]); }, [code]);
const latestFlow = capitalFlow.length > 0 ? capitalFlow[capitalFlow.length - 1] : null;
return ( return (
<div className="max-w-6xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5"> <div className="max-w-6xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-4">
{/* Back */} {/* Back */}
<a href="/" className="inline-flex items-center gap-1.5 text-xs text-text-muted hover:text-text-primary transition-colors animate-fade-in-up"> <a
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> href="/"
className="inline-flex items-center gap-1.5 text-xs text-text-muted hover:text-text-primary transition-colors animate-fade-in-up"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M19 12H5M12 19l-7-7 7-7" /> <path d="M19 12H5M12 19l-7-7 7-7" />
</svg> </svg>
@ -90,21 +124,91 @@ export default function StockDetailPage() {
<div className="flex items-baseline gap-3"> <div className="flex items-baseline gap-3">
<span <span
className={`text-3xl font-bold font-mono tabular-nums tracking-tight ${ className={`text-3xl font-bold font-mono tabular-nums tracking-tight ${
quote.pct_chg > 0 ? "text-red-400" : quote.pct_chg < 0 ? "text-emerald-400" : "text-text-primary" quote.pct_chg > 0
? "text-red-400"
: quote.pct_chg < 0
? "text-emerald-400"
: "text-text-primary"
}`} }`}
> >
{quote.price.toFixed(2)} {quote.price.toFixed(2)}
</span> </span>
<span className={`text-sm font-mono tabular-nums font-medium ${quote.pct_chg > 0 ? "text-red-400" : "text-emerald-400"}`}> <span
className={`text-sm font-mono tabular-nums font-medium ${
quote.pct_chg > 0 ? "text-red-400" : "text-emerald-400"
}`}
>
{quote.pct_chg > 0 ? "+" : ""} {quote.pct_chg > 0 ? "+" : ""}
{quote.pct_chg.toFixed(2)}% {quote.pct_chg.toFixed(2)}%
</span> </span>
{quote.pre_close && (
<span className="text-xs text-text-muted font-mono tabular-nums">
{quote.pre_close.toFixed(2)}
</span>
)}
</div> </div>
<div className="grid grid-cols-4 gap-3 mt-4">
<QuoteStat label="换手率" value={`${quote.turnover_rate?.toFixed(2)}%`} /> {/* OHLC row */}
<QuoteStat label="市盈率" value={quote.pe?.toFixed(1) ?? "-"} /> <div className="grid grid-cols-4 gap-2 mt-4">
<QuoteStat label="市净率" value={quote.pb?.toFixed(2) ?? "-"} /> <MiniStat
<QuoteStat label="流通市值" value={quote.circ_mv ? `${quote.circ_mv.toFixed(0)}亿` : "-"} /> label="开盘"
value={quote.open?.toFixed(2) ?? "-"}
color={
quote.open && quote.pre_close
? quote.open >= quote.pre_close
? "text-red-400"
: "text-emerald-400"
: undefined
}
/>
<MiniStat
label="最高"
value={quote.high?.toFixed(2) ?? "-"}
color="text-red-400"
/>
<MiniStat
label="最低"
value={quote.low?.toFixed(2) ?? "-"}
color="text-emerald-400"
/>
<MiniStat
label="振幅"
value={quote.amplitude != null ? `${quote.amplitude.toFixed(2)}%` : "-"}
/>
</div>
{/* Valuation row */}
<div className="grid grid-cols-4 gap-2 mt-2">
<MiniStat label="换手率" value={`${quote.turnover_rate?.toFixed(2)}%`} />
<MiniStat label="市盈率" value={quote.pe?.toFixed(1) ?? "-"} />
<MiniStat label="市净率" value={quote.pb?.toFixed(2) ?? "-"} />
<MiniStat
label="量比"
value={quote.volume_ratio?.toFixed(2) ?? "-"}
highlight={quote.volume_ratio != null && quote.volume_ratio > 2}
/>
</div>
{/* Market cap row */}
<div className="grid grid-cols-4 gap-2 mt-2">
<MiniStat
label="总市值"
value={quote.total_mv ? `${formatBigNum(quote.total_mv)}亿` : "-"}
/>
<MiniStat
label="流通市值"
value={quote.circ_mv ? `${formatBigNum(quote.circ_mv)}亿` : "-"}
/>
<MiniStat
label="涨停"
value={quote.limit_up?.toFixed(2) ?? "-"}
color="text-red-400"
/>
<MiniStat
label="跌停"
value={quote.limit_down?.toFixed(2) ?? "-"}
color="text-emerald-400"
/>
</div> </div>
</div> </div>
)} )}
@ -117,13 +221,64 @@ export default function StockDetailPage() {
)} )}
</div> </div>
{/* Position Safety + Capital Flow Breakdown */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-fade-in-up delay-75">
{/* Position Safety Card */}
{signals && (
<div className="glass-card-static p-5">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-4">
</h2>
<div className="flex items-center gap-6">
{/* Score ring */}
<div className="relative w-24 h-24 flex-shrink-0">
<svg viewBox="0 0 100 100" className="w-full h-full -rotate-90">
<circle cx="50" cy="50" r="42" fill="none" stroke="var(--surface-2)" strokeWidth="8" />
<circle
cx="50"
cy="50"
r="42"
fill="none"
stroke={getPositionColor(signals.position_score)}
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${(signals.position_score / 100) * 264} 264`}
className="transition-all duration-700"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={`text-xl font-bold font-mono tabular-nums ${getPositionColor(signals.position_score)}`}>
{Math.round(signals.position_score)}
</span>
<span className="text-[10px] text-text-muted"></span>
</div>
</div>
{/* Metrics */}
<div className="flex-1 space-y-3 min-w-0">
<PositionBar label="5日涨幅" value={signals.rally_pct_5d} />
<PositionBar label="10日涨幅" value={signals.rally_pct_10d} />
<PositionBar label="距高点" value={signals.distance_from_high} invert />
</div>
</div>
</div>
)}
{/* Capital Flow Breakdown */}
{latestFlow && (
<CapitalFlowBreakdown flow={latestFlow} />
)}
</div>
{/* Technical signals */} {/* Technical signals */}
{signals && ( {signals && (
<div className="glass-card-static p-5 animate-fade-in-up delay-75"> <div className="glass-card-static p-5 animate-fade-in-up delay-75">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider"></h2> <h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
</h2>
<div className={`text-lg font-bold font-mono tabular-nums ${getScoreColor(signals.score)}`}> <div className={`text-lg font-bold font-mono tabular-nums ${getScoreColor(signals.score)}`}>
{signals.score}<span className="text-xs text-text-muted ml-0.5"></span> {signals.score}
<span className="text-xs text-text-muted ml-0.5"></span>
</div> </div>
</div> </div>
@ -138,24 +293,30 @@ export default function StockDetailPage() {
</div> </div>
{/* Price levels */} {/* Price levels */}
<div className="flex justify-between mt-4 pt-4 border-t border-white/[0.04] text-xs"> <div className="flex justify-between mt-4 pt-4 border-t border-border-subtle text-xs">
<div> <div>
<span className="text-text-muted"> </span> <span className="text-text-muted"> </span>
<span className="text-orange-400 font-mono tabular-nums">{signals.support_price ?? "-"}</span> <span className="text-orange-400 font-mono tabular-nums">
{signals.support_price?.toFixed(2) ?? "-"}
</span>
</div> </div>
<div> <div>
<span className="text-text-muted"> </span> <span className="text-text-muted"> </span>
<span className="text-red-400 font-mono tabular-nums">{signals.resist_price ?? "-"}</span> <span className="text-red-400 font-mono tabular-nums">
{signals.resist_price?.toFixed(2) ?? "-"}
</span>
</div> </div>
<div> <div>
<span className="text-text-muted"> </span> <span className="text-text-muted"> </span>
<span className="text-emerald-400 font-mono tabular-nums">{signals.stop_loss_price ?? "-"}</span> <span className="text-emerald-400 font-mono tabular-nums">
{signals.stop_loss_price?.toFixed(2) ?? "-"}
</span>
</div> </div>
</div> </div>
</div> </div>
)} )}
{/* K-line + Capital flow */} {/* K-line + Capital flow chart */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-fade-in-up delay-150"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-fade-in-up delay-150">
{kline.length > 0 && <KlineChart data={kline} />} {kline.length > 0 && <KlineChart data={kline} />}
{capitalFlow.length > 0 && <CapitalFlowChart data={capitalFlow} />} {capitalFlow.length > 0 && <CapitalFlowChart data={capitalFlow} />}
@ -171,11 +332,124 @@ export default function StockDetailPage() {
); );
} }
function QuoteStat({ label, value }: { label: string; value: string }) { /* ── Helper components ── */
function MiniStat({
label,
value,
color,
highlight,
}: {
label: string;
value: string;
color?: string;
highlight?: boolean;
}) {
return ( return (
<div className="bg-white/[0.02] rounded-lg px-3 py-2 border border-white/[0.03]"> <div className={`rounded-lg px-2.5 py-1.5 border ${highlight ? "border-amber-500/20 bg-amber-500/[0.04]" : "border-border-subtle bg-surface-1"}`}>
<div className="text-xs text-text-muted mb-0.5">{label}</div> <div className="text-[10px] text-text-muted leading-tight">{label}</div>
<div className="text-xs font-mono tabular-nums">{value}</div> <div className={`text-xs font-mono tabular-nums ${color ?? ""}`}>
{value}
</div>
</div>
);
}
function PositionBar({ label, value, invert = false }: { label: string; value: number; invert?: boolean }) {
const absVal = Math.abs(value);
const maxDisplay = 30;
const pct = Math.min(absVal / maxDisplay, 1) * 100;
const isPositive = value > 0;
const showWarning = invert ? value < -20 : value > 20;
return (
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-text-muted">{label}</span>
<span
className={`font-mono tabular-nums ${
showWarning
? "text-amber-400"
: isPositive
? "text-red-400"
: "text-emerald-400"
}`}
>
{isPositive ? "+" : ""}
{value.toFixed(1)}%
</span>
</div>
<div className="h-1.5 rounded-full bg-surface-2 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
showWarning
? "bg-amber-400"
: isPositive
? "bg-red-400"
: "bg-emerald-400"
}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
function CapitalFlowBreakdown({ flow }: { flow: FlowRecord }) {
const maxDisplay = Math.max(
...[flow.elg_net, flow.lg_net, flow.md_net, flow.sm_net].map(Math.abs),
1
);
return (
<div className="glass-card-static p-5">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-4">
</h2>
<div className="space-y-2.5">
<FlowBar label="特大单" value={flow.elg_net} max={maxDisplay} />
<FlowBar label="大单" value={flow.lg_net} max={maxDisplay} />
<FlowBar label="中单" value={flow.md_net} max={maxDisplay} />
<FlowBar label="小单" value={flow.sm_net} max={maxDisplay} />
</div>
<div className="mt-3 pt-3 border-t border-border-subtle flex items-center justify-between text-xs">
<span className="text-text-muted"></span>
<span
className={`font-mono tabular-nums font-semibold ${
flow.main_net_inflow > 0 ? "text-red-400" : "text-emerald-400"
}`}
>
{flow.main_net_inflow > 0 ? "+" : ""}
{formatFlowAmount(flow.main_net_inflow)}
</span>
</div>
</div>
);
}
function FlowBar({ label, value, max }: { label: string; value: number; max: number }) {
const absVal = Math.abs(value);
const pct = (absVal / max) * 100;
const isInflow = value > 0;
return (
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-text-muted">{label}</span>
<span
className={`font-mono tabular-nums ${isInflow ? "text-red-400" : "text-emerald-400"}`}
>
{isInflow ? "+" : ""}
{formatFlowAmount(value)}
</span>
</div>
<div className="h-1.5 rounded-full bg-surface-2 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
isInflow ? "bg-red-400" : "bg-emerald-400"
}`}
style={{ width: `${pct}%` }}
/>
</div>
</div> </div>
); );
} }
@ -186,7 +460,7 @@ function SignalItem({ label, active, points }: { label: string; active: boolean;
className={`flex items-center justify-between px-3 py-2 rounded-xl text-xs transition-all duration-200 ${ className={`flex items-center justify-between px-3 py-2 rounded-xl text-xs transition-all duration-200 ${
active active
? "bg-red-500/[0.08] text-red-400 border border-red-500/10" ? "bg-red-500/[0.08] text-red-400 border border-red-500/10"
: "bg-white/[0.02] text-text-muted border border-transparent" : "bg-surface-1 text-text-muted border border-transparent"
}`} }`}
> >
<span className="font-medium">{label}</span> <span className="font-medium">{label}</span>
@ -196,3 +470,22 @@ function SignalItem({ label, active, points }: { label: string; active: boolean;
</div> </div>
); );
} }
/* ── Helper functions ── */
function getPositionColor(score: number) {
if (score >= 70) return "#22c55e";
if (score >= 40) return "#eab308";
return "#ef4444";
}
function formatBigNum(val: number): string {
if (val >= 10000) return (val / 10000).toFixed(1) + "万";
return val.toFixed(0);
}
function formatFlowAmount(val: number): string {
const absVal = Math.abs(val);
if (absVal >= 10000) return (val / 10000).toFixed(1) + "亿";
return val.toFixed(0) + "万";
}

View File

@ -143,7 +143,7 @@ export default function UsersPage() {
> >
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
{/* Avatar */} {/* Avatar */}
<div className="w-9 h-9 rounded-full bg-white/[0.04] border border-white/[0.06] flex items-center justify-center text-sm font-medium text-text-secondary shrink-0"> <div className="w-9 h-9 rounded-full bg-surface-3 border border-border-default flex items-center justify-center text-sm font-medium text-text-secondary shrink-0">
{u.username.charAt(0).toUpperCase()} {u.username.charAt(0).toUpperCase()}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
@ -155,7 +155,7 @@ export default function UsersPage() {
className={`text-[10px] px-1.5 py-0.5 rounded ${ className={`text-[10px] px-1.5 py-0.5 rounded ${
u.role === "admin" u.role === "admin"
? "bg-amber-500/10 text-amber-400/80" ? "bg-amber-500/10 text-amber-400/80"
: "bg-white/[0.04] text-text-muted" : "bg-surface-3 text-text-muted"
}`} }`}
> >
{u.role} {u.role}
@ -179,7 +179,7 @@ export default function UsersPage() {
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<button <button
onClick={() => handleResetPassword(u.id)} onClick={() => handleResetPassword(u.id)}
className="px-3 py-1.5 rounded-lg text-xs text-text-secondary hover:text-text-primary bg-white/[0.03] hover:bg-white/[0.06] border border-white/[0.04] transition-all" className="px-3 py-1.5 rounded-lg text-xs text-text-secondary hover:text-text-primary bg-surface-2 hover:bg-surface-4 border border-border-subtle transition-all"
> >
</button> </button>
@ -210,11 +210,11 @@ export default function UsersPage() {
{showCreate && ( {showCreate && (
<div className="fixed inset-0 z-[60] flex items-center justify-center"> <div className="fixed inset-0 z-[60] flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowCreate(false)} /> <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowCreate(false)} />
<div className="relative w-full max-w-sm mx-4 p-6 rounded-2xl bg-bg-card border border-white/[0.06] shadow-card"> <div className="relative w-full max-w-sm mx-4 p-6 rounded-2xl bg-bg-card border border-border-default shadow-card">
{createdResult ? ( {createdResult ? (
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-base font-semibold text-text-primary"></h3> <h3 className="text-base font-semibold text-text-primary"></h3>
<div className="p-4 rounded-xl bg-white/[0.02] border border-white/[0.04] space-y-2"> <div className="p-4 rounded-xl bg-surface-1 border border-border-subtle space-y-2">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-text-muted"></span> <span className="text-text-muted"></span>
<span className="text-text-primary font-medium">{createdResult.username}</span> <span className="text-text-primary font-medium">{createdResult.username}</span>
@ -234,7 +234,7 @@ export default function UsersPage() {
</button> </button>
<button <button
onClick={() => setShowCreate(false)} onClick={() => setShowCreate(false)}
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-white/[0.04] border border-white/[0.06] text-text-secondary hover:text-text-primary hover:bg-white/[0.06] transition-all" className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-surface-3 border border-border-default text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all"
> >
</button> </button>
@ -249,12 +249,12 @@ export default function UsersPage() {
placeholder="用户名" placeholder="用户名"
value={newUsername} value={newUsername}
onChange={(e) => setNewUsername(e.target.value)} onChange={(e) => setNewUsername(e.target.value)}
className="w-full bg-white/[0.03] border border-white/[0.06] rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40" className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40"
/> />
<select <select
value={newRole} value={newRole}
onChange={(e) => setNewRole(e.target.value)} onChange={(e) => setNewRole(e.target.value)}
className="w-full bg-white/[0.03] border border-white/[0.06] rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 appearance-none" className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 appearance-none"
> >
<option value="user" className="bg-bg-card text-text-primary"></option> <option value="user" className="bg-bg-card text-text-primary"></option>
<option value="admin" className="bg-bg-card text-text-primary"></option> <option value="admin" className="bg-bg-card text-text-primary"></option>
@ -264,7 +264,7 @@ export default function UsersPage() {
<button <button
type="button" type="button"
onClick={() => setShowCreate(false)} onClick={() => setShowCreate(false)}
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-white/[0.04] border border-white/[0.06] text-text-secondary hover:text-text-primary hover:bg-white/[0.06] transition-all" className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-surface-3 border border-border-default text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all"
> >
</button> </button>
@ -287,9 +287,9 @@ export default function UsersPage() {
{resetResult && ( {resetResult && (
<div className="fixed inset-0 z-[60] flex items-center justify-center"> <div className="fixed inset-0 z-[60] flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setResetResult(null)} /> <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setResetResult(null)} />
<div className="relative w-full max-w-sm mx-4 p-6 rounded-2xl bg-bg-card border border-white/[0.06] shadow-card space-y-4"> <div className="relative w-full max-w-sm mx-4 p-6 rounded-2xl bg-bg-card border border-border-default shadow-card space-y-4">
<h3 className="text-base font-semibold text-text-primary"></h3> <h3 className="text-base font-semibold text-text-primary"></h3>
<div className="p-4 rounded-xl bg-white/[0.02] border border-white/[0.04] space-y-2"> <div className="p-4 rounded-xl bg-surface-1 border border-border-subtle space-y-2">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-text-muted"></span> <span className="text-text-muted"></span>
<span className="text-text-primary font-medium">{resetResult.username}</span> <span className="text-text-primary font-medium">{resetResult.username}</span>
@ -309,7 +309,7 @@ export default function UsersPage() {
</button> </button>
<button <button
onClick={() => setResetResult(null)} onClick={() => setResetResult(null)}
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-white/[0.04] border border-white/[0.06] text-text-secondary hover:text-text-primary hover:bg-white/[0.06] transition-all" className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-surface-3 border border-border-default text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all"
> >
</button> </button>

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useTheme } from "next-themes";
interface FlowData { interface FlowData {
trade_date: string; trade_date: string;
@ -10,6 +11,7 @@ interface FlowData {
export default function CapitalFlowChart({ data }: { data: FlowData[] }) { export default function CapitalFlowChart({ data }: { data: FlowData[] }) {
const chartRef = useRef<HTMLDivElement>(null); const chartRef = useRef<HTMLDivElement>(null);
const { theme } = useTheme();
useEffect(() => { useEffect(() => {
if (!chartRef.current || !data.length) return; if (!chartRef.current || !data.length) return;
@ -18,7 +20,13 @@ export default function CapitalFlowChart({ data }: { data: FlowData[] }) {
import("echarts").then((ec) => { import("echarts").then((ec) => {
if (!chartRef.current) return; if (!chartRef.current) return;
chart = ec.init(chartRef.current, "dark"); const isDark = theme !== "light";
chart = ec.init(chartRef.current, isDark ? "dark" : undefined);
const isLight = theme === "light";
const axisLineColor = isLight ? "#d1d5db" : "#475569";
const axisLabelColor = isLight ? "#6b7280" : "#94a3b8";
const splitLineColor = isLight ? "#f3f4f6" : "#1e293b";
const dates = data.map((d) => d.trade_date); const dates = data.map((d) => d.trade_date);
const values = data.map((d) => d.main_net_inflow); const values = data.map((d) => d.main_net_inflow);
@ -36,15 +44,15 @@ export default function CapitalFlowChart({ data }: { data: FlowData[] }) {
xAxis: { xAxis: {
type: "category", type: "category",
data: dates, data: dates,
axisLine: { lineStyle: { color: "#475569" } }, axisLine: { lineStyle: { color: axisLineColor } },
axisLabel: { fontSize: 10, color: "#94a3b8", rotate: 30 }, axisLabel: { fontSize: 10, color: axisLabelColor, rotate: 30 },
}, },
yAxis: { yAxis: {
type: "value", type: "value",
splitLine: { lineStyle: { color: "#1e293b" } }, splitLine: { lineStyle: { color: splitLineColor } },
axisLabel: { axisLabel: {
fontSize: 10, fontSize: 10,
color: "#94a3b8", color: axisLabelColor,
formatter: (v: number) => (Math.abs(v) >= 10000 ? (v / 10000).toFixed(1) + "亿" : v + "万"), formatter: (v: number) => (Math.abs(v) >= 10000 ? (v / 10000).toFixed(1) + "亿" : v + "万"),
}, },
}, },
@ -67,7 +75,7 @@ export default function CapitalFlowChart({ data }: { data: FlowData[] }) {
return () => { return () => {
chart?.dispose(); chart?.dispose();
}; };
}, [data]); }, [data, theme]);
return ( return (
<div className="bg-bg-card rounded-xl p-4"> <div className="bg-bg-card rounded-xl p-4">

View File

@ -66,7 +66,7 @@ export function ChangePasswordDialog({ open, onClose }: ChangePasswordDialogProp
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={handleClose} /> <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={handleClose} />
{/* Dialog */} {/* Dialog */}
<div className="relative w-full max-w-sm mx-4 p-6 rounded-2xl bg-bg-card border border-white/[0.06] shadow-card"> <div className="relative w-full max-w-sm mx-4 p-6 rounded-2xl bg-bg-card border border-border-default shadow-card">
<h3 className="text-base font-semibold text-text-primary mb-5"></h3> <h3 className="text-base font-semibold text-text-primary mb-5"></h3>
{success ? ( {success ? (
@ -74,7 +74,7 @@ export function ChangePasswordDialog({ open, onClose }: ChangePasswordDialogProp
<p className="text-sm text-emerald-400">使</p> <p className="text-sm text-emerald-400">使</p>
<button <button
onClick={handleClose} onClick={handleClose}
className="w-full py-2.5 rounded-xl text-sm font-medium bg-white/[0.04] border border-white/[0.06] text-text-secondary hover:text-text-primary hover:bg-white/[0.06] transition-all" className="w-full py-2.5 rounded-xl text-sm font-medium bg-surface-3 border border-border-default text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all"
> >
</button> </button>
@ -86,7 +86,7 @@ export function ChangePasswordDialog({ open, onClose }: ChangePasswordDialogProp
placeholder="旧密码" placeholder="旧密码"
value={oldPassword} value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)} onChange={(e) => setOldPassword(e.target.value)}
className="w-full bg-white/[0.03] border border-white/[0.06] rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40" className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40"
autoComplete="current-password" autoComplete="current-password"
/> />
<input <input
@ -94,7 +94,7 @@ export function ChangePasswordDialog({ open, onClose }: ChangePasswordDialogProp
placeholder="新密码(至少 6 位)" placeholder="新密码(至少 6 位)"
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
className="w-full bg-white/[0.03] border border-white/[0.06] rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40" className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40"
autoComplete="new-password" autoComplete="new-password"
/> />
<input <input
@ -102,7 +102,7 @@ export function ChangePasswordDialog({ open, onClose }: ChangePasswordDialogProp
placeholder="确认新密码" placeholder="确认新密码"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full bg-white/[0.03] border border-white/[0.06] rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40" className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40"
autoComplete="new-password" autoComplete="new-password"
/> />
@ -114,7 +114,7 @@ export function ChangePasswordDialog({ open, onClose }: ChangePasswordDialogProp
<button <button
type="button" type="button"
onClick={handleClose} onClick={handleClose}
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-white/[0.04] border border-white/[0.06] text-text-secondary hover:text-text-primary hover:bg-white/[0.06] transition-all" className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-surface-3 border border-border-default text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all"
> >
</button> </button>

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useTheme } from "next-themes";
interface KlineData { interface KlineData {
trade_date: string; trade_date: string;
@ -18,17 +19,22 @@ interface KlineData {
export default function KlineChart({ data }: { data: KlineData[] }) { export default function KlineChart({ data }: { data: KlineData[] }) {
const chartRef = useRef<HTMLDivElement>(null); const chartRef = useRef<HTMLDivElement>(null);
const { theme } = useTheme();
useEffect(() => { useEffect(() => {
if (!chartRef.current || !data.length) return; if (!chartRef.current || !data.length) return;
let echarts: typeof import("echarts") | null = null;
let chart: ReturnType<typeof import("echarts")["init"]> | null = null; let chart: ReturnType<typeof import("echarts")["init"]> | null = null;
import("echarts").then((ec) => { import("echarts").then((ec) => {
echarts = ec;
if (!chartRef.current) return; if (!chartRef.current) return;
chart = ec.init(chartRef.current, "dark"); const isDark = theme !== "light";
chart = ec.init(chartRef.current, isDark ? "dark" : undefined);
const isLight = theme === "light";
const axisLineColor = isLight ? "#d1d5db" : "#475569";
const axisLabelColor = isLight ? "#6b7280" : "#94a3b8";
const splitLineColor = isLight ? "#f3f4f6" : "#1e293b";
const dates = data.map((d) => d.trade_date); const dates = data.map((d) => d.trade_date);
const ohlc = data.map((d) => [d.open, d.close, d.low, d.high]); const ohlc = data.map((d) => [d.open, d.close, d.low, d.high]);
@ -51,29 +57,29 @@ export default function KlineChart({ data }: { data: KlineData[] }) {
{ {
type: "category", type: "category",
data: dates, data: dates,
axisLine: { lineStyle: { color: "#475569" } }, axisLine: { lineStyle: { color: axisLineColor } },
axisLabel: { fontSize: 10, color: "#94a3b8" }, axisLabel: { fontSize: 10, color: axisLabelColor },
}, },
{ {
type: "category", type: "category",
gridIndex: 1, gridIndex: 1,
data: dates, data: dates,
axisLabel: { show: false }, axisLabel: { show: false },
axisLine: { lineStyle: { color: "#475569" } }, axisLine: { lineStyle: { color: axisLineColor } },
}, },
], ],
yAxis: [ yAxis: [
{ {
scale: true, scale: true,
splitLine: { lineStyle: { color: "#1e293b" } }, splitLine: { lineStyle: { color: splitLineColor } },
axisLabel: { fontSize: 10, color: "#94a3b8" }, axisLabel: { fontSize: 10, color: axisLabelColor },
}, },
{ {
scale: true, scale: true,
gridIndex: 1, gridIndex: 1,
splitNumber: 2, splitNumber: 2,
splitLine: { lineStyle: { color: "#1e293b" } }, splitLine: { lineStyle: { color: splitLineColor } },
axisLabel: { fontSize: 10, color: "#94a3b8" }, axisLabel: { fontSize: 10, color: axisLabelColor },
}, },
], ],
series: [ series: [
@ -143,7 +149,7 @@ export default function KlineChart({ data }: { data: KlineData[] }) {
return () => { return () => {
chart?.dispose(); chart?.dispose();
}; };
}, [data]); }, [data, theme]);
return ( return (
<div className="bg-bg-card rounded-xl p-4"> <div className="bg-bg-card rounded-xl p-4">

View File

@ -94,7 +94,7 @@ export default function MarketTemp({ data, indices }: MarketTempProps) {
{/* Middle row: Broken rate + MA20 */} {/* Middle row: Broken rate + MA20 */}
<div className="grid grid-cols-2 gap-2 mb-2"> <div className="grid grid-cols-2 gap-2 mb-2">
<div className="bg-white/[0.02] rounded-lg px-3 py-2 border border-white/[0.03]"> <div className="bg-surface-1 rounded-lg px-3 py-2 border border-border-subtle">
<div className="text-xs text-text-muted mb-0.5"></div> <div className="text-xs text-text-muted mb-0.5"></div>
{hasBrokenRate ? ( {hasBrokenRate ? (
<div className="flex items-baseline gap-1"> <div className="flex items-baseline gap-1">
@ -109,7 +109,7 @@ export default function MarketTemp({ data, indices }: MarketTempProps) {
<span className="text-sm text-text-muted/40">-</span> <span className="text-sm text-text-muted/40">-</span>
)} )}
</div> </div>
<div className="bg-white/[0.02] rounded-lg px-3 py-2 border border-white/[0.03]"> <div className="bg-surface-1 rounded-lg px-3 py-2 border border-border-subtle">
<div className="text-xs text-text-muted mb-0.5">线</div> <div className="text-xs text-text-muted mb-0.5">线</div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className={`w-1.5 h-1.5 rounded-full ${data.index_above_ma20 ? "bg-red-400" : "bg-emerald-400"}`} /> <span className={`w-1.5 h-1.5 rounded-full ${data.index_above_ma20 ? "bg-red-400" : "bg-emerald-400"}`} />
@ -124,7 +124,7 @@ export default function MarketTemp({ data, indices }: MarketTempProps) {
{indices && indices.length > 0 && ( {indices && indices.length > 0 && (
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{indices.map((idx) => ( {indices.map((idx) => (
<div key={idx.code} className="bg-white/[0.02] rounded-lg px-3 py-2 border border-white/[0.03]"> <div key={idx.code} className="bg-surface-1 rounded-lg px-3 py-2 border border-border-subtle">
<div className="text-xs text-text-muted mb-0.5"> <div className="text-xs text-text-muted mb-0.5">
{idx.name} {idx.name}
{idx.realtime && <span className="text-emerald-400/60 ml-1">· </span>} {idx.realtime && <span className="text-emerald-400/60 ml-1">· </span>}
@ -145,7 +145,7 @@ export default function MarketTemp({ data, indices }: MarketTempProps) {
function StatCard({ label, children }: { label: string; children: React.ReactNode }) { function StatCard({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
<div className="bg-white/[0.02] rounded-lg px-3 py-2 border border-white/[0.03]"> <div className="bg-surface-1 rounded-lg px-3 py-2 border border-border-subtle">
<div className="text-xs text-text-muted mb-0.5 font-medium">{label}</div> <div className="text-xs text-text-muted mb-0.5 font-medium">{label}</div>
<div className="text-sm">{children}</div> <div className="text-sm">{children}</div>
</div> </div>

View File

@ -3,6 +3,7 @@
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { ThemeToggle } from "@/components/theme-toggle";
function DashboardIcon() { function DashboardIcon() {
return ( return (
@ -61,8 +62,8 @@ function SideNavItem({ href, icon, label }: { href: string; icon: React.ReactNod
href={href} href={href}
className={`flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm transition-all duration-200 ${ className={`flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm transition-all duration-200 ${
isActive isActive
? "text-text-primary bg-white/[0.06]" ? "text-text-primary bg-surface-4"
: "text-text-secondary hover:text-text-primary hover:bg-white/[0.04]" : "text-text-secondary hover:text-text-primary hover:bg-surface-3"
}`} }`}
> >
<span className="text-base opacity-70">{icon}</span> <span className="text-base opacity-70">{icon}</span>
@ -106,7 +107,7 @@ function MobileNavItem({ href, label, children }: { href: string; label: string;
export function MobileBottomNav() { export function MobileBottomNav() {
return ( return (
<nav className="fixed bottom-0 left-0 right-0 md:hidden z-50 bg-bg-secondary/95 backdrop-blur-xl border-t border-white/[0.04]"> <nav className="fixed bottom-0 left-0 right-0 md:hidden z-50 bg-bg-secondary/95 backdrop-blur-xl border-t border-border-subtle">
<div className="flex justify-around py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]"> <div className="flex justify-around py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
<MobileNavItem href="/" label="总览"> <MobileNavItem href="/" label="总览">
<DashboardIcon /> <DashboardIcon />
@ -120,6 +121,9 @@ export function MobileBottomNav() {
<MobileNavItem href="/chat" label="对话"> <MobileNavItem href="/chat" label="对话">
<ChatIcon /> <ChatIcon />
</MobileNavItem> </MobileNavItem>
<div className="flex flex-col items-center gap-1">
<ThemeToggle />
</div>
</div> </div>
</nav> </nav>
); );

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useTheme } from "next-themes";
interface SignalsData { interface SignalsData {
score: number; score: number;
@ -15,6 +16,7 @@ interface SignalsData {
export default function ScoreRadar({ signals }: { signals: SignalsData }) { export default function ScoreRadar({ signals }: { signals: SignalsData }) {
const chartRef = useRef<HTMLDivElement>(null); const chartRef = useRef<HTMLDivElement>(null);
const { theme } = useTheme();
useEffect(() => { useEffect(() => {
if (!chartRef.current) return; if (!chartRef.current) return;
@ -23,7 +25,13 @@ export default function ScoreRadar({ signals }: { signals: SignalsData }) {
import("echarts").then((ec) => { import("echarts").then((ec) => {
if (!chartRef.current) return; if (!chartRef.current) return;
chart = ec.init(chartRef.current, "dark"); const isDark = theme !== "light";
chart = ec.init(chartRef.current, isDark ? "dark" : undefined);
const isLight = theme === "light";
const axisLineColor = isLight ? "#d1d5db" : "#475569";
const axisLabelColor = isLight ? "#6b7280" : "#94a3b8";
const splitLineColor = isLight ? "#e5e7eb" : "#334155";
const indicators = [ const indicators = [
{ name: "均线多头", max: 15 }, { name: "均线多头", max: 15 },
@ -51,10 +59,10 @@ export default function ScoreRadar({ signals }: { signals: SignalsData }) {
indicator: indicators, indicator: indicators,
shape: "polygon", shape: "polygon",
splitNumber: 3, splitNumber: 3,
axisName: { color: "#94a3b8", fontSize: 10 }, axisName: { color: axisLabelColor, fontSize: 10 },
splitLine: { lineStyle: { color: "#334155" } }, splitLine: { lineStyle: { color: splitLineColor } },
splitArea: { areaStyle: { color: ["transparent"] } }, splitArea: { areaStyle: { color: ["transparent"] } },
axisLine: { lineStyle: { color: "#475569" } }, axisLine: { lineStyle: { color: axisLineColor } },
}, },
series: [ series: [
{ {
@ -80,7 +88,7 @@ export default function ScoreRadar({ signals }: { signals: SignalsData }) {
return () => { return () => {
chart?.dispose(); chart?.dispose();
}; };
}, [signals]); }, [signals, theme]);
return ( return (
<div className="bg-bg-card rounded-xl p-4"> <div className="bg-bg-card rounded-xl p-4">

View File

@ -66,7 +66,7 @@ export default function SectorHeatmap({ sectors }: { sectors: SectorData[] }) {
? "bg-gradient-to-br from-slate-400/20 to-slate-500/15 text-slate-300 border border-slate-400/15" ? "bg-gradient-to-br from-slate-400/20 to-slate-500/15 text-slate-300 border border-slate-400/15"
: index === 2 : index === 2
? "bg-gradient-to-br from-amber-700/20 to-amber-800/15 text-amber-400 border border-amber-600/15" ? "bg-gradient-to-br from-amber-700/20 to-amber-800/15 text-amber-400 border border-amber-600/15"
: "bg-white/[0.03] text-text-muted border border-white/[0.04]" : "bg-surface-2 text-text-muted border border-border-subtle"
}`}> }`}>
{index + 1} {index + 1}
</span> </span>
@ -94,7 +94,7 @@ export default function SectorHeatmap({ sectors }: { sectors: SectorData[] }) {
<span className={`font-mono tabular-nums text-xs font-semibold px-2 py-1 rounded-lg min-w-[36px] text-center ${ <span className={`font-mono tabular-nums text-xs font-semibold px-2 py-1 rounded-lg min-w-[36px] text-center ${
isTop3 isTop3
? "bg-amber-500/20 text-amber-400 border border-amber-500/15" ? "bg-amber-500/20 text-amber-400 border border-amber-500/15"
: "bg-white/[0.04] text-text-muted border border-white/[0.04]" : "bg-surface-3 text-text-muted border border-border-subtle"
}`}> }`}>
{s.heat_score.toFixed(0)} {s.heat_score.toFixed(0)}
</span> </span>

View File

@ -66,7 +66,7 @@ export default function StockCard({ rec, showLLMLoading = false }: { rec: Recomm
{/* Price reference */} {/* Price reference */}
{rec.entry_price && ( {rec.entry_price && (
<div className="flex justify-between text-xs mb-3 bg-white/[0.03] rounded-xl px-4 py-2.5 border border-white/[0.04]"> <div className="flex justify-between text-xs mb-3 bg-surface-2 rounded-xl px-4 py-2.5 border border-border-subtle">
<div> <div>
<span className="text-text-muted"> </span> <span className="text-text-muted"> </span>
<span className="text-red-400 font-mono tabular-nums">{rec.entry_price}</span> <span className="text-red-400 font-mono tabular-nums">{rec.entry_price}</span>
@ -176,7 +176,7 @@ function MarkdownText({ text }: { text: string }) {
// 分隔线 --- 或 *** // 分隔线 --- 或 ***
if (/^[-*_]{3,}$/.test(trimmed)) { if (/^[-*_]{3,}$/.test(trimmed)) {
return <div key={i} className="border-t border-white/[0.06] my-1.5" />; return <div key={i} className="border-t border-border-default my-1.5" />;
} }
// 无序列表 - 开头 // 无序列表 - 开头
@ -215,7 +215,7 @@ function renderInlineFormat(text: string) {
return parts.map((part, i) => { return parts.map((part, i) => {
if (part.startsWith("`") && part.endsWith("`")) { if (part.startsWith("`") && part.endsWith("`")) {
return ( return (
<code key={i} className="bg-white/[0.08] px-1 py-0.5 rounded text-accent-cyan/90 font-mono text-[11px]"> <code key={i} className="bg-surface-4 px-1 py-0.5 rounded text-accent-cyan/90 font-mono text-[11px]">
{part.slice(1, -1)} {part.slice(1, -1)}
</code> </code>
); );
@ -240,7 +240,7 @@ function ScoreBar({ label, value }: { label: string; value: number }) {
<span className="font-medium">{label}</span> <span className="font-medium">{label}</span>
<span className="font-mono tabular-nums">{value.toFixed(0)}</span> <span className="font-mono tabular-nums">{value.toFixed(0)}</span>
</div> </div>
<div className="h-1.5 bg-white/[0.04] rounded-full overflow-hidden"> <div className="h-1.5 bg-surface-3 rounded-full overflow-hidden">
<div <div
className={`h-full rounded-full transition-all duration-700 ease-out ${gradientClass}`} className={`h-full rounded-full transition-all duration-700 ease-out ${gradientClass}`}
style={{ width: `${width}%` }} style={{ width: `${width}%` }}

View File

@ -0,0 +1,45 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
return (
<button className="w-8 h-8 rounded-lg bg-surface-3 border border-border-subtle" aria-label="Toggle theme" />
);
}
const isLight = theme === "light";
return (
<button
onClick={() => setTheme(isLight ? "dark" : "light")}
className="w-8 h-8 rounded-lg bg-surface-3 hover:bg-surface-4 border border-border-subtle flex items-center justify-center transition-all duration-200"
aria-label={isLight ? "切换到深色模式" : "切换到浅色模式"}
>
{isLight ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-text-secondary">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-text-secondary">
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
)}
</button>
);
}

View File

@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { ChangePasswordDialog } from "@/components/change-password-dialog"; import { ChangePasswordDialog } from "@/components/change-password-dialog";
import { ThemeToggle } from "@/components/theme-toggle";
export function UserMenu() { export function UserMenu() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
@ -14,10 +15,13 @@ export function UserMenu() {
<> <>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-text-muted">{user.username}</span> <div className="flex items-center gap-2">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-400/80"> <span className="text-xs text-text-muted">{user.username}</span>
{user.role} <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-400/80">
</span> {user.role}
</span>
</div>
<ThemeToggle />
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button

View File

@ -1,5 +1,6 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
darkMode: "class",
content: [ content: [
"./src/app/**/*.{js,ts,jsx,tsx,mdx}", "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}", "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
@ -7,23 +8,33 @@ module.exports = {
theme: { theme: {
extend: { extend: {
colors: { colors: {
up: "#ff6b6b", up: "var(--color-up)",
down: "#34d399", down: "var(--color-down)",
hot: "#f59e0b", hot: "var(--color-hot)",
bg: { bg: {
primary: "#0a0a0c", primary: "var(--bg-primary)",
secondary: "#101013", secondary: "var(--bg-secondary)",
card: "#161619", card: "var(--bg-card)",
elevated: "#1e1e22", elevated: "var(--bg-elevated)",
}, },
text: { text: {
primary: "#f5f5f7", primary: "var(--text-primary)",
secondary: "#9898a4", secondary: "var(--text-secondary)",
muted: "#58585f", muted: "var(--text-muted)",
}, },
accent: { accent: {
amber: "#f59e0b", amber: "var(--accent-amber)",
cyan: "#22d3ee", cyan: "var(--accent-cyan)",
},
surface: {
"1": "var(--surface-1)",
"2": "var(--surface-2)",
"3": "var(--surface-3)",
"4": "var(--surface-4)",
},
border: {
subtle: "var(--border-subtle)",
default: "var(--border-default)",
}, },
}, },
fontFamily: { fontFamily: {
@ -35,9 +46,9 @@ module.exports = {
"3xl": "20px", "3xl": "20px",
}, },
boxShadow: { boxShadow: {
card: "0 4px 24px rgba(0, 0, 0, 0.3)", card: "var(--shadow-card)",
glow: "0 0 20px rgba(245, 158, 11, 0.12)", glow: "var(--shadow-glow)",
"glow-sm": "0 0 10px rgba(245, 158, 11, 0.08)", "glow-sm": "var(--shadow-glow-sm)",
}, },
animation: { animation: {
"fade-in-up": "fadeInUp 0.5s cubic-bezier(0.4, 0, 0.2, 1) both", "fade-in-up": "fadeInUp 0.5s cubic-bezier(0.4, 0, 0.2, 1) both",

View File

@ -1920,6 +1920,11 @@ natural-compare@^1.4.0:
resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
next-themes@^0.4.6:
version "0.4.6"
resolved "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz"
integrity sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==
next@^14.2.0: next@^14.2.0:
version "14.2.35" version "14.2.35"
resolved "https://registry.npmjs.org/next/-/next-14.2.35.tgz" resolved "https://registry.npmjs.org/next/-/next-14.2.35.tgz"
@ -2226,7 +2231,7 @@ queue-microtask@^1.2.2:
resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
react-dom@^18.2.0, react-dom@^18.3.0: "react-dom@^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", react-dom@^18.2.0, react-dom@^18.3.0:
version "18.3.1" version "18.3.1"
resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz" resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz"
integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==
@ -2239,7 +2244,7 @@ react-is@^16.13.1:
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
"react@^15.0.0 || >=16.0.0", react@^18.2.0, react@^18.3.0, react@^18.3.1, "react@>= 16.8.0 || 17.x.x || ^18.0.0-0": "react@^15.0.0 || >=16.0.0", "react@^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", react@^18.2.0, react@^18.3.0, react@^18.3.1, "react@>= 16.8.0 || 17.x.x || ^18.0.0-0":
version "18.3.1" version "18.3.1"
resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz" resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz"
integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==